diff --git a/PLAN.md b/PLAN.md index 3528310..b9ca81a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -154,6 +154,20 @@ distribution PATCH → task 폴링 → 배지 갱신 → 감사 로그 기록. --- +## [x] 단계 6 — 목록 컨트롤: 검색 · 헤더 정렬 · 페이지네이션 + +**목표:** 저장소가 많아져도 찾고/정렬하고/넘겨볼 수 있게. + +- `pulp_client.list_repos`: Pulp `next` 페이지를 끝까지 순회(첫-페이지-only 버그 수정). +- BFF에서 전체 enrich 후 메모리 검색(이름)·정렬(이름/검증/버전/패키지/동기화)·페이지네이션 + (수십 개 규모 가정; 요약 카드는 항상 전체 인벤토리 기준). +- `GET /repos/table` 조각 추가 — 검색창은 표 바깥(포커스 유지), 정렬 헤더/페이저는 표 안. + 검색은 `[data-state]`로 정렬 상태를, 헤더/페이저는 `#repo-search`로 검색어를 함께 전송. + +**검증:** 이름 검색 필터, 헤더 클릭 정렬(방향 토글·표시), 20개 초과 시 페이저. + +--- + ## 교차 관심사 (전 단계 공통) - **에러 처리:** httpx 타임아웃/연결 실패/4xx·5xx를 잡아 화면에 사람이 읽을 수 있는 diff --git a/app/demo.py b/app/demo.py index 226350f..5f279e2 100644 --- a/app/demo.py +++ b/app/demo.py @@ -1,62 +1,88 @@ -"""임시 데모용 가짜 Pulp 클라이언트 — 성공 대시보드 미리보기. +"""임시 데모용 가짜 Pulp 클라이언트 — 실제 Pulp 없이 화면 확인. -PULP_DEMO=true 일 때만 사용된다. 실제 Pulp 서버 없이 화면을 확인하기 위한 것. -정식 기능과 무관하므로 단계 5(또는 실제 Pulp 연동 시) 제거 가능. +PULP_DEMO=true 일 때만 사용. 검색·정렬·페이지네이션을 눈으로 보도록 저장소를 여러 개 생성한다. +정식 기능과 무관하므로 실제 Pulp 연동 시 제거 가능. """ from __future__ import annotations from typing import Any -_REPOS: list[dict[str, Any]] = [ - { - "pulp_href": "/pulp/api/v3/repositories/rpm/rpm/0001/", - "name": "rocky9-baseos", - "latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/0001/versions/3/", - "repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1}, - }, - { - "pulp_href": "/pulp/api/v3/repositories/rpm/rpm/0002/", - "name": "rocky9-appstream", - "latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/0002/versions/2/", - "repo_config": {"gpgcheck": 1, "repo-gpgcheck": 1}, - }, - { - "pulp_href": "/pulp/api/v3/repositories/rpm/rpm/0003/", - "name": "ubuntu2204-main", - "latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/0003/versions/5/", - "repo_config": {"gpgcheck": 1, "repo-gpgcheck": 0}, # 부분 검증 - }, - { - "pulp_href": "/pulp/api/v3/repositories/rpm/rpm/0004/", - "name": "internal-tools", - "latest_version_href": "/pulp/api/v3/repositories/rpm/rpm/0004/versions/1/", - "repo_config": {}, # 미설정 - }, +_NAMES = [ + "rocky9-baseos", + "rocky9-appstream", + "rocky9-extras", + "rocky8-baseos", + "rocky8-appstream", + "almalinux9-baseos", + "almalinux9-appstream", + "ubuntu2204-main", + "ubuntu2204-updates", + "ubuntu2204-security", + "ubuntu2004-main", + "debian12-main", + "debian12-updates", + "epel9", + "epel8", + "centos-stream9", + "docker-ce", + "kubernetes", + "postgresql16", + "nginx-stable", + "grafana", + "elastic-8", + "hashicorp", + "internal-tools", + "internal-agents", + "monitoring-exporters", ] -_VERSIONS: dict[str, dict[str, Any]] = { - "/pulp/api/v3/repositories/rpm/rpm/0001/versions/3/": { - "pulp_created": "2026-06-15T02:11:00Z", - "content_summary": {"present": {"rpm.package": {"count": 4821}}}, - }, - "/pulp/api/v3/repositories/rpm/rpm/0002/versions/2/": { - "pulp_created": "2026-06-14T23:40:00Z", - "content_summary": {"present": {"rpm.package": {"count": 8934}}}, - }, - "/pulp/api/v3/repositories/rpm/rpm/0003/versions/5/": { - "pulp_created": "2026-06-16T08:05:00Z", - "content_summary": {"present": {"deb.package": {"count": 12500}}}, - }, - "/pulp/api/v3/repositories/rpm/rpm/0004/versions/1/": { - "pulp_created": "2026-05-30T11:20:00Z", - "content_summary": {"present": {"rpm.package": {"count": 87}}}, - }, -} +# 검증 상태를 섞는다: pass / warn(부분) / unset +_CFGS = [ + {"gpgcheck": 1, "repo-gpgcheck": 1}, + {"gpgcheck": 1, "repo-gpgcheck": 0}, + {}, +] + + +def _uuid(i: int) -> str: + return f"{i:04d}" + + +def _latest(i: int) -> int: + return (i % 5) + 1 # 1~5 + + +_REPOS: list[dict[str, Any]] = [ + { + "pulp_href": f"/pulp/api/v3/repositories/rpm/rpm/{_uuid(i)}/", + "name": name, + "latest_version_href": ( + f"/pulp/api/v3/repositories/rpm/rpm/{_uuid(i)}/versions/{_latest(i)}/" + ), + "repo_config": _CFGS[i % 3], + } + for i, name in enumerate(_NAMES, start=1) +] + + +def _synth_version(version_href: str) -> dict[str, Any]: + parts = version_href.rstrip("/").split("/") + n = int(parts[-1]) if parts[-1].isdigit() else 1 + base = 800 + (int(parts[-3]) if parts[-3].isdigit() else 1) * 53 + present = base + n * 137 + return { + "pulp_created": f"2026-06-{(n * 5) % 27 + 1:02d}T0{n}:11:00Z", + "content_summary": { + "present": {"rpm.package": {"count": present}}, + "added": {"rpm.package": {"count": n * 137}}, + "removed": {"rpm.package": {"count": n * 7}}, + }, + } class DemoPulpClient: - def close(self) -> None: # 인터페이스 호환 + def close(self) -> None: pass def status(self) -> dict[str, Any]: @@ -66,7 +92,7 @@ class DemoPulpClient: return _REPOS def get_version(self, version_href: str) -> dict[str, Any]: - return _VERSIONS.get(version_href, {}) + return _synth_version(version_href) def get_repo(self, uuid: str) -> dict[str, Any]: for r in _REPOS: @@ -74,59 +100,32 @@ class DemoPulpClient: return {**r, "remote": "/pulp/api/v3/remotes/rpm/rpm/demo/"} return {"remote": "/pulp/api/v3/remotes/rpm/rpm/demo/"} - def sync_repo(self, uuid: str, remote_href: str) -> str: - return f"/pulp/api/v3/tasks/demo-{uuid}/" - - def get_task(self, task_href: str) -> dict[str, Any]: - # 데모: 첫 폴링에서 바로 완료 처리 - return { - "state": "completed", - "progress_reports": [{"done": 120, "total": 120}], - "created_resources": ["/pulp/api/v3/repositories/rpm/rpm/demo/versions/9/"], - } - def list_versions(self, uuid: str) -> list[dict[str, Any]]: base = f"/pulp/api/v3/repositories/rpm/rpm/{uuid}/versions/" - return [ - { - "pulp_href": base + "1/", - "pulp_created": "2026-05-02T01:00:00Z", - "content_summary": { - "present": {"rpm.package": {"count": 4500}}, - "added": {"rpm.package": {"count": 4500}}, - "removed": {}, - }, - }, - { - "pulp_href": base + "2/", - "pulp_created": "2026-05-28T04:30:00Z", - "content_summary": { - "present": {"rpm.package": {"count": 4700}}, - "added": {"rpm.package": {"count": 250}}, - "removed": {"rpm.package": {"count": 50}}, - }, - }, - { - "pulp_href": base + "3/", - "pulp_created": "2026-06-15T02:11:00Z", - "content_summary": { - "present": {"rpm.package": {"count": 4821}}, - "added": {"rpm.package": {"count": 130}}, - "removed": {"rpm.package": {"count": 9}}, - }, - }, - ] + latest = 1 + for i, r in enumerate(_NAMES, start=1): + if _uuid(i) == uuid: + latest = _latest(i) + break + out = [] + for n in range(1, latest + 1): + href = f"{base}{n}/" + out.append({"pulp_href": href, **_synth_version(href)}) + return out def list_distributions(self) -> list[dict[str, Any]]: - # 저장소별 현재 배포 버전 (publication href 에 uuid-버전 인코딩) - deployed = {"0001": 2, "0002": 1, "0003": 3, "0004": 1} - return [ - { - "pulp_href": f"/pulp/api/v3/distributions/rpm/rpm/{u}/", - "publication": f"/pulp/api/v3/publications/rpm/rpm/{u}-{v}/", - } - for u, v in deployed.items() - ] + # 저장소별 현재 배포 버전 (보통 최신-1). publication href 에 uuid-버전 인코딩 + dists = [] + for i in range(1, len(_NAMES) + 1): + deployed = max(1, _latest(i) - 1) + u = _uuid(i) + dists.append( + { + "pulp_href": f"/pulp/api/v3/distributions/rpm/rpm/{u}/", + "publication": f"/pulp/api/v3/publications/rpm/rpm/{u}-{deployed}/", + } + ) + return dists def get_publication(self, publication_href: str) -> dict[str, Any]: tail = publication_href.rstrip("/").split("/")[-1] # "0001-2" @@ -137,6 +136,16 @@ class DemoPulpClient: ) } + def sync_repo(self, uuid: str, remote_href: str) -> str: + return f"/pulp/api/v3/tasks/demo-{uuid}/" + + def get_task(self, task_href: str) -> dict[str, Any]: + return { + "state": "completed", + "progress_reports": [{"done": 120, "total": 120}], + "created_resources": ["/pulp/api/v3/repositories/rpm/rpm/demo/versions/9/"], + } + def create_publication(self, version_href: str) -> str: return "/pulp/api/v3/tasks/demo-pub/" @@ -144,7 +153,6 @@ class DemoPulpClient: return "/pulp/api/v3/tasks/demo-dist/" def wait_for_task(self, task_href: str, attempts: int = 120, delay: float = 1.0): - # 데모: 즉시 완료. publication task 면 새 publication 리소스를 돌려준다. created = ( ["/pulp/api/v3/publications/rpm/rpm/demo-new/"] if "pub" in task_href diff --git a/app/pulp_client.py b/app/pulp_client.py index 29a5bd6..cfc4356 100644 --- a/app/pulp_client.py +++ b/app/pulp_client.py @@ -62,9 +62,14 @@ class PulpClient: return self.get(f"{API_PREFIX}/status/") def list_repos(self) -> list[dict[str, Any]]: - """RPM 저장소 목록: GET /pulp/api/v3/repositories/rpm/rpm/ → results.""" - data = self.get(f"{API_PREFIX}/repositories/rpm/rpm/") - return data.get("results", []) + """RPM 저장소 전체 목록. Pulp 페이지네이션(next)을 끝까지 따라가 모두 모은다.""" + results: list[dict[str, Any]] = [] + path: str | None = f"{API_PREFIX}/repositories/rpm/rpm/?limit=100" + while path: + data = self.get(path) + results.extend(data.get("results", [])) + path = data.get("next") # 절대 URL/상대경로 모두 httpx가 처리 + return results def get_version(self, version_href: str) -> dict[str, Any]: """RepositoryVersion 단건 조회 (href 기준). content_summary 등 포함.""" diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 53e9e76..c983993 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -1,11 +1,16 @@ -"""대시보드 (화면 1, 읽기 전용). +"""대시보드 (화면 1, 읽기 전용) + 목록 컨트롤(검색·정렬·페이지네이션). -GET / → 전체 페이지(상태 + 요약 카드 + 저장소 목록) -GET /repos → 저장소 목록 조각 (새로고침/폴링용 partial) +GET / 전체 페이지 +GET /repos 저장소 목록 전체 조각 (요약+검색창+표) — 새로고침용 +GET /repos/table 표+페이저 조각 — 검색/정렬/페이지 이동 대상 + +저장소 수십 개 규모 가정: 전체를 가져와 enrich 후 메모리에서 검색·정렬·페이지네이션한다. +(요약 카드는 검색/필터와 무관하게 항상 전체 인벤토리 기준.) """ from __future__ import annotations +from math import ceil from typing import Any import httpx @@ -18,17 +23,33 @@ from ..pulp_client import PulpClient router = APIRouter() +PAGE_SIZE = 20 -def _load_repos(pulp: PulpClient) -> dict[str, Any]: - """저장소 목록을 읽어 표시 모델 + 요약 + (실패 시) 에러 메시지를 만든다.""" + +def _load_repos( + pulp: PulpClient, + q: str = "", + sort: str = "name", + direction: str = "asc", + page: int = 1, +) -> dict[str, Any]: syncing = task_store.active_count() + base = {"q": q, "sort": sort, "dir": direction} try: - repos = pulp.list_repos() + raw = pulp.list_repos() except httpx.HTTPError as exc: - return {"repos": [], "summary": views.summarize([], syncing), "error": str(exc)} + return { + **base, + "repos": [], + "summary": views.summarize([], syncing), + "page": 1, + "pages": 1, + "total": 0, + "error": str(exc), + } repo_views: list[dict[str, Any]] = [] - for repo in repos: + for repo in raw: version = None vhref = repo.get("latest_version_href") if vhref: @@ -38,24 +59,65 @@ def _load_repos(pulp: PulpClient) -> dict[str, Any]: version = None # 버전 상세 실패는 치명적이지 않음 — 행은 표시 repo_views.append(views.build_repo_view(repo, version)) + summary = views.summarize(repo_views, syncing) # 전체 기준 + + ql = q.strip().lower() + filtered = [r for r in repo_views if ql in r["name"].lower()] if ql else repo_views + filtered = views.sort_repos(filtered, sort, direction) + + total = len(filtered) + pages = max(1, ceil(total / PAGE_SIZE)) + page = min(max(page, 1), pages) + window = filtered[(page - 1) * PAGE_SIZE : page * PAGE_SIZE] + return { - "repos": repo_views, - "summary": views.summarize(repo_views, syncing), + **base, + "repos": window, + "summary": summary, + "page": page, + "pages": pages, + "total": total, "error": None, } @router.get("/", response_class=HTMLResponse) def dashboard( - request: Request, pulp: PulpClient = Depends(get_pulp_client) + request: Request, + q: str = "", + sort: str = "name", + dir: str = "asc", + page: int = 1, + pulp: PulpClient = Depends(get_pulp_client), ) -> HTMLResponse: - return templates.TemplateResponse(request, "dashboard.html", _load_repos(pulp)) + return templates.TemplateResponse( + request, "dashboard.html", _load_repos(pulp, q, sort, dir, page) + ) @router.get("/repos", response_class=HTMLResponse) def repos_fragment( - request: Request, pulp: PulpClient = Depends(get_pulp_client) + request: Request, + q: str = "", + sort: str = "name", + dir: str = "asc", + page: int = 1, + pulp: PulpClient = Depends(get_pulp_client), ) -> HTMLResponse: return templates.TemplateResponse( - request, "partials/repo_list.html", _load_repos(pulp) + request, "partials/repo_list.html", _load_repos(pulp, q, sort, dir, page) + ) + + +@router.get("/repos/table", response_class=HTMLResponse) +def repos_table( + request: Request, + q: str = "", + sort: str = "name", + dir: str = "asc", + page: int = 1, + pulp: PulpClient = Depends(get_pulp_client), +) -> HTMLResponse: + return templates.TemplateResponse( + request, "partials/table_wrap.html", _load_repos(pulp, q, sort, dir, page) ) diff --git a/app/static/app.css b/app/static/app.css index a6180ef..6094d8e 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -153,10 +153,10 @@ button:disabled, [role="button"]:disabled { opacity: 0.45; } -/* 표 안 액션 버튼: 컴팩트 */ +/* 표 안 액션 버튼: 컴팩트하되 글자는 또렷하게 */ .btn-sm { padding: 7px 14px; - font-size: 13px; + font-size: 14px; margin: 0; width: auto; } @@ -213,7 +213,7 @@ button:disabled, margin: 0; } .table-card thead th { - font-size: 12px; + font-size: 13px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; @@ -224,6 +224,7 @@ button:disabled, } .table-card tbody td { height: 56px; + font-size: 14px; border-bottom: 1px solid var(--border); padding-block: 12px; vertical-align: middle; @@ -266,9 +267,9 @@ th.num { display: inline-flex; align-items: center; gap: 6px; - padding: 4px 10px; + padding: 4px 11px; border-radius: 999px; - font-size: 13px; + font-size: 14px; font-weight: 500; white-space: nowrap; } @@ -301,7 +302,8 @@ th.num { display: flex; align-items: center; gap: 12px; - margin-bottom: 16px; + margin-top: 20px; + margin-bottom: 20px; } .verify-detail small { color: var(--text-muted); @@ -362,6 +364,47 @@ th.num { color: var(--text-muted); } +/* 검색 툴바 / 정렬 헤더 / 페이저 */ +.list-toolbar { + margin-bottom: 14px; +} +.repo-search { + max-width: 280px; + margin: 0; + font-size: 14px; +} +.table-card thead th a.th-sort { + display: inline-flex; + align-items: center; + gap: 4px; + color: inherit; + text-decoration: none; + cursor: pointer; +} +.table-card thead th a.th-sort:hover { + color: var(--text-secondary); +} +.table-card thead th a.th-sort.is-active { + color: var(--accent); +} +.sort-ind { + font-size: 11px; +} +.pager { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; + padding: 12px 16px; + border-top: 1px solid var(--border); + color: var(--text-muted); + font-size: 13px; +} +.empty-note { + padding: 16px; + color: var(--text-muted); +} + @media (max-width: 640px) { .summary { grid-template-columns: repeat(2, 1fr); diff --git a/app/templates/partials/repo_list.html b/app/templates/partials/repo_list.html index d7b0bf6..d0342a0 100644 --- a/app/templates/partials/repo_list.html +++ b/app/templates/partials/repo_list.html @@ -9,43 +9,15 @@
| 저장소 | -검증 | -최신 버전 | -패키지 수 | -마지막 동기화 | -- |
|---|---|---|---|---|---|
| {{ repo.name }} | -{{ repo.gpg.label }} | -{% if repo.latest_version is not none %}v{{ repo.latest_version }}{% else %}—{% endif %} | -{% if repo.package_count is not none %}{{ repo.package_count }}{% else %}—{% endif %} | -{{ repo.last_sync or "—" }} | -
-
-
-
- |
-
등록된 저장소가 없습니다.
- {% endif %} + + {% include "partials/table_wrap.html" %} {% endif %}| + | |||||
|---|---|---|---|---|---|
| {{ repo.name }} | +{{ repo.gpg.label }} | +{% if repo.latest_version is not none %}v{{ repo.latest_version }}{% else %}—{% endif %} | +{% if repo.package_count is not none %}{{ repo.package_count }}{% else %}—{% endif %} | +{{ repo.last_sync or "—" }} | +
+
+
+
+ |
+
'{{ q }}' 검색 결과가 없습니다.
+ {% else %} +등록된 저장소가 없습니다.
+ {% endif %} +