Align biz_state pages with network management UI patterns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-17 16:48:09 +08:00
parent 06331ca763
commit 1352535a9b
4 changed files with 818 additions and 563 deletions

View file

@ -166,6 +166,86 @@ const en = {
body: "This capability is not available yet.", body: "This capability is not available yet.",
}, },
}, },
bizState: {
title: "Business state",
create: "Create task",
createHint: "Pick a managed NE (LLDP enabled by default)",
created: "Task created",
createFailed: "Create failed",
empty: "No business-state tasks yet.",
pickNe: "Select managed NE…",
start: "Start schedule",
pause: "Pause",
collectNow: "Collect now",
delete: "Delete",
confirmDelete: "Delete this task and all batches?",
deleted: "Deleted",
started: "Schedule started",
paused: "Paused",
collecting: "Collecting",
profiles: "Monitor items",
enable: "On",
command: "Command template",
params: "Bindings",
discoverVrf: "Discover VRFs",
saveBindings: "Save bindings",
cancel: "Cancel",
bindTitle: "Select VRF bindings",
unbound: "Not bound",
batches: "Batches",
viewBatch: "View",
export: "Export",
noBatches: "No batches yet",
batchDetail: "Batch detail",
colNe: "NE",
colStatus: "Status",
colLast: "Last collect",
colTime: "Time",
colRows: "Rows",
colActions: "Actions",
pickTask: "Select a task on the left",
noNeedBind: "This item needs no parameter bindings",
discoverFailed: "Discover failed",
bindingsSaved: "Bindings saved",
},
bizCompare: {
title: "Business compare",
mapping: "Port mapping",
newMapping: "New mapping…",
mapName: "Mapping name",
saveMapping: "Save mapping",
validateMapping: "Validate",
mapHint: "before_if,after_if one pair per line",
mappingSaved: "Mapping saved",
createJob: "Create compare job",
jobName: "Name",
template: "Template",
mode: "Mode",
modeManual: "Manual batches",
modeAuto: "Auto (pin before; latest after batch)",
beforeTask: "Before task",
beforeBatch: "Before batch",
afterTask: "After task",
afterBatch: "After batch",
pick: "Select…",
sameAsBefore: "Same as before / select…",
autoHint: "auto: new after-task batches trigger compare",
createCompare: "Create job",
runNow: "Run now",
jobList: "Compare jobs",
runs: "Run history",
result: "Result",
pickRun: "Pick a run or click Run now",
emptyJobs: "No compare jobs yet",
created: "Compare job created",
ran: "Compare finished",
colName: "Name",
colMode: "Mode",
colStatus: "Status",
colKind: "Kind",
colKey: "Key",
colChange: "Changes",
},
portTraffic: { portTraffic: {
title: "Port traffic", title: "Port traffic",
create: "Monitor device", create: "Monitor device",

View file

@ -166,6 +166,86 @@ const zh = {
body: "该能力尚未开放。", body: "该能力尚未开放。",
}, },
}, },
bizState: {
title: "业务状态监控",
create: "创建任务",
createHint: "选择托管网元后创建(默认启用 LLDP)",
created: "已创建任务",
createFailed: "创建失败",
empty: "暂无业务监控任务。",
pickNe: "选择托管网元…",
start: "启动周期",
pause: "暂停",
collectNow: "立即采集",
delete: "删除",
confirmDelete: "删除该业务监控任务及所有批次?",
deleted: "已删除",
started: "已启动周期采集",
paused: "已暂停",
collecting: "采集中",
profiles: "监控项",
enable: "启用",
command: "命令模板",
params: "参数关联",
discoverVrf: "发现 VRF",
saveBindings: "保存关联",
cancel: "取消",
bindTitle: "选择 VRF 绑定",
unbound: "未关联",
batches: "采集批次",
viewBatch: "查看",
export: "导出",
noBatches: "尚无批次",
batchDetail: "批次详情",
colNe: "网元",
colStatus: "状态",
colLast: "最近采集",
colTime: "时间",
colRows: "行数",
colActions: "操作",
pickTask: "选择左侧任务查看详情",
noNeedBind: "该监控项无需参数关联",
discoverFailed: "发现失败",
bindingsSaved: "参数关联已保存",
},
bizCompare: {
title: "业务状态比对",
mapping: "端口映射",
newMapping: "新建映射…",
mapName: "映射名称",
saveMapping: "保存映射",
validateMapping: "校验映射",
mapHint: "before_if,after_if 每行一对",
mappingSaved: "映射已保存",
createJob: "建立比对任务",
jobName: "名称",
template: "模板",
mode: "模式",
modeManual: "手工选批次",
modeAuto: "自动(钉死操作前,操作后取最新)",
beforeTask: "操作前任务",
beforeBatch: "操作前批次",
afterTask: "操作后任务",
afterBatch: "操作后批次",
pick: "选择…",
sameAsBefore: "同操作前 / 选择…",
autoHint: "auto:操作后任务新批次到达会自动跑比对",
createCompare: "创建比对任务",
runNow: "立即比对",
jobList: "比对任务列表",
runs: "历史 Run",
result: "比对结果",
pickRun: "选择 Run 或点「立即比对」",
emptyJobs: "暂无比对任务",
created: "比对任务已创建",
ran: "比对完成",
colName: "名称",
colMode: "模式",
colStatus: "状态",
colKind: "类型",
colKey: "Key",
colChange: "变更",
},
portTraffic: { portTraffic: {
title: "端口流量监控", title: "端口流量监控",
create: "添加设备监控", create: "添加设备监控",

View file

@ -1,4 +1,8 @@
import { Button, Input } from "@heroui/react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import { import {
bizCompareCreateJob, bizCompareCreateJob,
bizCompareCreateMapping, bizCompareCreateMapping,
@ -13,11 +17,14 @@ import {
bizCompareValidateMapping, bizCompareValidateMapping,
bizStateListBatches, bizStateListBatches,
bizStateListTasks, bizStateListTasks,
formatErr,
} from "../../services/api"; } from "../../services/api";
import { formatSystemTime } from "../../utils/time";
import { jobChipColor, NmStatusChip } from "./nmChips";
type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string }; type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string };
type BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null }; type BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null };
type Template = { id: string; name: string; metric_id: string; key_fields: string[]; iface_fields: string[] }; type Template = { id: string; name: string; metric_id: string };
type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] }; type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] };
type Job = { type Job = {
id: string; id: string;
@ -32,7 +39,14 @@ type Job = {
status: string; status: string;
}; };
function fmtTime(v?: string | null) {
if (!v) return "—";
return formatSystemTime(v) || v;
}
export function BizComparePage() { export function BizComparePage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [tasks, setTasks] = useState<TaskOpt[]>([]); const [tasks, setTasks] = useState<TaskOpt[]>([]);
const [templates, setTemplates] = useState<Template[]>([]); const [templates, setTemplates] = useState<Template[]>([]);
const [mappings, setMappings] = useState<Mapping[]>([]); const [mappings, setMappings] = useState<Mapping[]>([]);
@ -40,10 +54,8 @@ export function BizComparePage() {
const [selectedJobId, setSelectedJobId] = useState(""); const [selectedJobId, setSelectedJobId] = useState("");
const [runs, setRuns] = useState<any[]>([]); const [runs, setRuns] = useState<any[]>([]);
const [runDetail, setRunDetail] = useState<any>(null); const [runDetail, setRunDetail] = useState<any>(null);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// create job form
const [name, setName] = useState("割接比对"); const [name, setName] = useState("割接比对");
const [templateId, setTemplateId] = useState(""); const [templateId, setTemplateId] = useState("");
const [mappingId, setMappingId] = useState(""); const [mappingId, setMappingId] = useState("");
@ -55,19 +67,18 @@ export function BizComparePage() {
const [afterBatchId, setAfterBatchId] = useState(""); const [afterBatchId, setAfterBatchId] = useState("");
const [mode, setMode] = useState<"manual" | "auto">("manual"); const [mode, setMode] = useState<"manual" | "auto">("manual");
// mapping editor
const [mapName, setMapName] = useState("端口映射"); const [mapName, setMapName] = useState("端口映射");
const [mapText, setMapText] = useState("old-if,new-if\n"); const [mapText, setMapText] = useState("before_if,after_if\n");
const [validateOut, setValidateOut] = useState<any>(null); const [validateOut, setValidateOut] = useState<any>(null);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const [t, tpl, maps, j] = await Promise.all([ const [taskRes, tpl, maps, j] = await Promise.all([
bizStateListTasks(), bizStateListTasks(),
bizCompareListTemplates(), bizCompareListTemplates(),
bizCompareListMappings(), bizCompareListMappings(),
bizCompareListJobs(), bizCompareListJobs(),
]); ]);
setTasks((t.items || []) as TaskOpt[]); setTasks((taskRes.items || []) as TaskOpt[]);
setTemplates((tpl.items || []) as Template[]); setTemplates((tpl.items || []) as Template[]);
setMappings((maps.items || []) as Mapping[]); setMappings((maps.items || []) as Mapping[]);
setJobs((j.items || []) as Job[]); setJobs((j.items || []) as Job[]);
@ -79,10 +90,11 @@ export function BizComparePage() {
void (async () => { void (async () => {
try { try {
await refresh(); await refresh();
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} }
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount once
}, [refresh]); }, [refresh]);
useEffect(() => { useEffect(() => {
@ -111,7 +123,9 @@ export function BizComparePage() {
const rows: { before_if: string; after_if: string }[] = []; const rows: { before_if: string; after_if: string }[] = [];
for (const line of mapText.split(/\r?\n/)) { for (const line of mapText.split(/\r?\n/)) {
const s = line.trim(); const s = line.trim();
if (!s || s.startsWith("#") || s.toLowerCase().startsWith("old")) continue; if (!s || s.startsWith("#") || s.toLowerCase().startsWith("old") || s.toLowerCase().startsWith("before")) {
continue;
}
const parts = s.split(/[,|\t]+/).map((x) => x.trim()); const parts = s.split(/[,|\t]+/).map((x) => x.trim());
if (parts.length >= 2 && parts[0] && parts[1]) { if (parts.length >= 2 && parts[0] && parts[1]) {
rows.push({ before_if: parts[0], after_if: parts[1] }); rows.push({ before_if: parts[0], after_if: parts[1] });
@ -120,16 +134,20 @@ export function BizComparePage() {
return rows; return rows;
}; };
const saveMapping = async () => { const updateExistingMapping = async () => {
setBusy(true); setBusy(true);
setErr("");
try { try {
const rows = parseMapRows(); const rows = parseMapRows();
const m = await bizCompareCreateMapping({ name: mapName, rows }); if (mappingId) {
setMappingId(String(m.id)); await bizCompareUpdateMapping(mappingId, { name: mapName, rows });
} else {
const m = await bizCompareCreateMapping({ name: mapName, rows });
setMappingId(String(m.id));
}
showOk(t("bizCompare.mappingSaved"));
await refresh(); await refresh();
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -137,11 +155,10 @@ export function BizComparePage() {
const doValidate = async () => { const doValidate = async () => {
if (!mappingId || !beforeBatchId || !afterBatchId) { if (!mappingId || !beforeBatchId || !afterBatchId) {
setErr("请先选择映射与前后批次"); showError(`${t("bizCompare.validateMapping")}: mapping / batches`);
return; return;
} }
setBusy(true); setBusy(true);
setErr("");
try { try {
const v = await bizCompareValidateMapping({ const v = await bizCompareValidateMapping({
mapping_id: mappingId, mapping_id: mappingId,
@ -150,8 +167,8 @@ export function BizComparePage() {
template_id: templateId, template_id: templateId,
}); });
setValidateOut(v); setValidateOut(v);
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -159,7 +176,6 @@ export function BizComparePage() {
const createJob = async () => { const createJob = async () => {
setBusy(true); setBusy(true);
setErr("");
try { try {
const j = await bizCompareCreateJob({ const j = await bizCompareCreateJob({
name, name,
@ -172,11 +188,12 @@ export function BizComparePage() {
mode, mode,
}); });
setSelectedJobId(String(j.id)); setSelectedJobId(String(j.id));
showOk(t("bizCompare.created"));
await refresh(); await refresh();
const r = await bizCompareListRuns(String(j.id)); const r = await bizCompareListRuns(String(j.id));
setRuns(r.items || []); setRuns(r.items || []);
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -197,15 +214,14 @@ export function BizComparePage() {
setBeforeBatchId(job.before_batch_id); setBeforeBatchId(job.before_batch_id);
setAfterBatchId(job.after_batch_id); setAfterBatchId(job.after_batch_id);
setMode(job.mode === "auto" ? "auto" : "manual"); setMode(job.mode === "auto" ? "auto" : "manual");
if (job.mapping_id) loadMappingText(job.mapping_id);
} }
}; };
const runNow = async () => { const runNow = async () => {
if (!selectedJobId) return; if (!selectedJobId) return;
setBusy(true); setBusy(true);
setErr("");
try { try {
// sync latest batch selection onto job before run
await bizCompareUpdateJob(selectedJobId, { await bizCompareUpdateJob(selectedJobId, {
before_batch_id: beforeBatchId, before_batch_id: beforeBatchId,
after_batch_id: mode === "manual" ? afterBatchId : "", after_batch_id: mode === "manual" ? afterBatchId : "",
@ -215,18 +231,23 @@ export function BizComparePage() {
}); });
const run = await bizCompareRunJob(selectedJobId); const run = await bizCompareRunJob(selectedJobId);
setRunDetail(run); setRunDetail(run);
showOk(t("bizCompare.ran"));
const r = await bizCompareListRuns(selectedJobId); const r = await bizCompareListRuns(selectedJobId);
setRuns(r.items || []); setRuns(r.items || []);
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
}; };
const loadRun = async (runId: string) => { const loadRun = async (runId: string) => {
const d = await bizCompareGetRun(runId); try {
setRunDetail(d); const d = await bizCompareGetRun(runId);
setRunDetail(d);
} catch (e) {
showError(formatErr(e));
}
}; };
const loadMappingText = (id: string) => { const loadMappingText = (id: string) => {
@ -237,251 +258,278 @@ export function BizComparePage() {
setMapText(["before_if,after_if", ...m.rows.map((r) => `${r.before_if},${r.after_if}`)].join("\n")); setMapText(["before_if,after_if", ...m.rows.map((r) => `${r.before_if},${r.after_if}`)].join("\n"));
}; };
const updateExistingMapping = async () => { const taskLabel = (row: TaskOpt) => `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`;
if (!mappingId) {
await saveMapping();
return;
}
setBusy(true);
try {
await bizCompareUpdateMapping(mappingId, { name: mapName, rows: parseMapRows() });
await refresh();
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setBusy(false);
}
};
const taskLabel = (t: TaskOpt) => `${t.ne_name || t.ne_ip || t.id} (${t.vendor || "-"})`;
return ( return (
<div className="wb-page"> <section className="panel nm-page-panel">
<div className="wb-page__header"> <div className="panel__toolbar">
<h1>业务状态比对</h1> <h2>{t("bizCompare.title")}</h2>
<p className="wb-muted">Phase2:模板 + 端口映射 + 前后批次 diff(支持 auto 追比)</p> <div className="btn-row">
</div> <Button size="sm" variant="primary" isDisabled={busy} onPress={() => void createJob()}>
{err ? <div className="wb-alert wb-alert--error">{err}</div> : null} {t("bizCompare.createCompare")}
</Button>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}> <Button
<div className="wb-card"> size="sm"
<h3>端口映射</h3> variant="secondary"
<div style={{ display: "flex", gap: 8, marginBottom: 8, flexWrap: "wrap" }}> isDisabled={busy || !selectedJobId}
<select onPress={() => void runNow()}
value={mappingId} >
onChange={(e) => { {t("bizCompare.runNow")}
const id = e.target.value; </Button>
if (id) loadMappingText(id);
else {
setMappingId("");
setMapText("before_if,after_if\n");
}
}}
>
<option value="">新建映射…</option>
{mappings.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<input value={mapName} onChange={(e) => setMapName(e.target.value)} placeholder="映射名称" />
<button type="button" disabled={busy} onClick={() => void updateExistingMapping()}>
保存映射
</button>
<button type="button" disabled={busy} onClick={() => void doValidate()}>
校验映射
</button>
</div>
<textarea
value={mapText}
onChange={(e) => setMapText(e.target.value)}
rows={8}
style={{ width: "100%", fontFamily: "monospace" }}
placeholder="before_if,after_if 每行一对"
/>
{validateOut ? (
<pre className="wb-muted" style={{ fontSize: 12, maxHeight: 160, overflow: "auto" }}>
{JSON.stringify(validateOut, null, 2)}
</pre>
) : null}
</div> </div>
</div>
<div className="wb-card"> <div className="pt-list">
<h3>建立比对任务</h3> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div style={{ display: "grid", gap: 8 }}> <div>
<label> <h3 style={{ marginTop: 0 }}>{t("bizCompare.mapping")}</h3>
名称{" "} <div className="filter-inline" style={{ marginBottom: 8 }}>
<input value={name} onChange={(e) => setName(e.target.value)} style={{ width: "70%" }} /> <FieldSelect
</label> value={mappingId}
<label> onChange={(e) => {
模板{" "} const id = e.target.value;
<select value={templateId} onChange={(e) => setTemplateId(e.target.value)}> if (id) loadMappingText(id);
{templates.map((t) => ( else {
<option key={t.id} value={t.id}> setMappingId("");
{t.name} ({t.metric_id}) setMapText("before_if,after_if\n");
}
}}
>
<option value="">{t("bizCompare.newMapping")}</option>
{mappings.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> <Input value={mapName} placeholder={t("bizCompare.mapName")} onChange={(e) => setMapName(e.target.value)} />
<label> <Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void updateExistingMapping()}>
模式{" "} {t("bizCompare.saveMapping")}
<select value={mode} onChange={(e) => setMode(e.target.value as "manual" | "auto")}> </Button>
<option value="manual">手工选批次</option> <Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void doValidate()}>
<option value="auto">自动(钉死操作前,操作后取最新批次)</option> {t("bizCompare.validateMapping")}
</select> </Button>
</label> </div>
<label> <textarea
操作前任务{" "} value={mapText}
<select value={beforeTaskId} onChange={(e) => setBeforeTaskId(e.target.value)}> onChange={(e) => setMapText(e.target.value)}
<option value="">选择…</option> placeholder={t("bizCompare.mapHint")}
{tasks.map((t) => ( rows={8}
<option key={t.id} value={t.id}> style={{ width: "100%", fontFamily: "ui-monospace, monospace" }}
{taskLabel(t)} />
{validateOut ? (
<pre className="muted" style={{ fontSize: 12, maxHeight: 160, overflow: "auto" }}>
{JSON.stringify(validateOut, null, 2)}
</pre>
) : null}
</div>
<div>
<h3 style={{ marginTop: 0 }}>{t("bizCompare.createJob")}</h3>
<div style={{ display: "grid", gap: 8 }}>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.jobName")}</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<FieldSelect
label={t("bizCompare.template")}
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
fullWidth
>
{templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>
{tpl.name} ({tpl.metric_id})
</option> </option>
))} ))}
</select> </FieldSelect>
</label> <FieldSelect
<label> label={t("bizCompare.mode")}
操作前批次{" "} value={mode}
<select value={beforeBatchId} onChange={(e) => setBeforeBatchId(e.target.value)}> onChange={(e) => setMode(e.target.value as "manual" | "auto")}
<option value="">选择…</option> fullWidth
>
<option value="manual">{t("bizCompare.modeManual")}</option>
<option value="auto">{t("bizCompare.modeAuto")}</option>
</FieldSelect>
<FieldSelect
label={t("bizCompare.beforeTask")}
value={beforeTaskId}
onChange={(e) => setBeforeTaskId(e.target.value)}
fullWidth
>
<option value="">{t("bizCompare.pick")}</option>
{tasks.map((row) => (
<option key={row.id} value={row.id}>
{taskLabel(row)}
</option>
))}
</FieldSelect>
<FieldSelect
label={t("bizCompare.beforeBatch")}
value={beforeBatchId}
onChange={(e) => setBeforeBatchId(e.target.value)}
fullWidth
>
<option value="">{t("bizCompare.pick")}</option>
{beforeBatches.map((b) => ( {beforeBatches.map((b) => (
<option key={b.id} value={b.id}> <option key={b.id} value={b.id}>
{b.started_at} · {b.status} · rows={b.row_count} {fmtTime(b.started_at)} · {b.status} · rows={b.row_count}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> <FieldSelect
<label> label={t("bizCompare.afterTask")}
操作后任务{" "} value={afterTaskId}
<select value={afterTaskId} onChange={(e) => setAfterTaskId(e.target.value)}> onChange={(e) => setAfterTaskId(e.target.value)}
<option value="">同操作前 / 选择…</option> fullWidth
{tasks.map((t) => ( >
<option key={t.id} value={t.id}> <option value="">{t("bizCompare.sameAsBefore")}</option>
{taskLabel(t)} {tasks.map((row) => (
<option key={row.id} value={row.id}>
{taskLabel(row)}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> {mode === "manual" ? (
{mode === "manual" ? ( <FieldSelect
<label> label={t("bizCompare.afterBatch")}
操作后批次{" "} value={afterBatchId}
<select value={afterBatchId} onChange={(e) => setAfterBatchId(e.target.value)}> onChange={(e) => setAfterBatchId(e.target.value)}
<option value="">选择…</option> fullWidth
>
<option value="">{t("bizCompare.pick")}</option>
{afterBatches.map((b) => ( {afterBatches.map((b) => (
<option key={b.id} value={b.id}> <option key={b.id} value={b.id}>
{b.started_at} · {b.status} · rows={b.row_count} {fmtTime(b.started_at)} · {b.status} · rows={b.row_count}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> ) : (
) : ( <p className="muted">{t("bizCompare.autoHint")}</p>
<p className="wb-muted">auto:每次操作后任务新批次到达会自动跑比对</p> )}
)}
<div style={{ display: "flex", gap: 8 }}>
<button type="button" disabled={busy} onClick={() => void createJob()}>
创建比对任务
</button>
<button type="button" disabled={busy || !selectedJobId} onClick={() => void runNow()}>
立即比对
</button>
</div> </div>
</div> </div>
</div> </div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1.6fr", gap: 16, marginTop: 16 }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1.6fr", gap: 16, marginTop: 16 }}>
<div className="wb-card"> <div>
<h3>比对任务列表</h3> <h3>{t("bizCompare.jobList")}</h3>
<table className="wb-table"> <div className="pt-list-table-wrap">
<thead> <table className="data-table pt-list-table">
<tr>
<th>名称</th>
<th>模式</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{jobs.map((j) => (
<tr
key={j.id}
style={{ cursor: "pointer", background: selectedJobId === j.id ? "var(--wb-row-active, #eef)" : undefined }}
onClick={() => void openJob(j.id)}
>
<td>{j.name}</td>
<td>{j.mode}</td>
<td>{j.status}</td>
</tr>
))}
{!jobs.length ? (
<tr>
<td colSpan={3} className="wb-muted">
暂无
</td>
</tr>
) : null}
</tbody>
</table>
<h4>历史 Run</h4>
<ul>
{runs.map((r) => (
<li key={r.id}>
<button type="button" onClick={() => void loadRun(r.id)}>
{r.created_at} · added={r.summary?.added} removed={r.summary?.removed} changed={r.summary?.changed}
</button>
</li>
))}
</ul>
</div>
<div className="wb-card">
<h3>比对结果</h3>
{!runDetail ? (
<p className="wb-muted">选择 Run 或点「立即比对」</p>
) : (
<>
<p>
before={runDetail.before_batch_id?.slice(0, 8)}… after={runDetail.after_batch_id?.slice(0, 8)}… ·{" "}
<strong>
+{runDetail.summary?.added || 0} / -{runDetail.summary?.removed || 0} / ~{runDetail.summary?.changed || 0} / ={runDetail.summary?.unchanged || 0}
</strong>
</p>
{runDetail.mapping_stats ? (
<p className="wb-muted" style={{ fontSize: 12 }}>
映射校验 ok={String(runDetail.mapping_stats.ok)} miss_before={(runDetail.mapping_stats.miss_before || []).length}{" "}
miss_after={(runDetail.mapping_stats.miss_after || []).length}
</p>
) : null}
<table className="wb-table">
<thead> <thead>
<tr> <tr>
<th>类型</th> <th>{t("bizCompare.colName")}</th>
<th>Key</th> <th>{t("bizCompare.colMode")}</th>
<th>变更</th> <th>{t("bizCompare.colStatus")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{(runDetail.diffs || []) {jobs.map((j) => (
.filter((d: any) => d.kind !== "unchanged") <tr
.slice(0, 300) key={j.id}
.map((d: any, i: number) => ( className={selectedJobId === j.id ? "is-selected" : undefined}
<tr key={i}> style={{ cursor: "pointer" }}
<td>{d.kind}</td> onClick={() => void openJob(j.id)}
<td> >
<code style={{ fontSize: 11 }}>{JSON.stringify(d.key)}</code> <td>
</td> <div className="pt-list-task-name">{j.name}</div>
<td> </td>
<code style={{ fontSize: 11 }}>{d.kind === "changed" ? JSON.stringify(d.changes) : "-"}</code> <td>{j.mode}</td>
</td> <td>
</tr> <NmStatusChip color={jobChipColor(j.status)}>{j.status}</NmStatusChip>
))} </td>
</tr>
))}
{!jobs.length ? (
<tr>
<td colSpan={3}>
<div className="pt-list-empty">{t("bizCompare.emptyJobs")}</div>
</td>
</tr>
) : null}
</tbody> </tbody>
</table> </table>
</> </div>
)}
<h4 style={{ marginTop: 16 }}>{t("bizCompare.runs")}</h4>
<div className="pt-list-actions" style={{ flexDirection: "column", alignItems: "stretch", gap: 6 }}>
{runs.map((r) => (
<Button key={r.id} size="sm" variant="secondary" onPress={() => void loadRun(r.id)}>
{fmtTime(r.created_at)} · +{r.summary?.added ?? 0} / -{r.summary?.removed ?? 0} / ~
{r.summary?.changed ?? 0}
</Button>
))}
</div>
</div>
<div>
<h3>{t("bizCompare.result")}</h3>
{!runDetail ? (
<div className="pt-list-empty">{t("bizCompare.pickRun")}</div>
) : (
<>
<p>
before={String(runDetail.before_batch_id || "").slice(0, 8)}… after=
{String(runDetail.after_batch_id || "").slice(0, 8)}… ·{" "}
<strong>
+{runDetail.summary?.added || 0} / -{runDetail.summary?.removed || 0} / ~
{runDetail.summary?.changed || 0} / ={runDetail.summary?.unchanged || 0}
</strong>
</p>
{runDetail.mapping_stats ? (
<p className="muted" style={{ fontSize: 12 }}>
mapping ok={String(runDetail.mapping_stats.ok)} miss_before=
{(runDetail.mapping_stats.miss_before || []).length} miss_after=
{(runDetail.mapping_stats.miss_after || []).length}
</p>
) : null}
<div className="pt-list-table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("bizCompare.colKind")}</th>
<th>{t("bizCompare.colKey")}</th>
<th>{t("bizCompare.colChange")}</th>
</tr>
</thead>
<tbody>
{(runDetail.diffs || [])
.filter((d: any) => d.kind !== "unchanged")
.slice(0, 300)
.map((d: any, i: number) => (
<tr key={i}>
<td>
<NmStatusChip
color={
d.kind === "added"
? "success"
: d.kind === "removed"
? "danger"
: "warning"
}
>
{d.kind}
</NmStatusChip>
</td>
<td>
<code style={{ fontSize: 11 }}>{JSON.stringify(d.key)}</code>
</td>
<td>
<code style={{ fontSize: 11 }}>
{d.kind === "changed" ? JSON.stringify(d.changes) : "—"}
</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</div>
</div> </div>
</div> </div>
</div> </section>
); );
} }

View file

@ -1,4 +1,8 @@
import { Button, Input } from "@heroui/react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import { import {
bizStateCollectNow, bizStateCollectNow,
bizStateCreateTask, bizStateCreateTask,
@ -12,9 +16,12 @@ import {
bizStateListTasks, bizStateListTasks,
bizStatePatchTask, bizStatePatchTask,
bizStateSetBindings, bizStateSetBindings,
fetchManagedNe, fetchCliTargets,
formatErr,
} from "../../services/api"; } from "../../services/api";
import type { ManagedNeItem } from "../../types"; import type { CliTargetItem } from "../../types";
import { formatSystemTime } from "../../utils/time";
import { jobChipColor, NmStatusChip } from "./nmChips";
type TaskRow = { type TaskRow = {
id: string; id: string;
@ -31,7 +38,6 @@ type Placeholder = {
name: string; name: string;
bind_mode?: string; bind_mode?: string;
discover_profile_id?: string; discover_profile_id?: string;
discover_value_field?: string;
}; };
type Profile = { type Profile = {
@ -54,17 +60,23 @@ type BatchRow = {
type Candidate = { value: string; label: string; rd?: string }; type Candidate = { value: string; label: string; rd?: string };
function fmtTime(v?: string | null) {
if (!v) return "—";
return formatSystemTime(v) || v;
}
export function BizStatePage() { export function BizStatePage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [tasks, setTasks] = useState<TaskRow[]>([]); const [tasks, setTasks] = useState<TaskRow[]>([]);
const [selectedId, setSelectedId] = useState(""); const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<any>(null); const [detail, setDetail] = useState<any>(null);
const [batches, setBatches] = useState<BatchRow[]>([]); const [batches, setBatches] = useState<BatchRow[]>([]);
const [batchDetail, setBatchDetail] = useState<any>(null); const [batchDetail, setBatchDetail] = useState<any>(null);
const [profiles, setProfiles] = useState<Profile[]>([]); const [profiles, setProfiles] = useState<Profile[]>([]);
const [nes, setNes] = useState<ManagedNeItem[]>([]); const [nes, setNes] = useState<CliTargetItem[]>([]);
const [neId, setNeId] = useState(""); const [neId, setNeId] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [bindItemId, setBindItemId] = useState(""); const [bindItemId, setBindItemId] = useState("");
const [candidates, setCandidates] = useState<Candidate[]>([]); const [candidates, setCandidates] = useState<Candidate[]>([]);
const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]); const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]);
@ -79,18 +91,18 @@ export function BizStatePage() {
void (async () => { void (async () => {
try { try {
await refreshTasks(); await refreshTasks();
const neRes = await fetchManagedNe({ const neRes = await fetchCliTargets({
source: "managed",
keyword: "", keyword: "",
vendor: "",
connectStatus: "",
page: 1, page: 1,
pageSize: 200, pageSize: 200,
}); });
setNes(neRes.items || []); setNes(neRes.items || []);
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} }
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount once
}, [refreshTasks]); }, [refreshTasks]);
const collectProfiles = useMemo( const collectProfiles = useMemo(
@ -104,19 +116,18 @@ export function BizStatePage() {
setBindItemId(""); setBindItemId("");
setCandidates([]); setCandidates([]);
setSelectedVrfs([]); setSelectedVrfs([]);
setErr("");
try { try {
const t = await bizStateGetTask(id); const task = await bizStateGetTask(id);
setDetail(t); setDetail(task);
const b = await bizStateListBatches(id); const b = await bizStateListBatches(id);
setBatches((b.items || []) as BatchRow[]); setBatches((b.items || []) as BatchRow[]);
const p = await bizStateListProfiles({ const p = await bizStateListProfiles({
vendor: t.vendor || "", vendor: task.vendor || "",
device_type: t.device_type || "", device_type: task.device_type || "",
}); });
setProfiles((p.items || []) as Profile[]); setProfiles((p.items || []) as Profile[]);
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} }
}; };
@ -125,9 +136,8 @@ export function BizStatePage() {
const ne = nes.find((n) => n.id === neId); const ne = nes.find((n) => n.id === neId);
if (!ne) return; if (!ne) return;
setBusy(true); setBusy(true);
setErr("");
try { try {
const t = await bizStateCreateTask({ const task = await bizStateCreateTask({
source: "managed", source: "managed",
ne_id: ne.id, ne_id: ne.id,
ne_name: ne.name, ne_name: ne.name,
@ -135,10 +145,12 @@ export function BizStatePage() {
vendor: ne.vendor, vendor: ne.vendor,
device_type: ne.device_type, device_type: ne.device_type,
}); });
showOk(t("bizState.created"));
await refreshTasks(); await refreshTasks();
await openTask(t.id); await openTask(String(task.id));
} catch (e: any) { setNeId("");
setErr(String(e?.message || e)); } catch (e) {
showError(t("bizState.createFailed") + ": " + formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -149,10 +161,11 @@ export function BizStatePage() {
setBusy(true); setBusy(true);
try { try {
await bizStatePatchTask(selectedId, { status }); await bizStatePatchTask(selectedId, { status });
showOk(status === "running" ? t("bizState.started") : t("bizState.paused"));
await openTask(selectedId); await openTask(selectedId);
await refreshTasks(); await refreshTasks();
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -161,24 +174,23 @@ export function BizStatePage() {
const collectNow = async () => { const collectNow = async () => {
if (!selectedId) return; if (!selectedId) return;
setBusy(true); setBusy(true);
setErr("");
try { try {
await bizStateCollectNow(selectedId); await bizStateCollectNow(selectedId);
await openTask(selectedId); await openTask(selectedId);
await refreshTasks(); await refreshTasks();
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 1500)); await new Promise((r) => setTimeout(r, 1500));
const t = await bizStateGetTask(selectedId); const task = await bizStateGetTask(selectedId);
setDetail(t); setDetail(task);
if (!t.collect_running) { if (!task.collect_running) {
const b = await bizStateListBatches(selectedId); const b = await bizStateListBatches(selectedId);
setBatches((b.items || []) as BatchRow[]); setBatches((b.items || []) as BatchRow[]);
break; break;
} }
} }
await refreshTasks(); await refreshTasks();
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -188,24 +200,25 @@ export function BizStatePage() {
try { try {
const d = await bizStateGetBatch(batchId); const d = await bizStateGetBatch(batchId);
setBatchDetail(d); setBatchDetail(d);
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} }
}; };
const removeTask = async () => { const removeTask = async () => {
if (!selectedId) return; if (!selectedId) return;
if (!window.confirm("删除该业务监控任务及所有批次?")) return; if (!window.confirm(t("bizState.confirmDelete"))) return;
setBusy(true); setBusy(true);
try { try {
await bizStateDeleteTask(selectedId); await bizStateDeleteTask(selectedId);
showOk(t("bizState.deleted"));
setSelectedId(""); setSelectedId("");
setDetail(null); setDetail(null);
setBatches([]); setBatches([]);
setBatchDetail(null); setBatchDetail(null);
await refreshTasks(); await refreshTasks();
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -216,9 +229,8 @@ export function BizStatePage() {
const items = [...(detail.items || [])]; const items = [...(detail.items || [])];
const idx = items.findIndex((it: any) => it.source_profile_id === profile.profile_id); const idx = items.findIndex((it: any) => it.source_profile_id === profile.profile_id);
if (enable) { if (enable) {
if (idx >= 0) { if (idx >= 0) items[idx] = { ...items[idx], enabled: true };
items[idx] = { ...items[idx], enabled: true }; else {
} else {
items.push({ items.push({
source_profile_id: profile.profile_id, source_profile_id: profile.profile_id,
kind: "catalog", kind: "catalog",
@ -234,8 +246,8 @@ export function BizStatePage() {
try { try {
await bizStatePatchTask(selectedId, { items }); await bizStatePatchTask(selectedId, { items });
await openTask(selectedId); await openTask(selectedId);
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -246,11 +258,10 @@ export function BizStatePage() {
const prof = profiles.find((p) => p.profile_id === item.source_profile_id); const prof = profiles.find((p) => p.profile_id === item.source_profile_id);
const ph = (prof?.placeholders || [])[0]; const ph = (prof?.placeholders || [])[0];
if (!ph) { if (!ph) {
setErr("该监控项无需参数关联"); showError(t("bizState.noNeedBind"));
return; return;
} }
setBusy(true); setBusy(true);
setErr("");
setBindItemId(item.id); setBindItemId(item.id);
try { try {
const res = await bizStateDiscover({ const res = await bizStateDiscover({
@ -259,7 +270,7 @@ export function BizStatePage() {
placeholder: ph.name, placeholder: ph.name,
}); });
if (!res.ok) { if (!res.ok) {
setErr(res.error || "discover failed"); showError(res.error || t("bizState.discoverFailed"));
setCandidates([]); setCandidates([]);
return; return;
} }
@ -270,8 +281,8 @@ export function BizStatePage() {
.filter((b: any) => b.placeholder === ph.name) .filter((b: any) => b.placeholder === ph.name)
.map((b: any) => String(b.value)); .map((b: any) => String(b.value));
setSelectedVrfs(existing.length ? existing : cand.map((c) => c.value)); setSelectedVrfs(existing.length ? existing : cand.map((c) => c.value));
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -289,307 +300,343 @@ export function BizStatePage() {
bindItemId, bindItemId,
selectedVrfs.map((v) => ({ placeholder: phName, value: v })), selectedVrfs.map((v) => ({ placeholder: phName, value: v })),
); );
showOk(t("bizState.bindingsSaved"));
await openTask(selectedId); await openTask(selectedId);
setBindItemId(""); setBindItemId("");
setCandidates([]); setCandidates([]);
} catch (e: any) { } catch (e) {
setErr(String(e?.message || e)); showError(formatErr(e));
} finally { } finally {
setBusy(false); setBusy(false);
} }
}; };
return ( const runningCount = tasks.filter((x) => x.status === "running").length;
<div className="wb-page">
<div className="wb-page__header">
<h1>业务状态监控</h1>
<p className="wb-muted">
LLDP 快照 + VRF 发现关联(选 VRF 后采路由摘要)+ 割接比对见「业务状态比对」
</p>
</div>
{err ? <div className="wb-alert wb-alert--error">{err}</div> : null}
<div className="wb-card" style={{ marginBottom: 16 }}> return (
<h3>新建任务</h3> <section className="panel nm-page-panel">
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}> <div className="panel__toolbar">
<select value={neId} onChange={(e) => setNeId(e.target.value)}> <h2>{t("bizState.title")}</h2>
<option value="">选择托管网元…</option> <div className="btn-row">
<FieldSelect
value={neId}
onChange={(e) => setNeId(e.target.value)}
aria-label={t("bizState.pickNe")}
>
<option value="">{t("bizState.pickNe")}</option>
{nes.map((n) => ( {nes.map((n) => (
<option key={n.id} value={n.id}> <option key={n.id} value={n.id}>
{n.name || n.ip_address} ({n.vendor}) {n.name || n.ip_address} ({n.vendor || "-"})
</option> </option>
))} ))}
</select> </FieldSelect>
<button type="button" disabled={busy || !neId} onClick={() => void createTask()}> <Button size="sm" variant="primary" isDisabled={busy || !neId} onPress={() => void createTask()}>
创建(默认启用 LLDP) {t("bizState.create")}
</button> </Button>
</div> </div>
</div> </div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1.4fr", gap: 16 }}> <div className="pt-list">
<div className="wb-card"> <div className="pt-list-kpis">
<h3>任务列表</h3> <div className="pt-list-kpi">
<table className="wb-table"> <div className="pt-list-kpi__label">{t("bizState.colNe")}</div>
<thead> <div className="pt-list-kpi__value">{tasks.length}</div>
<tr> </div>
<th>网元</th> <div className="pt-list-kpi pt-list-kpi--live">
<th>状态</th> <div className="pt-list-kpi__label">{t("bizState.start")}</div>
<th>最近采集</th> <div className="pt-list-kpi__value">{runningCount}</div>
</tr> </div>
</thead>
<tbody>
{tasks.map((t) => (
<tr
key={t.id}
onClick={() => void openTask(t.id)}
style={{
cursor: "pointer",
background: selectedId === t.id ? "var(--wb-row-active, #eef)" : undefined,
}}
>
<td>
{t.ne_name || t.ne_ip}
<div className="wb-muted">{t.vendor}</div>
</td>
<td>
{t.status}
{t.collect_running ? " · 采集中" : ""}
</td>
<td className="wb-muted">{t.last_collect_ended_at || "-"}</td>
</tr>
))}
{!tasks.length ? (
<tr>
<td colSpan={3} className="wb-muted">
暂无任务
</td>
</tr>
) : null}
</tbody>
</table>
</div> </div>
<div className="wb-card"> <div className="nm-split" style={{ display: "grid", gridTemplateColumns: "1fr 1.45fr", gap: 16 }}>
{!detail ? ( <div className="pt-list-table-wrap">
<p className="wb-muted">选择左侧任务查看详情</p> <table className="data-table pt-list-table">
) : ( <thead>
<> <tr>
<h3> <th>{t("bizState.colNe")}</th>
{detail.ne_name} · {detail.status} <th>{t("bizState.colStatus")}</th>
</h3> <th>{t("bizState.colLast")}</th>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}> </tr>
<button type="button" disabled={busy} onClick={() => void setStatus("running")}> </thead>
启动周期 <tbody>
</button> {tasks.map((row) => (
<button type="button" disabled={busy} onClick={() => void setStatus("paused")}> <tr
暂停 key={row.id}
</button> className={selectedId === row.id ? "is-selected" : undefined}
<button type="button" disabled={busy} onClick={() => void collectNow()}> style={{ cursor: "pointer" }}
立即采集 onClick={() => void openTask(row.id)}
</button> >
<button type="button" disabled={busy} onClick={() => void removeTask()}> <td>
删除 <div className="pt-list-task-name">{row.ne_name || row.ne_ip || "—"}</div>
</button> <div className="muted">{row.vendor || "—"}</div>
</div> </td>
{detail.last_error ? <div className="wb-alert wb-alert--error">{detail.last_error}</div> : null} <td>
<div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}>
<h4>可用监控项</h4> <NmStatusChip color={jobChipColor(row.status)}>{row.status}</NmStatusChip>
<table className="wb-table"> {row.collect_running ? (
<thead> <NmStatusChip color="accent">{t("bizState.collecting")}</NmStatusChip>
<tr> ) : null}
<th>启用</th> </div>
<th>项</th> </td>
<th>模板</th> <td className="pt-list-time">{fmtTime(row.last_collect_ended_at)}</td>
<th>参数</th>
<th />
</tr> </tr>
</thead> ))}
<tbody> {!tasks.length ? (
{collectProfiles.map((prof) => { <tr>
const it = (detail.items || []).find( <td colSpan={3}>
(x: any) => x.source_profile_id === prof.profile_id, <div className="pt-list-empty">{t("bizState.empty")}</div>
); </td>
const enabled = Boolean(it?.enabled); </tr>
const binds = it?.bindings || []; ) : null}
const needsBind = (prof.placeholders || []).length > 0; </tbody>
return ( </table>
<tr key={prof.profile_id}> </div>
<td>
<div>
{!detail ? (
<div className="pt-list-empty">{t("bizState.pickTask")}</div>
) : (
<>
<div className="panel__toolbar" style={{ padding: 0, marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>
{detail.ne_name || detail.ne_ip} · {detail.status}
</h3>
<div className="btn-row">
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void setStatus("running")}>
{t("bizState.start")}
</Button>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void setStatus("paused")}>
{t("bizState.pause")}
</Button>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void collectNow()}>
{t("bizState.collectNow")}
</Button>
<Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeTask()}>
{t("bizState.delete")}
</Button>
</div>
</div>
{detail.last_error ? <p className="form-error">{detail.last_error}</p> : null}
<h4 style={{ margin: "12px 0 8px" }}>{t("bizState.profiles")}</h4>
<div className="pt-list-table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("bizState.enable")}</th>
<th>{t("bizState.profiles")}</th>
<th>{t("bizState.command")}</th>
<th>{t("bizState.params")}</th>
<th />
</tr>
</thead>
<tbody>
{collectProfiles.map((prof) => {
const it = (detail.items || []).find(
(x: any) => x.source_profile_id === prof.profile_id,
);
const enabled = Boolean(it?.enabled);
const binds = it?.bindings || [];
const needsBind = (prof.placeholders || []).length > 0;
return (
<tr key={prof.profile_id}>
<td>
<input
type="checkbox"
checked={enabled}
disabled={busy}
onChange={(e) => void toggleProfileItem(prof, e.target.checked)}
/>
</td>
<td>
<div className="pt-list-task-name">{prof.title}</div>
{prof.description ? <div className="muted">{prof.description}</div> : null}
</td>
<td>
<code>{prof.command_template}</code>
</td>
<td>
{needsBind
? binds.length
? binds.map((b: any) => b.value).join(", ")
: t("bizState.unbound")
: "—"}
</td>
<td>
{needsBind && enabled && it ? (
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => void startDiscover(it)}
>
{t("bizState.discoverVrf")}
</Button>
) : null}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{bindItemId && candidates.length ? (
<div className="panel" style={{ marginTop: 12, padding: 12 }}>
<h4 style={{ marginTop: 0 }}>{t("bizState.bindTitle")}</h4>
<p className="muted">
<code>{discoverCmd}</code> · {selectedVrfs.length}
</p>
<div style={{ maxHeight: 220, overflow: "auto", marginBottom: 8 }}>
{candidates.map((c) => (
<label key={c.value} style={{ display: "block", marginBottom: 4 }}>
<input <input
type="checkbox" type="checkbox"
checked={enabled} checked={selectedVrfs.includes(c.value)}
disabled={busy} onChange={(e) => {
onChange={(e) => void toggleProfileItem(prof, e.target.checked)} setSelectedVrfs((prev) =>
/> e.target.checked
</td> ? [...prev, c.value]
<td> : prev.filter((x) => x !== c.value),
{prof.title} );
<div className="wb-muted" style={{ fontSize: 12 }}> }}
{prof.description} />{" "}
</div> {c.label}
</td> {c.rd ? <span className="muted"> · RD {c.rd}</span> : null}
<td> </label>
<code>{prof.command_template}</code> ))}
</td> </div>
<td> <div className="btn-row">
{needsBind <Button
? binds.length size="sm"
? binds.map((b: any) => b.value).join(", ") variant="primary"
: "未关联" isDisabled={busy || !selectedVrfs.length}
: "—"} onPress={() => void saveBindings()}
</td> >
<td> {t("bizState.saveBindings")}
{needsBind && enabled && it ? ( </Button>
<button type="button" disabled={busy} onClick={() => void startDiscover(it)}> <Button
发现 VRF size="sm"
</button> variant="secondary"
) : null} isDisabled={busy}
</td> onPress={() => {
setBindItemId("");
setCandidates([]);
}}
>
{t("bizState.cancel")}
</Button>
</div>
</div>
) : null}
<h4 style={{ margin: "16px 0 8px" }}>{t("bizState.batches")}</h4>
<div className="pt-list-table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{t("bizState.colTime")}</th>
<th>{t("bizState.colStatus")}</th>
<th>{t("bizState.colRows")}</th>
<th>{t("bizState.colActions")}</th>
</tr> </tr>
); </thead>
})} <tbody>
</tbody> {batches.map((b) => (
</table> <tr key={b.id}>
<td className="pt-list-time">{fmtTime(b.started_at)}</td>
{bindItemId && candidates.length ? ( <td>
<div style={{ marginTop: 12, padding: 12, border: "1px solid var(--wb-border, #ddd)" }}> <NmStatusChip color={jobChipColor(b.status)}>{b.status}</NmStatusChip>
<h4>选择 VRF 绑定</h4> </td>
<p className="wb-muted"> <td className="pt-list-num">{b.row_count}</td>
命令 <code>{discoverCmd}</code> · 已选 {selectedVrfs.length} <td>
</p> <div className="pt-list-actions">
<div style={{ maxHeight: 220, overflow: "auto", marginBottom: 8 }}> <Button size="sm" variant="secondary" onPress={() => void openBatch(b.id)}>
{candidates.map((c) => ( {t("bizState.viewBatch")}
<label key={c.value} style={{ display: "block", marginBottom: 4 }}> </Button>
<input <Button
type="checkbox" size="sm"
checked={selectedVrfs.includes(c.value)} variant="secondary"
onChange={(e) => { onPress={() => void bizStateDownloadExport(b.id)}
setSelectedVrfs((prev) => >
e.target.checked {t("bizState.export")}
? [...prev, c.value] </Button>
: prev.filter((x) => x !== c.value), </div>
); </td>
}} </tr>
/>{" "} ))}
{c.label} {!batches.length ? (
{c.rd ? <span className="wb-muted"> · RD {c.rd}</span> : null} <tr>
</label> <td colSpan={4}>
))} <div className="pt-list-empty">{t("bizState.noBatches")}</div>
</div> </td>
<div style={{ display: "flex", gap: 8 }}> </tr>
<button type="button" disabled={busy || !selectedVrfs.length} onClick={() => void saveBindings()}> ) : null}
保存关联 </tbody>
</button> </table>
<button
type="button"
disabled={busy}
onClick={() => {
setBindItemId("");
setCandidates([]);
}}
>
取消
</button>
</div>
</div> </div>
) : null}
<h4 style={{ marginTop: 16 }}>采集批次</h4> {batchDetail ? (
<table className="wb-table"> <div style={{ marginTop: 16 }}>
<thead> <h4 style={{ marginBottom: 4 }}>
<tr> {t("bizState.batchDetail")} {batchDetail.id}
<th>时间</th> </h4>
<th>状态</th> <p className="muted">
<th>行数</th> {batchDetail.command_count} · {batchDetail.row_count} · {batchDetail.status}
<th>操作</th> </p>
</tr> {(batchDetail.lldp_neighbors || []).length ? (
</thead> <div className="pt-list-table-wrap" style={{ marginTop: 8 }}>
<tbody> <table className="data-table">
{batches.map((b) => ( <thead>
<tr key={b.id}> <tr>
<td>{b.started_at || "-"}</td> <th>local_if</th>
<td>{b.status}</td> <th>remote_sys</th>
<td>{b.row_count}</td> <th>remote_if</th>
<td style={{ display: "flex", gap: 8 }}> <th>remote_ip</th>
<button type="button" onClick={() => void openBatch(b.id)}>
查看
</button>
<button type="button" onClick={() => void bizStateDownloadExport(b.id)}>
导出
</button>
</td>
</tr>
))}
{!batches.length ? (
<tr>
<td colSpan={4} className="wb-muted">
尚无批次
</td>
</tr>
) : null}
</tbody>
</table>
{batchDetail ? (
<div style={{ marginTop: 16 }}>
<h4>批次详情 {batchDetail.id}</h4>
<p className="wb-muted">
命令 {batchDetail.command_count} · 行 {batchDetail.row_count} · {batchDetail.status}
</p>
{(batchDetail.lldp_neighbors || []).length ? (
<>
<h5>LLDP</h5>
<table className="wb-table">
<thead>
<tr>
<th>本端口</th>
<th>对端系统</th>
<th>对端口</th>
<th>管理IP</th>
</tr>
</thead>
<tbody>
{(batchDetail.lldp_neighbors || []).slice(0, 200).map((n: any, i: number) => (
<tr key={i}>
<td>{n.local_if}</td>
<td>{n.remote_sys}</td>
<td>{n.remote_if}</td>
<td>{n.remote_ip}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {(batchDetail.lldp_neighbors || []).slice(0, 200).map((n: any, i: number) => (
</> <tr key={i}>
) : null} <td>{n.local_if}</td>
{(batchDetail.vrf_route_summary || []).length ? ( <td>{n.remote_sys}</td>
<> <td>{n.remote_if}</td>
<h5>VRF 路由摘要</h5> <td>{n.remote_ip}</td>
<table className="wb-table"> </tr>
<thead> ))}
<tr> </tbody>
<th>VRF</th> </table>
<th>来源</th> </div>
<th>条数</th> ) : null}
</tr> {(batchDetail.vrf_route_summary || []).length ? (
</thead> <div className="pt-list-table-wrap" style={{ marginTop: 8 }}>
<tbody> <table className="data-table">
{(batchDetail.vrf_route_summary || []).map((r: any, i: number) => ( <thead>
<tr key={i}> <tr>
<td>{r.vrf}</td> <th>vrf</th>
<td>{r.source}</td> <th>source</th>
<td>{r.networks}</td> <th>networks</th>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {(batchDetail.vrf_route_summary || []).map((r: any, i: number) => (
</> <tr key={i}>
) : null} <td>{r.vrf}</td>
</div> <td>{r.source}</td>
) : null} <td className="pt-list-num">{r.networks}</td>
</> </tr>
)} ))}
</tbody>
</table>
</div>
) : null}
</div>
) : null}
</>
)}
</div>
</div> </div>
</div> </div>
</div> </section>
); );
} }