netx/web/src/pages/network/BizMonitorTemplatesPage.tsx
oliver 285c19e5a3 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>
2026-09-19 21:11:31 +08:00

1484 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Button, Input, Modal } from "@heroui/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";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import {
bizCompareListMetrics,
bizCompareListTemplates,
bizMonitorCreateTemplate,
bizMonitorDeleteTemplate,
bizMonitorListTemplates,
bizMonitorUpdateTemplate,
formatErr,
} from "../../services/api";
type MetricField = { name: string; display_name?: string };
type MetricSchema = { metric_id: string; fields: MetricField[] };
type CompareSheet = {
sheet_id?: string;
title?: string;
metric_id: string;
key_fields?: string[];
iface_fields?: string[];
compare_fields?: string[];
display_fields?: string[];
};
type CompareTpl = { id: string; name: string; metrics?: CompareSheet[] };
/** Leaf: presence (row existence) or value (field + strategy). */
type RuleCond = {
type: "presence" | "value";
field?: string;
op?: string;
value?: string | string[];
};
/** OR-of-AND: each inner list is AND; outer list is OR. */
type CondGroup = RuleCond[];
type DualPat = {
old_groups?: CondGroup[];
new_groups?: CondGroup[];
/** Legacy — migrated into groups on normalize. */
old?: string[];
new?: string[];
old_mode?: "any" | "all";
new_mode?: "any" | "all";
old_conds?: RuleCond[];
new_conds?: RuleCond[];
};
type SheetOverride = {
metric_id: string;
sheet_id?: string;
status_fields?: string[];
down_values?: string[];
up_values?: string[];
success?: DualPat[];
anomaly?: DualPat[];
skip_dual?: boolean;
field_tokens?: Array<{ side: string; field: string; in: 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?: SheetOverride[];
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" },
{ id: "unchanged", labelKey: "tokUnchanged" },
{ id: "changed", labelKey: "tokChanged" },
] as const;
const VALUE_OPS = [
"eq",
"ne",
"in",
"not_in",
"empty",
"not_empty",
"changed",
"unchanged",
] as const;
const OPS_NEED_VALUE = new Set(["eq", "ne", "in", "not_in"]);
function emptyCond(type: RuleCond["type"] = "presence", field = ""): RuleCond {
if (type === "value") return { type: "value", field, op: "eq", value: "" };
return { type: "presence", value: "removed" };
}
function emptyGroup(type: RuleCond["type"] = "presence", field = ""): CondGroup {
return [emptyCond(type, field)];
}
function migrateLegacyCond(raw: Record<string, unknown>): RuleCond {
const typ = String(raw.type || "").toLowerCase();
if (typ === "status") {
// Legacy status class: bind a field later; empty field still matches the old class.
return { type: "value", field: "", op: "eq", value: String(raw.value ?? raw.status ?? "") };
}
if (typ === "field" || typ === "value") {
return {
type: "value",
field: String(raw.field || ""),
op: String(raw.op || "eq"),
value: (raw.value as string | string[]) ?? "",
};
}
if (typ === "kind" || typ === "presence" || raw.kind != null) {
return {
type: "presence",
value: String(raw.value ?? raw.kind ?? "removed"),
};
}
if (raw.field) {
return {
type: "value",
field: String(raw.field),
op: String(raw.op || "eq"),
value: (raw.value as string | string[]) ?? "",
};
}
return { type: "presence", value: String(raw.value || "removed") };
}
function tokenToCond(tok: string): RuleCond {
if (PRESENCE_TOKENS.some((x) => x.id === tok)) return { type: "presence", value: tok };
if (tok === "up" || tok === "down" || tok === "other") {
return { type: "value", field: "", op: "eq", value: tok };
}
if (tok.startsWith("field:")) {
const rest = tok.slice("field:".length);
const i = rest.indexOf(":");
if (i > 0) {
return { type: "value", field: rest.slice(0, i), op: "eq", value: rest.slice(i + 1) };
}
}
return { type: "presence", value: tok };
}
function groupsFromFlat(conds: RuleCond[], mode: "any" | "all"): CondGroup[] {
if (!conds.length) return [];
if (mode === "all") return [conds];
return conds.map((c) => [c]);
}
function groupsFromLegacyTokens(tokens: string[] | undefined): CondGroup[] {
return (tokens || []).map((tok) => [tokenToCond(tok)]);
}
function normalizeGroups(
groups: CondGroup[] | undefined,
flatConds: RuleCond[] | undefined,
mode: "any" | "all" | undefined,
legacyTokens: string[] | undefined,
): CondGroup[] {
if (Array.isArray(groups)) {
if (!groups.length) return [];
return groups
.map((g) =>
(g || [])
.filter(Boolean)
.map((c) => migrateLegacyCond(c as unknown as Record<string, unknown>)),
)
.filter((g) => g.length > 0);
}
if (flatConds?.length) {
return groupsFromFlat(
flatConds.map((c) => migrateLegacyCond(c as unknown as Record<string, unknown>)),
mode || "any",
);
}
return groupsFromLegacyTokens(legacyTokens);
}
function normalizePat(pat: DualPat, opts?: { allowEmptySide?: boolean }): DualPat {
const allowEmpty = Boolean(opts?.allowEmptySide);
const old_groups = normalizeGroups(pat.old_groups, pat.old_conds, pat.old_mode, pat.old);
const new_groups = normalizeGroups(pat.new_groups, pat.new_conds, pat.new_mode, pat.new);
if (allowEmpty) {
return { old_groups, new_groups };
}
return {
old_groups: old_groups.length ? old_groups : [emptyGroup("presence")],
new_groups: new_groups.length ? new_groups : [emptyGroup("presence")],
};
}
function successFromPresence(oldVals: string[], newVals: string[]): DualPat {
return normalizePat({
old_groups: oldVals.map((v) => [{ type: "presence", value: v }]),
new_groups: newVals.map((v) => [{ type: "presence", value: v }]),
});
}
function anomalyBothGone(): DualPat {
return normalizePat(
{
old_groups: [[{ type: "presence", value: "removed" }]],
new_groups: [[{ type: "presence", value: "removed" }]],
},
{ allowEmptySide: true },
);
}
function anomalySideOnly(
side: "old" | "new",
groups: CondGroup[],
): DualPat {
return normalizePat(
{
old_groups: side === "old" ? groups : [],
new_groups: side === "new" ? groups : [],
},
{ allowEmptySide: true },
);
}
function defaultAnomalyForState(field: string, downValues: string[]): DualPat[] {
const downGroup: CondGroup = [{ type: "value", field, op: "in", value: downValues }];
return [
anomalyBothGone(),
anomalySideOnly("old", [[{ type: "presence", value: "removed" }], downGroup]),
anomalySideOnly("new", [[{ type: "presence", value: "removed" }], downGroup]),
];
}
function defaultAnomalyPresenceOnly(): DualPat[] {
return [
anomalyBothGone(),
anomalySideOnly("old", [[{ type: "presence", value: "removed" }]]),
anomalySideOnly("new", [[{ type: "presence", value: "removed" }]]),
];
}
function emptyOverride(metricId: string, sheetId = ""): SheetOverride {
return {
metric_id: metricId,
sheet_id: sheetId || metricId,
status_fields: [],
down_values: [],
up_values: [],
success: [successFromPresence(["removed"], ["added"])],
anomaly: defaultAnomalyPresenceOnly(),
skip_dual: false,
};
}
function sheetIdentity(s: { sheet_id?: string; metric_id?: string } | null | undefined): string {
return String(s?.sheet_id || s?.metric_id || "").trim();
}
function sheetLabel(s: { title?: string; sheet_id?: string; metric_id?: string } | null | undefined): string {
return String(s?.title || s?.sheet_id || s?.metric_id || "").trim() || "—";
}
export function presetForMetric(metricId: string, sheetId = ""): SheetOverride {
const mid = metricId;
const sid = sheetId || mid;
const withId = (ov: SheetOverride): SheetOverride => ({
...ov,
metric_id: mid,
sheet_id: sid,
});
if (mid === "interface_brief") {
const upConds: RuleCond[] = [
{ type: "value", field: "admin", op: "in", value: ["up"] },
{ type: "value", field: "phy", op: "in", value: ["up"] },
];
return withId({
metric_id: mid,
status_fields: ["admin", "phy", "prot"],
down_values: ["down"],
up_values: ["up"],
success: [
normalizePat({
old_groups: [
[{ type: "presence", value: "removed" }],
[
{ type: "value", field: "admin", op: "in", value: ["down"] },
{ type: "value", field: "phy", op: "in", value: ["down"] },
],
],
new_groups: [
[{ type: "presence", value: "added" }, ...upConds],
[{ type: "presence", value: "unchanged" }, ...upConds],
upConds,
],
}),
],
anomaly: defaultAnomalyForState("admin", ["down"]),
skip_dual: false,
});
}
if (mid === "bgp_peer" || mid.startsWith("bgp_peer")) {
const upConds: RuleCond[] = [{ type: "value", field: "state", op: "eq", value: "established" }];
return withId({
metric_id: mid,
status_fields: ["state"],
down_values: ["idle", "active", "connect", "down"],
up_values: ["established"],
success: [
normalizePat({
old_groups: [
[{ type: "presence", value: "removed" }],
[
{
type: "value",
field: "state",
op: "in",
value: ["idle", "active", "connect", "down"],
},
],
],
new_groups: [
[{ type: "presence", value: "added" }, ...upConds],
[{ type: "presence", value: "unchanged" }, ...upConds],
upConds,
],
}),
],
anomaly: defaultAnomalyForState("state", ["idle", "active", "connect", "down"]),
skip_dual: false,
});
}
if (mid === "arp" || mid === "nd6_cache" || mid === "lldp_neighbor") {
return withId({
metric_id: mid,
status_fields: [],
down_values: [],
up_values: [],
success: [successFromPresence(["removed"], ["added"])],
anomaly: defaultAnomalyPresenceOnly(),
skip_dual: false,
});
}
if (mid.includes("isis") || mid.includes("ospf") || mid.includes("adjacency")) {
const upConds: RuleCond[] = [
{ type: "value", field: "state", op: "in", value: ["up", "full", "2way"] },
];
return withId({
metric_id: mid,
status_fields: ["state"],
down_values: ["down", "init", "idle"],
up_values: ["up", "full", "2way"],
success: [
normalizePat({
old_groups: [
[{ type: "presence", value: "removed" }],
[
{
type: "value",
field: "state",
op: "in",
value: ["down", "init", "idle"],
},
],
],
new_groups: [
[{ type: "presence", value: "added" }, ...upConds],
[{ type: "presence", value: "unchanged" }, ...upConds],
upConds,
],
}),
],
anomaly: defaultAnomalyForState("state", ["down", "init", "idle"]),
skip_dual: false,
});
}
if (mid.includes("route") || mid.includes("vrf")) {
return withId({
metric_id: mid,
status_fields: [],
down_values: [],
up_values: [],
success: [successFromPresence(["removed"], ["added"])],
anomaly: defaultAnomalyPresenceOnly(),
skip_dual: false,
});
}
return emptyOverride(mid, sid);
}
function skipPreset(metricId: string, sheetId = ""): SheetOverride {
return { ...emptyOverride(metricId, sheetId), skip_dual: true, success: [], anomaly: [] };
}
function overrideFor(overrides: SheetOverride[], metricId: string, sheetId = ""): SheetOverride {
const sid = sheetId || metricId;
const found =
overrides.find((o) => (o.sheet_id || o.metric_id) === sid) ||
overrides.find((o) => !o.sheet_id && o.metric_id === metricId);
const merged = found
? { ...emptyOverride(metricId, sid), ...found, metric_id: metricId, sheet_id: sid }
: emptyOverride(metricId, sid);
return {
...merged,
success: (merged.success || []).map((p) => normalizePat(p)),
anomaly: (merged.anomaly || []).map((p) => normalizePat(p, { allowEmptySide: true })),
};
}
function csvValues(raw: string): string[] {
return raw
.split(/[,;\s]+/)
.map((x) => x.trim().toLowerCase())
.filter(Boolean);
}
function Chip({
active,
label,
onClick,
}: {
active: boolean;
label: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`mt-chip${active ? " is-on" : ""}`}
aria-pressed={active}
>
{label}
</button>
);
}
function patchGroupCond(
groups: CondGroup[],
gi: number,
ci: number,
patch: Partial<RuleCond> | RuleCond,
): CondGroup[] {
return groups.map((g, i) =>
i === gi ? g.map((x, j) => (j === ci ? { ...x, ...patch } : x)) : g,
);
}
function SideGroupsEditor({
allowEmpty,
groups,
fieldChoices,
t,
onChange,
}: {
allowEmpty: boolean;
groups: CondGroup[];
fieldChoices: MetricField[];
t: (k: string, vars?: Record<string, string | number>) => string;
onChange: (next: CondGroup[]) => void;
}) {
const dontCare = allowEmpty && groups.length === 0;
const setGroups = (next: CondGroup[]) => {
if (!next.length && !allowEmpty) onChange([emptyGroup()]);
else onChange(next);
};
return (
<div className="mt-side">
{allowEmpty ? (
<label className="mt-dontcare">
<input
type="checkbox"
checked={dontCare}
onChange={(e) => {
if (e.target.checked) setGroups([]);
else setGroups([emptyGroup("presence")]);
}}
/>
<span>{t("bizMonitorTpl.sideDontCare")}</span>
</label>
) : null}
{dontCare ? (
<div className="mt-side-empty-box">
<p className="muted mt-side-empty">{t("bizMonitorTpl.sideDontCareHint")}</p>
</div>
) : (
<>
{groups.map((group, gi) => (
<div key={gi} className="mt-or-wrap">
{gi > 0 ? <div className="mt-logic-badge mt-logic-badge--or">{t("bizMonitorTpl.or")}</div> : null}
<div className="mt-and-box">
<div className="mt-and-box__head">
<span className="muted">{t("bizMonitorTpl.groupN", { n: String(gi + 1) })}</span>
<button
type="button"
className="mt-icon-btn"
aria-label={t("bizMonitorTpl.removeRow")}
onClick={() => {
const next = groups.filter((_, i) => i !== gi);
setGroups(next.length ? next : allowEmpty ? [] : [emptyGroup()]);
}}
>
×
</button>
</div>
{group.map((cond, ci) => (
<div key={ci}>
{ci > 0 ? (
<div className="mt-logic-badge mt-logic-badge--and">{t("bizMonitorTpl.and")}</div>
) : null}
<div className={`mt-cond-row${cond.type === "value" ? " is-value" : " is-presence"}`}>
<select
className="mt-select"
value={cond.type}
onChange={(e) => {
const typ = e.target.value as RuleCond["type"];
setGroups(
patchGroupCond(
groups,
gi,
ci,
emptyCond(typ, fieldChoices[0]?.name || ""),
),
);
}}
>
<option value="presence">{t("bizMonitorTpl.groupPresence")}</option>
<option value="value">{t("bizMonitorTpl.groupValue")}</option>
</select>
{cond.type === "presence" ? (
<select
className="mt-select mt-select--grow"
value={String(cond.value || "removed")}
onChange={(e) =>
setGroups(patchGroupCond(groups, gi, ci, { value: e.target.value }))
}
>
{PRESENCE_TOKENS.map((tok) => (
<option key={tok.id} value={tok.id}>
{t(`bizMonitorTpl.${tok.labelKey}`)}
</option>
))}
</select>
) : (
<>
<select
className="mt-select"
value={cond.field || ""}
onChange={(e) =>
setGroups(patchGroupCond(groups, gi, ci, { field: e.target.value }))
}
>
<option value="">{t("bizMonitorTpl.pickField")}</option>
{fieldChoices.map((f) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
</select>
<select
className="mt-select"
value={cond.op || "eq"}
onChange={(e) =>
setGroups(patchGroupCond(groups, gi, ci, { op: e.target.value }))
}
>
{VALUE_OPS.map((op) => (
<option key={op} value={op}>
{t(`bizMonitorTpl.op_${op}`)}
</option>
))}
</select>
<Input
className="mt-cond-value"
value={
Array.isArray(cond.value)
? cond.value.join(",")
: String(cond.value ?? "")
}
disabled={!OPS_NEED_VALUE.has(cond.op || "eq")}
placeholder={
cond.op === "in" || cond.op === "not_in"
? "a,b,c"
: t("bizMonitorTpl.condValue")
}
onChange={(e) => {
const raw = e.target.value;
const val =
cond.op === "in" || cond.op === "not_in" ? csvValues(raw) : raw;
setGroups(patchGroupCond(groups, gi, ci, { value: val }));
}}
/>
</>
)}
<button
type="button"
className="mt-icon-btn"
aria-label={t("bizMonitorTpl.removeRow")}
onClick={() => {
const next = groups
.map((g, i) => (i === gi ? g.filter((_, j) => j !== ci) : g))
.filter((g) => g.length > 0);
setGroups(next.length ? next : allowEmpty ? [] : [emptyGroup()]);
}}
>
×
</button>
</div>
</div>
))}
<button
type="button"
className="mt-link-btn"
onClick={() =>
setGroups(
groups.map((g, i) =>
i === gi ? [...g, emptyCond("value", fieldChoices[0]?.name || "")] : g,
),
)
}
>
{t("bizMonitorTpl.addAndShort")}
</button>
</div>
</div>
))}
<button
type="button"
className="mt-link-btn"
onClick={() => setGroups([...groups, emptyGroup("presence")])}
>
{t("bizMonitorTpl.addOrShort")}
</button>
</>
)}
</div>
);
}
function DualPatternList({
kind,
patterns,
fieldChoices,
t,
onChange,
}: {
kind: "success" | "anomaly";
patterns: DualPat[];
fieldChoices: MetricField[];
t: (k: string, vars?: Record<string, string | number>) => string;
onChange: (next: DualPat[]) => void;
}) {
const allowEmpty = kind === "anomaly";
const cardClass = kind === "anomaly" ? "mt-rule-card mt-rule-card--anomaly" : "mt-rule-card";
const rowKey = kind === "anomaly" ? "anomalyRow" : "successRow";
const addKey = kind === "anomaly" ? "addAnomalyRow" : "addSuccessRow";
const hintKey = kind === "anomaly" ? "anomalyPatternsHintShort" : "successPatternsHintShort";
const setPat = (pi: number, nextPat: DualPat) => {
const list = [...patterns];
list[pi] = normalizePat(nextPat, { allowEmptySide: allowEmpty });
onChange(list);
};
const updateSideGroups = (pi: number, side: "old" | "new", groups: CondGroup[]) => {
const pat = normalizePat(patterns[pi] || {}, { allowEmptySide: allowEmpty });
const nextGroups = groups.length > 0 ? groups : allowEmpty ? [] : [emptyGroup()];
if (side === "old") setPat(pi, { ...pat, old_groups: nextGroups });
else setPat(pi, { ...pat, new_groups: nextGroups });
};
const defaultNewPat = (): DualPat =>
kind === "anomaly" ? anomalyBothGone() : successFromPresence(["removed"], ["added"]);
return (
<div className={`mt-block mt-block--${kind}`}>
<p className="muted mt-block__hint">{t(`bizMonitorTpl.${hintKey}`)}</p>
{patterns.map((rawPat, pi) => {
const pat = normalizePat(rawPat, { allowEmptySide: allowEmpty });
return (
<div key={pi} className={cardClass}>
<div className="mt-rule-card__head">
<span>{t(`bizMonitorTpl.${rowKey}`, { n: String(pi + 1) })}</span>
<Button
size="sm"
variant="ghost"
onPress={() => {
const next = patterns.filter((_, i) => i !== pi);
onChange(next.length ? next : kind === "success" ? [defaultNewPat()] : []);
}}
>
{t("bizMonitorTpl.removeRow")}
</Button>
</div>
<div className="mt-rule-card__grid">
<div className="mt-side-col">
<div className="mt-side-col__label">{t("bizMonitorTpl.oldSideAny")}</div>
<SideGroupsEditor
allowEmpty={allowEmpty}
groups={pat.old_groups || []}
fieldChoices={fieldChoices}
t={t}
onChange={(g) => updateSideGroups(pi, "old", g)}
/>
</div>
<div className="mt-rule-arrow" aria-hidden>
→
</div>
<div className="mt-side-col">
<div className="mt-side-col__label">{t("bizMonitorTpl.newSideAny")}</div>
<SideGroupsEditor
allowEmpty={allowEmpty}
groups={pat.new_groups || []}
fieldChoices={fieldChoices}
t={t}
onChange={(g) => updateSideGroups(pi, "new", g)}
/>
</div>
</div>
</div>
);
})}
<Button
size="sm"
variant="secondary"
onPress={() =>
onChange([
...patterns.map((p) => normalizePat(p, { allowEmptySide: allowEmpty })),
defaultNewPat(),
])
}
>
{t(`bizMonitorTpl.${addKey}`)}
</Button>
</div>
);
}
export function BizMonitorTemplatesPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [items, setItems] = useState<MonitorTpl[]>([]);
const [compareTpls, setCompareTpls] = useState<CompareTpl[]>([]);
const [metricSchemas, setMetricSchemas] = useState<MetricSchema[]>([]);
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 [outOfExpect, setOutOfExpect] = useState("strict");
const [collectIds, setCollectIds] = useState<string[]>([]);
const [overrides, setOverrides] = useState<SheetOverride[]>([]);
const [activeSheetIdx, setActiveSheetIdx] = useState(0);
const [ruleTab, setRuleTab] = useState<"success" | "anomaly">("success");
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([
bizMonitorListTemplates(),
bizCompareListTemplates(),
bizCompareListMetrics(),
]);
setItems((mon.items || []) as MonitorTpl[]);
setCompareTpls(
((cmp.items || []) as Record<string, unknown>[]).map((x) => ({
id: String(x.id || ""),
name: String(x.name || x.id || ""),
metrics: Array.isArray(x.metrics) ? (x.metrics as CompareSheet[]) : [],
})),
);
setMetricSchemas(
((metrics.items || []) as MetricSchema[]).map((m) => ({
metric_id: m.metric_id,
fields: (m.fields || []).map((f) => ({
name: f.name,
display_name: f.display_name,
})),
})),
);
}, []);
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 selectedCompare = useMemo(
() => compareTpls.find((c) => c.id === compareId) || null,
[compareTpls, compareId],
);
const sheets = useMemo(() => selectedCompare?.metrics || [], [selectedCompare]);
const activeSheet = sheets[activeSheetIdx] || sheets[0] || null;
const activeMetricId = activeSheet?.metric_id || "";
const activeSheetId = activeSheet ? sheetIdentity(activeSheet) : "";
const activeOverride = useMemo(
() =>
activeMetricId
? overrideFor(overrides, activeMetricId, activeSheetId)
: emptyOverride(""),
[overrides, activeMetricId, activeSheetId],
);
const activeFields = useMemo(() => {
const schema = metricSchemas.find((m) => m.metric_id === activeMetricId);
return schema?.fields || [];
}, [metricSchemas, activeMetricId]);
const sheetKey = useMemo(() => sheets.map((s) => sheetIdentity(s)).join("|"), [sheets]);
useEffect(() => {
if (!sheets.length) {
setActiveSheetIdx(0);
return;
}
if (activeSheetIdx >= sheets.length) setActiveSheetIdx(0);
}, [sheets, activeSheetIdx]);
useEffect(() => {
if (!sheetKey) return;
setOverrides((prev) => {
const byId = new Map(prev.map((o) => [o.sheet_id || o.metric_id, o]));
return sheets.map(
(s) =>
byId.get(sheetIdentity(s)) ||
emptyOverride(s.metric_id, sheetIdentity(s)),
);
});
}, [compareId, sheetKey]);
const syncOverride = (metricId: string, patch: Partial<SheetOverride>, sheetId = "") => {
const sid = sheetId || sheetIdentity({ metric_id: metricId, sheet_id: sheetId });
setOverrides((prev) => {
const others = prev.filter((o) => (o.sheet_id || o.metric_id) !== sid);
const base = overrideFor(prev, metricId, sid);
return [...others, { ...base, ...patch, metric_id: metricId, sheet_id: sid }];
});
};
const openCreate = () => {
setEditId("");
setName("");
setCompareId(compareTpls[0]?.id || "");
setNote("");
setOutOfExpect("strict");
setCollectIds([]);
setOverrides([]);
setActiveSheetIdx(0);
setRuleTab("success");
setShowMore(false);
setShowAdvancedJson(false);
setOverridesText("[]");
setEditOpen(true);
};
const openEdit = (row: MonitorTpl) => {
setEditId(row.id);
setName(row.name || "");
setCompareId(row.compare_template_id || "");
setNote(row.note || "");
setOutOfExpect(String((row.defaults || {}).out_of_expect || "strict"));
setCollectIds(Array.isArray(row.collect_metric_ids) ? [...row.collect_metric_ids] : []);
setOverrides(Array.isArray(row.sheet_overrides) ? (row.sheet_overrides as SheetOverride[]) : []);
setActiveSheetIdx(0);
setRuleTab("success");
setShowMore(false);
setShowAdvancedJson(false);
setOverridesText(JSON.stringify(row.sheet_overrides || [], null, 2));
setEditOpen(true);
};
const closeEdit = () => setEditOpen(false);
const applyPreset = (kind: "auto" | "skip") => {
if (!activeMetricId || !activeSheetId) return;
syncOverride(
activeMetricId,
kind === "skip"
? skipPreset(activeMetricId, activeSheetId)
: presetForMetric(activeMetricId, activeSheetId),
activeSheetId,
);
};
const applyAllPresets = () => {
setOverrides(sheets.map((s) => presetForMetric(s.metric_id, sheetIdentity(s))));
};
const save = async () => {
if (!name.trim()) {
showError(t("bizMonitorTpl.needName"));
return;
}
let sheet_overrides = overrides
.filter((o) => o.metric_id)
.map((o) => ({
...o,
success: (o.success || []).map((p) => normalizePat(p)),
anomaly: (o.anomaly || []).map((p) => normalizePat(p, { allowEmptySide: true })),
}));
if (showAdvancedJson) {
try {
const parsed = JSON.parse(overridesText || "[]") as unknown[];
if (!Array.isArray(parsed)) throw new Error("overrides");
sheet_overrides = (parsed as SheetOverride[]).map((o) => ({
...o,
success: (o.success || []).map((p) => normalizePat(p)),
anomaly: (o.anomaly || []).map((p) => normalizePat(p, { allowEmptySide: true })),
}));
} catch {
showError(t("bizMonitorTpl.overridesInvalid"));
return;
}
}
const body = {
name: name.trim(),
compare_template_id: compareId,
collect_metric_ids: collectIds,
defaults: { dual_mode: "migrate_pair", out_of_expect: outOfExpect || "strict" },
sheet_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);
}
};
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
: [...(activeSheet?.compare_fields || []), ...(activeSheet?.key_fields || [])].map((n) => ({
name: n,
}));
return (
<section className="panel nm-page-panel">
<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>
</div>
</div>
<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">
<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 || "—"}</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="secondary" onPress={() => exportTemplate(row)}>
{t("bizMonitorTpl.exportTemplate")}
</Button>
<Button size="sm" variant="ghost" 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="cover">
<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 mt-editor">
{/* Basics */}
<div className="mt-editor__basics">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("bizMonitorTpl.namePh")}
aria-label={t("bizMonitorTpl.colName")}
/>
<FieldSelect
label={t("bizMonitorTpl.colCompare")}
value={compareId}
onChange={(e) => {
setCompareId(e.target.value);
setActiveSheetIdx(0);
}}
fullWidth
>
<option value="">{t("bizMonitorTpl.pickCompare")}</option>
{compareTpls.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{(c.metrics || []).length ? ` · ${(c.metrics || []).length}${t("bizMonitorTpl.sheetsUnit")}` : ""}
</option>
))}
</FieldSelect>
<FieldSelect
label={t("bizMonitorTpl.outOfExpect")}
value={outOfExpect}
onChange={(e) => setOutOfExpect(e.target.value)}
fullWidth
>
<option value="strict">{t("bizMonitorTpl.ooeStrict")}</option>
<option value="warn">{t("bizMonitorTpl.ooeWarn")}</option>
<option value="ignore">{t("bizMonitorTpl.ooeIgnore")}</option>
</FieldSelect>
</div>
{!sheets.length ? (
<p className="muted" style={{ margin: 0 }}>
{t("bizMonitorTpl.needCompareFirst")}
</p>
) : (
<div className="mt-editor__main">
{/* Left sheet nav */}
<aside className="mt-editor__nav" aria-label={t("bizMonitorTpl.sheetRules")}>
<div className="mt-editor__nav-head">
<span>{t("bizMonitorTpl.sheetRules")}</span>
<Button size="sm" variant="ghost" onPress={applyAllPresets}>
{t("bizMonitorTpl.applyAllPresetsShort")}
</Button>
</div>
<div className="mt-editor__nav-list" role="tablist">
{sheets.map((s, i) => {
const sid = sheetIdentity(s);
const ov = overrideFor(overrides, s.metric_id, sid);
return (
<button
key={sid}
type="button"
role="tab"
aria-selected={activeSheetIdx === i}
className={`mt-editor__nav-item${activeSheetIdx === i ? " is-active" : ""}`}
onClick={() => {
setActiveSheetIdx(i);
setRuleTab("success");
}}
>
<span className="mt-editor__nav-name">{sheetLabel(s)}</span>
<span className="mt-editor__nav-tag">
{ov.skip_dual
? t("bizMonitorTpl.tagSkip")
: (ov.success || []).length || (ov.anomaly || []).length
? t("bizMonitorTpl.tagRules", {
n: String(
(ov.success || []).length + (ov.anomaly || []).length,
),
})
: t("bizMonitorTpl.tagEmpty")}
</span>
</button>
);
})}
</div>
</aside>
{/* Right rule editor */}
{activeSheet ? (
<div className="mt-editor__pane">
<div className="mt-editor__pane-head">
<div>
<div className="mt-editor__metric">{sheetLabel(activeSheet)}</div>
<div className="mt-editor__meta muted">
<span>{activeMetricId}</span>
<span>
{t("bizMonitorTpl.compare")}{" "}
{(activeSheet.compare_fields || []).length
? (activeSheet.compare_fields || []).join(" · ")
: t("bizMonitorTpl.presenceOnly")}
</span>
<Link to="/network/cutover/compare-templates">{t("bizMonitorTpl.editInCompare")}</Link>
</div>
</div>
<div className="btn-row" style={{ gap: 6 }}>
<Button size="sm" variant="secondary" onPress={() => applyPreset("auto")}>
{t("bizMonitorTpl.presetAuto")}
</Button>
<Button size="sm" variant="ghost" onPress={() => applyPreset("skip")}>
{t("bizMonitorTpl.presetSkip")}
</Button>
</div>
</div>
<label className="mt-skip-check">
<input
type="checkbox"
checked={Boolean(activeOverride.skip_dual)}
onChange={(e) =>
syncOverride(activeMetricId, { skip_dual: e.target.checked }, activeSheetId)
}
/>
<span>{t("bizMonitorTpl.skipDual")}</span>
</label>
{!activeOverride.skip_dual ? (
<>
<div className="mt-rule-tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={ruleTab === "success"}
className={`mt-rule-tab${ruleTab === "success" ? " is-active" : ""}`}
onClick={() => setRuleTab("success")}
>
{t("bizMonitorTpl.successPatternsShort")}
<span className="mt-rule-tab__n">{(activeOverride.success || []).length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={ruleTab === "anomaly"}
className={`mt-rule-tab mt-rule-tab--anomaly${ruleTab === "anomaly" ? " is-active" : ""}`}
onClick={() => setRuleTab("anomaly")}
>
{t("bizMonitorTpl.anomalyPatternsShort")}
<span className="mt-rule-tab__n">{(activeOverride.anomaly || []).length}</span>
</button>
</div>
{ruleTab === "success" ? (
<DualPatternList
kind="success"
patterns={activeOverride.success || []}
fieldChoices={fieldChoices}
t={t}
onChange={(success) =>
syncOverride(activeMetricId, { success }, activeSheetId)
}
/>
) : (
<DualPatternList
kind="anomaly"
patterns={activeOverride.anomaly || []}
fieldChoices={fieldChoices}
t={t}
onChange={(anomaly) =>
syncOverride(activeMetricId, { anomaly }, activeSheetId)
}
/>
)}
</>
) : (
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
{t("bizMonitorTpl.skipDualHint")}
</p>
)}
</div>
) : null}
</div>
)}
<div className="mt-editor__more">
<Button size="sm" variant="ghost" onPress={() => setShowMore((v) => !v)}>
{showMore ? t("bizMonitorTpl.hideMore") : t("bizMonitorTpl.showMore")}
</Button>
{showMore ? (
<div className="flex flex-col gap-3" style={{ marginTop: 8 }}>
<Input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("bizMonitorTpl.colNote")}
aria-label={t("bizMonitorTpl.colNote")}
/>
<div>
<div className="mt-block__title">{t("bizMonitorTpl.collectTitle")}</div>
<p className="muted" style={{ margin: "0 0 6px", fontSize: 12 }}>
{t("bizMonitorTpl.collectHint")}
</p>
<div className="mt-chip-row">
{Array.from(new Set(sheets.map((s) => s.metric_id).filter(Boolean))).map((mid) => {
const checked = collectIds.length === 0 || collectIds.includes(mid);
const explicit = collectIds.length > 0;
const allMids = Array.from(
new Set(sheets.map((s) => s.metric_id).filter(Boolean)),
);
return (
<Chip
key={mid}
active={checked}
label={mid}
onClick={() => {
if (!explicit) {
setCollectIds(allMids.filter((id) => id !== mid));
return;
}
setCollectIds((prev) => {
if (prev.includes(mid)) {
const next = prev.filter((x) => x !== mid);
return next.length === allMids.length ? [] : next;
}
const next = [...prev, mid];
return next.length === allMids.length ? [] : next;
});
}}
/>
);
})}
{collectIds.length ? (
<Button size="sm" variant="ghost" onPress={() => setCollectIds([])}>
{t("bizMonitorTpl.collectAll")}
</Button>
) : null}
</div>
</div>
<Button
size="sm"
variant="ghost"
onPress={() => {
if (!showAdvancedJson) setOverridesText(JSON.stringify(overrides, null, 2));
else {
try {
const parsed = JSON.parse(overridesText || "[]") as SheetOverride[];
if (Array.isArray(parsed)) setOverrides(parsed);
} catch {
/* keep */
}
}
setShowAdvancedJson((v) => !v);
}}
>
{showAdvancedJson ? t("bizMonitorTpl.hideJson") : t("bizMonitorTpl.showJson")}
</Button>
{showAdvancedJson ? (
<textarea
className="form-textarea"
style={{ minHeight: 140, fontFamily: "ui-monospace, monospace", fontSize: 12 }}
value={overridesText}
onChange={(e) => setOverridesText(e.target.value)}
aria-label={t("bizMonitorTpl.overrides")}
/>
) : null}
</div>
) : null}
</div>
</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>
);
}