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:
oliver 2026-09-18 21:28:42 +08:00
parent 2c2768a3d0
commit 27c6b7c61d
7 changed files with 985 additions and 136 deletions

View file

@ -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",

View file

@ -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: "端口流量监控",

View file

@ -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}>