diff --git a/netx_api/collection_router.py b/netx_api/collection_router.py index a7206fc..6470650 100644 --- a/netx_api/collection_router.py +++ b/netx_api/collection_router.py @@ -8,6 +8,7 @@ from .collection_service import ( build_collection_job_zip, create_collection, delete_collection_job, + get_collection_dashboard, get_collection_job, list_collection_jobs, list_collection_runs, @@ -36,6 +37,11 @@ def api_eligible_ne( return list_eligible_ne(db, page=page, page_size=page_size, keyword=keyword) +@router.get("/dashboard") +def api_collection_dashboard(db: Session = Depends(get_db)): + return get_collection_dashboard(db).model_dump() + + @router.post("") def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)): return create_collection(db, body).model_dump() diff --git a/netx_api/collection_schemas.py b/netx_api/collection_schemas.py index e15e86d..040ab47 100644 --- a/netx_api/collection_schemas.py +++ b/netx_api/collection_schemas.py @@ -41,3 +41,23 @@ class CollectionJobOut(BaseModel): started_at: datetime | None ended_at: datetime | None last_run_at: datetime | None = None + + +class CollectionJobSummary(BaseModel): + id: str + title: str + status: str + ne_count: int + success_count: int + fail_count: int + created_at: datetime | None = None + started_at: datetime | None = None + ended_at: datetime | None = None + last_run_at: datetime | None = None + + +class CollectionDashboardOut(BaseModel): + job_count: int = 0 + active_count: int = 0 + running_job: CollectionJobSummary | None = None + last_job: CollectionJobSummary | None = None diff --git a/netx_api/collection_service.py b/netx_api/collection_service.py index 93b1b80..75fee0b 100644 --- a/netx_api/collection_service.py +++ b/netx_api/collection_service.py @@ -20,7 +20,13 @@ from .collection_job_state import ( sync_job_progress, _sync_job_counts, ) -from .collection_schemas import CollectionJobCreate, CollectionJobOut, CollectionRunOut +from .collection_schemas import ( + CollectionDashboardOut, + CollectionJobCreate, + CollectionJobOut, + CollectionJobSummary, + CollectionRunOut, +) from .ne_collection_paths import clear_run_output_files, collection_data_root _log = logging.getLogger("netx.collection") @@ -94,6 +100,76 @@ def job_to_out(row: NeCollectionJob, *, output_count: int | None = None) -> Coll ) +def job_to_summary(row: NeCollectionJob | None) -> CollectionJobSummary | None: + if row is None: + return None + return CollectionJobSummary( + id=str(row.id), + title=str(row.title or "").strip() or str(row.id)[:8], + status=str(row.status or "pending"), + ne_count=int(row.ne_count or 0), + success_count=int(row.success_count or 0), + fail_count=int(row.fail_count or 0), + created_at=row.created_at, + started_at=row.started_at, + ended_at=row.ended_at, + last_run_at=row.last_run_at, + ) + + +def has_active_collection_job(db: Session) -> NeCollectionJob | None: + return ( + db.query(NeCollectionJob) + .filter(NeCollectionJob.status.in_(("pending", "running", "paused"))) + .order_by(NeCollectionJob.created_at.desc()) + .first() + ) + + +def last_finished_collection_job(db: Session) -> NeCollectionJob | None: + return ( + db.query(NeCollectionJob) + .filter(NeCollectionJob.status.in_(("done", "failed"))) + .order_by(NeCollectionJob.created_at.desc()) + .first() + ) + + +def get_collection_dashboard(db: Session) -> CollectionDashboardOut: + job_count = int(db.query(NeCollectionJob).count() or 0) + active_count = int( + db.query(NeCollectionJob) + .filter(NeCollectionJob.status.in_(("pending", "running", "paused"))) + .count() + or 0 + ) + running = has_active_collection_job(db) + if running is not None: + jid = str(running.id) + if str(running.status or "") not in ("done", "failed", "paused"): + reconcile_stale_collection_job(db, jid) + db.refresh(running) + if str(running.status or "") == "running": + sync_job_progress(db, jid) + finalize_collection_job(db, jid) + db.refresh(running) + if str(running.status or "") not in ("pending", "running", "paused"): + running = has_active_collection_job(db) + active_count = int( + db.query(NeCollectionJob) + .filter(NeCollectionJob.status.in_(("pending", "running", "paused"))) + .count() + or 0 + ) + last = last_finished_collection_job(db) + return CollectionDashboardOut( + job_count=job_count, + active_count=active_count, + running_job=job_to_summary(running), + last_job=job_to_summary(last), + ) + + def run_to_out(row: NeCollectionRun) -> CollectionRunOut: rel = str(row.output_rel_path or "").strip() return CollectionRunOut( diff --git a/netx_api/ops_tasks_service.py b/netx_api/ops_tasks_service.py index 22bb9a0..2fe494d 100644 --- a/netx_api/ops_tasks_service.py +++ b/netx_api/ops_tasks_service.py @@ -185,6 +185,7 @@ def _collection_items(db: Session, actors: dict[str, str]) -> list[dict[str, Any ok = int(row.success_count or 0) fail = int(row.fail_count or 0) total = int(row.ne_count or 0) + done = ok + fail items.append( _item( kind="ne_collect", @@ -195,7 +196,7 @@ def _collection_items(db: Session, actors: dict[str, str]) -> list[dict[str, Any actor=actor, started_at=row.started_at or row.created_at, updated_at=row.last_run_at or row.ended_at or row.started_at or row.created_at, - progress=f"{ok}/{fail}/{total}", + progress=f"{done}/{total}", inflight=int(running), detail=str(row.error_message or "")[:240], href="/network/tasks/collect", diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index ee964fe..4136930 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -28,6 +28,7 @@ export const queryKeys = { collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const, neCollectionsAll: ["neCollections"] as const, neCollections: (page: number) => ["neCollections", page] as const, + neCollectionDashboard: ["neCollectionDashboard"] as const, neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const, neCollectionRunsAll: ["neCollectionRuns"] as const, neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) => diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index d5a4d44..d7d53b2 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -604,8 +604,8 @@ const en = { added: "Added", create: "Create job", creating: "Creating…", - expand: "Expand", - collapse: "Collapse", + expand: "New", + collapse: "Close", meta: "{{ne}} NE(s) selected · {{cmd}} command(s)", }, eligible: { @@ -659,6 +659,13 @@ const en = { autoRefresh: "Auto-refresh every 2s while jobs are running", runsInProgress: "Some devices are still being collected…", }, + kpi: { + jobs: "Jobs", + running: "In progress", + idle: "Idle", + last: "Last job", + active: "Active jobs", + }, runs: { status: "Status", message: "Message", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index a6accf5..82f45dc 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -600,8 +600,8 @@ const zh = { added: "已添加", create: "创建任务", creating: "创建中…", - expand: "展开", - collapse: "收起", + expand: "新建", + collapse: "关闭", meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令", }, eligible: { @@ -655,6 +655,13 @@ const zh = { autoRefresh: "任务进行中,每 2 秒自动刷新", runsInProgress: "部分网元仍在采集中,请稍候…", }, + kpi: { + jobs: "任务总数", + running: "进行中", + idle: "空闲", + last: "最近任务", + active: "活跃任务", + }, runs: { status: "状态", message: "说明", diff --git a/web/src/index.css b/web/src/index.css index 57bcabe..243af4a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -2525,6 +2525,16 @@ pre { max-height: 88px; } +.collect-create-modal .collect-selected-block, +.collect-create-modal .collect-pick-block { + margin-top: 16px; +} + +.collect-create-modal .panel__toolbar { + margin-bottom: 10px; + padding-bottom: 8px; +} + .collect-runs-panel { display: flex; flex-direction: column; diff --git a/web/src/pages/CollectPage.tsx b/web/src/pages/CollectPage.tsx index ecc9f88..ebf599b 100644 --- a/web/src/pages/CollectPage.tsx +++ b/web/src/pages/CollectPage.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createNeCollection, deleteCollectionJob, + fetchCollectionDashboard, fetchCollectionJob, fetchCollectionRuns, fetchEligibleNe, @@ -58,6 +59,7 @@ export function CollectPage() { queryFn: () => fetchEligibleNe({ page: nePage, pageSize: ELIGIBLE_PAGE_SIZE, keyword: neKeyword }), staleTime: 5000, + enabled: createOpen, }); const jobsQuery = useQuery({ @@ -66,7 +68,20 @@ export function CollectPage() { staleTime: 1000, refetchInterval: (q) => { const items = q.state.data?.items ?? []; - return items.some((j) => j.status === "running") ? POLL_MS : false; + return items.some((j) => j.status === "running" || j.status === "pending") ? POLL_MS : false; + }, + }); + + const dashQuery = useQuery({ + queryKey: queryKeys.neCollectionDashboard, + queryFn: fetchCollectionDashboard, + staleTime: 1000, + refetchInterval: (q) => { + const running = q.state.data?.running_job; + return running && + (running.status === "running" || running.status === "pending" || running.status === "paused") + ? POLL_MS + : false; }, }); @@ -79,15 +94,24 @@ export function CollectPage() { }); const jobs = jobsQuery.data?.items ?? []; - const jobActive = jobs.some((j) => j.status === "running"); + const dash = dashQuery.data; + const running = dash?.running_job; + const last = dash?.last_job; + const jobActive = Boolean( + running && (running.status === "running" || running.status === "pending" || running.status === "paused"), + ); const expandedJobRunning = Boolean(expandedJobId) && (jobs.find((j) => j.id === expandedJobId)?.status === "running" || detailQuery.data?.job.status === "running"); const autoPolling = jobActive || expandedJobRunning; + const invalidateOpsTasks = () => queryClient.invalidateQueries({ queryKey: ["opsTasks"] }); + const refreshAll = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }), + queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard }), + invalidateOpsTasks(), expandedJobId ? queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(expandedJobId) }) : Promise.resolve(), @@ -99,6 +123,8 @@ export function CollectPage() { const invalidateJobs = async (jobId?: string) => { await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }); + await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard }); + await invalidateOpsTasks(); if (jobId) { await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(jobId) }); await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionRunsAll }); @@ -163,8 +189,7 @@ export function CollectPage() { showOk(t("collect.created", { id: job.id })); setCreateOpen(false); setExpandedJobId(job.id); - await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }); - await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(job.id) }); + await invalidateJobs(job.id); }, onError: (err) => showError(String(err)), }); @@ -225,241 +250,6 @@ export function CollectPage() { return (
-
-
-
-

{t("collect.create.title")}

-

- {createOpen - ? t("collect.create.hint") - : t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })} -

-
- -
- - {createOpen ? ( - <> -
- -