Add cutover-compare nav group and monitor templates.

Move biz monitor/compare/migration under Cutover compare, split compare templates into their own page, and add monitor-template CRUD that overlays compare templates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-18 20:09:26 +08:00
parent f3a8f80822
commit 202196a197
13 changed files with 812 additions and 36 deletions

View file

@ -52,6 +52,11 @@ const BizComparePage = lazy(() =>
const BizMigrationPage = lazy(() =>
import("./pages/network/BizMigrationPage").then((m) => ({ default: m.BizMigrationPage })),
);
const BizMonitorTemplatesPage = lazy(() =>
import("./pages/network/BizMonitorTemplatesPage").then((m) => ({
default: m.BizMonitorTemplatesPage,
})),
);
const UsersPage = lazy(() => import("./pages/UsersPage").then((m) => ({ default: m.UsersPage })));
const AuditLayout = lazy(() =>
import("./pages/audit/AuditLayout").then((m) => ({ default: m.AuditLayout })),
@ -129,9 +134,23 @@ function ProtectedApp() {
<Route path="tasks/config-sync" element={<ConfigSyncPage />} />
<Route path="tasks/port-traffic/wall" element={<LegacyPortTrafficWallRedirect />} />
<Route path="tasks/port-traffic" element={<PortTrafficPage />} />
<Route path="tasks/biz-state" element={<BizStatePage />} />
<Route path="tasks/biz-compare" element={<BizComparePage />} />
<Route path="tasks/biz-migration" element={<BizMigrationPage />} />
<Route path="tasks/biz-state" element={<Navigate to="/network/cutover/biz-state" replace />} />
<Route
path="tasks/biz-compare"
element={<Navigate to="/network/cutover/biz-compare" replace />}
/>
<Route
path="tasks/biz-migration"
element={<Navigate to="/network/cutover/biz-migration" replace />}
/>
<Route path="cutover/biz-state" element={<BizStatePage />} />
<Route path="cutover/biz-compare" element={<BizComparePage pageMode="jobs" />} />
<Route path="cutover/biz-migration" element={<BizMigrationPage />} />
<Route
path="cutover/compare-templates"
element={<BizComparePage pageMode="templates" />}
/>
<Route path="cutover/monitor-templates" element={<BizMonitorTemplatesPage />} />
</Route>
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />
<Route path="/users" element={<UsersPage />} />

View file

@ -1,9 +1,9 @@
/**
* Left-nav tree for the Network Management module.
* Add leaf items here when extending (devices / topology / alarms / tasks).
* Add leaf items here when extending (devices / topology / alarms / tasks / cutover).
*/
export type NetworkNavGroupId = "detail" | "topologyMgmt" | "tasks";
export type NetworkNavGroupId = "detail" | "topologyMgmt" | "tasks" | "cutoverCompare";
export type NetworkNavItem = {
id: string;
@ -74,23 +74,41 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [
labelKey: "network.nav.portTrafficWall",
group: "tasks",
},
],
},
{
id: "cutoverCompare",
labelKey: "network.nav.cutoverCompare",
items: [
{
id: "biz-state",
path: "/network/tasks/biz-state",
path: "/network/cutover/biz-state",
labelKey: "network.nav.bizState",
group: "tasks",
group: "cutoverCompare",
},
{
id: "biz-compare",
path: "/network/tasks/biz-compare",
path: "/network/cutover/biz-compare",
labelKey: "network.nav.bizCompare",
group: "tasks",
group: "cutoverCompare",
},
{
id: "biz-migration",
path: "/network/tasks/biz-migration",
path: "/network/cutover/biz-migration",
labelKey: "network.nav.bizMigration",
group: "tasks",
group: "cutoverCompare",
},
{
id: "compare-templates",
path: "/network/cutover/compare-templates",
labelKey: "network.nav.compareTemplates",
group: "cutoverCompare",
},
{
id: "monitor-templates",
path: "/network/cutover/monitor-templates",
labelKey: "network.nav.monitorTemplates",
group: "cutoverCompare",
},
],
},

View file

@ -144,6 +144,7 @@ const en = {
nav: {
detail: "Network details",
tasks: "Monitoring tasks",
cutoverCompare: "Cutover compare",
topologyMgmt: "Topology management",
devices: "Device list",
topology: "Topology",
@ -159,6 +160,8 @@ const en = {
bizState: "Business monitor",
bizCompare: "Business compare",
bizMigration: "Cutover monitor",
compareTemplates: "Compare templates",
monitorTemplates: "Monitor templates",
},
collapseNav: "Collapse sidebar",
expandNav: "Expand sidebar",
@ -568,6 +571,40 @@ const en = {
colStatus: "Status",
verdictUnfinished: "Unfinished",
},
bizMonitorTpl: {
title: "Monitor templates",
hint: "Reference a compare template for HOW-to-compare, then add dual-device / baseline rules for cutover monitor.",
create: "New monitor template",
edit: "Edit",
save: "Save",
cancel: "Close",
created: "Created",
saved: "Saved",
deleted: "Deleted",
confirmDelete: "Delete this monitor template?",
delete: "Delete",
empty: "No monitor templates yet.",
listFilterPh: "Filter name / compare template / note",
kpiTotal: "Templates",
colName: "Name",
colCompare: "Compare template",
colCollect: "HF collect metrics",
colNote: "Note",
colActions: "Actions",
needName: "Name is required",
defaultsInvalid: "Invalid defaults JSON",
overridesInvalid: "Invalid sheet_overrides JSON",
formHint: "Compare template owns alignment & field rules; this page owns dual verdict + optional HF collect subset.",
compareHint: "From Compare templates; create/edit sheets there first if needed.",
pickCompare: "Pick compare template…",
openCompare: "Manage compare templates",
collectAll: "All sheets",
collectPh: "interface_brief, bgp_peer (empty = all)",
collectHint: "Comma-separated metric_id; empty uses every sheet on the compare template.",
defaults: "defaults (JSON)",
overrides: "sheet_overrides (JSON)",
overridesHint: "Optional per-metric status_fields / success patterns; empty array uses global defaults.",
},
portTraffic: {
title: "Port traffic",
create: "Monitor device",

View file

@ -144,6 +144,7 @@ const zh = {
nav: {
detail: "网络详情",
tasks: "监控任务",
cutoverCompare: "割接对比",
topologyMgmt: "拓扑管理",
devices: "设备列表",
topology: "拓扑信息",
@ -159,6 +160,8 @@ const zh = {
bizState: "业务监控",
bizCompare: "业务对比",
bizMigration: "割接监控",
compareTemplates: "对比模板",
monitorTemplates: "监控模板",
},
collapseNav: "折叠侧栏",
expandNav: "展开侧栏",
@ -565,6 +568,40 @@ const zh = {
colStatus: "状态",
verdictUnfinished: "未完成",
},
bizMonitorTpl: {
title: "监控模板",
hint: "引用对比模板定义「如何比」,再叠加双端迁移/基线判定规则。供割接监控选用。",
create: "新建监控模板",
edit: "编辑",
save: "保存",
cancel: "关闭",
created: "已创建",
saved: "已保存",
deleted: "已删除",
confirmDelete: "删除该监控模板?",
delete: "删除",
empty: "暂无监控模板。",
listFilterPh: "筛选名称 / 对比模板 / 备注",
kpiTotal: "模板数",
colName: "名称",
colCompare: "对比模板",
colCollect: "高频采集项",
colNote: "备注",
colActions: "操作",
needName: "请填写名称",
defaultsInvalid: "defaults JSON 无效",
overridesInvalid: "sheet_overrides JSON 无效",
formHint: "对比模板决定对齐与字段规则;本页配置双端判定与可选高频采集子集。",
compareHint: "来自「对比模板」;可先去那边新建或编辑 sheets。",
pickCompare: "选择对比模板…",
openCompare: "管理对比模板",
collectAll: "模板全量",
collectPh: "interface_brief, bgp_peer(空=全量)",
collectHint: "逗号分隔 metric_id;留空表示使用对比模板全部 sheets。",
defaults: "defaults(JSON)",
overrides: "sheet_overrides(JSON)",
overridesHint: "可按 metric 声明 status_fields / success 模式;空数组则用全局 defaults。",
},
portTraffic: {
title: "端口流量监控",
create: "添加设备监控",

View file

@ -529,11 +529,14 @@ function metricLabel(id: string) {
return id;
}
export function BizComparePage() {
export type BizComparePageMode = "jobs" | "templates" | "all";
export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePageMode }) {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [pageTab, setPageTab] = useState<PageTab>("jobs");
const [pageTab, setPageTab] = useState<PageTab>(pageMode === "templates" ? "templates" : "jobs");
const showTabSwitch = pageMode === "all";
const [busy, setBusy] = useState(false);
const [tasks, setTasks] = useState<TaskOpt[]>([]);
@ -1094,7 +1097,7 @@ export function BizComparePage() {
});
showOk(t("bizCompare.templateImported"));
await refresh();
setPageTab("templates");
if (pageMode !== "jobs") setPageTab("templates");
} catch (e) {
showError(formatErr(e));
} finally {
@ -1453,7 +1456,9 @@ export function BizComparePage() {
return (
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("bizCompare.title")}</h2>
<h2>
{pageMode === "templates" ? t("bizCompare.templates") : t("bizCompare.title")}
</h2>
<div className="btn-row">
{pageTab === "templates" ? (
<>
@ -1488,24 +1493,26 @@ export function BizComparePage() {
</div>
<div className="pt-list">
<div className="btn-row nm-config-modal__tabs" role="tablist">
<Button
size="sm"
variant={pageTab === "jobs" ? "primary" : "secondary"}
className={pageTab === "jobs" ? "is-active" : undefined}
onPress={() => setPageTab("jobs")}
>
{t("bizCompare.jobList")}
</Button>
<Button
size="sm"
variant={pageTab === "templates" ? "primary" : "secondary"}
className={pageTab === "templates" ? "is-active" : undefined}
onPress={() => setPageTab("templates")}
>
{t("bizCompare.templates")}
</Button>
</div>
{showTabSwitch ? (
<div className="btn-row nm-config-modal__tabs" role="tablist">
<Button
size="sm"
variant={pageTab === "jobs" ? "primary" : "secondary"}
className={pageTab === "jobs" ? "is-active" : undefined}
onPress={() => setPageTab("jobs")}
>
{t("bizCompare.jobList")}
</Button>
<Button
size="sm"
variant={pageTab === "templates" ? "primary" : "secondary"}
className={pageTab === "templates" ? "is-active" : undefined}
onPress={() => setPageTab("templates")}
>
{t("bizCompare.templates")}
</Button>
</div>
) : null}
<div className="filter-inline">
<Input

View file

@ -688,7 +688,12 @@ export function BizMigrationPage() {
</Button>
</div>
</div>
<p className="panel__hint muted">{t("bizMigration.hintPort")}</p>
<p className="panel__hint muted">
{t("bizMigration.hintPort")}{" "}
<Link to="/network/cutover/monitor-templates">{t("network.nav.monitorTemplates")}</Link>
{" · "}
<Link to="/network/cutover/compare-templates">{t("network.nav.compareTemplates")}</Link>
</p>
<div className="pt-list">
<div className="pt-list-kpis">
@ -838,7 +843,7 @@ export function BizMigrationPage() {
{!tasks.length ? (
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
{t("bizMigration.needBizStateTasks")}{" "}
<Link to="/network/tasks/biz-state" onClick={closeCreate}>
<Link to="/network/cutover/biz-state" onClick={closeCreate}>
{t("bizMigration.openBizState")}
</Link>
</p>
@ -941,7 +946,7 @@ export function BizMigrationPage() {
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void onCollectNow()}>
{t("bizMigration.collectNow")}
</Button>
<Link to="/network/tasks/biz-state" style={{ fontSize: 12, alignSelf: "center" }}>
<Link to="/network/cutover/biz-state" style={{ fontSize: 12, alignSelf: "center" }}>
{t("bizMigration.openBizState")}
</Link>
{openRedCount > 0 ? (

View file

@ -0,0 +1,333 @@
import { Button, Input, Modal } from "@heroui/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import {
bizCompareListTemplates,
bizMonitorCreateTemplate,
bizMonitorDeleteTemplate,
bizMonitorListTemplates,
bizMonitorUpdateTemplate,
formatErr,
} from "../../services/api";
type CompareTpl = { id: string; name: string };
type MonitorTpl = {
id: string;
name: string;
compare_template_id: string;
compare_template_name?: string;
collect_metric_ids?: string[];
defaults?: Record<string, unknown>;
sheet_overrides?: unknown[];
note?: string;
};
export function BizMonitorTemplatesPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [items, setItems] = useState<MonitorTpl[]>([]);
const [compareTpls, setCompareTpls] = useState<CompareTpl[]>([]);
const [busy, setBusy] = useState(false);
const [listKw, setListKw] = useState("");
const debouncedKw = useDebouncedValue(listKw, 250);
const [editOpen, setEditOpen] = useState(false);
const [editId, setEditId] = useState("");
const [name, setName] = useState("");
const [compareId, setCompareId] = useState("");
const [note, setNote] = useState("");
const [collectText, setCollectText] = useState("");
const [defaultsText, setDefaultsText] = useState('{"dual_mode":"migrate_pair","out_of_expect":"strict"}');
const [overridesText, setOverridesText] = useState("[]");
const refresh = useCallback(async () => {
const [mon, cmp] = await Promise.all([bizMonitorListTemplates(), bizCompareListTemplates()]);
setItems((mon.items || []) as MonitorTpl[]);
setCompareTpls(
((cmp.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
name: String(x.name || x.id || ""),
})),
);
}, []);
useEffect(() => {
void (async () => {
try {
await refresh();
} catch (e) {
showError(formatErr(e));
}
})();
}, [refresh, showError]);
const filtered = useMemo(() => {
const kw = debouncedKw.trim().toLowerCase();
if (!kw) return items;
return items.filter((row) => {
const blob = `${row.name} ${row.compare_template_name || ""} ${row.note || ""} ${row.compare_template_id}`.toLowerCase();
return blob.includes(kw);
});
}, [items, debouncedKw]);
const openCreate = () => {
setEditId("");
setName("");
setCompareId(compareTpls[0]?.id || "");
setNote("");
setCollectText("");
setDefaultsText('{"dual_mode":"migrate_pair","out_of_expect":"strict"}');
setOverridesText("[]");
setEditOpen(true);
};
const openEdit = (row: MonitorTpl) => {
setEditId(row.id);
setName(row.name || "");
setCompareId(row.compare_template_id || "");
setNote(row.note || "");
setCollectText((row.collect_metric_ids || []).join(", "));
setDefaultsText(JSON.stringify(row.defaults || {}, null, 2));
setOverridesText(JSON.stringify(row.sheet_overrides || [], null, 2));
setEditOpen(true);
};
const closeEdit = () => setEditOpen(false);
const save = async () => {
if (!name.trim()) {
showError(t("bizMonitorTpl.needName"));
return;
}
let defaults: Record<string, unknown> = {};
let overrides: unknown[] = [];
try {
defaults = JSON.parse(defaultsText || "{}") as Record<string, unknown>;
if (!defaults || typeof defaults !== "object" || Array.isArray(defaults)) {
throw new Error("defaults");
}
} catch {
showError(t("bizMonitorTpl.defaultsInvalid"));
return;
}
try {
overrides = JSON.parse(overridesText || "[]") as unknown[];
if (!Array.isArray(overrides)) throw new Error("overrides");
} catch {
showError(t("bizMonitorTpl.overridesInvalid"));
return;
}
const collect_metric_ids = collectText
.split(/[,;\s]+/)
.map((x) => x.trim())
.filter(Boolean);
const body = {
name: name.trim(),
compare_template_id: compareId,
collect_metric_ids,
defaults,
sheet_overrides: overrides,
note: note.trim(),
};
setBusy(true);
try {
if (editId) {
await bizMonitorUpdateTemplate(editId, body);
showOk(t("bizMonitorTpl.saved"));
} else {
await bizMonitorCreateTemplate(body);
showOk(t("bizMonitorTpl.created"));
}
closeEdit();
await refresh();
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
const remove = async (id: string) => {
if (!window.confirm(t("bizMonitorTpl.confirmDelete"))) return;
setBusy(true);
try {
await bizMonitorDeleteTemplate(id);
showOk(t("bizMonitorTpl.deleted"));
await refresh();
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
return (
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("bizMonitorTpl.title")}</h2>
<div className="btn-row">
<Button size="sm" variant="primary" onPress={openCreate}>
{t("bizMonitorTpl.create")}
</Button>
</div>
</div>
<p className="panel__hint muted">{t("bizMonitorTpl.hint")}</p>
<div className="pt-list">
<div className="pt-list-kpis">
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("bizMonitorTpl.kpiTotal")}</div>
<div className="pt-list-kpi__value">{items.length}</div>
</div>
</div>
<div className="filter-inline">
<Input
value={listKw}
placeholder={t("bizMonitorTpl.listFilterPh")}
onChange={(e) => setListKw(e.target.value)}
/>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("bizMonitorTpl.colName")}</th>
<th>{t("bizMonitorTpl.colCompare")}</th>
<th>{t("bizMonitorTpl.colCollect")}</th>
<th>{t("bizMonitorTpl.colNote")}</th>
<th>{t("bizMonitorTpl.colActions")}</th>
</tr>
</thead>
<tbody>
{filtered.map((row) => (
<tr key={row.id}>
<td>
<div className="pt-list-task-name">{row.name}</div>
</td>
<td>
{row.compare_template_name || row.compare_template_id || "—"}
{row.compare_template_id ? (
<div className="muted" style={{ fontSize: 11 }}>
<Link to="/network/cutover/compare-templates">
{t("bizMonitorTpl.openCompare")}
</Link>
</div>
) : null}
</td>
<td>
{(row.collect_metric_ids || []).length
? (row.collect_metric_ids || []).join(", ")
: t("bizMonitorTpl.collectAll")}
</td>
<td className="muted">{row.note || "—"}</td>
<td>
<div className="pt-list-actions">
<Button size="sm" variant="primary" onPress={() => openEdit(row)}>
{t("bizMonitorTpl.edit")}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={busy}
onPress={() => void remove(row.id)}
>
{t("bizMonitorTpl.delete")}
</Button>
</div>
</td>
</tr>
))}
{!filtered.length ? (
<tr>
<td colSpan={5}>
<div className="pt-list-empty">{t("bizMonitorTpl.empty")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
<AppModalShell open={editOpen} onClose={closeEdit} size="lg">
<Modal.Header>
<Modal.Heading>
{editId ? t("bizMonitorTpl.edit") : t("bizMonitorTpl.create")}
</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
{t("bizMonitorTpl.formHint")}
</p>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizMonitorTpl.colName")}</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<FieldSelect
label={t("bizMonitorTpl.colCompare")}
value={compareId}
onChange={(e) => setCompareId(e.target.value)}
fullWidth
hint={t("bizMonitorTpl.compareHint")}
>
<option value="">{t("bizMonitorTpl.pickCompare")}</option>
{compareTpls.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</FieldSelect>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizMonitorTpl.colCollect")}</span>
<Input
value={collectText}
onChange={(e) => setCollectText(e.target.value)}
placeholder={t("bizMonitorTpl.collectPh")}
/>
<span className="ui-field__hint">{t("bizMonitorTpl.collectHint")}</span>
</label>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizMonitorTpl.defaults")}</span>
<textarea
value={defaultsText}
onChange={(e) => setDefaultsText(e.target.value)}
rows={4}
style={{ width: "100%", fontFamily: "ui-monospace, monospace", fontSize: 12 }}
/>
</label>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizMonitorTpl.overrides")}</span>
<textarea
value={overridesText}
onChange={(e) => setOverridesText(e.target.value)}
rows={6}
style={{ width: "100%", fontFamily: "ui-monospace, monospace", fontSize: 12 }}
/>
<span className="ui-field__hint">{t("bizMonitorTpl.overridesHint")}</span>
</label>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizMonitorTpl.colNote")}</span>
<Input value={note} onChange={(e) => setNote(e.target.value)} />
</label>
</Modal.Body>
<Modal.Footer>
<Button size="sm" variant="secondary" onPress={closeEdit}>
{t("bizMonitorTpl.cancel")}
</Button>
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void save()}>
{t("bizMonitorTpl.save")}
</Button>
</Modal.Footer>
</AppModalShell>
</section>
);
}

View file

@ -2096,3 +2096,20 @@ export const bizMigrationListDiffs = (params: {
`/v1/biz-migration/runs/${encodeURIComponent(params.runId)}/diffs${qs ? `?${qs}` : ""}`,
);
};
export const bizMonitorListTemplates = () =>
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-migration/monitor-templates");
export const bizMonitorCreateTemplate = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-migration/monitor-templates", body);
export const bizMonitorUpdateTemplate = (templateId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(
`/v1/biz-migration/monitor-templates/${encodeURIComponent(templateId)}`,
body,
);
export const bizMonitorDeleteTemplate = (templateId: string) =>
apiDelete<{ ok: boolean }>(
`/v1/biz-migration/monitor-templates/${encodeURIComponent(templateId)}`,
);