diff --git a/netx_api/biz_migration/evaluate.py b/netx_api/biz_migration/evaluate.py index f435a19..3614246 100644 --- a/netx_api/biz_migration/evaluate.py +++ b/netx_api/biz_migration/evaluate.py @@ -119,7 +119,59 @@ def side_verdict( return "ok", "gray" -def _side_tokens(kind: str, status: str) -> set[str]: +def field_token(field: str, value: str) -> str: + """Canonical field token used in success patterns: field::.""" + return f"field:{str(field).strip().lower()}:{str(value).strip().lower()}" + + +def _fields_for_tokens( + sheet_override: dict[str, Any] | None, + success_patterns: list[dict[str, Any]] | None, +) -> set[str]: + """Fields whose current values should be emitted as field:* tokens.""" + ov = sheet_override or {} + fields: set[str] = set() + for f in ov.get("status_fields") or []: + s = str(f or "").strip() + if s: + fields.add(s) + for ft in ov.get("field_tokens") or []: + if isinstance(ft, dict): + s = str(ft.get("field") or "").strip() + if s: + fields.add(s) + for pat in success_patterns or []: + if not isinstance(pat, dict): + continue + for side in ("old", "new"): + for tok in pat.get(side) or []: + t = str(tok or "") + if t.startswith("field:") and t.count(":") >= 2: + # field:name:value + parts = t.split(":", 2) + if parts[1]: + fields.add(parts[1]) + return fields + + +def _field_tokens_from_row(row: dict[str, Any] | None, fields: set[str]) -> set[str]: + if not row or not fields: + return set() + out: set[str] = set() + for f in fields: + v = str(row.get(f) or "").strip() + if v: + out.add(field_token(f, v)) + return out + + +def _side_tokens( + kind: str, + status: str, + *, + row: dict[str, Any] | None = None, + fields: set[str] | None = None, +) -> set[str]: toks: set[str] = set() k = str(kind or "").strip() if k: @@ -127,6 +179,7 @@ def _side_tokens(kind: str, status: str) -> set[str]: s = str(status or "").strip() if s and s != "none": toks.add(s) + toks |= _field_tokens_from_row(row, fields or set()) return toks @@ -147,6 +200,28 @@ def _match_success( return False +def _match_field_token_rules( + old_row: dict[str, Any] | None, + new_row: dict[str, Any] | None, + field_tokens: list[dict[str, Any]] | None, +) -> bool: + """Optional AND constraints: each {side, field, in:[...]} must hold.""" + rules = [r for r in (field_tokens or []) if isinstance(r, dict)] + if not rules: + return True + for ft in rules: + side = str(ft.get("side") or "").strip().lower() + field = str(ft.get("field") or "").strip() + allowed = {str(x).strip().lower() for x in (ft.get("in") or []) if str(x).strip()} + if not field or not allowed: + continue + row = old_row if side == "old" else new_row if side == "new" else None + val = str((row or {}).get(field) or "").strip().lower() + if val not in allowed: + return False + return True + + def dual_verdict( *, old_kind: str, @@ -157,6 +232,10 @@ def dual_verdict( old_status: str = "none", new_status: str = "none", success_patterns: list[dict[str, Any]] | None = None, + out_of_expect: str = "strict", + old_row: dict[str, Any] | None = None, + new_row: dict[str, Any] | None = None, + sheet_override: dict[str, Any] | None = None, ) -> tuple[str, str]: """Synthesize old+new into migration board verdict. @@ -164,21 +243,28 @@ def dual_verdict( (``unfinished`` / ``lost``), not yellow migrating. When ``success_patterns`` is set (from monitor sheet_overrides), tokens may - include kinds and status classes (up/down) so e.g. old down + new up → migrated. + include kinds, status classes (up/down), and ``field:name:value``. + + ``out_of_expect``: strict (default) | warn (yellow instead of red) | ignore. """ - if in_expect and _match_success( - _side_tokens(old_kind, old_status), - _side_tokens(new_kind, new_status), - success_patterns, - ): + ov = sheet_override or {} + fields = _fields_for_tokens(ov, success_patterns) + old_toks = _side_tokens(old_kind, old_status, row=old_row, fields=fields) + new_toks = _side_tokens(new_kind, new_status, row=new_row, fields=fields) + field_ok = _match_field_token_rules(old_row, new_row, list(ov.get("field_tokens") or []) or None) + + if in_expect and field_ok and _match_success(old_toks, new_toks, success_patterns): return "migrated", "green" + ooe = str(out_of_expect or "strict").strip().lower() if not in_expect: + if ooe == "ignore": + return "not_involved", "gray" if old_kind in ("removed", "changed") or new_kind in ("removed", "changed"): if old_kind == "removed" and new_kind in ("", "removed"): - return "lost", "red" + return ("lost", "yellow") if ooe == "warn" else ("lost", "red") if old_kind in ("removed", "changed") or new_kind in ("changed",): - return "anomaly", "red" + return ("anomaly", "yellow") if ooe == "warn" else ("anomaly", "red") if old_kind == "added" or new_kind == "added": return "unexpected_new", "yellow" return "not_involved", "gray" @@ -195,12 +281,12 @@ def dual_verdict( if acceptance: return "unfinished", "red" return "migrating", "yellow" - ov, oc = side_verdict(kind=old_kind or "unchanged", in_expect=True, window_active=window_active) - if oc == "red" or (new_kind in ("removed",) and old_kind != "removed"): + side_v, side_c = side_verdict(kind=old_kind or "unchanged", in_expect=True, window_active=window_active) + if side_c == "red" or (new_kind in ("removed",) and old_kind != "removed"): return "anomaly", "red" if acceptance: return "unfinished", "red" - if window_active or ov.startswith("expected"): + if window_active or side_v.startswith("expected"): return "migrating", "yellow" return "migrating", "yellow" @@ -280,10 +366,22 @@ def evaluate_metric_dual( acceptance: bool = False, field_rules: list[dict[str, Any]] | None = None, sheet_override: dict[str, Any] | None = None, + out_of_expect: str = "strict", ) -> dict[str, Any]: """Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge.""" expect_keys = expect_keys_for_metric(expect, metric_id=metric_id, iface_fields=iface_fields) ov = sheet_override or {} + if ov.get("skip_dual"): + return { + "metric_id": metric_id, + "old_summary": {}, + "new_summary": {}, + "progress_ok": 0, + "progress_total": 0, + "anomaly": 0, + "rows": [], + "skipped": True, + } success_patterns = list(ov.get("success") or []) if isinstance(ov.get("success"), list) else [] old_cmp = compare_rows( @@ -372,6 +470,10 @@ def evaluate_metric_dual( old_status=old_st, new_status=new_st, success_patterns=success_patterns or None, + out_of_expect=out_of_expect, + old_row=old_cur or None, + new_row=new_cur or None, + sheet_override=ov, ) if in_exp and verdict == "migrated" and color == "green": progress_ok += 1 diff --git a/netx_api/biz_migration/monitor_templates.py b/netx_api/biz_migration/monitor_templates.py index 44f827d..491ec40 100644 --- a/netx_api/biz_migration/monitor_templates.py +++ b/netx_api/biz_migration/monitor_templates.py @@ -67,6 +67,58 @@ def ensure_port_compare_template(db: Session) -> BizCompareTemplate: return row +def preset_override_for_metric(metric_id: str) -> dict[str, Any]: + """Default dual-verdict overlay for a compare-template sheet.""" + mid = str(metric_id or "").strip() + if mid == PORT_METRIC_ID: + return { + "metric_id": mid, + "status_fields": list(PORT_STATUS_FIELDS), + "down_values": ["down"], + "up_values": ["up"], + "success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}], + } + 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"]}], + } + if mid in ("arp", "nd6_cache", "lldp_neighbor"): + return { + "metric_id": mid, + "status_fields": [], + "down_values": [], + "up_values": [], + "success": [{"old": ["removed"], "new": ["added", "unchanged"]}], + } + if "isis" in mid or "ospf" in mid or "adjacency" in mid: + return { + "metric_id": mid, + "status_fields": ["state"], + "down_values": ["down", "init", "idle"], + "up_values": ["up", "full", "2way"], + "success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}], + } + if "route" in mid or "vrf" in mid: + return { + "metric_id": mid, + "status_fields": [], + "down_values": [], + "up_values": [], + "success": [{"old": ["removed"], "new": ["added", "unchanged"]}], + } + return { + "metric_id": mid, + "status_fields": [], + "down_values": ["down"], + "up_values": ["up"], + "success": [{"old": ["removed"], "new": ["added", "unchanged"]}], + } + + def ensure_default_monitor_templates(db: Session) -> None: """Seed once when monitor-template table is empty.""" if db.query(BizMonitorTemplate.id).limit(1).first(): @@ -85,26 +137,15 @@ def ensure_default_monitor_templates(db: Session) -> None: compare_template_id=port_tpl.id, collect_metric_ids_json=[PORT_METRIC_ID], defaults_json={"dual_mode": "migrate_pair", "out_of_expect": "strict"}, - sheet_overrides_json=[ - { - "metric_id": PORT_METRIC_ID, - "status_fields": list(PORT_STATUS_FIELDS), - "down_values": ["down"], - "up_values": ["up"], - "success": [ - { - "old": ["removed", "down"], - "new": ["added", "up", "unchanged"], - } - ], - } - ], + sheet_overrides_json=[preset_override_for_metric(PORT_METRIC_ID)], note="Default: port status dual-verdict with up/down semantics", created_at=_utcnow(), updated_at=_utcnow(), ), ] if zte: + zte_sheets = cmp_svc.template_metrics(zte) + zte_overrides = [preset_override_for_metric(str(s.get("metric_id") or "")) for s in zte_sheets if s.get("metric_id")] seeds.append( BizMonitorTemplate( id=uuid4().hex, @@ -112,8 +153,8 @@ def ensure_default_monitor_templates(db: Session) -> None: compare_template_id=zte.id, collect_metric_ids_json=[], defaults_json={"dual_mode": "migrate_pair", "out_of_expect": "strict"}, - sheet_overrides_json=[], - note="Uses ZTE status compare template; presence dual-verdict", + sheet_overrides_json=zte_overrides, + note="ZTE multi-sheet: port/ARP/BGP/… dual presets", created_at=_utcnow(), updated_at=_utcnow(), ) diff --git a/netx_api/biz_migration/service.py b/netx_api/biz_migration/service.py index 115f890..365c3c9 100644 --- a/netx_api/biz_migration/service.py +++ b/netx_api/biz_migration/service.py @@ -360,7 +360,8 @@ def run_evaluate( # Final acceptance: window closed → unfinished expect = red window_active = (mb.status == "active") and (not acceptance) mt = resolve_project_monitor_template(db, proj) - sheets, sheet_overrides, _defaults = resolve_evaluate_sheets(db, mt) + sheets, sheet_overrides, defaults = resolve_evaluate_sheets(db, mt) + out_of_expect = str((defaults or {}).get("out_of_expect") or "strict").strip().lower() sheet_cards: list[dict[str, Any]] = [] all_rows: list[dict[str, Any]] = [] @@ -377,6 +378,8 @@ def run_evaluate( row_filters = list(sheet.get("row_filters") or []) field_rules = list(sheet.get("field_rules") or []) sheet_ov = override_for_metric(sheet_overrides, mid) + if sheet_ov.get("skip_dual"): + continue old_base = apply_row_filters( _load_metric_rows(db, batch_id=proj.old_baseline_batch_id, metric_id=mid), @@ -412,6 +415,7 @@ def run_evaluate( acceptance=acceptance, field_rules=field_rules, sheet_override=sheet_ov, + out_of_expect=out_of_expect, ) sheet_cards.append( { diff --git a/tests/test_biz_migration_evaluate.py b/tests/test_biz_migration_evaluate.py index 4e715af..0b44098 100644 --- a/tests/test_biz_migration_evaluate.py +++ b/tests/test_biz_migration_evaluate.py @@ -131,6 +131,49 @@ class VerdictTests(unittest.TestCase): ("migrated", "green"), ) + def test_out_of_expect_ignore(self): + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="", + in_expect=False, + window_active=False, + out_of_expect="ignore", + ), + ("not_involved", "gray"), + ) + + def test_out_of_expect_warn_is_yellow(self): + self.assertEqual( + dual_verdict( + old_kind="removed", + new_kind="unchanged", + in_expect=False, + window_active=False, + out_of_expect="warn", + ), + ("anomaly", "yellow"), + ) + + def test_field_token_success(self): + self.assertEqual( + dual_verdict( + old_kind="changed", + new_kind="changed", + in_expect=True, + window_active=True, + old_status="other", + new_status="other", + success_patterns=[ + {"old": ["field:state:idle"], "new": ["field:state:established"]} + ], + old_row={"state": "Idle"}, + new_row={"state": "Established"}, + sheet_override={"status_fields": ["state"]}, + ), + ("migrated", "green"), + ) + class EvaluateMetricDualTests(unittest.TestCase): def test_port_migration_happy_path(self): @@ -206,6 +249,85 @@ class EvaluateMetricDualTests(unittest.TestCase): migrated = [r for r in out["rows"] if r["verdict"] == "migrated"] self.assertTrue(migrated, out["rows"]) + def test_arp_presence_migrated(self): + old_base = [{"ip": "10.0.0.1", "mac": "aaaa.bbbb.cccc"}] + old_cur: list[dict] = [] + new_cur = [{"ip": "10.0.0.1", "mac": "aaaa.bbbb.cccc"}] + expect = parse_expect_set({"items": [{"metric_id": "arp", "key": "10.0.0.1"}]}) + out = evaluate_metric_dual( + metric_id="arp", + key_fields=["ip"], + iface_fields=[], + compare_fields=[], + old_baseline_rows=old_base, + old_current_rows=old_cur, + new_baseline_rows=None, + new_current_rows=new_cur, + port_map={}, + expect=expect, + window_active=True, + sheet_override={ + "metric_id": "arp", + "success": [{"old": ["removed"], "new": ["added", "unchanged"]}], + }, + ) + migrated = [r for r in out["rows"] if r["verdict"] == "migrated"] + self.assertTrue(migrated, out["rows"]) + + def test_bgp_idle_to_established(self): + bgp_ov = { + "metric_id": "bgp_peer", + "status_fields": ["state"], + "down_values": ["idle", "active", "connect", "down"], + "up_values": ["established"], + "success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}], + } + old_base = [{"peer": "1.1.1.1", "state": "Established"}] + old_cur = [{"peer": "1.1.1.1", "state": "Idle"}] + new_base = [{"peer": "1.1.1.1", "state": "Idle"}] + new_cur = [{"peer": "1.1.1.1", "state": "Established"}] + expect = parse_expect_set({"items": [{"metric_id": "bgp_peer", "key": "1.1.1.1"}]}) + out = evaluate_metric_dual( + metric_id="bgp_peer", + key_fields=["peer"], + iface_fields=[], + compare_fields=["state"], + old_baseline_rows=old_base, + old_current_rows=old_cur, + new_baseline_rows=new_base, + new_current_rows=new_cur, + port_map={}, + expect=expect, + window_active=True, + sheet_override=bgp_ov, + ) + migrated = [r for r in out["rows"] if r["verdict"] == "migrated"] + self.assertTrue(migrated, out["rows"]) + + def test_out_of_expect_ignore_no_red(self): + old_base = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}] + old_cur: list[dict] = [] + new_cur = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}] + expect = parse_expect_set({"ports": []}) + out = evaluate_metric_dual( + metric_id="interface_brief", + key_fields=["interface"], + iface_fields=["interface"], + compare_fields=["admin", "phy", "prot"], + old_baseline_rows=old_base, + old_current_rows=old_cur, + new_baseline_rows=None, + new_current_rows=new_cur, + port_map={}, + expect=expect, + window_active=False, + out_of_expect="ignore", + ) + reds = [r for r in out["rows"] if r["color"] == "red"] + self.assertFalse(reds, out["rows"]) + involved = [r for r in out["rows"] if r["verdict"] != "not_involved"] + self.assertFalse(involved, out["rows"]) + def test_unexpected_loss_is_anomaly(self): old_base = [{"interface": "gei-keep", "admin": "up", "phy": "up", "prot": "up"}] old_cur: list[dict] = [] diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 86243eb..293166c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 9fc4a95..5a8356a 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "端口流量监控", diff --git a/web/src/pages/network/BizMonitorTemplatesPage.tsx b/web/src/pages/network/BizMonitorTemplatesPage.tsx index 99621c6..b104e2f 100644 --- a/web/src/pages/network/BizMonitorTemplatesPage.tsx +++ b/web/src/pages/network/BizMonitorTemplatesPage.tsx @@ -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; - 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([]); const [compareTpls, setCompareTpls] = useState([]); + const [metricSchemas, setMetricSchemas] = useState([]); 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([]); + const [overrides, setOverrides] = useState([]); + 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[]).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) => { + 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 = {}; - let overrides: unknown[] = []; - try { - defaults = JSON.parse(defaultsText || "{}") as Record; - 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 (
@@ -178,6 +379,9 @@ export function BizMonitorTemplatesPage() {

{t("bizMonitorTpl.hint")}

+

+ {t("bizMonitorTpl.openCompare")} +

@@ -212,16 +416,7 @@ export function BizMonitorTemplatesPage() {
{row.name}
- - {row.compare_template_name || row.compare_template_id || "—"} - {row.compare_template_id ? ( -
- - {t("bizMonitorTpl.openCompare")} - -
- ) : null} - + {row.compare_template_name || row.compare_template_id || "—"} {(row.collect_metric_ids || []).length ? (row.collect_metric_ids || []).join(", ") @@ -233,12 +428,7 @@ export function BizMonitorTemplatesPage() { -
@@ -257,67 +447,393 @@ export function BizMonitorTemplatesPage() {
- + {editId ? t("bizMonitorTpl.edit") : t("bizMonitorTpl.create")} - +

{t("bizMonitorTpl.formHint")}

- - setCompareId(e.target.value)} - fullWidth - hint={t("bizMonitorTpl.compareHint")} - > - - {compareTpls.map((c) => ( - - ))} - -