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();
if (mappingId) {
await bizCompareUpdateMapping(mappingId, { name: mapName, rows });
} else {
const m = await bizCompareCreateMapping({ name: mapName, rows }); const m = await bizCompareCreateMapping({ name: mapName, rows });
setMappingId(String(m.id)); 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) => {
try {
const d = await bizCompareGetRun(runId); const d = await bizCompareGetRun(runId);
setRunDetail(d); setRunDetail(d);
} catch (e) {
showError(formatErr(e));
}
}; };
const loadMappingText = (id: string) => { const loadMappingText = (id: string) => {
@ -237,37 +258,33 @@ 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">
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void createJob()}>
{t("bizCompare.createCompare")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={busy || !selectedJobId}
onPress={() => void runNow()}
>
{t("bizCompare.runNow")}
</Button>
</div>
</div> </div>
{err ? <div className="wb-alert wb-alert--error">{err}</div> : null}
<div className="pt-list">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div className="wb-card"> <div>
<h3>端口映射</h3> <h3 style={{ marginTop: 0 }}>{t("bizCompare.mapping")}</h3>
<div style={{ display: "flex", gap: 8, marginBottom: 8, flexWrap: "wrap" }}> <div className="filter-inline" style={{ marginBottom: 8 }}>
<select <FieldSelect
value={mappingId} value={mappingId}
onChange={(e) => { onChange={(e) => {
const id = e.target.value; const id = e.target.value;
@ -278,187 +295,202 @@ export function BizComparePage() {
} }
}} }}
> >
<option value="">新建映射…</option> <option value="">{t("bizCompare.newMapping")}</option>
{mappings.map((m) => ( {mappings.map((m) => (
<option key={m.id} value={m.id}> <option key={m.id} value={m.id}>
{m.name} {m.name}
</option> </option>
))} ))}
</select> </FieldSelect>
<input value={mapName} onChange={(e) => setMapName(e.target.value)} placeholder="映射名称" /> <Input value={mapName} placeholder={t("bizCompare.mapName")} onChange={(e) => setMapName(e.target.value)} />
<button type="button" disabled={busy} onClick={() => void updateExistingMapping()}> <Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void updateExistingMapping()}>
保存映射 {t("bizCompare.saveMapping")}
</button> </Button>
<button type="button" disabled={busy} onClick={() => void doValidate()}> <Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void doValidate()}>
校验映射 {t("bizCompare.validateMapping")}
</button> </Button>
</div> </div>
<textarea <textarea
value={mapText} value={mapText}
onChange={(e) => setMapText(e.target.value)} onChange={(e) => setMapText(e.target.value)}
placeholder={t("bizCompare.mapHint")}
rows={8} rows={8}
style={{ width: "100%", fontFamily: "monospace" }} style={{ width: "100%", fontFamily: "ui-monospace, monospace" }}
placeholder="before_if,after_if 每行一对"
/> />
{validateOut ? ( {validateOut ? (
<pre className="wb-muted" style={{ fontSize: 12, maxHeight: 160, overflow: "auto" }}> <pre className="muted" style={{ fontSize: 12, maxHeight: 160, overflow: "auto" }}>
{JSON.stringify(validateOut, null, 2)} {JSON.stringify(validateOut, null, 2)}
</pre> </pre>
) : null} ) : null}
</div> </div>
<div className="wb-card"> <div>
<h3>建立比对任务</h3> <h3 style={{ marginTop: 0 }}>{t("bizCompare.createJob")}</h3>
<div style={{ display: "grid", gap: 8 }}> <div style={{ display: "grid", gap: 8 }}>
<label> <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)} style={{ width: "70%" }} /> <Input value={name} onChange={(e) => setName(e.target.value)} />
</label> </label>
<label> <FieldSelect
模板{" "} label={t("bizCompare.template")}
<select value={templateId} onChange={(e) => setTemplateId(e.target.value)}> value={templateId}
{templates.map((t) => ( onChange={(e) => setTemplateId(e.target.value)}
<option key={t.id} value={t.id}> fullWidth
{t.name} ({t.metric_id}) >
{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={mode} onChange={(e) => setMode(e.target.value as "manual" | "auto")}> onChange={(e) => setMode(e.target.value as "manual" | "auto")}
<option value="manual">手工选批次</option> fullWidth
<option value="auto">自动(钉死操作前,操作后取最新批次)</option> >
</select> <option value="manual">{t("bizCompare.modeManual")}</option>
</label> <option value="auto">{t("bizCompare.modeAuto")}</option>
<label> </FieldSelect>
操作前任务{" "} <FieldSelect
<select value={beforeTaskId} onChange={(e) => setBeforeTaskId(e.target.value)}> label={t("bizCompare.beforeTask")}
<option value="">选择…</option> value={beforeTaskId}
{tasks.map((t) => ( onChange={(e) => setBeforeTaskId(e.target.value)}
<option key={t.id} value={t.id}> fullWidth
{taskLabel(t)} >
<option value="">{t("bizCompare.pick")}</option>
{tasks.map((row) => (
<option key={row.id} value={row.id}>
{taskLabel(row)}
</option> </option>
))} ))}
</select> </FieldSelect>
</label> <FieldSelect
<label> label={t("bizCompare.beforeBatch")}
操作前批次{" "} value={beforeBatchId}
<select value={beforeBatchId} onChange={(e) => setBeforeBatchId(e.target.value)}> onChange={(e) => setBeforeBatchId(e.target.value)}
<option value="">选择…</option> 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" ? (
<label> <FieldSelect
操作后批次{" "} label={t("bizCompare.afterBatch")}
<select value={afterBatchId} onChange={(e) => setAfterBatchId(e.target.value)}> value={afterBatchId}
<option value="">选择…</option> onChange={(e) => setAfterBatchId(e.target.value)}
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="wb-muted">auto:每次操作后任务新批次到达会自动跑比对</p> <p className="muted">{t("bizCompare.autoHint")}</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">
<table className="data-table pt-list-table">
<thead> <thead>
<tr> <tr>
<th>名称</th> <th>{t("bizCompare.colName")}</th>
<th>模式</th> <th>{t("bizCompare.colMode")}</th>
<th>状态</th> <th>{t("bizCompare.colStatus")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{jobs.map((j) => ( {jobs.map((j) => (
<tr <tr
key={j.id} key={j.id}
style={{ cursor: "pointer", background: selectedJobId === j.id ? "var(--wb-row-active, #eef)" : undefined }} className={selectedJobId === j.id ? "is-selected" : undefined}
style={{ cursor: "pointer" }}
onClick={() => void openJob(j.id)} onClick={() => void openJob(j.id)}
> >
<td>{j.name}</td> <td>
<div className="pt-list-task-name">{j.name}</div>
</td>
<td>{j.mode}</td> <td>{j.mode}</td>
<td>{j.status}</td> <td>
<NmStatusChip color={jobChipColor(j.status)}>{j.status}</NmStatusChip>
</td>
</tr> </tr>
))} ))}
{!jobs.length ? ( {!jobs.length ? (
<tr> <tr>
<td colSpan={3} className="wb-muted"> <td colSpan={3}>
暂无 <div className="pt-list-empty">{t("bizCompare.emptyJobs")}</div>
</td> </td>
</tr> </tr>
) : null} ) : null}
</tbody> </tbody>
</table> </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>
<div className="wb-card"> <h4 style={{ marginTop: 16 }}>{t("bizCompare.runs")}</h4>
<h3>比对结果</h3> <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 ? ( {!runDetail ? (
<p className="wb-muted">选择 Run 或点「立即比对」</p> <div className="pt-list-empty">{t("bizCompare.pickRun")}</div>
) : ( ) : (
<> <>
<p> <p>
before={runDetail.before_batch_id?.slice(0, 8)}… after={runDetail.after_batch_id?.slice(0, 8)}… ·{" "} before={String(runDetail.before_batch_id || "").slice(0, 8)}… after=
{String(runDetail.after_batch_id || "").slice(0, 8)}… ·{" "}
<strong> <strong>
+{runDetail.summary?.added || 0} / -{runDetail.summary?.removed || 0} / ~{runDetail.summary?.changed || 0} / ={runDetail.summary?.unchanged || 0} +{runDetail.summary?.added || 0} / -{runDetail.summary?.removed || 0} / ~
{runDetail.summary?.changed || 0} / ={runDetail.summary?.unchanged || 0}
</strong> </strong>
</p> </p>
{runDetail.mapping_stats ? ( {runDetail.mapping_stats ? (
<p className="wb-muted" style={{ fontSize: 12 }}> <p className="muted" style={{ fontSize: 12 }}>
映射校验 ok={String(runDetail.mapping_stats.ok)} miss_before={(runDetail.mapping_stats.miss_before || []).length}{" "} mapping ok={String(runDetail.mapping_stats.ok)} miss_before=
miss_after={(runDetail.mapping_stats.miss_after || []).length} {(runDetail.mapping_stats.miss_before || []).length} miss_after=
{(runDetail.mapping_stats.miss_after || []).length}
</p> </p>
) : null} ) : null}
<table className="wb-table"> <div className="pt-list-table-wrap">
<table className="data-table">
<thead> <thead>
<tr> <tr>
<th>类型</th> <th>{t("bizCompare.colKind")}</th>
<th>Key</th> <th>{t("bizCompare.colKey")}</th>
<th>变更</th> <th>{t("bizCompare.colChange")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -467,21 +499,37 @@ export function BizComparePage() {
.slice(0, 300) .slice(0, 300)
.map((d: any, i: number) => ( .map((d: any, i: number) => (
<tr key={i}> <tr key={i}>
<td>{d.kind}</td> <td>
<NmStatusChip
color={
d.kind === "added"
? "success"
: d.kind === "removed"
? "danger"
: "warning"
}
>
{d.kind}
</NmStatusChip>
</td>
<td> <td>
<code style={{ fontSize: 11 }}>{JSON.stringify(d.key)}</code> <code style={{ fontSize: 11 }}>{JSON.stringify(d.key)}</code>
</td> </td>
<td> <td>
<code style={{ fontSize: 11 }}>{d.kind === "changed" ? JSON.stringify(d.changes) : "-"}</code> <code style={{ fontSize: 11 }}>
{d.kind === "changed" ? JSON.stringify(d.changes) : "—"}
</code>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </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,79 +300,91 @@ 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>
<div className="pt-list-kpi__value">{tasks.length}</div>
</div>
<div className="pt-list-kpi pt-list-kpi--live">
<div className="pt-list-kpi__label">{t("bizState.start")}</div>
<div className="pt-list-kpi__value">{runningCount}</div>
</div>
</div>
<div className="nm-split" style={{ display: "grid", gridTemplateColumns: "1fr 1.45fr", gap: 16 }}>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead> <thead>
<tr> <tr>
<th>网元</th> <th>{t("bizState.colNe")}</th>
<th>状态</th> <th>{t("bizState.colStatus")}</th>
<th>最近采集</th> <th>{t("bizState.colLast")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{tasks.map((t) => ( {tasks.map((row) => (
<tr <tr
key={t.id} key={row.id}
onClick={() => void openTask(t.id)} className={selectedId === row.id ? "is-selected" : undefined}
style={{ style={{ cursor: "pointer" }}
cursor: "pointer", onClick={() => void openTask(row.id)}
background: selectedId === t.id ? "var(--wb-row-active, #eef)" : undefined,
}}
> >
<td> <td>
{t.ne_name || t.ne_ip} <div className="pt-list-task-name">{row.ne_name || row.ne_ip || "—"}</div>
<div className="wb-muted">{t.vendor}</div> <div className="muted">{row.vendor || "—"}</div>
</td> </td>
<td> <td>
{t.status} <div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}>
{t.collect_running ? " · 采集中" : ""} <NmStatusChip color={jobChipColor(row.status)}>{row.status}</NmStatusChip>
{row.collect_running ? (
<NmStatusChip color="accent">{t("bizState.collecting")}</NmStatusChip>
) : null}
</div>
</td> </td>
<td className="wb-muted">{t.last_collect_ended_at || "-"}</td> <td className="pt-list-time">{fmtTime(row.last_collect_ended_at)}</td>
</tr> </tr>
))} ))}
{!tasks.length ? ( {!tasks.length ? (
<tr> <tr>
<td colSpan={3} className="wb-muted"> <td colSpan={3}>
暂无任务 <div className="pt-list-empty">{t("bizState.empty")}</div>
</td> </td>
</tr> </tr>
) : null} ) : null}
@ -369,38 +392,41 @@ export function BizStatePage() {
</table> </table>
</div> </div>
<div className="wb-card"> <div>
{!detail ? ( {!detail ? (
<p className="wb-muted">选择左侧任务查看详情</p> <div className="pt-list-empty">{t("bizState.pickTask")}</div>
) : ( ) : (
<> <>
<h3> <div className="panel__toolbar" style={{ padding: 0, marginBottom: 8 }}>
{detail.ne_name} · {detail.status} <h3 style={{ margin: 0 }}>
{detail.ne_name || detail.ne_ip} · {detail.status}
</h3> </h3>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 12 }}> <div className="btn-row">
<button type="button" disabled={busy} onClick={() => void setStatus("running")}> <Button size="sm" variant="primary" isDisabled={busy} onPress={() => void setStatus("running")}>
启动周期 {t("bizState.start")}
</button> </Button>
<button type="button" disabled={busy} onClick={() => void setStatus("paused")}> <Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void setStatus("paused")}>
暂停 {t("bizState.pause")}
</button> </Button>
<button type="button" disabled={busy} onClick={() => void collectNow()}> <Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void collectNow()}>
立即采集 {t("bizState.collectNow")}
</button> </Button>
<button type="button" disabled={busy} onClick={() => void removeTask()}> <Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeTask()}>
删除 {t("bizState.delete")}
</button> </Button>
</div> </div>
{detail.last_error ? <div className="wb-alert wb-alert--error">{detail.last_error}</div> : null} </div>
{detail.last_error ? <p className="form-error">{detail.last_error}</p> : null}
<h4>可用监控项</h4> <h4 style={{ margin: "12px 0 8px" }}>{t("bizState.profiles")}</h4>
<table className="wb-table"> <div className="pt-list-table-wrap">
<table className="data-table">
<thead> <thead>
<tr> <tr>
<th>启用</th> <th>{t("bizState.enable")}</th>
<th>项</th> <th>{t("bizState.profiles")}</th>
<th>模板</th> <th>{t("bizState.command")}</th>
<th>参数</th> <th>{t("bizState.params")}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@ -423,10 +449,8 @@ export function BizStatePage() {
/> />
</td> </td>
<td> <td>
{prof.title} <div className="pt-list-task-name">{prof.title}</div>
<div className="wb-muted" style={{ fontSize: 12 }}> {prof.description ? <div className="muted">{prof.description}</div> : null}
{prof.description}
</div>
</td> </td>
<td> <td>
<code>{prof.command_template}</code> <code>{prof.command_template}</code>
@ -435,14 +459,19 @@ export function BizStatePage() {
{needsBind {needsBind
? binds.length ? binds.length
? binds.map((b: any) => b.value).join(", ") ? binds.map((b: any) => b.value).join(", ")
: "未关联" : t("bizState.unbound")
: "—"} : "—"}
</td> </td>
<td> <td>
{needsBind && enabled && it ? ( {needsBind && enabled && it ? (
<button type="button" disabled={busy} onClick={() => void startDiscover(it)}> <Button
发现 VRF size="sm"
</button> variant="secondary"
isDisabled={busy}
onPress={() => void startDiscover(it)}
>
{t("bizState.discoverVrf")}
</Button>
) : null} ) : null}
</td> </td>
</tr> </tr>
@ -450,12 +479,13 @@ export function BizStatePage() {
})} })}
</tbody> </tbody>
</table> </table>
</div>
{bindItemId && candidates.length ? ( {bindItemId && candidates.length ? (
<div style={{ marginTop: 12, padding: 12, border: "1px solid var(--wb-border, #ddd)" }}> <div className="panel" style={{ marginTop: 12, padding: 12 }}>
<h4>选择 VRF 绑定</h4> <h4 style={{ marginTop: 0 }}>{t("bizState.bindTitle")}</h4>
<p className="wb-muted"> <p className="muted">
命令 <code>{discoverCmd}</code> · 已选 {selectedVrfs.length} <code>{discoverCmd}</code> · {selectedVrfs.length}
</p> </p>
<div style={{ maxHeight: 220, overflow: "auto", marginBottom: 8 }}> <div style={{ maxHeight: 220, overflow: "auto", marginBottom: 8 }}>
{candidates.map((c) => ( {candidates.map((c) => (
@ -472,80 +502,97 @@ export function BizStatePage() {
}} }}
/>{" "} />{" "}
{c.label} {c.label}
{c.rd ? <span className="wb-muted"> · RD {c.rd}</span> : null} {c.rd ? <span className="muted"> · RD {c.rd}</span> : null}
</label> </label>
))} ))}
</div> </div>
<div style={{ display: "flex", gap: 8 }}> <div className="btn-row">
<button type="button" disabled={busy || !selectedVrfs.length} onClick={() => void saveBindings()}> <Button
保存关联 size="sm"
</button> variant="primary"
<button isDisabled={busy || !selectedVrfs.length}
type="button" onPress={() => void saveBindings()}
disabled={busy} >
onClick={() => { {t("bizState.saveBindings")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => {
setBindItemId(""); setBindItemId("");
setCandidates([]); setCandidates([]);
}} }}
> >
取消 {t("bizState.cancel")}
</button> </Button>
</div> </div>
</div> </div>
) : null} ) : null}
<h4 style={{ marginTop: 16 }}>采集批次</h4> <h4 style={{ margin: "16px 0 8px" }}>{t("bizState.batches")}</h4>
<table className="wb-table"> <div className="pt-list-table-wrap">
<table className="data-table">
<thead> <thead>
<tr> <tr>
<th>时间</th> <th>{t("bizState.colTime")}</th>
<th>状态</th> <th>{t("bizState.colStatus")}</th>
<th>行数</th> <th>{t("bizState.colRows")}</th>
<th>操作</th> <th>{t("bizState.colActions")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{batches.map((b) => ( {batches.map((b) => (
<tr key={b.id}> <tr key={b.id}>
<td>{b.started_at || "-"}</td> <td className="pt-list-time">{fmtTime(b.started_at)}</td>
<td>{b.status}</td> <td>
<td>{b.row_count}</td> <NmStatusChip color={jobChipColor(b.status)}>{b.status}</NmStatusChip>
<td style={{ display: "flex", gap: 8 }}> </td>
<button type="button" onClick={() => void openBatch(b.id)}> <td className="pt-list-num">{b.row_count}</td>
查看 <td>
</button> <div className="pt-list-actions">
<button type="button" onClick={() => void bizStateDownloadExport(b.id)}> <Button size="sm" variant="secondary" onPress={() => void openBatch(b.id)}>
导出 {t("bizState.viewBatch")}
</button> </Button>
<Button
size="sm"
variant="secondary"
onPress={() => void bizStateDownloadExport(b.id)}
>
{t("bizState.export")}
</Button>
</div>
</td> </td>
</tr> </tr>
))} ))}
{!batches.length ? ( {!batches.length ? (
<tr> <tr>
<td colSpan={4} className="wb-muted"> <td colSpan={4}>
尚无批次 <div className="pt-list-empty">{t("bizState.noBatches")}</div>
</td> </td>
</tr> </tr>
) : null} ) : null}
</tbody> </tbody>
</table> </table>
</div>
{batchDetail ? ( {batchDetail ? (
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<h4>批次详情 {batchDetail.id}</h4> <h4 style={{ marginBottom: 4 }}>
<p className="wb-muted"> {t("bizState.batchDetail")} {batchDetail.id}
命令 {batchDetail.command_count} · 行 {batchDetail.row_count} · {batchDetail.status} </h4>
<p className="muted">
{batchDetail.command_count} · {batchDetail.row_count} · {batchDetail.status}
</p> </p>
{(batchDetail.lldp_neighbors || []).length ? ( {(batchDetail.lldp_neighbors || []).length ? (
<> <div className="pt-list-table-wrap" style={{ marginTop: 8 }}>
<h5>LLDP</h5> <table className="data-table">
<table className="wb-table">
<thead> <thead>
<tr> <tr>
<th>本端口</th> <th>local_if</th>
<th>对端系统</th> <th>remote_sys</th>
<th>对端口</th> <th>remote_if</th>
<th>管理IP</th> <th>remote_ip</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -559,17 +606,16 @@ export function BizStatePage() {
))} ))}
</tbody> </tbody>
</table> </table>
</> </div>
) : null} ) : null}
{(batchDetail.vrf_route_summary || []).length ? ( {(batchDetail.vrf_route_summary || []).length ? (
<> <div className="pt-list-table-wrap" style={{ marginTop: 8 }}>
<h5>VRF 路由摘要</h5> <table className="data-table">
<table className="wb-table">
<thead> <thead>
<tr> <tr>
<th>VRF</th> <th>vrf</th>
<th>来源</th> <th>source</th>
<th>条数</th> <th>networks</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -577,12 +623,12 @@ export function BizStatePage() {
<tr key={i}> <tr key={i}>
<td>{r.vrf}</td> <td>{r.vrf}</td>
<td>{r.source}</td> <td>{r.source}</td>
<td>{r.networks}</td> <td className="pt-list-num">{r.networks}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</> </div>
) : null} ) : null}
</div> </div>
) : null} ) : null}
@ -591,5 +637,6 @@ export function BizStatePage() {
</div> </div>
</div> </div>
</div> </div>
</section>
); );
} }