feat(collect): draft jobs, merged create UI, and unified start

Create collection jobs without auto-running; add /start for first run and re-run. Merge create form with filterable NE picker and selected list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-28 10:36:47 +08:00
parent fed7f56ad0
commit f1ed4fe753
9 changed files with 343 additions and 147 deletions

View file

@ -15,7 +15,7 @@ export const queryKeys = {
managedNe: (keyword: string, vendor: string, connectStatus: string, page: number, pageSize: number) =>
["managedNe", keyword, vendor, connectStatus, page, pageSize] as const,
collectionEligibleNeAll: ["collectionEligibleNe"] as const,
collectionEligibleNe: (page: number) => ["collectionEligibleNe", page] as const,
collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const,
neCollectionsAll: ["neCollections"] as const,
neCollections: (page: number) => ["neCollections", page] as const,
neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const,

View file

@ -42,6 +42,25 @@ const en = {
langEn: "English",
},
collect: {
create: {
title: "New collection job",
hint: "Configure commands and NEs, then create the job. Click Start in the job list to run collection.",
selectedTitle: "Selected NEs",
selectedEmpty: "Filter below and add NEs one by one.",
selectedCount: "{{count}} selected",
clearSelected: "Clear selection",
remove: "Remove",
pickTitle: "Eligible NEs (connectivity passed)",
pickHint: "Only NEs with connect_status=pass. Run connectivity test in NE Management first.",
pickEmpty: "No matching NEs.",
filterKeyword: "Filter",
filterKeywordPh: "Name / IP / vendor / device type",
add: "Add",
added: "Added",
create: "Create job",
creating: "Creating…",
meta: "{{ne}} NE(s) selected · {{cmd}} command(s)",
},
eligible: {
title: "Eligible NEs (connectivity passed)",
hint: "Only NEs with connect_status=pass. Run connectivity test in NE Management first.",
@ -58,7 +77,8 @@ const en = {
start: "Start collection",
starting: "Submitting…",
},
started: "Collection job started: {{id}}",
created: "Collection job created: {{id}}",
started: "Collection started: {{id}}",
paused: "Job paused",
restarted: "Collection restarted",
retryFailedDone: "Retrying failed devices",
@ -80,6 +100,8 @@ const en = {
expand: "Details",
collapse: "Hide",
pause: "Pause",
start: "Start",
starting: "Starting…",
restart: "Restart",
retryFailed: "Retry failed",
downloadResults: "Download results",

View file

@ -42,6 +42,25 @@ const zh = {
langEn: "English",
},
collect: {
create: {
title: "新建采集任务",
hint: "配置命令与网元后创建任务;创建后需在任务列表中点击「开始」执行采集。",
selectedTitle: "已选择网元",
selectedEmpty: "请从下方筛选并逐个添加网元。",
selectedCount: "共 {{count}} 台",
clearSelected: "清空已选",
remove: "移除",
pickTitle: "可选网元(连通性已通过)",
pickHint: "仅展示 connect_status=pass 的网元。请先在「网元管理」完成连通性测试。",
pickEmpty: "没有匹配的网元。",
filterKeyword: "筛选",
filterKeywordPh: "名称 / IP / 厂商 / 设备类型",
add: "添加",
added: "已添加",
create: "创建任务",
creating: "创建中…",
meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令",
},
eligible: {
title: "可选网元(连通性已通过)",
hint: "仅展示 connect_status=pass 的网元。请先在「网元管理」完成连通性测试。",
@ -58,7 +77,8 @@ const zh = {
start: "开始采集",
starting: "提交中…",
},
started: "采集任务已启动:{{id}}",
created: "采集任务已创建:{{id}}",
started: "采集已开始:{{id}}",
paused: "任务已暂停",
restarted: "已重新开始采集",
retryFailedDone: "已开始重采失败网元",
@ -80,6 +100,8 @@ const zh = {
expand: "详情",
collapse: "收起",
pause: "暂停",
start: "开始",
starting: "启动中…",
restart: "重新开始",
retryFailed: "重采失败网元",
downloadResults: "下载结果",

View file

@ -909,6 +909,64 @@ pre {
margin-right: auto;
}
.collect-create-panel h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #334155;
}
.collect-selected-block,
.collect-pick-block {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
}
.collect-selected-block__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
margin-bottom: 10px;
}
.collect-selected-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 220px;
overflow: auto;
padding: 4px 0;
}
.collect-selected-chip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.collect-selected-chip__main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.collect-selected-chip__meta {
font-size: 12px;
color: #64748b;
}
.collect-pick-filters {
margin-bottom: 12px;
}
.collect-cmd-preview {
font-size: 12px;
background: #f8fafc;

View file

@ -8,7 +8,7 @@ import {
fetchEligibleNe,
fetchNeCollections,
pauseCollectionJob,
restartCollectionJob,
startCollectionJob,
retryFailedCollectionJob,
collectionJobDownloadUrl,
collectionRunDownloadUrl,
@ -20,6 +20,9 @@ import type { CollectionJobDetail, CollectionJobItem, EligibleNeItem } from "../
import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
const POLL_MS = 2000;
const ELIGIBLE_PAGE_SIZE = 20;
export function CollectPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
@ -27,17 +30,19 @@ export function CollectPage() {
const [commands, setCommands] = useState("");
const [title, setTitle] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [selectedMap, setSelectedMap] = useState<Record<string, EligibleNeItem>>({});
const [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1);
const [jobPage, setJobPage] = useState(1);
const [expandedJobId, setExpandedJobId] = useState("");
const POLL_MS = 2000;
const ELIGIBLE_PAGE_SIZE = 20;
const selectedIds = useMemo(() => Object.keys(selectedMap), [selectedMap]);
const selectedList = useMemo(() => Object.values(selectedMap), [selectedMap]);
const eligibleQuery = useQuery({
queryKey: queryKeys.collectionEligibleNe(nePage),
queryFn: () => fetchEligibleNe({ page: nePage, pageSize: ELIGIBLE_PAGE_SIZE }),
queryKey: queryKeys.collectionEligibleNe(nePage, neKeyword),
queryFn: () =>
fetchEligibleNe({ page: nePage, pageSize: ELIGIBLE_PAGE_SIZE, keyword: neKeyword }),
staleTime: 5000,
});
@ -95,10 +100,10 @@ export function CollectPage() {
onError: (err) => showError(String(err)),
});
const restartMutation = useMutation({
mutationFn: restartCollectionJob,
const startJobMutation = useMutation({
mutationFn: startCollectionJob,
onSuccess: async (job) => {
showOk(t("collect.restarted"));
showOk(t("collect.started", { id: job.id }));
setExpandedJobId(job.id);
await invalidateJobs(job.id);
},
@ -125,15 +130,15 @@ export function CollectPage() {
onError: (err) => showError(String(err)),
});
const startMutation = useMutation({
const createMutation = useMutation({
mutationFn: () =>
createNeCollection({
title: title.trim(),
commands,
ne_ids: selected,
ne_ids: selectedIds,
}),
onSuccess: async (job) => {
showOk(t("collect.started", { id: job.id }));
showOk(t("collect.created", { id: job.id }));
setExpandedJobId(job.id);
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(job.id) });
@ -141,18 +146,21 @@ export function CollectPage() {
onError: (err) => showError(String(err)),
});
const items = eligibleQuery.data?.items ?? [];
const allSelected = items.length > 0 && items.every((x) => selected.includes(x.id));
const toggleAll = () => {
if (allSelected) {
const ids = new Set(items.map((x) => x.id));
setSelected((prev) => prev.filter((id) => !ids.has(id)));
} else {
setSelected((prev) => [...new Set([...prev, ...items.map((x) => x.id)])]);
}
const addNe = (row: EligibleNeItem) => {
setSelectedMap((prev) => (prev[row.id] ? prev : { ...prev, [row.id]: row }));
};
const removeNe = (id: string) => {
setSelectedMap((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
};
const clearSelected = () => setSelectedMap({});
const eligibleItems = eligibleQuery.data?.items ?? [];
const neTotal = eligibleQuery.data?.total ?? 0;
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
@ -168,77 +176,20 @@ export function CollectPage() {
[commands],
);
const actionPending =
pauseMutation.isPending ||
startJobMutation.isPending ||
retryFailedMutation.isPending ||
deleteMutation.isPending;
return (
<div className="page-stack">
<section className="panel">
<div className="panel__toolbar">
<div>
<h2>{t("collect.eligible.title")}</h2>
<p className="panel__hint">{t("collect.eligible.hint")}</p>
</div>
<button type="button" onClick={() => eligibleQuery.refetch()} disabled={eligibleQuery.isFetching}>
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
<section className="panel collect-create-panel">
<div>
<h2>{t("collect.create.title")}</h2>
<p className="panel__hint">{t("collect.create.hint")}</p>
</div>
{eligibleQuery.isLoading ? <p>{t("common.refreshing")}</p> : null}
{!eligibleQuery.isLoading && items.length === 0 ? (
<p>{t("collect.eligible.empty")}</p>
) : (
<table>
<thead>
<tr>
<th>
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="select all" />
</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>
</tr>
</thead>
<tbody>
{items.map((row: EligibleNeItem) => (
<tr key={row.id}>
<td>
<input
type="checkbox"
checked={selected.includes(row.id)}
onChange={() =>
setSelected((prev) =>
prev.includes(row.id) ? prev.filter((x) => x !== row.id) : [...prev, row.id],
)
}
/>
</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>
</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)}>
{t("common.prevPage")}
</button>
<button className="pager__btn" disabled={nePage >= nePages} onClick={() => setNePage(nePage + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
) : null}
</section>
<section className="panel">
<h2>{t("collect.form.title")}</h2>
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
<div className="form-grid form-grid--single">
<label className="form-grid__full">
{t("collect.form.jobTitle")}
@ -248,23 +199,137 @@ export function CollectPage() {
{t("collect.form.commands")}
<textarea
className="collect-commands"
rows={10}
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) => (
<div key={row.id} className="collect-selected-chip">
<span className="collect-selected-chip__main">
<strong>{row.name || row.ip_address}</strong>
<span className="collect-selected-chip__meta">
{row.ip_address}
{row.vendor ? ` · ${row.vendor}` : ""}
</span>
</span>
<button type="button" className="link-btn" onClick={() => removeNe(row.id)}>
{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>
<button type="button" onClick={() => eligibleQuery.refetch()} disabled={eligibleQuery.isFetching}>
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
</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);
}}
/>
</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>{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 picked = Boolean(selectedMap[row.id]);
return (
<tr key={row.id}>
<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)}
>
{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)}>
{t("common.prevPage")}
</button>
<button className="pager__btn" disabled={nePage >= nePages} onClick={() => setNePage(nePage + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
) : null}
</div>
<div className="panel__actions">
<span className="collect-meta">
{t("collect.form.meta", { ne: selected.length, cmd: commandLines })}
</span>
<span className="collect-meta">{t("collect.create.meta", { ne: selectedIds.length, cmd: commandLines })}</span>
<button
type="button"
disabled={selected.length === 0 || commandLines === 0 || startMutation.isPending}
onClick={() => startMutation.mutate()}
disabled={selectedIds.length === 0 || commandLines === 0 || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{startMutation.isPending ? t("collect.form.starting") : t("collect.form.start")}
{createMutation.isPending ? t("collect.create.creating") : t("collect.create.create")}
</button>
</div>
</section>
@ -304,17 +369,13 @@ export function CollectPage() {
detail={expandedJobId === job.id ? detailQuery.data : undefined}
onToggle={() => setExpandedJobId(expandedJobId === job.id ? "" : job.id)}
onPause={() => pauseMutation.mutate(job.id)}
onRestart={() => restartMutation.mutate(job.id)}
onStart={() => startJobMutation.mutate(job.id)}
onRetryFailed={() => retryFailedMutation.mutate(job.id)}
onDelete={() => {
if (window.confirm(t("collect.confirmDelete"))) deleteMutation.mutate(job.id);
}}
actionPending={
pauseMutation.isPending ||
restartMutation.isPending ||
retryFailedMutation.isPending ||
deleteMutation.isPending
}
actionPending={actionPending}
startPending={startJobMutation.isPending}
/>
))}
</tbody>
@ -341,24 +402,26 @@ function JobRow({
detail,
onToggle,
onPause,
onRestart,
onStart,
onRetryFailed,
onDelete,
actionPending,
startPending,
}: {
job: CollectionJobItem;
expanded: boolean;
detail?: CollectionJobDetail;
onToggle: () => void;
onPause: () => void;
onRestart: () => void;
onStart: () => void;
onRetryFailed: () => void;
onDelete: () => void;
actionPending: boolean;
startPending: boolean;
}) {
const { t } = useI18n();
const canPause = job.status === "running";
const canRestart = job.status !== "running";
const canStart = job.status !== "running";
const canRetryFailed = job.status !== "running" && job.fail_count > 0;
const canDelete = job.status !== "running";
const hasResults = (job.output_count ?? 0) > 0;
@ -385,9 +448,9 @@ function JobRow({
{t("collect.jobs.pause")}
</button>
) : null}
{canRestart ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onRestart}>
{t("collect.jobs.restart")}
{canStart ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onStart}>
{startPending ? t("collect.jobs.starting") : t("collect.jobs.start")}
</button>
) : null}
{canRetryFailed ? (

View file

@ -142,10 +142,11 @@ export const importManagedNe = async (file: File): Promise<ManagedNeImportResult
return data as ManagedNeImportResult;
};
export const fetchEligibleNe = (params: { page: number; pageSize: number }) => {
export const fetchEligibleNe = (params: { page: number; pageSize: number; keyword?: string }) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, params.page)));
p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize))));
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
return apiGet<{ total: number; page: number; page_size: number; items: EligibleNeItem[] }>(
`/v1/ne-collections/eligible-ne?${p.toString()}`,
);
@ -183,6 +184,9 @@ export const fetchCollectionRuns = (params: {
export const pauseCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/pause`, {});
export const startCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/start`, {});
export const restartCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/restart`, {});