mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 14:10:44 +08:00
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:
parent
a65c9fc420
commit
285c19e5a3
67 changed files with 9510 additions and 723 deletions
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue