Decouple cutover HF from portrait and tighten ops UI.

Portrait stays on biz-monitor; cutover create/ensure owns purpose=cutover_hf slots with project collect override, multi-interval bindings, window pause, and industrial detail UX. Also expand ZTE collect/config parsers and TextFSM for route/BGP/L2VPN/VRRP coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-19 21:11:31 +08:00
parent a65c9fc420
commit 285c19e5a3
67 changed files with 9510 additions and 723 deletions

View file

@ -85,6 +85,7 @@ type Template = {
key_fields: string[];
iface_fields: string[];
compare_fields: string[];
iface_normalize_rules?: { from: string; to: string }[];
note: string;
};
@ -405,6 +406,7 @@ function templateExportPayload(tpl: Template) {
exported_at: new Date().toISOString(),
name: tpl.name,
note: tpl.note || "",
iface_normalize_rules: [...(tpl.iface_normalize_rules || [])],
metrics,
};
}
@ -413,6 +415,7 @@ function parseTemplateImport(raw: unknown): {
name: string;
note: string;
metrics: MetricSheet[];
iface_normalize_rules?: { from: string; to: string }[];
} | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
@ -462,7 +465,49 @@ function parseTemplateImport(raw: unknown): {
});
}
if (!metrics.length) return null;
return { name, note, metrics };
const normRaw = o.iface_normalize_rules;
const iface_normalize_rules: { from: string; to: string }[] = [];
if (Array.isArray(normRaw)) {
for (const item of normRaw) {
if (!item || typeof item !== "object") continue;
const r = item as Record<string, unknown>;
const fr = String(r.from || "").trim();
const to = String(r.to || "").trim();
if (fr && to) iface_normalize_rules.push({ from: fr, to });
}
}
return { name, note, metrics, iface_normalize_rules };
}
function ifaceNormRulesToText(rules?: { from: string; to: string }[]): string {
return (rules || []).map((r) => `${r.from},${r.to}`).join("\n");
}
function parseIfaceNormText(text: string): { from: string; to: string }[] {
const out: { from: string; to: string }[] = [];
for (const line of text.split(/\r?\n/)) {
const s = line.trim();
if (!s || s.startsWith("#")) continue;
let fr = "";
let to = "";
if (s.includes(",")) {
const i = s.indexOf(",");
fr = s.slice(0, i).trim();
to = s.slice(i + 1).trim();
} else if (s.includes("\t")) {
const i = s.indexOf("\t");
fr = s.slice(0, i).trim();
to = s.slice(i + 1).trim();
} else {
const parts = s.split(/\s+/);
if (parts.length >= 2) {
fr = parts[0];
to = parts.slice(1).join(" ");
}
}
if (fr && to) out.push({ from: fr, to });
}
return out;
}
function downloadJsonFile(filename: string, data: unknown) {
@ -721,6 +766,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const [tplEditId, setTplEditId] = useState("");
const [tplName, setTplName] = useState("");
const [tplNote, setTplNote] = useState("");
const [tplNormText, setTplNormText] = useState("");
const [tplSheets, setTplSheets] = useState<MetricSheet[]>([]);
const [tplSheetIdx, setTplSheetIdx] = useState(0);
const [tplPaneTab, setTplPaneTab] = useState<"fields" | "filters">("fields");
@ -1127,6 +1173,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setTplEditId("");
setTplName("");
setTplNote("");
setTplNormText("XXVGE,xxvgei\nXGE,xgei\nCGE,cgei\nGE,gei\nSG,smartgroup");
setTplSheets([]);
setTplSheetIdx(0);
setTplPaneTab("fields");
@ -1139,6 +1186,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setTplEditId(tpl.id);
setTplName(tpl.name);
setTplNote(tpl.note || "");
setTplNormText(ifaceNormRulesToText(tpl.iface_normalize_rules));
setTplSheets(sheets.length ? sheets.map(cloneSheet) : []);
setTplSheetIdx(0);
setTplPaneTab("fields");
@ -1229,6 +1277,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const body = {
name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"),
note: tplNote,
iface_normalize_rules: parseIfaceNormText(tplNormText),
metrics: tplSheets.map((s) => ({
sheet_id: sheetIdentity(s),
title: s.title || sheetIdentity(s),
@ -1293,6 +1342,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
await bizCompareCreateTemplate({
name: body.name,
note: body.note,
iface_normalize_rules: body.iface_normalize_rules || [],
metrics: body.metrics.map((s) => ({
sheet_id: sheetIdentity(s),
title: s.title || sheetIdentity(s),
@ -1890,6 +1940,19 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
/>
</div>
<div className="ct-editor__norm">
<div className="ct-editor__pick-title">{t("bizCompare.ifaceNormalize")}</div>
<p className="muted ct-editor__pick-hint">{t("bizCompare.ifaceNormalizeHint")}</p>
<textarea
className="ct-editor__norm-area"
rows={5}
value={tplNormText}
onChange={(e) => setTplNormText(e.target.value)}
placeholder={"GE,gei\nSG,smartgroup"}
aria-label={t("bizCompare.ifaceNormalize")}
/>
</div>
<div className="ct-editor__pick">
<div className="ct-editor__pick-head">
<span className="ct-editor__pick-title">

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
import { Button, Input, Modal } from "@heroui/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
@ -70,6 +70,71 @@ type MonitorTpl = {
note?: string;
};
const MONITOR_EXPORT_FORMAT = "netx.biz_monitor_template";
const MONITOR_EXPORT_VERSION = 1;
function downloadJsonFile(filename: string, data: unknown) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function monitorExportPayload(tpl: MonitorTpl) {
return {
format: MONITOR_EXPORT_FORMAT,
version: MONITOR_EXPORT_VERSION,
exported_at: new Date().toISOString(),
name: tpl.name,
note: tpl.note || "",
compare_template_id: tpl.compare_template_id || "",
compare_template_name: tpl.compare_template_name || "",
collect_metric_ids: [...(tpl.collect_metric_ids || [])],
defaults: { ...(tpl.defaults || {}) },
sheet_overrides: JSON.parse(JSON.stringify(tpl.sheet_overrides || [])),
};
}
function parseMonitorImport(raw: unknown): {
name: string;
note: string;
compare_template_id: string;
compare_template_name: string;
collect_metric_ids: string[];
defaults: Record<string, unknown>;
sheet_overrides: SheetOverride[];
} | null {
if (!raw || typeof raw !== "object") return null;
const o = raw as Record<string, unknown>;
const name = String(o.name || "").trim();
if (!name) return null;
const compare_template_id = String(o.compare_template_id || "").trim();
const compare_template_name = String(o.compare_template_name || "").trim();
if (!compare_template_id && !compare_template_name) return null;
const collect = Array.isArray(o.collect_metric_ids)
? (o.collect_metric_ids as unknown[]).map((x) => String(x).trim()).filter(Boolean)
: [];
const defaults =
o.defaults && typeof o.defaults === "object" && !Array.isArray(o.defaults)
? (o.defaults as Record<string, unknown>)
: { dual_mode: "migrate_pair", out_of_expect: "strict" };
const sheet_overrides = Array.isArray(o.sheet_overrides)
? (o.sheet_overrides as SheetOverride[])
: [];
return {
name,
note: String(o.note || ""),
compare_template_id,
compare_template_name,
collect_metric_ids: collect,
defaults,
sheet_overrides,
};
}
const PRESENCE_TOKENS = [
{ id: "removed", labelKey: "tokRemoved" },
{ id: "added", labelKey: "tokAdded" },
@ -481,7 +546,9 @@ function SideGroupsEditor({
</label>
) : null}
{dontCare ? (
<p className="muted mt-side-empty">{t("bizMonitorTpl.sideDontCareHint")}</p>
<div className="mt-side-empty-box">
<p className="muted mt-side-empty">{t("bizMonitorTpl.sideDontCareHint")}</p>
</div>
) : (
<>
{groups.map((group, gi) => (
@ -759,6 +826,7 @@ export function BizMonitorTemplatesPage() {
const [showMore, setShowMore] = useState(false);
const [showAdvancedJson, setShowAdvancedJson] = useState(false);
const [overridesText, setOverridesText] = useState("[]");
const tplImportRef = useRef<HTMLInputElement>(null);
const refresh = useCallback(async () => {
const [mon, cmp, metrics] = await Promise.all([
@ -970,6 +1038,60 @@ export function BizMonitorTemplatesPage() {
}
};
const exportTemplate = (tpl: MonitorTpl) => {
const payload = monitorExportPayload(tpl);
const safe = (tpl.name || "template").replace(/[^\w\u4e00-\u9fff.-]+/g, "_").slice(0, 64);
downloadJsonFile(`netx-monitor-template-${safe}.json`, payload);
showOk(t("bizMonitorTpl.templateExported"));
};
const importTemplateFile = async (file: File) => {
setBusy(true);
try {
const text = await file.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
showError(t("bizMonitorTpl.templateImportInvalid"));
return;
}
const body = parseMonitorImport(parsed);
if (!body) {
showError(t("bizMonitorTpl.templateImportInvalid"));
return;
}
let compareId = body.compare_template_id;
if (!compareTpls.some((c) => c.id === compareId)) {
compareId = "";
}
if (!compareId && body.compare_template_name) {
const want = body.compare_template_name.trim().toLowerCase();
const byName = compareTpls.find((c) => c.name.trim().toLowerCase() === want);
compareId = byName?.id || "";
}
if (!compareId) {
showError(t("bizMonitorTpl.templateImportCompareMissing"));
return;
}
await bizMonitorCreateTemplate({
name: body.name,
note: body.note,
compare_template_id: compareId,
collect_metric_ids: body.collect_metric_ids,
defaults: body.defaults,
sheet_overrides: body.sheet_overrides,
});
showOk(t("bizMonitorTpl.templateImported"));
await refresh();
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
if (tplImportRef.current) tplImportRef.current.value = "";
}
};
const fieldChoices: MetricField[] =
activeFields.length > 0
? activeFields
@ -982,6 +1104,24 @@ export function BizMonitorTemplatesPage() {
<div className="panel__toolbar">
<h2>{t("bizMonitorTpl.title")}</h2>
<div className="btn-row">
<input
ref={tplImportRef}
type="file"
accept="application/json,.json"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void importTemplateFile(f);
}}
/>
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => tplImportRef.current?.click()}
>
{t("bizMonitorTpl.importTemplate")}
</Button>
<Button size="sm" variant="primary" onPress={openCreate}>
{t("bizMonitorTpl.create")}
</Button>
@ -990,6 +1130,8 @@ export function BizMonitorTemplatesPage() {
<p className="panel__hint muted">{t("bizMonitorTpl.hint")}</p>
<p className="muted" style={{ fontSize: 12, marginTop: -4 }}>
<Link to="/network/cutover/compare-templates">{t("bizMonitorTpl.openCompare")}</Link>
{" · "}
{t("bizMonitorTpl.templateImportHint")}
</p>
<div className="pt-list">
@ -1035,6 +1177,9 @@ export function BizMonitorTemplatesPage() {
<Button size="sm" variant="primary" onPress={() => openEdit(row)}>
{t("bizMonitorTpl.edit")}
</Button>
<Button size="sm" variant="secondary" onPress={() => exportTemplate(row)}>
{t("bizMonitorTpl.exportTemplate")}
</Button>
<Button size="sm" variant="ghost" isDisabled={busy} onPress={() => void remove(row.id)}>
{t("bizMonitorTpl.delete")}
</Button>

View file

@ -38,6 +38,7 @@ type TaskRow = {
ne_ip: string;
vendor: string;
note?: string;
purpose?: string;
status: string;
collect_running: boolean;
last_error: string;
@ -257,6 +258,7 @@ export function BizStatePage() {
const [busy, setBusy] = useState(false);
const [listKeyword, setListKeyword] = useState("");
const debouncedListKw = useDebouncedValue(listKeyword, 250);
const [purposeFilter, setPurposeFilter] = useState<"all" | "portrait" | "cutover_hf">("all");
// create-task modal (all valid CLI targets)
const [createOpen, setCreateOpen] = useState(false);
@ -296,9 +298,11 @@ export function BizStatePage() {
const debouncedSheetKw = useDebouncedValue(sheetKeyword, 200);
const refreshTasks = useCallback(async () => {
const res = await bizStateListTasks();
const purpose =
purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf";
const res = await bizStateListTasks(purpose);
setTasks((res.items || []) as TaskRow[]);
}, []);
}, [purposeFilter]);
useEffect(() => {
void (async () => {
@ -308,7 +312,7 @@ export function BizStatePage() {
showError(formatErr(e));
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount once
// eslint-disable-next-line react-hooks/exhaustive-deps -- refresh when purpose filter / refreshTasks changes
}, [refreshTasks]);
useEffect(() => {
@ -344,7 +348,7 @@ export function BizStatePage() {
if (!kw) return tasks;
return tasks.filter((row) => {
const blob =
`${row.ne_name} ${row.ne_ip} ${row.vendor} ${row.note || ""} ${row.status} ${row.source || ""} ${row.last_error}`.toLowerCase();
`${row.ne_name} ${row.ne_ip} ${row.vendor} ${row.note || ""} ${row.purpose || ""} ${row.status} ${row.source || ""} ${row.last_error}`.toLowerCase();
return blob.includes(kw);
});
}, [tasks, debouncedListKw]);
@ -740,6 +744,17 @@ export function BizStatePage() {
placeholder={t("bizState.listFilterPh")}
onChange={(e) => setListKeyword(e.target.value)}
/>
<FieldSelect
value={purposeFilter}
onChange={(e) =>
setPurposeFilter(e.target.value as "all" | "portrait" | "cutover_hf")
}
aria-label={t("bizState.purposeFilter")}
>
<option value="all">{t("bizState.purposeAll")}</option>
<option value="portrait">{t("bizState.purposePortrait")}</option>
<option value="cutover_hf">{t("bizState.purposeCutoverHf")}</option>
</FieldSelect>
</div>
<div className="pt-list-table-wrap">
@ -748,6 +763,7 @@ export function BizStatePage() {
<tr>
<th>{t("bizState.colNe")}</th>
<th>{t("bizState.colSource")}</th>
<th>{t("bizState.colPurpose")}</th>
<th>{t("bizState.colInterval")}</th>
<th>{t("bizState.scheduleEnabled")}</th>
<th>{t("bizState.colStatus")}</th>
@ -771,6 +787,15 @@ export function BizStatePage() {
{row.source || "managed"}
</NmStatusChip>
</td>
<td>
<NmStatusChip
color={row.purpose === "cutover_hf" ? "accent" : "default"}
>
{row.purpose === "cutover_hf"
? t("bizState.purposeCutoverHf")
: t("bizState.purposePortrait")}
</NmStatusChip>
</td>
<td className="pt-list-num">
{fmtIntervalLabel(
Number(row.interval_sec || 3600),
@ -824,7 +849,7 @@ export function BizStatePage() {
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 1500));
await refreshTasks();
const cur = (await bizStateListTasks()).items?.find(
const cur = (await bizStateListTasks(purposeFilter === "all" ? "" : purposeFilter)).items?.find(
(x: any) => x.id === row.id,
);
if (!cur?.collect_running) break;
@ -853,7 +878,7 @@ export function BizStatePage() {
))}
{!filteredTasks.length ? (
<tr>
<td colSpan={7}>
<td colSpan={8}>
<div className="pt-list-empty">{t("bizState.empty")}</div>
</td>
</tr>