mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 07:40:50 +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,
|
build_collection_job_zip,
|
||||||
create_collection,
|
create_collection,
|
||||||
delete_collection_job,
|
delete_collection_job,
|
||||||
|
get_collection_dashboard,
|
||||||
get_collection_job,
|
get_collection_job,
|
||||||
list_collection_jobs,
|
list_collection_jobs,
|
||||||
list_collection_runs,
|
list_collection_runs,
|
||||||
|
|
@ -36,6 +37,11 @@ def api_eligible_ne(
|
||||||
return list_eligible_ne(db, page=page, page_size=page_size, keyword=keyword)
|
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("")
|
@router.post("")
|
||||||
def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)):
|
def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)):
|
||||||
return create_collection(db, body).model_dump()
|
return create_collection(db, body).model_dump()
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,23 @@ class CollectionJobOut(BaseModel):
|
||||||
started_at: datetime | None
|
started_at: datetime | None
|
||||||
ended_at: datetime | None
|
ended_at: datetime | None
|
||||||
last_run_at: datetime | None = 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_progress,
|
||||||
_sync_job_counts,
|
_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
|
from .ne_collection_paths import clear_run_output_files, collection_data_root
|
||||||
|
|
||||||
_log = logging.getLogger("netx.collection")
|
_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:
|
def run_to_out(row: NeCollectionRun) -> CollectionRunOut:
|
||||||
rel = str(row.output_rel_path or "").strip()
|
rel = str(row.output_rel_path or "").strip()
|
||||||
return CollectionRunOut(
|
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)
|
ok = int(row.success_count or 0)
|
||||||
fail = int(row.fail_count or 0)
|
fail = int(row.fail_count or 0)
|
||||||
total = int(row.ne_count or 0)
|
total = int(row.ne_count or 0)
|
||||||
|
done = ok + fail
|
||||||
items.append(
|
items.append(
|
||||||
_item(
|
_item(
|
||||||
kind="ne_collect",
|
kind="ne_collect",
|
||||||
|
|
@ -195,7 +196,7 @@ def _collection_items(db: Session, actors: dict[str, str]) -> list[dict[str, Any
|
||||||
actor=actor,
|
actor=actor,
|
||||||
started_at=row.started_at or row.created_at,
|
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,
|
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),
|
inflight=int(running),
|
||||||
detail=str(row.error_message or "")[:240],
|
detail=str(row.error_message or "")[:240],
|
||||||
href="/network/tasks/collect",
|
href="/network/tasks/collect",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ export const queryKeys = {
|
||||||
collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const,
|
collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const,
|
||||||
neCollectionsAll: ["neCollections"] as const,
|
neCollectionsAll: ["neCollections"] as const,
|
||||||
neCollections: (page: number) => ["neCollections", page] as const,
|
neCollections: (page: number) => ["neCollections", page] as const,
|
||||||
|
neCollectionDashboard: ["neCollectionDashboard"] as const,
|
||||||
neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const,
|
neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const,
|
||||||
neCollectionRunsAll: ["neCollectionRuns"] as const,
|
neCollectionRunsAll: ["neCollectionRuns"] as const,
|
||||||
neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) =>
|
neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) =>
|
||||||
|
|
|
||||||
|
|
@ -604,8 +604,8 @@ const en = {
|
||||||
added: "Added",
|
added: "Added",
|
||||||
create: "Create job",
|
create: "Create job",
|
||||||
creating: "Creating…",
|
creating: "Creating…",
|
||||||
expand: "Expand",
|
expand: "New",
|
||||||
collapse: "Collapse",
|
collapse: "Close",
|
||||||
meta: "{{ne}} NE(s) selected · {{cmd}} command(s)",
|
meta: "{{ne}} NE(s) selected · {{cmd}} command(s)",
|
||||||
},
|
},
|
||||||
eligible: {
|
eligible: {
|
||||||
|
|
@ -659,6 +659,13 @@ const en = {
|
||||||
autoRefresh: "Auto-refresh every 2s while jobs are running",
|
autoRefresh: "Auto-refresh every 2s while jobs are running",
|
||||||
runsInProgress: "Some devices are still being collected…",
|
runsInProgress: "Some devices are still being collected…",
|
||||||
},
|
},
|
||||||
|
kpi: {
|
||||||
|
jobs: "Jobs",
|
||||||
|
running: "In progress",
|
||||||
|
idle: "Idle",
|
||||||
|
last: "Last job",
|
||||||
|
active: "Active jobs",
|
||||||
|
},
|
||||||
runs: {
|
runs: {
|
||||||
status: "Status",
|
status: "Status",
|
||||||
message: "Message",
|
message: "Message",
|
||||||
|
|
|
||||||
|
|
@ -600,8 +600,8 @@ const zh = {
|
||||||
added: "已添加",
|
added: "已添加",
|
||||||
create: "创建任务",
|
create: "创建任务",
|
||||||
creating: "创建中…",
|
creating: "创建中…",
|
||||||
expand: "展开",
|
expand: "新建",
|
||||||
collapse: "收起",
|
collapse: "关闭",
|
||||||
meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令",
|
meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令",
|
||||||
},
|
},
|
||||||
eligible: {
|
eligible: {
|
||||||
|
|
@ -655,6 +655,13 @@ const zh = {
|
||||||
autoRefresh: "任务进行中,每 2 秒自动刷新",
|
autoRefresh: "任务进行中,每 2 秒自动刷新",
|
||||||
runsInProgress: "部分网元仍在采集中,请稍候…",
|
runsInProgress: "部分网元仍在采集中,请稍候…",
|
||||||
},
|
},
|
||||||
|
kpi: {
|
||||||
|
jobs: "任务总数",
|
||||||
|
running: "进行中",
|
||||||
|
idle: "空闲",
|
||||||
|
last: "最近任务",
|
||||||
|
active: "活跃任务",
|
||||||
|
},
|
||||||
runs: {
|
runs: {
|
||||||
status: "状态",
|
status: "状态",
|
||||||
message: "说明",
|
message: "说明",
|
||||||
|
|
|
||||||
|
|
@ -2525,6 +2525,16 @@ pre {
|
||||||
max-height: 88px;
|
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 {
|
.collect-runs-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createNeCollection,
|
createNeCollection,
|
||||||
deleteCollectionJob,
|
deleteCollectionJob,
|
||||||
|
fetchCollectionDashboard,
|
||||||
fetchCollectionJob,
|
fetchCollectionJob,
|
||||||
fetchCollectionRuns,
|
fetchCollectionRuns,
|
||||||
fetchEligibleNe,
|
fetchEligibleNe,
|
||||||
|
|
@ -58,6 +59,7 @@ export function CollectPage() {
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
fetchEligibleNe({ page: nePage, pageSize: ELIGIBLE_PAGE_SIZE, keyword: neKeyword }),
|
fetchEligibleNe({ page: nePage, pageSize: ELIGIBLE_PAGE_SIZE, keyword: neKeyword }),
|
||||||
staleTime: 5000,
|
staleTime: 5000,
|
||||||
|
enabled: createOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobsQuery = useQuery({
|
const jobsQuery = useQuery({
|
||||||
|
|
@ -66,7 +68,20 @@ export function CollectPage() {
|
||||||
staleTime: 1000,
|
staleTime: 1000,
|
||||||
refetchInterval: (q) => {
|
refetchInterval: (q) => {
|
||||||
const items = q.state.data?.items ?? [];
|
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 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 =
|
const expandedJobRunning =
|
||||||
Boolean(expandedJobId) &&
|
Boolean(expandedJobId) &&
|
||||||
(jobs.find((j) => j.id === expandedJobId)?.status === "running" || detailQuery.data?.job.status === "running");
|
(jobs.find((j) => j.id === expandedJobId)?.status === "running" || detailQuery.data?.job.status === "running");
|
||||||
const autoPolling = jobActive || expandedJobRunning;
|
const autoPolling = jobActive || expandedJobRunning;
|
||||||
|
|
||||||
|
const invalidateOpsTasks = () => queryClient.invalidateQueries({ queryKey: ["opsTasks"] });
|
||||||
|
|
||||||
const refreshAll = async () => {
|
const refreshAll = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard }),
|
||||||
|
invalidateOpsTasks(),
|
||||||
expandedJobId
|
expandedJobId
|
||||||
? queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(expandedJobId) })
|
? queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(expandedJobId) })
|
||||||
: Promise.resolve(),
|
: Promise.resolve(),
|
||||||
|
|
@ -99,6 +123,8 @@ export function CollectPage() {
|
||||||
|
|
||||||
const invalidateJobs = async (jobId?: string) => {
|
const invalidateJobs = async (jobId?: string) => {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard });
|
||||||
|
await invalidateOpsTasks();
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(jobId) });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(jobId) });
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionRunsAll });
|
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionRunsAll });
|
||||||
|
|
@ -163,8 +189,7 @@ export function CollectPage() {
|
||||||
showOk(t("collect.created", { id: job.id }));
|
showOk(t("collect.created", { id: job.id }));
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setExpandedJobId(job.id);
|
setExpandedJobId(job.id);
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
|
await invalidateJobs(job.id);
|
||||||
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(job.id) });
|
|
||||||
},
|
},
|
||||||
onError: (err) => showError(String(err)),
|
onError: (err) => showError(String(err)),
|
||||||
});
|
});
|
||||||
|
|
@ -225,241 +250,6 @@ export function CollectPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-stack">
|
<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">
|
<section className="panel">
|
||||||
<div className="panel__toolbar">
|
<div className="panel__toolbar">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -467,14 +257,58 @@ export function CollectPage() {
|
||||||
{autoPolling ? (
|
{autoPolling ? (
|
||||||
<p className="panel__hint panel__hint--live">
|
<p className="panel__hint panel__hint--live">
|
||||||
{t("collect.jobs.autoRefresh")}
|
{t("collect.jobs.autoRefresh")}
|
||||||
{jobsQuery.isFetching ? ` · ${t("common.refreshing")}` : ""}
|
{jobsQuery.isFetching || dashQuery.isFetching ? ` · ${t("common.refreshing")}` : ""}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching}>
|
<div className="btn-row">
|
||||||
{jobsQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching || dashQuery.isFetching}>
|
||||||
</button>
|
{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>
|
||||||
|
|
||||||
|
<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">
|
<div className="pt-list">
|
||||||
{!(jobsQuery.data?.items ?? []).length && !jobsQuery.isLoading ? (
|
{!(jobsQuery.data?.items ?? []).length && !jobsQuery.isLoading ? (
|
||||||
<div className="pt-list-empty">
|
<div className="pt-list-empty">
|
||||||
|
|
@ -533,6 +367,273 @@ export function CollectPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ? (
|
{expandedJobId ? (
|
||||||
<div
|
<div
|
||||||
className="modal-backdrop"
|
className="modal-backdrop"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type {
|
import type {
|
||||||
CollectionJobDetail,
|
CollectionJobDetail,
|
||||||
CollectionJobItem,
|
CollectionJobItem,
|
||||||
|
CollectionDashboard,
|
||||||
CollectionRunList,
|
CollectionRunList,
|
||||||
EligibleNeItem,
|
EligibleNeItem,
|
||||||
IntegrationStatus,
|
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 fetchCollectionJob = (jobId: string) => apiGet<CollectionJobDetail>(`/v1/ne-collections/${jobId}`);
|
||||||
|
|
||||||
export const fetchCollectionRuns = (params: {
|
export const fetchCollectionRuns = (params: {
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,26 @@ export type CollectionJobItem = {
|
||||||
last_run_at: string | null;
|
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 = {
|
export type CollectionRunItem = {
|
||||||
id: string;
|
id: string;
|
||||||
job_id: string;
|
job_id: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue