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.",
},
},
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: {
title: "Port traffic",
create: "Monitor device",

View file

@ -166,6 +166,86 @@ const zh = {
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: {
title: "端口流量监控",
create: "添加设备监控",

View file

@ -1,4 +1,8 @@
import { Button, Input } from "@heroui/react";
import { useCallback, useEffect, useState } from "react";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import {
bizCompareCreateJob,
bizCompareCreateMapping,
@ -13,11 +17,14 @@ import {
bizCompareValidateMapping,
bizStateListBatches,
bizStateListTasks,
formatErr,
} 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 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 Job = {
id: string;
@ -32,7 +39,14 @@ type Job = {
status: string;
};
function fmtTime(v?: string | null) {
if (!v) return "—";
return formatSystemTime(v) || v;
}
export function BizComparePage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [tasks, setTasks] = useState<TaskOpt[]>([]);
const [templates, setTemplates] = useState<Template[]>([]);
const [mappings, setMappings] = useState<Mapping[]>([]);
@ -40,10 +54,8 @@ export function BizComparePage() {
const [selectedJobId, setSelectedJobId] = useState("");
const [runs, setRuns] = useState<any[]>([]);
const [runDetail, setRunDetail] = useState<any>(null);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
// create job form
const [name, setName] = useState("割接比对");
const [templateId, setTemplateId] = useState("");
const [mappingId, setMappingId] = useState("");
@ -55,19 +67,18 @@ export function BizComparePage() {
const [afterBatchId, setAfterBatchId] = useState("");
const [mode, setMode] = useState<"manual" | "auto">("manual");
// mapping editor
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 refresh = useCallback(async () => {
const [t, tpl, maps, j] = await Promise.all([
const [taskRes, tpl, maps, j] = await Promise.all([
bizStateListTasks(),
bizCompareListTemplates(),
bizCompareListMappings(),
bizCompareListJobs(),
]);
setTasks((t.items || []) as TaskOpt[]);
setTasks((taskRes.items || []) as TaskOpt[]);
setTemplates((tpl.items || []) as Template[]);
setMappings((maps.items || []) as Mapping[]);
setJobs((j.items || []) as Job[]);
@ -79,10 +90,11 @@ export function BizComparePage() {
void (async () => {
try {
await refresh();
} catch (e: any) {
setErr(String(e?.message || e));
} catch (e) {
showError(formatErr(e));
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount once
}, [refresh]);
useEffect(() => {
@ -111,7 +123,9 @@ export function BizComparePage() {
const rows: { before_if: string; after_if: string }[] = [];
for (const line of mapText.split(/\r?\n/)) {
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());
if (parts.length >= 2 && parts[0] && parts[1]) {
rows.push({ before_if: parts[0], after_if: parts[1] });
@ -120,16 +134,20 @@ export function BizComparePage() {
return rows;
};
const saveMapping = async () => {
const updateExistingMapping = async () => {
setBusy(true);
setErr("");
try {
const rows = parseMapRows();
const m = await bizCompareCreateMapping({ name: mapName, rows });
setMappingId(String(m.id));
if (mappingId) {
await bizCompareUpdateMapping(mappingId, { name: mapName, rows });
} else {
const m = await bizCompareCreateMapping({ name: mapName, rows });
setMappingId(String(m.id));
}
showOk(t("bizCompare.mappingSaved"));
await refresh();
} catch (e: any) {
setErr(String(e?.message || e));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
@ -137,11 +155,10 @@ export function BizComparePage() {
const doValidate = async () => {
if (!mappingId || !beforeBatchId || !afterBatchId) {
setErr("请先选择映射与前后批次");
showError(`${t("bizCompare.validateMapping")}: mapping / batches`);
return;
}
setBusy(true);
setErr("");
try {
const v = await bizCompareValidateMapping({
mapping_id: mappingId,
@ -150,8 +167,8 @@ export function BizComparePage() {
template_id: templateId,
});
setValidateOut(v);
} catch (e: any) {
setErr(String(e?.message || e));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
@ -159,7 +176,6 @@ export function BizComparePage() {
const createJob = async () => {
setBusy(true);
setErr("");
try {
const j = await bizCompareCreateJob({
name,
@ -172,11 +188,12 @@ export function BizComparePage() {
mode,
});
setSelectedJobId(String(j.id));
showOk(t("bizCompare.created"));
await refresh();
const r = await bizCompareListRuns(String(j.id));
setRuns(r.items || []);
} catch (e: any) {
setErr(String(e?.message || e));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
@ -197,15 +214,14 @@ export function BizComparePage() {
setBeforeBatchId(job.before_batch_id);
setAfterBatchId(job.after_batch_id);
setMode(job.mode === "auto" ? "auto" : "manual");
if (job.mapping_id) loadMappingText(job.mapping_id);
}
};
const runNow = async () => {
if (!selectedJobId) return;
setBusy(true);
setErr("");
try {
// sync latest batch selection onto job before run
await bizCompareUpdateJob(selectedJobId, {
before_batch_id: beforeBatchId,
after_batch_id: mode === "manual" ? afterBatchId : "",
@ -215,18 +231,23 @@ export function BizComparePage() {
});
const run = await bizCompareRunJob(selectedJobId);
setRunDetail(run);
showOk(t("bizCompare.ran"));
const r = await bizCompareListRuns(selectedJobId);
setRuns(r.items || []);
} catch (e: any) {
setErr(String(e?.message || e));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
const loadRun = async (runId: string) => {
const d = await bizCompareGetRun(runId);
setRunDetail(d);
try {
const d = await bizCompareGetRun(runId);
setRunDetail(d);
} catch (e) {
showError(formatErr(e));
}
};
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"));
};
const updateExistingMapping = async () => {
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 || "-"})`;
const taskLabel = (row: TaskOpt) => `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`;
return (
<div className="wb-page">
<div className="wb-page__header">
<h1>业务状态比对</h1>
<p className="wb-muted">Phase2:模板 + 端口映射 + 前后批次 diff(支持 auto 追比)</p>
</div>
{err ? <div className="wb-alert wb-alert--error">{err}</div> : null}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div className="wb-card">
<h3>端口映射</h3>
<div style={{ display: "flex", gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
<select
value={mappingId}
onChange={(e) => {
const id = e.target.value;
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}
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("bizCompare.title")}</h2>
<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 className="wb-card">
<h3>建立比对任务</h3>
<div style={{ display: "grid", gap: 8 }}>
<label>
名称{" "}
<input value={name} onChange={(e) => setName(e.target.value)} style={{ width: "70%" }} />
</label>
<label>
模板{" "}
<select value={templateId} onChange={(e) => setTemplateId(e.target.value)}>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.metric_id})
<div className="pt-list">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div>
<h3 style={{ marginTop: 0 }}>{t("bizCompare.mapping")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<FieldSelect
value={mappingId}
onChange={(e) => {
const id = e.target.value;
if (id) loadMappingText(id);
else {
setMappingId("");
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>
))}
</select>
</label>
<label>
模式{" "}
<select value={mode} onChange={(e) => setMode(e.target.value as "manual" | "auto")}>
<option value="manual">手工选批次</option>
<option value="auto">自动(钉死操作前,操作后取最新批次)</option>
</select>
</label>
<label>
操作前任务{" "}
<select value={beforeTaskId} onChange={(e) => setBeforeTaskId(e.target.value)}>
<option value="">选择…</option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{taskLabel(t)}
</FieldSelect>
<Input value={mapName} placeholder={t("bizCompare.mapName")} onChange={(e) => setMapName(e.target.value)} />
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void updateExistingMapping()}>
{t("bizCompare.saveMapping")}
</Button>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void doValidate()}>
{t("bizCompare.validateMapping")}
</Button>
</div>
<textarea
value={mapText}
onChange={(e) => setMapText(e.target.value)}
placeholder={t("bizCompare.mapHint")}
rows={8}
style={{ width: "100%", fontFamily: "ui-monospace, monospace" }}
/>
{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>
))}
</select>
</label>
<label>
操作前批次{" "}
<select value={beforeBatchId} onChange={(e) => setBeforeBatchId(e.target.value)}>
<option value="">选择…</option>
</FieldSelect>
<FieldSelect
label={t("bizCompare.mode")}
value={mode}
onChange={(e) => setMode(e.target.value as "manual" | "auto")}
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) => (
<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>
))}
</select>
</label>
<label>
操作后任务{" "}
<select value={afterTaskId} onChange={(e) => setAfterTaskId(e.target.value)}>
<option value="">同操作前 / 选择…</option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{taskLabel(t)}
</FieldSelect>
<FieldSelect
label={t("bizCompare.afterTask")}
value={afterTaskId}
onChange={(e) => setAfterTaskId(e.target.value)}
fullWidth
>
<option value="">{t("bizCompare.sameAsBefore")}</option>
{tasks.map((row) => (
<option key={row.id} value={row.id}>
{taskLabel(row)}
</option>
))}
</select>
</label>
{mode === "manual" ? (
<label>
操作后批次{" "}
<select value={afterBatchId} onChange={(e) => setAfterBatchId(e.target.value)}>
<option value="">选择…</option>
</FieldSelect>
{mode === "manual" ? (
<FieldSelect
label={t("bizCompare.afterBatch")}
value={afterBatchId}
onChange={(e) => setAfterBatchId(e.target.value)}
fullWidth
>
<option value="">{t("bizCompare.pick")}</option>
{afterBatches.map((b) => (
<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>
))}
</select>
</label>
) : (
<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>
</FieldSelect>
) : (
<p className="muted">{t("bizCompare.autoHint")}</p>
)}
</div>
</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1.6fr", gap: 16, marginTop: 16 }}>
<div className="wb-card">
<h3>比对任务列表</h3>
<table className="wb-table">
<thead>
<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">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1.6fr", gap: 16, marginTop: 16 }}>
<div>
<h3>{t("bizCompare.jobList")}</h3>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>类型</th>
<th>Key</th>
<th>变更</th>
<th>{t("bizCompare.colName")}</th>
<th>{t("bizCompare.colMode")}</th>
<th>{t("bizCompare.colStatus")}</th>
</tr>
</thead>
<tbody>
{(runDetail.diffs || [])
.filter((d: any) => d.kind !== "unchanged")
.slice(0, 300)
.map((d: any, i: number) => (
<tr key={i}>
<td>{d.kind}</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>
))}
{jobs.map((j) => (
<tr
key={j.id}
className={selectedJobId === j.id ? "is-selected" : undefined}
style={{ cursor: "pointer" }}
onClick={() => void openJob(j.id)}
>
<td>
<div className="pt-list-task-name">{j.name}</div>
</td>
<td>{j.mode}</td>
<td>
<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>
</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>
</section>
);
}

View file

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