mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Add visual dual-verdict editor for monitor templates with out_of_expect and field tokens.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2c2768a3d0
commit
27c6b7c61d
7 changed files with 985 additions and 136 deletions
|
|
@ -580,7 +580,7 @@ const en = {
|
|||
},
|
||||
bizMonitorTpl: {
|
||||
title: "Monitor templates",
|
||||
hint: "Reference a compare template for HOW-to-compare, then add dual-device / baseline rules for cutover monitor.",
|
||||
hint: "Compare templates define HOW to align/diff; monitor templates define WHEN dual-device migrate succeeds — one sheet tab per compare sheet.",
|
||||
create: "New monitor template",
|
||||
edit: "Edit",
|
||||
save: "Save",
|
||||
|
|
@ -594,6 +594,7 @@ const en = {
|
|||
listFilterPh: "Filter name / compare template / note",
|
||||
kpiTotal: "Templates",
|
||||
colName: "Name",
|
||||
namePh: "e.g. Port+BGP cutover monitor",
|
||||
colCompare: "Compare template",
|
||||
colCollect: "HF collect metrics",
|
||||
colNote: "Note",
|
||||
|
|
@ -601,16 +602,47 @@ const en = {
|
|||
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.",
|
||||
formHint: "Pick a compare template → same sheet tabs appear → configure status fields and success patterns per sheet.",
|
||||
compareHint: "Owns which sheets and Key/compare fields; here you only set dual verdict rules.",
|
||||
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.",
|
||||
collectHint: "HF collect metrics; empty / “All sheets” uses every compare-template sheet.",
|
||||
collectTitle: "High-freq collect subset",
|
||||
defaults: "defaults (JSON)",
|
||||
overrides: "sheet_overrides (JSON)",
|
||||
overridesHint: "Optional per-metric status_fields / success patterns; empty array uses global defaults.",
|
||||
overridesHint: "Optional per-metric status_fields / success patterns.",
|
||||
globalPolicy: "Global policy",
|
||||
outOfExpect: "Out-of-expect objects",
|
||||
outOfExpectHint: "How to treat drift outside the batch expect set.",
|
||||
ooeStrict: "Strict (red anomaly)",
|
||||
ooeWarn: "Warn (yellow only, no red tickets)",
|
||||
ooeIgnore: "Ignore (always not involved)",
|
||||
sheetRules: "Per-sheet migrate rules",
|
||||
applyAllPresets: "Apply recommended presets to all",
|
||||
sheetHowReadonly: "Compare template (read-only)",
|
||||
iface: "Interface",
|
||||
compare: "Compare",
|
||||
presenceOnly: "presence only",
|
||||
editInCompare: "Edit HOW in compare templates",
|
||||
presetAuto: "Apply recommended preset",
|
||||
presetSkip: "Skip dual verdict",
|
||||
skipDual: "Skip dual for this sheet",
|
||||
skipDualHint: "This sheet is skipped in cutover evaluate (use a compare job for pure value checks).",
|
||||
statusFields: "Status fields",
|
||||
statusFieldsHint: "Classify current rows as up/down; ports use admin/phy/prot, BGP uses state.",
|
||||
downValues: "Down values",
|
||||
upValues: "Up values",
|
||||
noFields: "No fields (pick metrics on the compare template first)",
|
||||
successPatterns: "Success patterns (old any-of × new any-of)",
|
||||
successPatternsHint: "e.g. port: old removed/down + new added/up; ARP: old removed + new added; BGP: old down(idle) + new up(established).",
|
||||
oldSideAny: "Old side (any match)",
|
||||
newSideAny: "New side (any match)",
|
||||
addSuccessRow: "Add success pattern",
|
||||
needCompareFirst: "Pick a compare template that has sheets first.",
|
||||
showJson: "Advanced: edit JSON",
|
||||
hideJson: "Back to visual editor",
|
||||
},
|
||||
portTraffic: {
|
||||
title: "Port traffic",
|
||||
|
|
|
|||
|
|
@ -575,7 +575,7 @@ const zh = {
|
|||
},
|
||||
bizMonitorTpl: {
|
||||
title: "监控模板",
|
||||
hint: "引用对比模板定义「如何比」,再叠加双端迁移/基线判定规则。供割接监控选用。",
|
||||
hint: "对比模板定义「怎么对齐/比对」;监控模板按同样的表(sheet)定义「怎样算搬迁成功/失败」。",
|
||||
create: "新建监控模板",
|
||||
edit: "编辑",
|
||||
save: "保存",
|
||||
|
|
@ -589,6 +589,7 @@ const zh = {
|
|||
listFilterPh: "筛选名称 / 对比模板 / 备注",
|
||||
kpiTotal: "模板数",
|
||||
colName: "名称",
|
||||
namePh: "例如:端口+BGP 割接监控",
|
||||
colCompare: "对比模板",
|
||||
colCollect: "高频采集项",
|
||||
colNote: "备注",
|
||||
|
|
@ -596,16 +597,47 @@ const zh = {
|
|||
needName: "请填写名称",
|
||||
defaultsInvalid: "defaults JSON 无效",
|
||||
overridesInvalid: "sheet_overrides JSON 无效",
|
||||
formHint: "对比模板决定对齐与字段规则;本页配置双端判定与可选高频采集子集。",
|
||||
compareHint: "来自「对比模板」;可先去那边新建或编辑 sheets。",
|
||||
formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置状态字段与成功模式。",
|
||||
compareHint: "决定有哪些表以及 Key/比对字段;此处只配置双端判定。",
|
||||
pickCompare: "选择对比模板…",
|
||||
openCompare: "管理对比模板",
|
||||
collectAll: "模板全量",
|
||||
collectPh: "interface_brief, bgp_peer(空=全量)",
|
||||
collectHint: "逗号分隔 metric_id;留空表示使用对比模板全部 sheets。",
|
||||
collectHint: "勾选高频采集的 metric;全不勾或点「模板全量」表示对比模板全部 sheets。",
|
||||
collectTitle: "高频采集子集",
|
||||
defaults: "defaults(JSON)",
|
||||
overrides: "sheet_overrides(JSON)",
|
||||
overridesHint: "可按 metric 声明 status_fields / success 模式;空数组则用全局 defaults。",
|
||||
overridesHint: "可按 metric 声明 status_fields / success 模式。",
|
||||
globalPolicy: "全局策略",
|
||||
outOfExpect: "预期外对象",
|
||||
outOfExpectHint: "不在本批预期内的漂移如何处理。",
|
||||
ooeStrict: "严格(异常标红)",
|
||||
ooeWarn: "警告(仅标黄,不记红单)",
|
||||
ooeIgnore: "忽略(一律未涉及)",
|
||||
sheetRules: "各表搬迁判定",
|
||||
applyAllPresets: "全部套用推荐预设",
|
||||
sheetHowReadonly: "对比模板(只读)",
|
||||
iface: "接口",
|
||||
compare: "比对",
|
||||
presenceOnly: "仅存在性",
|
||||
editInCompare: "去对比模板改 HOW",
|
||||
presetAuto: "套用本表推荐预设",
|
||||
presetSkip: "跳过双端判定",
|
||||
skipDual: "本表不参与 dual 判定",
|
||||
skipDualHint: "该 sheet 将在割接判定中跳过(适合纯计数器等值校验类,改用业务对比 Job)。",
|
||||
statusFields: "状态字段",
|
||||
statusFieldsHint: "用于把当前行归类为 up/down;端口勾选 admin/phy/prot,BGP 勾选 state。",
|
||||
downValues: "Down 取值",
|
||||
upValues: "Up 取值",
|
||||
noFields: "无可用字段(先在对比模板勾选 metric)",
|
||||
successPatterns: "成功模式(老侧任意命中 × 新侧任意命中)",
|
||||
successPatternsHint: "例如端口:老 removed/down + 新 added/up;ARP:老 removed + 新 added;BGP:老 down(idle) + 新 up(established)。",
|
||||
oldSideAny: "老侧(任意命中)",
|
||||
newSideAny: "新侧(任意命中)",
|
||||
addSuccessRow: "添加成功模式",
|
||||
needCompareFirst: "请先选择带 sheets 的对比模板。",
|
||||
showJson: "高级:编辑 JSON",
|
||||
hideJson: "回到可视化编辑",
|
||||
},
|
||||
portTraffic: {
|
||||
title: "端口流量监控",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
|||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import {
|
||||
bizCompareListMetrics,
|
||||
bizCompareListTemplates,
|
||||
bizMonitorCreateTemplate,
|
||||
bizMonitorDeleteTemplate,
|
||||
|
|
@ -15,7 +16,26 @@ import {
|
|||
formatErr,
|
||||
} from "../../services/api";
|
||||
|
||||
type CompareTpl = { id: string; name: string };
|
||||
type MetricField = { name: string; display_name?: string };
|
||||
type MetricSchema = { metric_id: string; fields: MetricField[] };
|
||||
type CompareSheet = {
|
||||
metric_id: string;
|
||||
key_fields?: string[];
|
||||
iface_fields?: string[];
|
||||
compare_fields?: string[];
|
||||
display_fields?: string[];
|
||||
};
|
||||
type CompareTpl = { id: string; name: string; metrics?: CompareSheet[] };
|
||||
type SuccessPat = { old: string[]; new: string[] };
|
||||
type SheetOverride = {
|
||||
metric_id: string;
|
||||
status_fields?: string[];
|
||||
down_values?: string[];
|
||||
up_values?: string[];
|
||||
success?: SuccessPat[];
|
||||
skip_dual?: boolean;
|
||||
field_tokens?: Array<{ side: string; field: string; in: string[] }>;
|
||||
};
|
||||
type MonitorTpl = {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -23,16 +43,111 @@ type MonitorTpl = {
|
|||
compare_template_name?: string;
|
||||
collect_metric_ids?: string[];
|
||||
defaults?: Record<string, unknown>;
|
||||
sheet_overrides?: unknown[];
|
||||
sheet_overrides?: SheetOverride[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
const KIND_TOKENS = ["removed", "added", "unchanged", "changed"] as const;
|
||||
const STATUS_TOKENS = ["up", "down", "other"] as const;
|
||||
|
||||
function emptyOverride(metricId: string): SheetOverride {
|
||||
return {
|
||||
metric_id: metricId,
|
||||
status_fields: [],
|
||||
down_values: ["down"],
|
||||
up_values: ["up"],
|
||||
success: [{ old: ["removed"], new: ["added", "unchanged"] }],
|
||||
skip_dual: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Built-in presets aligned with backend seeds. */
|
||||
export function presetForMetric(metricId: string): SheetOverride {
|
||||
const mid = metricId;
|
||||
if (mid === "interface_brief") {
|
||||
return {
|
||||
metric_id: mid,
|
||||
status_fields: ["admin", "phy", "prot"],
|
||||
down_values: ["down"],
|
||||
up_values: ["up"],
|
||||
success: [{ old: ["removed", "down"], new: ["added", "up", "unchanged"] }],
|
||||
skip_dual: false,
|
||||
};
|
||||
}
|
||||
if (mid === "bgp_peer") {
|
||||
return {
|
||||
metric_id: mid,
|
||||
status_fields: ["state"],
|
||||
down_values: ["idle", "active", "connect", "down"],
|
||||
up_values: ["established"],
|
||||
success: [{ old: ["removed", "down"], new: ["added", "up", "unchanged"] }],
|
||||
skip_dual: false,
|
||||
};
|
||||
}
|
||||
if (mid === "arp" || mid === "nd6_cache" || mid === "lldp_neighbor") {
|
||||
return {
|
||||
metric_id: mid,
|
||||
status_fields: [],
|
||||
down_values: [],
|
||||
up_values: [],
|
||||
success: [{ old: ["removed"], new: ["added", "unchanged"] }],
|
||||
skip_dual: false,
|
||||
};
|
||||
}
|
||||
if (mid.includes("isis") || mid.includes("ospf") || mid.includes("adjacency")) {
|
||||
return {
|
||||
metric_id: mid,
|
||||
status_fields: ["state", "status"].filter(Boolean),
|
||||
down_values: ["down", "init", "idle"],
|
||||
up_values: ["up", "full", "2way"],
|
||||
success: [{ old: ["removed", "down"], new: ["added", "up", "unchanged"] }],
|
||||
skip_dual: false,
|
||||
};
|
||||
}
|
||||
if (mid.includes("route") || mid.includes("vrf")) {
|
||||
return {
|
||||
metric_id: mid,
|
||||
status_fields: [],
|
||||
down_values: [],
|
||||
up_values: [],
|
||||
success: [{ old: ["removed"], new: ["added", "unchanged"] }],
|
||||
skip_dual: false,
|
||||
};
|
||||
}
|
||||
return emptyOverride(mid);
|
||||
}
|
||||
|
||||
function skipPreset(metricId: string): SheetOverride {
|
||||
return { ...emptyOverride(metricId), skip_dual: true, success: [] };
|
||||
}
|
||||
|
||||
function overrideFor(
|
||||
overrides: SheetOverride[],
|
||||
metricId: string,
|
||||
): SheetOverride {
|
||||
const found = overrides.find((o) => o.metric_id === metricId);
|
||||
return found ? { ...emptyOverride(metricId), ...found, metric_id: metricId } : emptyOverride(metricId);
|
||||
}
|
||||
|
||||
function csvValues(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[,;\s]+/)
|
||||
.map((x) => x.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toggleInList(list: string[], value: string): string[] {
|
||||
if (list.includes(value)) return list.filter((x) => x !== value);
|
||||
return [...list, value];
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -42,17 +157,34 @@ export function BizMonitorTemplatesPage() {
|
|||
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 [outOfExpect, setOutOfExpect] = useState("strict");
|
||||
const [collectIds, setCollectIds] = useState<string[]>([]);
|
||||
const [overrides, setOverrides] = useState<SheetOverride[]>([]);
|
||||
const [activeSheetIdx, setActiveSheetIdx] = useState(0);
|
||||
const [showAdvancedJson, setShowAdvancedJson] = useState(false);
|
||||
const [overridesText, setOverridesText] = useState("[]");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [mon, cmp] = await Promise.all([bizMonitorListTemplates(), bizCompareListTemplates()]);
|
||||
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,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
|
@ -76,13 +208,62 @@ export function BizMonitorTemplatesPage() {
|
|||
});
|
||||
}, [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 activeOverride = useMemo(
|
||||
() => (activeMetricId ? overrideFor(overrides, activeMetricId) : emptyOverride("")),
|
||||
[overrides, activeMetricId],
|
||||
);
|
||||
|
||||
const activeFields = useMemo(() => {
|
||||
const schema = metricSchemas.find((m) => m.metric_id === activeMetricId);
|
||||
return schema?.fields || [];
|
||||
}, [metricSchemas, activeMetricId]);
|
||||
|
||||
const sheetKey = useMemo(() => sheets.map((s) => s.metric_id).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.metric_id, o]));
|
||||
return sheets.map((s) => byId.get(s.metric_id) || emptyOverride(s.metric_id));
|
||||
});
|
||||
}, [compareId, sheetKey]);
|
||||
|
||||
const syncOverride = (metricId: string, patch: Partial<SheetOverride>) => {
|
||||
setOverrides((prev) => {
|
||||
const others = prev.filter((o) => o.metric_id !== metricId);
|
||||
const base = overrideFor(prev, metricId);
|
||||
return [...others, { ...base, ...patch, metric_id: metricId }];
|
||||
});
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditId("");
|
||||
setName("");
|
||||
setCompareId(compareTpls[0]?.id || "");
|
||||
const first = compareTpls[0]?.id || "";
|
||||
setCompareId(first);
|
||||
setNote("");
|
||||
setCollectText("");
|
||||
setDefaultsText('{"dual_mode":"migrate_pair","out_of_expect":"strict"}');
|
||||
setOutOfExpect("strict");
|
||||
setCollectIds([]);
|
||||
setOverrides([]);
|
||||
setActiveSheetIdx(0);
|
||||
setShowAdvancedJson(false);
|
||||
setOverridesText("[]");
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
|
@ -92,47 +273,54 @@ export function BizMonitorTemplatesPage() {
|
|||
setName(row.name || "");
|
||||
setCompareId(row.compare_template_id || "");
|
||||
setNote(row.note || "");
|
||||
setCollectText((row.collect_metric_ids || []).join(", "));
|
||||
setDefaultsText(JSON.stringify(row.defaults || {}, null, 2));
|
||||
const d = row.defaults || {};
|
||||
setOutOfExpect(String(d.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);
|
||||
setShowAdvancedJson(false);
|
||||
setOverridesText(JSON.stringify(row.sheet_overrides || [], null, 2));
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const closeEdit = () => setEditOpen(false);
|
||||
|
||||
const applyPreset = (kind: "auto" | "skip") => {
|
||||
if (!activeMetricId) return;
|
||||
const next = kind === "skip" ? skipPreset(activeMetricId) : presetForMetric(activeMetricId);
|
||||
syncOverride(activeMetricId, next);
|
||||
};
|
||||
|
||||
const applyAllPresets = () => {
|
||||
setOverrides(sheets.map((s) => presetForMetric(s.metric_id)));
|
||||
};
|
||||
|
||||
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");
|
||||
let sheet_overrides = overrides.filter((o) => o.metric_id);
|
||||
if (showAdvancedJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(overridesText || "[]") as unknown[];
|
||||
if (!Array.isArray(parsed)) throw new Error("overrides");
|
||||
sheet_overrides = parsed as SheetOverride[];
|
||||
} catch {
|
||||
showError(t("bizMonitorTpl.overridesInvalid"));
|
||||
return;
|
||||
}
|
||||
} 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 defaults = {
|
||||
dual_mode: "migrate_pair",
|
||||
out_of_expect: outOfExpect || "strict",
|
||||
};
|
||||
const body = {
|
||||
name: name.trim(),
|
||||
compare_template_id: compareId,
|
||||
collect_metric_ids,
|
||||
collect_metric_ids: collectIds,
|
||||
defaults,
|
||||
sheet_overrides: overrides,
|
||||
sheet_overrides,
|
||||
note: note.trim(),
|
||||
};
|
||||
setBusy(true);
|
||||
|
|
@ -167,6 +355,19 @@ export function BizMonitorTemplatesPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const tokenOptions = useMemo(() => {
|
||||
const fieldToks: string[] = [];
|
||||
for (const f of activeOverride.status_fields || []) {
|
||||
for (const v of activeOverride.down_values || []) {
|
||||
fieldToks.push(`field:${f}:${v}`);
|
||||
}
|
||||
for (const v of activeOverride.up_values || []) {
|
||||
fieldToks.push(`field:${f}:${v}`);
|
||||
}
|
||||
}
|
||||
return [...KIND_TOKENS, ...STATUS_TOKENS, ...fieldToks];
|
||||
}, [activeOverride]);
|
||||
|
||||
return (
|
||||
<section className="panel nm-page-panel">
|
||||
<div className="panel__toolbar">
|
||||
|
|
@ -178,6 +379,9 @@ export function BizMonitorTemplatesPage() {
|
|||
</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>
|
||||
</p>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="pt-list-kpis">
|
||||
|
|
@ -212,16 +416,7 @@ export function BizMonitorTemplatesPage() {
|
|||
<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.compare_template_name || row.compare_template_id || "—"}</td>
|
||||
<td>
|
||||
{(row.collect_metric_ids || []).length
|
||||
? (row.collect_metric_ids || []).join(", ")
|
||||
|
|
@ -233,12 +428,7 @@ export function BizMonitorTemplatesPage() {
|
|||
<Button size="sm" variant="primary" onPress={() => openEdit(row)}>
|
||||
{t("bizMonitorTpl.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={busy}
|
||||
onPress={() => void remove(row.id)}
|
||||
>
|
||||
<Button size="sm" variant="ghost" isDisabled={busy} onPress={() => void remove(row.id)}>
|
||||
{t("bizMonitorTpl.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -257,67 +447,393 @@ export function BizMonitorTemplatesPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<AppModalShell open={editOpen} onClose={closeEdit} size="lg">
|
||||
<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">
|
||||
<Modal.Body className="flex flex-col gap-4">
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
value={collectText}
|
||||
onChange={(e) => setCollectText(e.target.value)}
|
||||
placeholder={t("bizMonitorTpl.collectPh")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("bizMonitorTpl.namePh")}
|
||||
aria-label={t("bizMonitorTpl.colName")}
|
||||
/>
|
||||
<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 }}
|
||||
<FieldSelect
|
||||
label={t("bizMonitorTpl.colCompare")}
|
||||
value={compareId}
|
||||
onChange={(e) => {
|
||||
setCompareId(e.target.value);
|
||||
setActiveSheetIdx(0);
|
||||
}}
|
||||
fullWidth
|
||||
hint={t("bizMonitorTpl.compareHint")}
|
||||
>
|
||||
<option value="">{t("bizMonitorTpl.pickCompare")}</option>
|
||||
{compareTpls.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
{(c.metrics || []).length ? ` (${(c.metrics || []).length})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
<Input
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t("bizMonitorTpl.colNote")}
|
||||
aria-label={t("bizMonitorTpl.colNote")}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<strong style={{ fontSize: 13 }}>{t("bizMonitorTpl.globalPolicy")}</strong>
|
||||
<FieldSelect
|
||||
label={t("bizMonitorTpl.outOfExpect")}
|
||||
value={outOfExpect}
|
||||
onChange={(e) => setOutOfExpect(e.target.value)}
|
||||
fullWidth
|
||||
hint={t("bizMonitorTpl.outOfExpectHint")}
|
||||
>
|
||||
<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 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<strong style={{ fontSize: 13 }}>{t("bizMonitorTpl.collectTitle")}</strong>
|
||||
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
|
||||
{t("bizMonitorTpl.collectHint")}
|
||||
</p>
|
||||
<div className="btn-row" style={{ flexWrap: "wrap", gap: 8 }}>
|
||||
{sheets.map((s) => {
|
||||
const checked = collectIds.length === 0 || collectIds.includes(s.metric_id);
|
||||
const explicit = collectIds.length > 0;
|
||||
return (
|
||||
<label key={s.metric_id} className="config-sync-policy-check" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
if (!explicit) {
|
||||
setCollectIds(
|
||||
sheets.map((x) => x.metric_id).filter((id) => id !== s.metric_id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setCollectIds((prev) => {
|
||||
if (prev.includes(s.metric_id)) {
|
||||
const next = prev.filter((x) => x !== s.metric_id);
|
||||
return next.length === sheets.length ? [] : next;
|
||||
}
|
||||
const next = [...prev, s.metric_id];
|
||||
return next.length === sheets.length ? [] : next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span>{s.metric_id}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{collectIds.length ? (
|
||||
<Button size="sm" variant="ghost" onPress={() => setCollectIds([])}>
|
||||
{t("bizMonitorTpl.collectAll")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{sheets.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="btn-row" style={{ justifyContent: "space-between", flexWrap: "wrap" }}>
|
||||
<strong style={{ fontSize: 13 }}>{t("bizMonitorTpl.sheetRules")}</strong>
|
||||
<Button size="sm" variant="secondary" onPress={applyAllPresets}>
|
||||
{t("bizMonitorTpl.applyAllPresets")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bs-sheet-tabs btn-row" style={{ flexWrap: "wrap", gap: 6 }}>
|
||||
{sheets.map((s, i) => (
|
||||
<Button
|
||||
key={s.metric_id}
|
||||
size="sm"
|
||||
variant={activeSheetIdx === i ? "primary" : "secondary"}
|
||||
onPress={() => setActiveSheetIdx(i)}
|
||||
>
|
||||
{s.metric_id}
|
||||
{overrideFor(overrides, s.metric_id).skip_dual ? " · skip" : ""}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeSheet ? (
|
||||
<div className="flex flex-col gap-3" style={{ borderTop: "1px solid var(--border, #e5e7eb)", paddingTop: 12 }}>
|
||||
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
|
||||
{t("bizMonitorTpl.sheetHowReadonly")}: Key=
|
||||
{(activeSheet.key_fields || []).join(",") || "—"} ·{" "}
|
||||
{t("bizMonitorTpl.iface")}=
|
||||
{(activeSheet.iface_fields || []).join(",") || "—"} ·{" "}
|
||||
{t("bizMonitorTpl.compare")}=
|
||||
{(activeSheet.compare_fields || []).length
|
||||
? (activeSheet.compare_fields || []).join(",")
|
||||
: t("bizMonitorTpl.presenceOnly")}
|
||||
{" · "}
|
||||
<Link to="/network/cutover/compare-templates">{t("bizMonitorTpl.editInCompare")}</Link>
|
||||
</p>
|
||||
|
||||
<div className="btn-row" style={{ flexWrap: "wrap", gap: 8 }}>
|
||||
<Button size="sm" variant="secondary" onPress={() => applyPreset("auto")}>
|
||||
{t("bizMonitorTpl.presetAuto")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onPress={() => applyPreset("skip")}>
|
||||
{t("bizMonitorTpl.presetSkip")}
|
||||
</Button>
|
||||
<label className="config-sync-policy-check" style={{ marginLeft: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(activeOverride.skip_dual)}
|
||||
onChange={(e) =>
|
||||
syncOverride(activeMetricId, { skip_dual: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span>{t("bizMonitorTpl.skipDual")}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!activeOverride.skip_dual ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<strong style={{ fontSize: 13 }}>{t("bizMonitorTpl.statusFields")}</strong>
|
||||
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
|
||||
{t("bizMonitorTpl.statusFieldsHint")}
|
||||
</p>
|
||||
<div className="btn-row" style={{ flexWrap: "wrap", gap: 8 }}>
|
||||
{(activeFields.length
|
||||
? activeFields
|
||||
: [
|
||||
...(activeSheet.compare_fields || []),
|
||||
...(activeSheet.key_fields || []),
|
||||
].map((n) => ({ name: n }))
|
||||
).map((f) => (
|
||||
<label key={f.name} className="config-sync-policy-check" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(activeOverride.status_fields || []).includes(f.name)}
|
||||
onChange={() =>
|
||||
syncOverride(activeMetricId, {
|
||||
status_fields: toggleInList(
|
||||
activeOverride.status_fields || [],
|
||||
f.name,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{f.display_name || f.name}</span>
|
||||
</label>
|
||||
))}
|
||||
{!activeFields.length && !(activeSheet.compare_fields || []).length ? (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{t("bizMonitorTpl.noFields")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{t("bizMonitorTpl.downValues")}
|
||||
</span>
|
||||
<Input
|
||||
value={(activeOverride.down_values || []).join(", ")}
|
||||
onChange={(e) =>
|
||||
syncOverride(activeMetricId, {
|
||||
down_values: csvValues(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="down, idle"
|
||||
aria-label={t("bizMonitorTpl.downValues")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{t("bizMonitorTpl.upValues")}
|
||||
</span>
|
||||
<Input
|
||||
value={(activeOverride.up_values || []).join(", ")}
|
||||
onChange={(e) =>
|
||||
syncOverride(activeMetricId, {
|
||||
up_values: csvValues(e.target.value),
|
||||
})
|
||||
}
|
||||
placeholder="up, established"
|
||||
aria-label={t("bizMonitorTpl.upValues")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<strong style={{ fontSize: 13 }}>{t("bizMonitorTpl.successPatterns")}</strong>
|
||||
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
|
||||
{t("bizMonitorTpl.successPatternsHint")}
|
||||
</p>
|
||||
{(activeOverride.success || []).map((pat, pi) => (
|
||||
<div
|
||||
key={pi}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr auto",
|
||||
gap: 12,
|
||||
alignItems: "start",
|
||||
padding: "8px 0",
|
||||
borderBottom: "1px solid var(--border, #eee)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>
|
||||
{t("bizMonitorTpl.oldSideAny")}
|
||||
</div>
|
||||
<div className="btn-row" style={{ flexWrap: "wrap", gap: 4 }}>
|
||||
{tokenOptions.map((tok) => (
|
||||
<label
|
||||
key={`o-${pi}-${tok}`}
|
||||
className="config-sync-policy-check"
|
||||
style={{ margin: 0, fontSize: 12 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(pat.old || []).includes(tok)}
|
||||
onChange={() => {
|
||||
const success = [...(activeOverride.success || [])];
|
||||
success[pi] = {
|
||||
...pat,
|
||||
old: toggleInList(pat.old || [], tok),
|
||||
};
|
||||
syncOverride(activeMetricId, { success });
|
||||
}}
|
||||
/>
|
||||
<span>{tok}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>
|
||||
{t("bizMonitorTpl.newSideAny")}
|
||||
</div>
|
||||
<div className="btn-row" style={{ flexWrap: "wrap", gap: 4 }}>
|
||||
{tokenOptions.map((tok) => (
|
||||
<label
|
||||
key={`n-${pi}-${tok}`}
|
||||
className="config-sync-policy-check"
|
||||
style={{ margin: 0, fontSize: 12 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(pat.new || []).includes(tok)}
|
||||
onChange={() => {
|
||||
const success = [...(activeOverride.success || [])];
|
||||
success[pi] = {
|
||||
...pat,
|
||||
new: toggleInList(pat.new || [], tok),
|
||||
};
|
||||
syncOverride(activeMetricId, { success });
|
||||
}}
|
||||
/>
|
||||
<span>{tok}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
const success = (activeOverride.success || []).filter((_, i) => i !== pi);
|
||||
syncOverride(activeMetricId, {
|
||||
success: success.length
|
||||
? success
|
||||
: [{ old: ["removed"], new: ["added"] }],
|
||||
});
|
||||
}}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
syncOverride(activeMetricId, {
|
||||
success: [
|
||||
...(activeOverride.success || []),
|
||||
{ old: ["removed"], new: ["added"] },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("bizMonitorTpl.addSuccessRow")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
|
||||
{t("bizMonitorTpl.skipDualHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted" style={{ margin: 0, fontSize: 12 }}>
|
||||
{t("bizMonitorTpl.needCompareFirst")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<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 visual */
|
||||
}
|
||||
}
|
||||
setShowAdvancedJson((v) => !v);
|
||||
}}
|
||||
>
|
||||
{showAdvancedJson ? t("bizMonitorTpl.hideJson") : t("bizMonitorTpl.showJson")}
|
||||
</Button>
|
||||
{showAdvancedJson ? (
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
style={{ minHeight: 160, fontFamily: "ui-monospace, monospace", fontSize: 12 }}
|
||||
value={overridesText}
|
||||
onChange={(e) => setOverridesText(e.target.value)}
|
||||
aria-label={t("bizMonitorTpl.overrides")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button size="sm" variant="secondary" onPress={closeEdit}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue