mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Add collect dashboard KPIs and create-job modal.
Align collection tasks with LLDP/config-sync chrome, and refresh active-task overview when jobs change. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0100fb8430
commit
6642f4b8b5
11 changed files with 502 additions and 249 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "说明",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="page-stack">
|
||||
<section className={`panel collect-create-panel${createOpen ? "" : " collect-create-panel--collapsed"}`}>
|
||||
<div className="panel__toolbar collect-create-panel__head">
|
||||
<div>
|
||||
<h2>{t("collect.create.title")}</h2>
|
||||
<p className="panel__hint">
|
||||
{createOpen
|
||||
? t("collect.create.hint")
|
||||
: t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="link-btn" onClick={() => setCreateOpen((open) => !open)}>
|
||||
{createOpen ? t("collect.create.collapse") : t("collect.create.expand")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createOpen ? (
|
||||
<>
|
||||
<div className="form-grid form-grid--single">
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.jobTitle")}
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t("collect.form.jobTitlePh")} />
|
||||
</label>
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.commands")}
|
||||
<textarea
|
||||
className="collect-commands"
|
||||
rows={8}
|
||||
value={commands}
|
||||
onChange={(e) => setCommands(e.target.value)}
|
||||
placeholder={t("collect.form.commandsPh")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
|
||||
|
||||
<div className="collect-selected-block">
|
||||
<div className="collect-selected-block__head">
|
||||
<h3>{t("collect.create.selectedTitle")}</h3>
|
||||
<span className="collect-meta">{t("collect.create.selectedCount", { count: selectedList.length })}</span>
|
||||
{selectedList.length > 0 ? (
|
||||
<button type="button" className="link-btn" onClick={clearSelected}>
|
||||
{t("collect.create.clearSelected")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedList.length === 0 ? (
|
||||
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
|
||||
) : (
|
||||
<div className="collect-selected-list">
|
||||
{selectedList.map((row) => {
|
||||
const key = eligibleKey(row);
|
||||
const src = String(row.source || "managed").toLowerCase();
|
||||
return (
|
||||
<div key={key} className="collect-selected-chip">
|
||||
<span className="collect-selected-chip__main">
|
||||
<strong>{row.name || row.ip_address}</strong>
|
||||
<span className="collect-selected-chip__meta">
|
||||
{src}
|
||||
{row.ip_address ? ` · ${row.ip_address}` : ""}
|
||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="link-btn" onClick={() => removeNe(key)}>
|
||||
{t("collect.create.remove")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="collect-pick-block">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h3>{t("collect.create.pickTitle")}</h3>
|
||||
<p className="panel__hint">{t("collect.create.pickHint")}</p>
|
||||
</div>
|
||||
<div className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={batchPickCount === 0}
|
||||
onClick={() => {
|
||||
const rows = eligibleItems.filter(
|
||||
(x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
|
||||
);
|
||||
addBatchNe(rows);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{t("collect.create.addBatch", { count: batchPickCount })}
|
||||
</button>
|
||||
<button type="button" onClick={() => eligibleQuery.refetch()} disabled={eligibleQuery.isFetching}>
|
||||
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collect-pick-filters">
|
||||
<label className="collect-runs-filter">
|
||||
{t("collect.create.filterKeyword")}
|
||||
<input
|
||||
type="search"
|
||||
value={neKeyword}
|
||||
placeholder={t("collect.create.filterKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setNeKeyword(e.target.value);
|
||||
setNePage(1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{eligibleQuery.isLoading ? <p>{t("common.refreshing")}</p> : null}
|
||||
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
|
||||
<p>{t("collect.create.pickEmpty")}</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allPickSelected}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setPickSelectedIds(selectablePickItems.map((row) => eligibleKey(row)));
|
||||
return;
|
||||
}
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
aria-label="pick all"
|
||||
/>
|
||||
</th>
|
||||
<th>{t("managedNe.col.source")}</th>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("managedNe.col.connect")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eligibleItems.map((row) => {
|
||||
const key = eligibleKey(row);
|
||||
const picked = Boolean(selectedMap[key]);
|
||||
const src = String(row.source || "managed").toLowerCase();
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pickSelectedIds.includes(key)}
|
||||
disabled={picked}
|
||||
onChange={(e) => {
|
||||
if (picked) return;
|
||||
setPickSelectedIds((prev) =>
|
||||
e.target.checked ? [...new Set([...prev, key])] : prev.filter((id) => id !== key),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className="table-tag">{src}</span>
|
||||
</td>
|
||||
<td>{row.name || row.ip_address}</td>
|
||||
<td>{row.vendor}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>
|
||||
<span className="conn-pill conn-pill--up">{row.connect_status}</span>
|
||||
</td>
|
||||
<td className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={picked}
|
||||
onClick={() => {
|
||||
addNe(row);
|
||||
setPickSelectedIds((prev) => prev.filter((id) => id !== key));
|
||||
}}
|
||||
>
|
||||
{picked ? t("collect.create.added") : t("collect.create.add")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{neTotal > 0 ? (
|
||||
<div className="pager">
|
||||
<div className="pager__meta">
|
||||
{t("common.pagerMeta", { total: neTotal, page: nePage, pages: nePages })}
|
||||
</div>
|
||||
<div className="pager__controls">
|
||||
<button
|
||||
className="pager__btn"
|
||||
disabled={nePage <= 1}
|
||||
onClick={() => {
|
||||
setNePage(nePage - 1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button
|
||||
className="pager__btn"
|
||||
disabled={nePage >= nePages}
|
||||
onClick={() => {
|
||||
setNePage(nePage + 1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="panel__actions">
|
||||
<span className="collect-meta">{t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
|
|
@ -467,14 +257,58 @@ export function CollectPage() {
|
|||
{autoPolling ? (
|
||||
<p className="panel__hint panel__hint--live">
|
||||
{t("collect.jobs.autoRefresh")}
|
||||
{jobsQuery.isFetching ? ` · ${t("common.refreshing")}` : ""}
|
||||
{jobsQuery.isFetching || dashQuery.isFetching ? ` · ${t("common.refreshing")}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching}>
|
||||
{jobsQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
<div className="btn-row">
|
||||
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching || dashQuery.isFetching}>
|
||||
{jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
<button type="button" className="btn-primary" onClick={() => setCreateOpen(true)}>
|
||||
{t("collect.create.expand")}
|
||||
</button>
|
||||
{running?.status === "running" || running?.status === "pending" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pauseMutation.mutate(running.id)}
|
||||
disabled={pauseMutation.isPending}
|
||||
>
|
||||
{t("collect.jobs.pause")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list" style={{ marginBottom: 16 }}>
|
||||
<div className="pt-list-kpis">
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("collect.kpi.jobs")}</div>
|
||||
<div className="pt-list-kpi__value">{dash?.job_count ?? "—"}</div>
|
||||
</div>
|
||||
<div className={`pt-list-kpi${running ? " pt-list-kpi--live" : ""}`}>
|
||||
<div className="pt-list-kpi__label">{t("collect.kpi.running")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: running ? 15 : 22 }}>
|
||||
{running
|
||||
? `${running.status} · ${running.success_count}/${running.ne_count}`
|
||||
: t("collect.kpi.idle")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("collect.kpi.active")}</div>
|
||||
<div className="pt-list-kpi__value">{dash?.active_count ?? "—"}</div>
|
||||
</div>
|
||||
<div className="pt-list-kpi">
|
||||
<div className="pt-list-kpi__label">{t("collect.kpi.last")}</div>
|
||||
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
|
||||
{last
|
||||
? `${last.title} · ${last.status} · ok ${last.success_count} / fail ${last.fail_count}`
|
||||
: t("common.empty")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
{!(jobsQuery.data?.items ?? []).length && !jobsQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
|
|
@ -533,6 +367,273 @@ export function CollectPage() {
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{createOpen ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setCreateOpen(false)}>
|
||||
<div
|
||||
className="modal modal--wide ops-detail-modal ops-detail-modal--xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("collect.create.title")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="ops-detail-modal__head">
|
||||
<div className="ops-detail-modal__title">
|
||||
<h3>{t("collect.create.title")}</h3>
|
||||
<p className="muted">{t("collect.create.hint")}</p>
|
||||
</div>
|
||||
<div className="btn-row ops-detail-modal__actions">
|
||||
<button type="button" onClick={() => setCreateOpen(false)}>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-detail-modal__scroll ops-detail-modal__scroll--pad collect-create-modal">
|
||||
<div className="form-grid form-grid--single">
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.jobTitle")}
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t("collect.form.jobTitlePh")}
|
||||
/>
|
||||
</label>
|
||||
<label className="form-grid__full">
|
||||
{t("collect.form.commands")}
|
||||
<textarea
|
||||
className="collect-commands"
|
||||
rows={6}
|
||||
value={commands}
|
||||
onChange={(e) => setCommands(e.target.value)}
|
||||
placeholder={t("collect.form.commandsPh")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
|
||||
|
||||
<div className="collect-selected-block">
|
||||
<div className="collect-selected-block__head">
|
||||
<h3>{t("collect.create.selectedTitle")}</h3>
|
||||
<span className="collect-meta">
|
||||
{t("collect.create.selectedCount", { count: selectedList.length })}
|
||||
</span>
|
||||
{selectedList.length > 0 ? (
|
||||
<button type="button" className="link-btn" onClick={clearSelected}>
|
||||
{t("collect.create.clearSelected")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedList.length === 0 ? (
|
||||
<p className="panel__hint">{t("collect.create.selectedEmpty")}</p>
|
||||
) : (
|
||||
<div className="collect-selected-list">
|
||||
{selectedList.map((row) => {
|
||||
const key = eligibleKey(row);
|
||||
const src = String(row.source || "managed").toLowerCase();
|
||||
return (
|
||||
<div key={key} className="collect-selected-chip">
|
||||
<span className="collect-selected-chip__main">
|
||||
<strong>{row.name || row.ip_address}</strong>
|
||||
<span className="collect-selected-chip__meta">
|
||||
{src}
|
||||
{row.ip_address ? ` · ${row.ip_address}` : ""}
|
||||
{row.vendor ? ` · ${row.vendor}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="link-btn" onClick={() => removeNe(key)}>
|
||||
{t("collect.create.remove")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="collect-pick-block">
|
||||
<div className="panel__toolbar">
|
||||
<div>
|
||||
<h3>{t("collect.create.pickTitle")}</h3>
|
||||
<p className="panel__hint">{t("collect.create.pickHint")}</p>
|
||||
</div>
|
||||
<div className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={batchPickCount === 0}
|
||||
onClick={() => {
|
||||
const rows = eligibleItems.filter(
|
||||
(x) => pickSelectedIds.includes(eligibleKey(x)) && !selectedMap[eligibleKey(x)],
|
||||
);
|
||||
addBatchNe(rows);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{t("collect.create.addBatch", { count: batchPickCount })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => eligibleQuery.refetch()}
|
||||
disabled={eligibleQuery.isFetching}
|
||||
>
|
||||
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ops-detail-modal__toolbar filter-inline">
|
||||
<input
|
||||
type="search"
|
||||
value={neKeyword}
|
||||
placeholder={t("collect.create.filterKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setNeKeyword(e.target.value);
|
||||
setNePage(1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{eligibleQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allPickSelected}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setPickSelectedIds(selectablePickItems.map((row) => eligibleKey(row)));
|
||||
return;
|
||||
}
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
aria-label="pick all"
|
||||
/>
|
||||
</th>
|
||||
<th>{t("managedNe.col.source")}</th>
|
||||
<th>{t("managedNe.col.name")}</th>
|
||||
<th>{t("managedNe.col.vendor")}</th>
|
||||
<th>{t("managedNe.col.ip")}</th>
|
||||
<th>{t("managedNe.col.connect")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{eligibleItems.map((row) => {
|
||||
const key = eligibleKey(row);
|
||||
const picked = Boolean(selectedMap[key]);
|
||||
const src = String(row.source || "managed").toLowerCase();
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pickSelectedIds.includes(key)}
|
||||
disabled={picked}
|
||||
onChange={(e) => {
|
||||
if (picked) return;
|
||||
setPickSelectedIds((prev) =>
|
||||
e.target.checked
|
||||
? [...new Set([...prev, key])]
|
||||
: prev.filter((id) => id !== key),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className="table-tag">{src}</span>
|
||||
</td>
|
||||
<td>{row.name || row.ip_address}</td>
|
||||
<td>{row.vendor}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>
|
||||
<span className="conn-pill conn-pill--up">{row.connect_status}</span>
|
||||
</td>
|
||||
<td className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
disabled={picked}
|
||||
onClick={() => {
|
||||
addNe(row);
|
||||
setPickSelectedIds((prev) => prev.filter((id) => id !== key));
|
||||
}}
|
||||
>
|
||||
{picked ? t("collect.create.added") : t("collect.create.add")}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!eligibleQuery.isLoading && eligibleItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
{t("collect.create.pickEmpty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{neTotal > 0 ? (
|
||||
<div className="ops-detail-modal__foot" style={{ marginTop: 10 }}>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(neTotal),
|
||||
page: String(nePage),
|
||||
pages: String(nePages),
|
||||
})}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={nePage <= 1}
|
||||
onClick={() => {
|
||||
setNePage(nePage - 1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={nePage >= nePages}
|
||||
onClick={() => {
|
||||
setNePage(nePage + 1);
|
||||
setPickSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ops-detail-modal__foot">
|
||||
<span className="muted">
|
||||
{t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}
|
||||
</span>
|
||||
<div className="btn-row">
|
||||
<button type="button" onClick={() => setCreateOpen(false)}>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{expandedJobId ? (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
CollectionJobDetail,
|
||||
CollectionJobItem,
|
||||
CollectionDashboard,
|
||||
CollectionRunList,
|
||||
EligibleNeItem,
|
||||
IntegrationStatus,
|
||||
|
|
@ -558,6 +559,9 @@ export const fetchNeCollections = (params: { page: number; pageSize: number }) =
|
|||
);
|
||||
};
|
||||
|
||||
export const fetchCollectionDashboard = () =>
|
||||
apiGet<CollectionDashboard>("/v1/ne-collections/dashboard");
|
||||
|
||||
export const fetchCollectionJob = (jobId: string) => apiGet<CollectionJobDetail>(`/v1/ne-collections/${jobId}`);
|
||||
|
||||
export const fetchCollectionRuns = (params: {
|
||||
|
|
|
|||
|
|
@ -316,6 +316,26 @@ export type CollectionJobItem = {
|
|||
last_run_at: string | null;
|
||||
};
|
||||
|
||||
export type CollectionJobSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
ne_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
created_at?: string | null;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
last_run_at?: string | null;
|
||||
};
|
||||
|
||||
export type CollectionDashboard = {
|
||||
job_count: number;
|
||||
active_count: number;
|
||||
running_job: CollectionJobSummary | null;
|
||||
last_job: CollectionJobSummary | null;
|
||||
};
|
||||
|
||||
export type CollectionRunItem = {
|
||||
id: string;
|
||||
job_id: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue