mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Align biz_state pages with network management UI patterns.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
06331ca763
commit
1352535a9b
4 changed files with 818 additions and 563 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "添加设备监控",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue