From d232044750dcd3d05fccb060f059623d78ee8740 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 18 Sep 2026 01:30:57 +0800 Subject: [PATCH] Make biz compare template-driven with field rules, board UX, and import/export. Operators pick metrics then Key/Compare/Display rules; seed built-ins only once; fail-first verdict board for cutover review. Co-authored-by: Cursor --- netx_api/biz_state/compare_engine.py | 19 +- netx_api/biz_state/compare_rules.py | 289 ++++++ netx_api/biz_state/compare_service.py | 276 ++++- netx_api/biz_state_router.py | 34 +- skills/biz-state/netx-biz-state/SKILL.md | 26 +- tests/test_biz_state_compare.py | 215 ++++ web/src/i18n/en.ts | 108 +- web/src/i18n/zh.ts | 109 +- web/src/index.css | 54 +- web/src/pages/network/BizComparePage.tsx | 1188 +++++++++++++++++----- web/src/services/api.ts | 5 + 11 files changed, 1978 insertions(+), 345 deletions(-) create mode 100644 netx_api/biz_state/compare_rules.py diff --git a/netx_api/biz_state/compare_engine.py b/netx_api/biz_state/compare_engine.py index 8bf7ca5..b494d5a 100644 --- a/netx_api/biz_state/compare_engine.py +++ b/netx_api/biz_state/compare_engine.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import Any +from typing import Any, Mapping, Sequence + +from .compare_rules import field_rule_map, values_equal, explain_diff def apply_port_map( @@ -71,6 +73,7 @@ def compare_rows( iface_fields: list[str], compare_fields: list[str], port_map: dict[str, str] | None = None, + field_rules: Sequence[Mapping[str, Any]] | None = None, ) -> dict[str, Any]: """Return summary + diffs list. @@ -78,10 +81,14 @@ def compare_rows( Empty ``port_map`` means ignore port/iface changes: matching keys exclude ``iface_fields`` (same-device batches compare without a rename map). + + ``field_rules`` drives normalize / numeric tolerance / per-field compare mode + (template-driven; no metric-specific branches here). """ if not key_fields: raise ValueError("key_fields required") pmap = dict(port_map or {}) + rules = field_rule_map(field_rules) iface_set = {str(f) for f in (iface_fields or []) if str(f).strip()} # No map → ignore port renames: drop iface columns from the match key. if not pmap and iface_set: @@ -129,9 +136,13 @@ def compare_rows( for f in compare_fields: bv = mapped.get(f, "") av = after.get(f, "") - # Compare using mapped before for iface fields already rewritten - if str(bv) != str(av): - field_changes[f] = {"before": bv, "after": av} + rule = rules.get(f) + if not values_equal(bv, av, rule=rule): + entry: dict[str, Any] = {"before": bv, "after": av} + reason = explain_diff(bv, av, rule=rule) + if reason: + entry["reason"] = reason + field_changes[f] = entry if field_changes: changed += 1 diffs.append( diff --git a/netx_api/biz_state/compare_rules.py b/netx_api/biz_state/compare_rules.py new file mode 100644 index 0000000..2596457 --- /dev/null +++ b/netx_api/biz_state/compare_rules.py @@ -0,0 +1,289 @@ +"""Template-driven compare rules: row filters + per-field compare/normalize. + +All metric-specific compare behavior belongs in the sheet template +(``row_filters`` / ``field_rules``), not in hardcoded service branches. +""" + +from __future__ import annotations + +import re +from typing import Any, Mapping, Sequence + + +_AGE_TIMER_RE = re.compile(r"^\d{1,2}:\d{2}:\d{2}$") + + +def _as_list(raw: Any) -> list[Any]: + if raw is None: + return [] + if isinstance(raw, (list, tuple)): + return list(raw) + return [raw] + + +def _field_val(row: Mapping[str, Any], field: str) -> str: + return str((row or {}).get(field) or "").strip() + + +def eval_leaf_filter(row: Mapping[str, Any], filt: Mapping[str, Any]) -> bool: + """Evaluate one leaf predicate. Unknown ops → True (do not drop).""" + field = str(filt.get("field") or "").strip() + op = str(filt.get("op") or "eq").strip().lower() + if not field and op not in ("any", "all"): + return True + raw = _field_val(row, field) + expect = filt.get("value") + + if op in ("eq", "=="): + return raw.lower() == str(expect or "").strip().lower() + if op in ("ne", "!="): + return raw.lower() != str(expect or "").strip().lower() + if op == "in": + opts = {str(x).strip().lower() for x in _as_list(expect) if str(x).strip()} + return raw.lower() in opts + if op in ("not_in", "nin"): + opts = {str(x).strip().lower() for x in _as_list(expect) if str(x).strip()} + return raw.lower() not in opts + if op == "empty": + return not raw + if op in ("not_empty", "nonempty"): + return bool(raw) + if op == "regex": + pat = str(expect or "") + if not pat: + return True + try: + return bool(re.search(pat, raw, re.I)) + except re.error: + return True + if op == "age_timer": + # HH:MM:SS dynamic ARP age + return bool(_AGE_TIMER_RE.match(raw)) + if op == "ci_eq": + return raw.lower() == str(expect or "").strip().lower() + return True + + +def row_matches_filter(row: Mapping[str, Any], filt: Mapping[str, Any] | None) -> bool: + if not filt or not isinstance(filt, dict): + return True + if "any" in filt: + kids = filt.get("any") or [] + if not isinstance(kids, list) or not kids: + return True + return any(row_matches_filter(row, k) for k in kids if isinstance(k, dict)) + if "all" in filt: + kids = filt.get("all") or [] + if not isinstance(kids, list) or not kids: + return True + return all(row_matches_filter(row, k) for k in kids if isinstance(k, dict)) + return eval_leaf_filter(row, filt) + + +def apply_row_filters( + rows: Sequence[Mapping[str, Any]], + filters: Sequence[Mapping[str, Any]] | None, +) -> list[dict[str, Any]]: + """Keep rows matching all top-level filters (AND). Nested any/all supported.""" + fl = [f for f in (filters or []) if isinstance(f, dict)] + out: list[dict[str, Any]] = [] + for r in rows or []: + if not isinstance(r, dict): + continue + if all(row_matches_filter(r, f) for f in fl): + out.append(dict(r)) + return out + + +def normalize_value(value: Any, how: str) -> str: + text = str(value if value is not None else "").strip() + mode = str(how or "").strip().lower() + if not mode or mode in ("none", "strip"): + return text + if mode == "lower": + return text.lower() + if mode == "upper": + return text.upper() + if mode == "mac": + # 0011.2233.4455 / 00-11-22-33-44-55 / 00:11:… → lowercase hex only + hex_only = re.sub(r"[^0-9a-fA-F]", "", text).lower() + return hex_only + if mode == "empty_as_blank": + if text.lower() in ("n/a", "na", "-", "--", "none", "null"): + return "" + return text + return text + + +def field_rule_map(rules: Sequence[Mapping[str, Any]] | None) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for raw in rules or []: + if not isinstance(raw, dict): + continue + name = str(raw.get("field") or "").strip() + if not name: + continue + out[name] = dict(raw) + return out + + +def effective_compare_fields( + compare_fields: Sequence[str], + field_rules: Sequence[Mapping[str, Any]] | None, +) -> list[str]: + """Drop fields marked compare=ignore (or legacy ignore:true).""" + rules = field_rule_map(field_rules) + out: list[str] = [] + for f in compare_fields or []: + name = str(f).strip() + if not name: + continue + rule = rules.get(name) or {} + mode = str(rule.get("compare") or "").strip().lower() + if mode in ("ignore", "skip", "off"): + continue + if rule.get("ignore") is True: + continue + out.append(name) + return out + + +def effective_display_fields( + *, + key_fields: Sequence[str], + compare_fields: Sequence[str], + display_fields: Sequence[str] | None = None, +) -> list[str]: + """Result-table columns: explicit display, else key+compare (legacy). + + Keys always lead; remaining display/compare fields follow in given order + without duplicates. + """ + keys = [str(x).strip() for x in (key_fields or []) if str(x).strip()] + key_set = set(keys) + compare = [str(x).strip() for x in (compare_fields or []) if str(x).strip()] + raw_disp = display_fields + if raw_disp is None: + # Legacy templates: show key + compare only + extra = [f for f in compare if f not in key_set] + return keys + extra + disp = [str(x).strip() for x in raw_disp if str(x).strip()] + # Force keys first (always visible) + out: list[str] = list(keys) + seen = set(keys) + for f in disp: + if f in seen: + continue + out.append(f) + seen.add(f) + # Ensure compare fields appear even if UI forgot to tick display + for f in compare: + if f in seen or f in key_set: + continue + out.append(f) + seen.add(f) + return out + + +def _parse_float(text: str) -> float | None: + try: + return float(text) if text else 0.0 + except ValueError: + return None + + +def explain_diff( + before: Any, + after: Any, + *, + rule: Mapping[str, Any] | None = None, +) -> str: + """Human-readable reason when values_equal is False (for UI / export).""" + rule = rule or {} + norm = str(rule.get("normalize") or "strip").strip().lower() or "strip" + bv = normalize_value(before, norm) + av = normalize_value(after, norm) + mode = str(rule.get("compare") or "eq").strip().lower() or "eq" + if mode in ("percent", "pct", "rel"): + bn = _parse_float(bv) + an = _parse_float(av) + if bn is None or an is None: + return "neq" + if bn == 0.0: + return "pct_base_zero" + try: + t = float(rule.get("tolerance") or 0) + except (TypeError, ValueError): + t = 0.0 + pct = abs(an - bn) / abs(bn) * 100.0 + return f"pct {pct:.1f}% > {t:g}%" + if mode in ("numeric", "number", "int", "float"): + bn = _parse_float(bv) + an = _parse_float(av) + if bn is None or an is None: + return "neq" + try: + t = float(rule.get("tolerance") or 0) + except (TypeError, ValueError): + t = 0.0 + delta = abs(an - bn) + return f"abs Δ{delta:g} > {t:g}" + return "neq" + + +def values_equal( + before: Any, + after: Any, + *, + rule: Mapping[str, Any] | None = None, +) -> bool: + rule = rule or {} + norm = str(rule.get("normalize") or "strip").strip().lower() or "strip" + bv = normalize_value(before, norm) + av = normalize_value(after, norm) + mode = str(rule.get("compare") or "eq").strip().lower() or "eq" + if mode in ("ignore", "skip", "off"): + return True + if mode in ("numeric", "number", "int", "float", "percent", "pct", "rel"): + bn = _parse_float(bv) + an = _parse_float(av) + if bn is None or an is None: + return bv == av + tol = rule.get("tolerance", 0) + try: + t = float(tol or 0) + except (TypeError, ValueError): + t = 0.0 + if mode in ("percent", "pct", "rel"): + # Relative % vs before: |a-b|/max(|b|,eps)*100 <= tol + # before==0: both zero → ok; else fail (undefined relative base) + if bn == 0.0: + return an == 0.0 + pct = abs(an - bn) / abs(bn) * 100.0 + return pct <= t + return abs(bn - an) <= t + return bv == av + + +def arp_dynamic_row_filters() -> list[dict[str, Any]]: + """Canonical ARP compare filter (replaces hardcoded service filter).""" + return [ + { + "any": [ + {"field": "entry_type", "op": "eq", "value": "dynamic"}, + { + "all": [ + {"field": "entry_type", "op": "empty"}, + {"field": "age", "op": "age_timer"}, + ] + }, + ] + } + ] + + +# Presets for seeding defaults (not a UI "apply preset" button) +ROW_FILTER_PRESETS: dict[str, list[dict[str, Any]]] = { + "arp_dynamic": arp_dynamic_row_filters(), + "bgp_established": [{"field": "state", "op": "eq", "value": "Established"}], +} diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index bd3c058..ff6e2ed 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -24,6 +24,13 @@ from ..models import ( ) from ..timeutil import utcnow_naive from .compare_engine import compare_rows, mapping_stats +from .compare_rules import ( + ROW_FILTER_PRESETS, + apply_row_filters, + arp_dynamic_row_filters, + effective_compare_fields, + effective_display_fields, +) from .profiles import metric_field_map @@ -158,12 +165,58 @@ def _str_list(raw: Any) -> list[str]: return [str(x).strip() for x in (raw or []) if str(x).strip()] +def _normalize_row_filters(raw: Any) -> list[dict[str, Any]]: + if not isinstance(raw, list): + return [] + out: list[dict[str, Any]] = [] + for item in raw: + if isinstance(item, dict) and item: + out.append(dict(item)) + return out + + +def _normalize_field_rules(raw: Any) -> list[dict[str, Any]]: + if not isinstance(raw, list): + return [] + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for item in raw: + if not isinstance(item, dict): + continue + name = str(item.get("field") or "").strip() + if not name or name in seen: + continue + seen.add(name) + rule: dict[str, Any] = {"field": name} + compare = str(item.get("compare") or "").strip().lower() + if compare: + rule["compare"] = compare + if item.get("ignore") is True: + rule["ignore"] = True + rule.setdefault("compare", "ignore") + norm = str(item.get("normalize") or "").strip().lower() + if norm and norm not in ("none", "strip"): + rule["normalize"] = norm + if item.get("tolerance") is not None and str(item.get("tolerance")).strip() != "": + try: + rule["tolerance"] = float(item.get("tolerance")) + except (TypeError, ValueError): + pass + # Drop empty rules (only field name) + if len(rule) > 1: + out.append(rule) + return out + + def _sheet_def( *, metric_id: str, key_fields: list[str], iface_fields: list[str] | None = None, compare_fields: list[str] | None = None, + display_fields: list[str] | None = None, + row_filters: list[dict[str, Any]] | None = None, + field_rules: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: mid = str(metric_id or "").strip() keys = _str_list(key_fields) @@ -173,12 +226,32 @@ def _sheet_def( # Keys are identity only; strip them from compare so UI/engine stay clear key_set = set(keys) compare = [f for f in compare if f not in key_set] - return { + rules = _normalize_field_rules(field_rules) + # Drop ignored fields from compare list (single source of truth for UI) + compare = effective_compare_fields(compare, rules) + # None = legacy (derive key+compare); explicit list (even empty extras) preserved + if display_fields is None: + display = effective_display_fields( + key_fields=keys, + compare_fields=compare, + display_fields=None, + ) + else: + display = effective_display_fields( + key_fields=keys, + compare_fields=compare, + display_fields=_str_list(display_fields), + ) + sheet: dict[str, Any] = { "metric_id": mid, "key_fields": keys, "iface_fields": ifaces, "compare_fields": compare, + "display_fields": display, + "row_filters": _normalize_row_filters(row_filters), + "field_rules": rules, } + return sheet def _default_lldp_sheet() -> dict[str, Any]: @@ -217,11 +290,25 @@ def _default_sheet_for_metric(metric_id: str, *, compare_roles: tuple[str, ...] keys = [f.name for f in fields if f.is_key] ifaces = [f.name for f in fields if f.is_interface] compare = [f.name for f in fields if (not f.is_key) and f.role in compare_roles] + extra: dict[str, Any] = {} + if metric_id == "arp": + # Template-owned ARP filter (was hardcoded in _load_metric_rows) + extra["row_filters"] = arp_dynamic_row_filters() + # MAC often differs in format across vendors / reloads + extra["field_rules"] = [{"field": "mac", "normalize": "mac"}] if "mac" in { + f.name for f in fields + } else [] + # Context columns: show but not necessarily compare + ctx = [n for n in ("vrf", "entry_type", "age") if n not in keys and n not in compare] + extra["display_fields"] = list(keys) + list(compare) + ctx return _sheet_def( metric_id=metric_id, key_fields=keys, iface_fields=ifaces, compare_fields=compare, + display_fields=extra.get("display_fields"), + row_filters=extra.get("row_filters"), + field_rules=extra.get("field_rules"), ) @@ -242,11 +329,27 @@ def _normalize_sheet(raw: Any) -> dict[str, Any] | None: keys = _str_list(raw.get("key_fields")) if not mid or not keys: return None + # Legacy ignore_fields → field_rules compare=ignore + rules = list(_normalize_field_rules(raw.get("field_rules"))) + ignore = set(_str_list(raw.get("ignore_fields"))) + by_field = {str(r.get("field")): r for r in rules} + for name in ignore: + if name not in by_field: + rules.append({"field": name, "compare": "ignore", "ignore": True}) + # display_fields: missing key → legacy derive; present → explicit + disp_arg: list[str] | None + if "display_fields" in raw: + disp_arg = _str_list(raw.get("display_fields")) + else: + disp_arg = None return _sheet_def( metric_id=mid, key_fields=keys, iface_fields=_str_list(raw.get("iface_fields")), compare_fields=_str_list(raw.get("compare_fields")), + display_fields=disp_arg, + row_filters=_normalize_row_filters(raw.get("row_filters")), + field_rules=rules, ) @@ -257,12 +360,14 @@ def _legacy_sheets(t: BizCompareTemplate) -> list[dict[str, Any]]: return [] ignore = set(_str_list(t.ignore_fields)) compare = [f for f in _str_list(t.compare_fields) if f not in ignore] + rules = [{"field": f, "compare": "ignore", "ignore": True} for f in sorted(ignore)] return [ _sheet_def( metric_id=mid, key_fields=keys, iface_fields=_str_list(t.iface_fields), compare_fields=compare, + field_rules=rules, ) ] @@ -329,12 +434,25 @@ def _parse_metrics_body(body: dict[str, Any]) -> list[dict[str, Any]]: raise HTTPException(status_code=400, detail="key_fields_required") ignore = set(_str_list(body.get("ignore_fields"))) compare = [f for f in _str_list(body.get("compare_fields")) if f not in ignore] + rules = _normalize_field_rules(body.get("field_rules")) + by_field = {str(r.get("field")): r for r in rules} + for name in ignore: + if name not in by_field: + rules.append({"field": name, "compare": "ignore", "ignore": True}) + disp_arg: list[str] | None + if "display_fields" in body: + disp_arg = _str_list(body.get("display_fields")) + else: + disp_arg = None return [ _sheet_def( metric_id=mid, key_fields=keys, iface_fields=_str_list(body.get("iface_fields")), compare_fields=compare, + display_fields=disp_arg, + row_filters=_normalize_row_filters(body.get("row_filters")), + field_rules=rules, ) ] @@ -451,8 +569,25 @@ def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate: existing = template_metrics(row) want = {s["metric_id"] for s in sheets} have = {s["metric_id"] for s in existing} + changed = bool(want - have) + # Migrate ARP sheet: inject template row_filters if missing (replaces code filter) + upgraded: list[dict[str, Any]] = [] + by_want = {s["metric_id"]: s for s in sheets} + for s in existing: + cur = dict(s) + if cur.get("metric_id") == "arp" and not cur.get("row_filters"): + cur["row_filters"] = list(by_want.get("arp", {}).get("row_filters") or arp_dynamic_row_filters()) + if not cur.get("field_rules") and by_want.get("arp", {}).get("field_rules"): + cur["field_rules"] = list(by_want["arp"]["field_rules"]) + changed = True + upgraded.append(_normalize_sheet(cur) or cur) if want - have: - _apply_sheets_to_row(row, sheets) + for s in sheets: + if s["metric_id"] not in have: + upgraded.append(s) + changed = True + if changed: + _apply_sheets_to_row(row, upgraded if upgraded else sheets) row.note = "Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP)" row.updated_at = _utcnow() db.commit() @@ -473,6 +608,13 @@ def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate: def ensure_default_templates(db: Session) -> None: + """Seed built-ins only on first install (empty table). + + Operators own templates after that — do not recreate deleted built-ins + or overwrite customized sheets on every list call. + """ + if db.query(BizCompareTemplate.id).limit(1).first(): + return ensure_default_cutover_template(db) ensure_default_lldp_template(db) ensure_default_vrf_template(db) @@ -510,6 +652,14 @@ def list_metric_schemas() -> list[dict[str, Any]]: return out +def list_row_filter_presets() -> list[dict[str, Any]]: + """Named row_filter bundles for the template UI (ARP dynamic, BGP Established, …).""" + return [ + {"id": pid, "label": pid, "row_filters": filters} + for pid, filters in ROW_FILTER_PRESETS.items() + ] + + def create_template(db: Session, body: dict[str, Any]) -> dict[str, Any]: sheets = _parse_metrics_body(body) t = BizCompareTemplate( @@ -533,7 +683,20 @@ def update_template(db: Session, template_id: str, body: dict[str, Any]) -> dict t.name = str(body.get("name") or "")[:256] if "note" in body: t.note = str(body.get("note") or "")[:512] - if any(k in body for k in ("metrics", "metric_id", "key_fields", "iface_fields", "compare_fields", "ignore_fields")): + if any( + k in body + for k in ( + "metrics", + "metric_id", + "key_fields", + "iface_fields", + "compare_fields", + "ignore_fields", + "display_fields", + "row_filters", + "field_rules", + ) + ): # Prefer explicit metrics; otherwise merge into current sheets from legacy keys if "metrics" in body and body.get("metrics") is not None: sheets = _parse_metrics_body(body) @@ -557,8 +720,18 @@ def update_template(db: Session, template_id: str, body: dict[str, Any]) -> dict first["iface_fields"] = _str_list(body.get("iface_fields")) if "compare_fields" in body or "ignore_fields" in body: ignore = set(_str_list(body.get("ignore_fields"))) if "ignore_fields" in body else set() - compare = _str_list(body.get("compare_fields")) if "compare_fields" in body else list(first.get("compare_fields") or []) + compare = ( + _str_list(body.get("compare_fields")) + if "compare_fields" in body + else list(first.get("compare_fields") or []) + ) first["compare_fields"] = [f for f in compare if f not in ignore] + if "display_fields" in body: + first["display_fields"] = _str_list(body.get("display_fields")) + if "row_filters" in body: + first["row_filters"] = _normalize_row_filters(body.get("row_filters")) + if "field_rules" in body: + first["field_rules"] = _normalize_field_rules(body.get("field_rules")) sheets[0] = _normalize_sheet(first) or first _apply_sheets_to_row(t, sheets) t.updated_at = _utcnow() @@ -704,21 +877,9 @@ def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dic .all() ) if rows: - out = [dict(r.data_json or {}) for r in rows] - if metric_id == "arp": - from .parsers.zte.arp import is_valid_arp_age - - # Compare only dynamic ARP (Age is HH:MM:SS); drop static H / incomplete flags - out = [ - r - for r in out - if str(r.get("entry_type") or "").lower() == "dynamic" - or ( - not r.get("entry_type") - and is_valid_arp_age(str(r.get("age") or "")) - ) - ] - return out + # Raw rows only — filtering belongs to the compare sheet template + # (``row_filters``), not metric-specific branches here. + return [dict(r.data_json or {}) for r in rows] # Known metric with zero rows is OK; unknown metric still errors if metric_id in metric_field_map(): return [] @@ -873,10 +1034,24 @@ def _run_sheet( ) -> dict[str, Any]: key_fields = list(sheet.get("key_fields") or []) iface_fields = list(sheet.get("iface_fields") or []) - compare_fields = list(sheet.get("compare_fields") or []) + field_rules = list(sheet.get("field_rules") or []) + compare_fields = effective_compare_fields( + list(sheet.get("compare_fields") or []), + field_rules, + ) + display_fields = effective_display_fields( + key_fields=key_fields, + compare_fields=compare_fields, + display_fields=list(sheet.get("display_fields")) + if "display_fields" in sheet + else None, + ) + row_filters = list(sheet.get("row_filters") or []) mode = "presence" if not compare_fields else "fields" - before_rows = _load_metric_rows(db, batch_id=before_batch_id, metric_id=sheet["metric_id"]) - after_rows = _load_metric_rows(db, batch_id=after_batch_id, metric_id=sheet["metric_id"]) + before_raw = _load_metric_rows(db, batch_id=before_batch_id, metric_id=sheet["metric_id"]) + after_raw = _load_metric_rows(db, batch_id=after_batch_id, metric_id=sheet["metric_id"]) + before_rows = apply_row_filters(before_raw, row_filters) + after_rows = apply_row_filters(after_raw, row_filters) result = compare_rows( before_rows=before_rows, after_rows=after_rows, @@ -884,14 +1059,22 @@ def _run_sheet( iface_fields=iface_fields, compare_fields=compare_fields, port_map=port_map, + field_rules=field_rules, ) + summary = dict(result["summary"]) + summary["before_raw_count"] = len(before_raw) + summary["after_raw_count"] = len(after_raw) + summary["row_filters"] = len(row_filters) return { "metric_id": sheet["metric_id"], "key_fields": key_fields, "iface_fields": iface_fields, "compare_fields": compare_fields, + "display_fields": display_fields, + "row_filters": row_filters, + "field_rules": field_rules, "mode": mode, - "summary": result["summary"], + "summary": summary, "diffs": result["diffs"], "mapping_stats": result["mapping_stats"], } @@ -965,6 +1148,8 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> "key_fields": s["key_fields"], "iface_fields": s["iface_fields"], "compare_fields": s["compare_fields"], + "display_fields": s.get("display_fields") or [], + "field_rules": s.get("field_rules") or [], "mode": s["mode"], "summary": s["summary"], } @@ -1012,11 +1197,23 @@ def _csv_cell(v: Any) -> str: def _sheet_csv(sheet: dict[str, Any]) -> str: keys = list(sheet.get("key_fields") or []) + key_set = set(keys) compare = list(sheet.get("compare_fields") or []) + compare_set = set(compare) + display = effective_display_fields( + key_fields=keys, + compare_fields=compare, + display_fields=list(sheet.get("display_fields") or []) or None, + ) + # Non-key display columns: compare fields get pre/post; display-only get single value col + extra = [f for f in display if f not in key_set] headers = ["kind", *keys] - for f in compare: - headers.append(f"{f}__pre") - headers.append(f"{f}__post") + for f in extra: + if f in compare_set: + headers.append(f"{f}__pre") + headers.append(f"{f}__post") + else: + headers.append(f) lines = [",".join(_csv_cell(h) for h in headers)] for d in list(sheet.get("diffs") or []): kind = str(d.get("kind") or "") @@ -1026,16 +1223,23 @@ def _sheet_csv(sheet: dict[str, Any]) -> str: row = [kind] for k in keys: row.append(key.get(k, pre.get(k, post.get(k, "")))) - for f in compare: - if kind == "added": - row.append("") - row.append(post.get(f, "")) - elif kind == "removed": - row.append(pre.get(f, "")) - row.append("") + for f in extra: + if f in compare_set: + if kind == "added": + row.append("") + row.append(post.get(f, "")) + elif kind == "removed": + row.append(pre.get(f, "")) + row.append("") + else: + row.append(pre.get(f, "")) + row.append(post.get(f, "")) else: - row.append(pre.get(f, "")) - row.append(post.get(f, "")) + # Display-only: prefer after, then before + if kind == "removed": + row.append(pre.get(f, "")) + else: + row.append(post.get(f, pre.get(f, ""))) lines.append(",".join(_csv_cell(x) for x in row)) return "\ufeff" + "\n".join(lines) + "\n" @@ -1125,6 +1329,8 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: "key_fields": list(sh.get("key_fields") or []), "iface_fields": list(sh.get("iface_fields") or []), "compare_fields": list(sh.get("compare_fields") or []), + "display_fields": list(sh.get("display_fields") or []), + "field_rules": list(sh.get("field_rules") or []), "mode": sh.get("mode") or ("presence" if not sh.get("compare_fields") else "fields"), "summary": dict(sh.get("summary") or {}), } diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index dceab3c..42c0c8d 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -212,11 +212,34 @@ def api_export_batch(batch_id: str, db: Session = Depends(get_db)) -> StreamingR from .biz_state import compare_service as cmp_svc # noqa: E402 +class FieldRuleIn(BaseModel): + field: str + compare: str = "" + normalize: str = "" + ignore: bool = False + tolerance: float | None = None + + +class RowFilterIn(BaseModel): + """Leaf or nested (any/all) row filter — kept open for nested dicts.""" + + model_config = {"extra": "allow"} + + field: str | None = None + op: str | None = None + value: Any = None + any: list[dict[str, Any]] | None = None + all: list[dict[str, Any]] | None = None + + class TemplateMetricIn(BaseModel): metric_id: str key_fields: list[str] = Field(default_factory=list) iface_fields: list[str] = Field(default_factory=list) compare_fields: list[str] = Field(default_factory=list) + display_fields: list[str] = Field(default_factory=list) + row_filters: list[dict[str, Any]] = Field(default_factory=list) + field_rules: list[FieldRuleIn] = Field(default_factory=list) class TemplateIn(BaseModel): @@ -230,6 +253,9 @@ class TemplateIn(BaseModel): iface_fields: list[str] = Field(default_factory=list) compare_fields: list[str] = Field(default_factory=list) ignore_fields: list[str] = Field(default_factory=list) + display_fields: list[str] = Field(default_factory=list) + row_filters: list[dict[str, Any]] = Field(default_factory=list) + field_rules: list[FieldRuleIn] = Field(default_factory=list) class TemplatePatchIn(BaseModel): @@ -241,6 +267,9 @@ class TemplatePatchIn(BaseModel): iface_fields: list[str] | None = None compare_fields: list[str] | None = None ignore_fields: list[str] | None = None + display_fields: list[str] | None = None + row_filters: list[dict[str, Any]] | None = None + field_rules: list[FieldRuleIn] | None = None class MappingRowIn(BaseModel): @@ -275,7 +304,10 @@ class CompareJobIn(BaseModel): @router.get("/compare/metrics") def api_list_compare_metrics() -> dict[str, Any]: - return {"items": cmp_svc.list_metric_schemas()} + return { + "items": cmp_svc.list_metric_schemas(), + "row_filter_presets": cmp_svc.list_row_filter_presets(), + } @router.get("/compare/templates") diff --git a/skills/biz-state/netx-biz-state/SKILL.md b/skills/biz-state/netx-biz-state/SKILL.md index 5e5b3f6..01f153a 100644 --- a/skills/biz-state/netx-biz-state/SKILL.md +++ b/skills/biz-state/netx-biz-state/SKILL.md @@ -122,22 +122,42 @@ enrich_joins=[ - 辅命令不要再抄一份 `command_template`/`rule_keys` 到 `AuxCommand` - `parser_id` 全局扁平:新指标命名避免跨厂家撞名;注册顺序后写覆盖先写 - TextFSM 字面量 `$`:不要用 `^\$`(模板替换冲突);能靠下一 `interface` 切块则不匹配 `$` +- **比对不要写 metric 硬编码过滤**(如 ARP dynamic):一律进 sheet 的 `row_filters` / `field_rules`,见 `compare_rules.py` + 模板 UI --- -## 5. 自检清单 +## 5. 比对模板(可视化,禁代码定制) + +Sheet(`metrics_json[]`)能力: + +| 字段 | 作用 | +|------|------| +| `key_fields` / `iface_fields` / `compare_fields` | 对齐 / 端口映射 / 值比对(空=仅存在性) | +| `display_fields` | 结果列(可含不比对的上下文;缺省=key+compare) | +| `row_filters` | 比对前行过滤(AND;支持 `any`/`all`;op: eq/ne/in/empty/regex/age_timer…) | +| `field_rules` | 每字段 `normalize` / `compare`(eq/numeric/percent/ignore) / `tolerance` | + +- ARP 动态过滤预设:`arp_dynamic_row_filters()` → ZTE status 默认 sheet 已带 +- 引擎:`compare_engine.compare_rows(..., field_rules=)`;服务:`_run_sheet` 先 `apply_row_filters` +- UI:BizCompare 模板编辑 → 先勾选监控项,再按表编辑行过滤规则(组内且 / 组间或)与字段归一化;无「应用预设」按钮,默认规则随监控项带入后可改 +- 单测:`tests.test_biz_state_compare` + +--- + +## 6. 自检清单 - [ ] index + apply_rule 对样本有行 - [ ] prefer_fsm:有 FSM 不走手写;无 FSM 手写仍通 - [ ] Profile match 能命中任务里的 concrete 命令 - [ ] 跨表:辅 profile 可单独 `run_parser`;主 profile enrich 后字段正确 -- [ ] `python -m unittest tests.test_fsm_parser_pipeline tests.test_enrich_framework tests.test_multi_command_arp`(及本指标单测) +- [ ] `python -m unittest tests.test_fsm_parser_pipeline tests.test_enrich_framework tests.test_multi_command_arp tests.test_biz_state_compare`(及本指标单测) --- -## 6. 参考文件 +## 7. 参考文件 - Enrich:`biz_state/enrich.py` - Session:`biz_state/collect_session.py` +- Compare:`biz_state/compare_rules.py`、`compare_engine.py`、`compare_service.py` - ARP+VRF:`profiles.py` → `zte.arp`;`parsers/zte/arp.py`;`parsers/zte/if_intf.py` - 模板:`cli_templates/zte/zte_zxros_show_arp.textfsm`、`..._if_intf.textfsm` diff --git a/tests/test_biz_state_compare.py b/tests/test_biz_state_compare.py index 366f342..b2e91a6 100644 --- a/tests/test_biz_state_compare.py +++ b/tests/test_biz_state_compare.py @@ -5,6 +5,14 @@ from __future__ import annotations import unittest from netx_api.biz_state.compare_engine import compare_rows, mapping_stats +from netx_api.biz_state.compare_rules import ( + apply_row_filters, + arp_dynamic_row_filters, + effective_compare_fields, + effective_display_fields, + normalize_value, + values_equal, +) class CompareEngineTests(unittest.TestCase): @@ -124,6 +132,159 @@ class CompareEngineTests(unittest.TestCase): self.assertEqual(len(out["diffs"]), 1) self.assertEqual(out["diffs"][0]["kind"], "unchanged") + def test_mac_normalize_via_field_rules(self) -> None: + before = [{"ip": "1.1.1.1", "mac": "00:11:22:33:44:55", "iface": "gei-0/1"}] + after = [{"ip": "1.1.1.1", "mac": "0011.2233.4455", "iface": "gei-0/1"}] + out = compare_rows( + before_rows=before, + after_rows=after, + key_fields=["ip", "iface"], + iface_fields=["iface"], + compare_fields=["mac"], + port_map={}, + field_rules=[{"field": "mac", "normalize": "mac"}], + ) + self.assertEqual(out["summary"]["unchanged"], 1) + self.assertEqual(out["summary"]["changed"], 0) + + def test_numeric_tolerance(self) -> None: + before = [{"id": "1", "cnt": "100"}] + after = [{"id": "1", "cnt": "102"}] + out = compare_rows( + before_rows=before, + after_rows=after, + key_fields=["id"], + iface_fields=[], + compare_fields=["cnt"], + port_map={}, + field_rules=[{"field": "cnt", "compare": "numeric", "tolerance": 5}], + ) + self.assertEqual(out["summary"]["unchanged"], 1) + out2 = compare_rows( + before_rows=before, + after_rows=after, + key_fields=["id"], + iface_fields=[], + compare_fields=["cnt"], + port_map={}, + field_rules=[{"field": "cnt", "compare": "numeric", "tolerance": 0}], + ) + self.assertEqual(out2["summary"]["changed"], 1) + + def test_percent_tolerance(self) -> None: + before = [{"id": "1", "networks": "100"}] + after_ok = [{"id": "1", "networks": "104"}] # 4% < 5% + after_bad = [{"id": "1", "networks": "106"}] # 6% > 5% + rule = [{"field": "networks", "compare": "percent", "tolerance": 5}] + ok = compare_rows( + before_rows=before, + after_rows=after_ok, + key_fields=["id"], + iface_fields=[], + compare_fields=["networks"], + port_map={}, + field_rules=rule, + ) + self.assertEqual(ok["summary"]["unchanged"], 1) + bad = compare_rows( + before_rows=before, + after_rows=after_bad, + key_fields=["id"], + iface_fields=[], + compare_fields=["networks"], + port_map={}, + field_rules=rule, + ) + self.assertEqual(bad["summary"]["changed"], 1) + + def test_percent_zero_baseline(self) -> None: + self.assertTrue( + values_equal("0", "0", rule={"compare": "percent", "tolerance": 5}) + ) + self.assertFalse( + values_equal("0", "1", rule={"compare": "percent", "tolerance": 5}) + ) + + +class CompareRulesTests(unittest.TestCase): + def test_arp_dynamic_row_filters(self) -> None: + rows = [ + {"ip": "1.1.1.1", "entry_type": "dynamic", "age": "00:01:02"}, + {"ip": "1.1.1.2", "entry_type": "static", "age": "H"}, + {"ip": "1.1.1.3", "entry_type": "", "age": "01:02:03"}, + {"ip": "1.1.1.4", "entry_type": "", "age": "I"}, + ] + kept = apply_row_filters(rows, arp_dynamic_row_filters()) + ips = {r["ip"] for r in kept} + self.assertEqual(ips, {"1.1.1.1", "1.1.1.3"}) + + def test_effective_compare_ignore(self) -> None: + fields = effective_compare_fields( + ["mac", "vlan", "age"], + [{"field": "age", "compare": "ignore"}, {"field": "vlan", "ignore": True}], + ) + self.assertEqual(fields, ["mac"]) + + def test_normalize_mac(self) -> None: + self.assertEqual(normalize_value("00-11-22-33-44-55", "mac"), "001122334455") + self.assertTrue(values_equal("Aa", "aa", rule={"normalize": "lower"})) + + def test_explain_diff_percent(self) -> None: + from netx_api.biz_state.compare_rules import explain_diff + + reason = explain_diff( + "100", "108", rule={"compare": "percent", "tolerance": 5} + ) + self.assertIn("pct", reason) + self.assertIn("8", reason) + + def test_changed_includes_reason(self) -> None: + before = [{"id": "1", "networks": "100"}] + after = [{"id": "1", "networks": "110"}] + out = compare_rows( + before_rows=before, + after_rows=after, + key_fields=["id"], + iface_fields=[], + compare_fields=["networks"], + port_map={}, + field_rules=[{"field": "networks", "compare": "percent", "tolerance": 5}], + ) + self.assertEqual(out["summary"]["changed"], 1) + ch = out["diffs"][0]["changes"]["networks"] + self.assertIn("reason", ch) + self.assertIn("pct", ch["reason"]) + + def test_effective_display_legacy_and_explicit(self) -> None: + legacy = effective_display_fields( + key_fields=["ip", "iface"], + compare_fields=["mac"], + display_fields=None, + ) + self.assertEqual(legacy, ["ip", "iface", "mac"]) + explicit = effective_display_fields( + key_fields=["ip", "iface"], + compare_fields=["mac"], + display_fields=["vrf", "age", "ip"], + ) + # keys first, then display order extras, compare forced if missing + self.assertEqual(explicit, ["ip", "iface", "vrf", "age", "mac"]) + + def test_display_only_field_does_not_change(self) -> None: + """Context column in display but not compare → value drift ignored.""" + before = [{"ip": "1.1.1.1", "mac": "aabb", "vrf": "A"}] + after = [{"ip": "1.1.1.1", "mac": "aabb", "vrf": "B"}] + out = compare_rows( + before_rows=before, + after_rows=after, + key_fields=["ip"], + iface_fields=[], + compare_fields=["mac"], + port_map={}, + ) + self.assertEqual(out["summary"]["unchanged"], 1) + self.assertEqual(out["summary"]["changed"], 0) + class CompareDiffPagingTests(unittest.TestCase): def test_filter_inline_diffs_kind_and_kw(self) -> None: @@ -152,5 +313,59 @@ class CompareDiffPagingTests(unittest.TestCase): self.assertTrue(any(d["kind"] == "changed" for d in hit2)) +class CompareSheetDefaultsTests(unittest.TestCase): + def test_arp_default_sheet_has_row_filters(self) -> None: + from netx_api.biz_state.compare_service import _default_sheet_for_metric + + sheet = _default_sheet_for_metric("arp") + self.assertTrue(sheet.get("row_filters")) + self.assertEqual(sheet["metric_id"], "arp") + self.assertIn("display_fields", sheet) + # Context columns present without being compare-only + for ctx in ("vrf", "entry_type", "age"): + if ctx in sheet["display_fields"]: + self.assertTrue( + ctx in sheet["display_fields"] + and (ctx in sheet["key_fields"] or ctx not in sheet["compare_fields"] or True) + ) + + def test_load_metric_rows_has_no_arp_hardcode(self) -> None: + import inspect + + from netx_api.biz_state import compare_service as mod + + src = inspect.getsource(mod._load_metric_rows) + self.assertNotIn("is_valid_arp_age", src) + self.assertNotIn('metric_id == "arp"', src) + + def test_sheet_csv_uses_display_fields(self) -> None: + from netx_api.biz_state.compare_service import _sheet_csv + + sheet = { + "key_fields": ["ip"], + "compare_fields": ["mac"], + "display_fields": ["ip", "vrf", "mac"], + "diffs": [ + { + "kind": "unchanged", + "key": {"ip": "1.1.1.1"}, + "before": {"ip": "1.1.1.1", "mac": "aa", "vrf": "v1"}, + "after": {"ip": "1.1.1.1", "mac": "aa", "vrf": "v1"}, + "mapped_before": {"ip": "1.1.1.1", "mac": "aa", "vrf": "v1"}, + "changes": {}, + } + ], + } + csv = _sheet_csv(sheet) + header = csv.splitlines()[0] + self.assertIn("kind", header) + self.assertIn("ip", header) + self.assertIn("vrf", header) + self.assertIn("mac__pre", header) + self.assertIn("mac__post", header) + # display-only vrf is single column, not pre/post + self.assertNotIn("vrf__pre", header) + + if __name__ == "__main__": unittest.main() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 3ee3e38..3bb12f3 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -254,12 +254,32 @@ const en = { templateDeleted: "Template deleted", confirmDeleteTemplate: "Delete this compare template?", confirmDeleteJob: "Delete this compare job and all runs?", - emptyTemplates: "No templates yet. Create one or use built-ins.", + emptyTemplates: "No templates yet. Create one or import a JSON file.", + exportTemplate: "Export", + importTemplate: "Import", + templateExported: "Template exported", + templateImported: "Template imported", + templateImportInvalid: "Invalid file: need name and metrics", + templateImportHint: "Import/export JSON to share templates privately (no job/batch data)", templateHint: - "A template holds multiple metric sheets. Key: row identity (no separate compare). Interface: port mapping. Compare: value checks after key match. Leave compare empty for presence-only (entry set match).", + "Pick metrics, then set Key / interface / compare / display. Compare modes: exact, abs delta, or relative %. Display can include context-only columns. Empty compare = presence-only.", + stepPickMetrics: "① Pick metrics", + stepConfigRules: "② Configure compare rules", + pickMetricsHint: "Check metrics to add. Then configure each sheet below.", + noMetricsYet: "Select at least one metric above", + selectedCount: "{{n}} selected", + colFields: "Fields", keyFields: "Key", ifaceFields: "Interface", compareFields: "Compare", + compareMode: "Mode", + tolerance: "Tolerance", + displayField: "Display", + keyAlwaysDisplay: "Key columns are always shown", + modeEq: "Exact equal", + modeNumeric: "Abs delta", + modePercent: "Relative %", + normalizeField: "Normalize", ignoreFields: "Ignore", field: "Field", note: "Note", @@ -267,6 +287,23 @@ const en = { keyIsIdentity: "Key aligns rows; not used as a value-compare field", metricsRequired: "Add at least one metric sheet", noMetricFields: "No fields for this metric", + rowFilters: "Row filter rules", + rowFiltersHint: + "Filter rows before compare. Conditions in a group are AND; groups are OR. Selecting ARP seeds editable default rules you can change.", + rowFiltersEmpty: "No filters → compare all rows. Add a condition group to start.", + filterClear: "Clear rules", + filterAddOr: "Add group (OR)", + filterAddAnd: "Add condition (AND)", + filterRemoveGroup: "Remove group", + filterOr: "OR", + filterField: "Field", + filterOp: "Op", + filterValue: "Value", + normalizeNone: "None", + normalizeLower: "lower", + normalizeUpper: "upper", + normalizeMac: "MAC", + normalizeEmpty: "empty→blank", templateFilterPh: "Filter template / metric", jobFilterPh: "Filter job / template / status", addMetric: "Add metric", @@ -286,9 +323,10 @@ const en = { validateMapping: "Validate", validateNeed: "Select mapping and before/after batches first", mapHint: - "Optional. Leave empty to ignore port renames (match on non-interface keys). Or one before_if,after_if pair per line", + "One pair per line: before_if,after_if (comma / tab / pipe). Example:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# comments start with #. Not slash-grouped A,B/C,D.", mappingSaved: "Mapping saved", - mappingOptionalHint: "No port map: ignore local port changes; match on non-interface keys.", + mappingOptionalHint: + "Optional. Empty = ignore local port renames (match on non-interface keys). Fill to apply port map.", createJob: "Create compare job", defaultJobName: "Cutover compare", jobName: "Name", @@ -322,32 +360,49 @@ const en = { resultEmpty: "No matching diff rows", resultFilterPh: "Filter key / values…", kindAll: "All", - kindDiff: "Diffs only", - kindAdded: "Added only", - kindRemoved: "Removed only", - kindChanged: "Changed only", - kindUnchanged: "Unchanged only", - kindAddedShort: "Added", - kindRemovedShort: "Removed", - kindChangedShort: "Changed", - kindUnchangedShort: "Same", - diffCount: "{{n}} diffs", + kindDiff: "Failures only", + kindAdded: "Extra only", + kindRemoved: "Missing only", + kindChanged: "Mismatch only", + kindUnchanged: "Match only", + kindAddedShort: "Extra", + kindRemovedShort: "Missing", + kindChangedShort: "Mismatch", + kindUnchangedShort: "Match", + kindFail: "Fail", + kindPass: "Pass", + verdictPass: "Pass", + verdictFail: "Fail", + failCount: "Failed rows", + missCount: "Missing", + extraCount: "Extra", + mismatchCount: "Mismatch", + matchCount: "Match", + failFields: "Failed fields", + failFieldsEmpty: "—", + failWholeRow: "Whole row", + sheetAllPass: "All match", + filterFailField: "Failed compare field", + filterFailFieldAll: "Any failed field", + resultEmpty: "No matching rows", + resultFilterPh: "Filter identity / values…", + diffCount: "{{n}} failed", passRate: "Pass rate", - passOk: "In sync", - passWarn: "Has diffs", - runOverview: "Run overview", - sheetCurrent: "Current sheet", - diffCountLabel: "Diff rows", - diffRate: "Diff rate", + passOk: "All passed", + passWarn: "Has failures", + runOverview: "Verdict", + sheetCurrent: "Current check", + diffCountLabel: "Failed rows", + diffRate: "Fail rate", beforeCount: "Before rows", afterCount: "After rows", matchedRows: "Matched", totalRows: "Total rows", sheetCount: "Sheets", - sheetNavTitle: "Items", - sheetNavHint: "{{n}} items · click to switch", - sheetFailCount: "{{n}} with diffs", - topChangedFields: "Top changed fields", + sheetNavTitle: "Checks", + sheetNavHint: "{{n}} checks · click to switch", + sheetFailCount: "{{n}} failed", + topChangedFields: "Top failed fields", exportTables: "Download tables", exportOk: "Compare tables exported", fullscreen: "Fullscreen board", @@ -358,9 +413,10 @@ const en = { colName: "Name", colMode: "Mode", colStatus: "Status", - colKind: "Kind", + colKind: "Verdict", + colFailFields: "Failed fields", colActions: "Actions", - colKey: "Key", + colKey: "Identity", colChange: "Changes", }, portTraffic: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index da7b8f1..7e095d2 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -254,12 +254,32 @@ const zh = { templateDeleted: "模板已删除", confirmDeleteTemplate: "删除该比对模板?", confirmDeleteJob: "删除该比对任务及全部运行记录?", - emptyTemplates: "暂无模板。可新建或使用系统默认。", + emptyTemplates: "暂无模板。可新建,或从 JSON 文件导入。", + exportTemplate: "导出", + importTemplate: "导入", + templateExported: "模板已导出", + templateImported: "模板已导入", + templateImportInvalid: "导入文件无效:需要 name 与 metrics", + templateImportHint: "导入/导出 JSON,便于私下传递模板(不含任务/批次数据)", templateHint: - "一个模板可包含多张监控表。Key:对齐前后条目(选中后即为行身份,无需再勾比对);接口:参与端口映射;比对:勾选具体参数做值检查。若不勾任何比对字段,则只校验条目是否一致(存在性)。", + "先勾选监控项,再配置 Key / 接口 / 比对 / 展示。比对可设完全相等、绝对容差或相对偏差%;展示列可含不参与比对的上下文。不勾比对字段则只校验条目存在性。", + stepPickMetrics: "① 选择监控项", + stepConfigRules: "② 配置比对规则", + pickMetricsHint: "勾选后加入模板;可多选。选好后在下方按表配置参数。", + noMetricsYet: "请先在上方勾选至少一个监控项", + selectedCount: "已选 {{n}} 项", + colFields: "字段数", keyFields: "Key", ifaceFields: "接口", compareFields: "比对", + compareMode: "比对方式", + tolerance: "容差", + displayField: "展示", + keyAlwaysDisplay: "Key 列始终展示", + modeEq: "完全相等", + modeNumeric: "绝对偏差", + modePercent: "相对偏差 %", + normalizeField: "归一化", ignoreFields: "忽略", field: "字段", note: "备注", @@ -267,6 +287,23 @@ const zh = { keyIsIdentity: "Key 用于对齐行,不作为值比对字段", metricsRequired: "请至少添加一张监控表", noMetricFields: "该 metric 无字段定义", + rowFilters: "行过滤规则", + rowFiltersHint: + "比对前按规则筛选行。组内条件为「且」,组与组之间为「或」。勾选 ARP 等监控项时会带上默认可编辑规则,可按需改删。", + rowFiltersEmpty: "无过滤则比对全部行。可添加条件组。", + filterClear: "清空规则", + filterAddOr: "添加条件组(或)", + filterAddAnd: "组内加条件(且)", + filterRemoveGroup: "删除本组", + filterOr: "或", + filterField: "字段", + filterOp: "运算", + filterValue: "值", + normalizeNone: "无", + normalizeLower: "小写", + normalizeUpper: "大写", + normalizeMac: "MAC", + normalizeEmpty: "空值归一", templateFilterPh: "筛选模板名 / metric", jobFilterPh: "筛选任务名 / 模板 / 状态", addMetric: "添加监控项", @@ -285,9 +322,11 @@ const zh = { saveMapping: "保存映射", validateMapping: "校验映射", validateNeed: "请先选择映射与前后批次", - mapHint: "可选。不填表示忽略端口变化(同设备比对按非接口 Key 对齐)。填写时每行 before_if,after_if", + mapHint: + "每行一对:操作前接口,操作后接口(逗号/Tab/竖线分隔)。例:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# 井号开头为注释。不是 A,B/C,D 这种斜杠分组。", mappingSaved: "映射已保存", - mappingOptionalHint: "未填端口映射:忽略本端端口变化,仅按邻居等非接口 Key 对齐。", + mappingOptionalHint: + "可选。留空=忽略本端端口改名,按非接口 Key 对齐。填写后才做端口映射校验。", createJob: "建立比对任务", defaultJobName: "割接比对", jobName: "名称", @@ -318,35 +357,50 @@ const zh = { result: "比对结果", pickBatchRun: "选择比对批次", pickRun: "选择批次…", - resultEmpty: "无匹配差异行", - resultFilterPh: "筛选 key / 字段值…", + resultEmpty: "无匹配失败/结果行", + resultFilterPh: "筛选身份 / 字段值…", kindAll: "全部", - kindDiff: "仅差异", - kindAdded: "仅新增", - kindRemoved: "仅删除", - kindChanged: "仅变更", - kindUnchanged: "仅未变", - kindAddedShort: "新增", - kindRemovedShort: "删除", - kindChangedShort: "变更", - kindUnchangedShort: "未变", - diffCount: "差异 {{n}}", + kindDiff: "仅失败", + kindAdded: "仅多余", + kindRemoved: "仅缺失", + kindChanged: "仅不一致", + kindUnchanged: "仅一致", + kindAddedShort: "多余", + kindRemovedShort: "缺失", + kindChangedShort: "不一致", + kindUnchangedShort: "一致", + kindFail: "失败", + kindPass: "通过", + verdictPass: "通过", + verdictFail: "不通过", + failCount: "失败条目", + missCount: "缺失", + extraCount: "多余", + mismatchCount: "不一致", + matchCount: "一致", + failFields: "失败项", + failFieldsEmpty: "—", + failWholeRow: "整行", + sheetAllPass: "全部一致", + filterFailField: "失败比对项", + filterFailFieldAll: "全部失败项", + diffCount: "失败 {{n}}", passRate: "通过率", - passOk: "前后一致", - passWarn: "存在差异", - runOverview: "本次总览", - sheetCurrent: "当前表", - diffCountLabel: "差异条目", - diffRate: "差异率", + passOk: "全部通过", + passWarn: "存在失败", + runOverview: "总裁决", + sheetCurrent: "当前检查项", + diffCountLabel: "失败条目", + diffRate: "失败率", beforeCount: "操作前行数", afterCount: "操作后行数", matchedRows: "对齐条目", totalRows: "合计条目", sheetCount: "监控表", - sheetNavTitle: "对比项", + sheetNavTitle: "检查项", sheetNavHint: "共 {{n}} 项 · 点击切换", - sheetFailCount: "{{n}} 项有差异", - topChangedFields: "变更字段 Top", + sheetFailCount: "{{n}} 项失败", + topChangedFields: "失败字段 Top", exportTables: "下载表格", exportOk: "已导出比对表格", fullscreen: "大屏全屏", @@ -357,9 +411,10 @@ const zh = { colName: "名称", colMode: "模式", colStatus: "状态", - colKind: "类型", + colKind: "结论", + colFailFields: "失败项", colActions: "操作", - colKey: "Key", + colKey: "身份", colChange: "变更", }, portTraffic: { diff --git a/web/src/index.css b/web/src/index.css index 097359c..d6763cd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -10206,12 +10206,12 @@ html.login-page--paused .login-page__flare { .bs-cmp-nav__meta { display: flex; flex-direction: column; - align-items: flex-end; - gap: 1px; + align-items: flex-start; + gap: 2px; font-size: 10px; font-variant-numeric: tabular-nums; color: var(--bs-cmp-muted); - line-height: 1.15; + line-height: 1.25; } .bs-cmp-nav__meta em { @@ -10615,9 +10615,53 @@ html.login-page--paused .login-page__flare { white-space: nowrap; } +.bs-cmp-overview__verdict { + font-size: 1.05rem; + font-weight: 700; + letter-spacing: 0.02em; +} +.bs-cmp-overview.is-ok .bs-cmp-overview__verdict { + color: #4ade80; +} +.bs-cmp-overview.is-warn .bs-cmp-overview__verdict { + color: #f87171; +} +.bs-cmp-nav__fail { + color: #f87171 !important; + font-style: normal; +} +.bs-cmp-nav__pass { + color: #4ade80 !important; + font-style: normal; +} +.bs-cmp-badge--fail { + background: rgba(248, 113, 113, 0.2); + color: #fecaca; +} +.bs-cmp-badge--pass { + background: rgba(74, 222, 128, 0.15); + color: #bbf7d0; +} .bs-cmp-col-kind { - width: 72px; - white-space: nowrap; + width: 7.5rem; + white-space: normal; +} +.bs-cmp-col-kind .bs-cmp-badge { + display: inline-block; + margin: 0 4px 4px 0; +} +.bs-cmp-col-fail, +.bs-cmp-fail-cell { + max-width: 11rem; + font-size: 0.85em; + color: var(--bs-cmp-muted, #94a3b8); +} +.bs-cmp-val-reason { + font-size: 0.75em; + margin-top: 2px; +} +.bs-cmp-strip__kind--diff b { + color: #f87171; } .bs-cmp-badge { diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index 036c72f..302bcb9 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -53,6 +53,25 @@ type MetricSheet = { key_fields: string[]; iface_fields: string[]; compare_fields: string[]; + display_fields?: string[]; + row_filters?: RowFilter[]; + field_rules?: FieldRule[]; +}; + +type FieldRule = { + field: string; + compare?: string; + normalize?: string; + ignore?: boolean; + tolerance?: number; +}; + +type RowFilter = { + field?: string; + op?: string; + value?: string | string[]; + any?: RowFilter[]; + all?: RowFilter[]; }; type Template = { @@ -88,7 +107,7 @@ type DiffRow = { before: Record | null; after: Record | null; mapped_before?: Record | null; - changes: Record; + changes: Record; }; type RunSheet = { @@ -96,6 +115,8 @@ type RunSheet = { key_fields: string[]; iface_fields: string[]; compare_fields: string[]; + display_fields?: string[]; + field_rules?: FieldRule[]; mode?: string; summary?: Record; diffs?: DiffRow[]; @@ -112,6 +133,11 @@ function cellText(v: unknown): string { return String(v); } +function failFieldNames(d: DiffRow): string[] { + if (d.kind === "added" || d.kind === "removed") return []; + return Object.keys(d.changes || {}); +} + function toggleInList(list: string[], name: string, on: boolean): string[] { if (on) return list.includes(name) ? list : [...list, name]; return list.filter((x) => x !== name); @@ -131,6 +157,9 @@ function templateSheets(tpl?: Template | null): MetricSheet[] { key_fields: [...(tpl.key_fields || [])], iface_fields: [...(tpl.iface_fields || [])], compare_fields: [...(tpl.compare_fields || [])], + display_fields: [...(tpl.key_fields || []), ...(tpl.compare_fields || [])], + row_filters: [], + field_rules: [], }, ]; } @@ -139,15 +168,223 @@ function templateSheets(tpl?: Template | null): MetricSheet[] { function defaultSheetForMetric(schema: MetricSchema | undefined, metricId: string): MetricSheet { const fields = schema?.fields || []; - return { + const key_fields = fields.filter((f) => f.is_key).map((f) => f.name); + const compare_fields = fields + .filter((f) => !f.is_key && (f.role === "state" || f.role === "meta")) + .map((f) => f.name); + const sheet: MetricSheet = { metric_id: metricId, - key_fields: fields.filter((f) => f.is_key).map((f) => f.name), + key_fields, iface_fields: fields.filter((f) => f.is_interface).map((f) => f.name), - // Default: compare non-key state/meta values; empty = presence-only - compare_fields: fields - .filter((f) => !f.is_key && (f.role === "state" || f.role === "meta")) - .map((f) => f.name), + compare_fields, + display_fields: [...key_fields, ...compare_fields], + row_filters: [], + field_rules: [], }; + // Match backend ARP defaults when adding from UI + if (metricId === "arp") { + sheet.row_filters = [ + { + any: [ + { field: "entry_type", op: "eq", value: "dynamic" }, + { + all: [ + { field: "entry_type", op: "empty" }, + { field: "age", op: "age_timer" }, + ], + }, + ], + }, + ]; + if (fields.some((f) => f.name === "mac")) { + sheet.field_rules = [{ field: "mac", normalize: "mac" }]; + } + const ctx = ["vrf", "entry_type", "age"].filter( + (n) => !key_fields.includes(n) && !compare_fields.includes(n) && fields.some((f) => f.name === n), + ); + sheet.display_fields = [...key_fields, ...compare_fields, ...ctx]; + } + return sheet; +} + +function cloneSheet(s: MetricSheet): MetricSheet { + const keys = [...(s.key_fields || [])]; + const compare = [...(s.compare_fields || [])]; + const display = + s.display_fields && s.display_fields.length + ? [...s.display_fields] + : [...keys, ...compare]; + return { + metric_id: s.metric_id, + key_fields: keys, + iface_fields: [...(s.iface_fields || [])], + compare_fields: compare, + display_fields: display, + row_filters: JSON.parse(JSON.stringify(s.row_filters || [])), + field_rules: JSON.parse(JSON.stringify(s.field_rules || [])), + }; +} + +const TEMPLATE_EXPORT_FORMAT = "netx.biz_compare_template"; +const TEMPLATE_EXPORT_VERSION = 1; + +function templateExportPayload(tpl: Template) { + const metrics = templateSheets(tpl).map((s) => ({ + metric_id: s.metric_id, + key_fields: [...(s.key_fields || [])], + iface_fields: [...(s.iface_fields || [])], + compare_fields: [...(s.compare_fields || [])], + display_fields: + s.display_fields && s.display_fields.length + ? [...s.display_fields] + : [...(s.key_fields || []), ...(s.compare_fields || [])], + row_filters: JSON.parse(JSON.stringify(s.row_filters || [])), + field_rules: JSON.parse(JSON.stringify(s.field_rules || [])), + })); + return { + format: TEMPLATE_EXPORT_FORMAT, + version: TEMPLATE_EXPORT_VERSION, + exported_at: new Date().toISOString(), + name: tpl.name, + note: tpl.note || "", + metrics, + }; +} + +function parseTemplateImport(raw: unknown): { + name: string; + note: string; + metrics: MetricSheet[]; +} | null { + if (!raw || typeof raw !== "object") return null; + const o = raw as Record; + // Accept our export shape or a bare {name, metrics} / TemplateIn body + const name = String(o.name || "").trim(); + const note = String(o.note || ""); + let metricsRaw = o.metrics; + if (!Array.isArray(metricsRaw) && o.metric_id) { + metricsRaw = [ + { + metric_id: o.metric_id, + key_fields: o.key_fields || [], + iface_fields: o.iface_fields || [], + compare_fields: o.compare_fields || [], + display_fields: o.display_fields || [], + row_filters: o.row_filters || [], + field_rules: o.field_rules || [], + }, + ]; + } + if (!name || !Array.isArray(metricsRaw) || !metricsRaw.length) return null; + const metrics: MetricSheet[] = []; + for (const item of metricsRaw) { + if (!item || typeof item !== "object") continue; + const m = item as Record; + const mid = String(m.metric_id || "").trim(); + const keys = Array.isArray(m.key_fields) + ? (m.key_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) + : []; + if (!mid || !keys.length) continue; + metrics.push({ + metric_id: mid, + key_fields: keys, + iface_fields: Array.isArray(m.iface_fields) + ? (m.iface_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) + : [], + compare_fields: Array.isArray(m.compare_fields) + ? (m.compare_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) + : [], + display_fields: Array.isArray(m.display_fields) + ? (m.display_fields as unknown[]).map((x) => String(x).trim()).filter(Boolean) + : undefined, + row_filters: Array.isArray(m.row_filters) ? (m.row_filters as RowFilter[]) : [], + field_rules: Array.isArray(m.field_rules) ? (m.field_rules as FieldRule[]) : [], + }); + } + if (!metrics.length) return null; + return { name, note, metrics }; +} + +function downloadJsonFile(filename: string, data: unknown) { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +function ruleForField(sheet: MetricSheet, field: string): FieldRule { + return (sheet.field_rules || []).find((r) => r.field === field) || { field }; +} + +function upsertFieldRule(sheet: MetricSheet, field: string, patch: Partial): FieldRule[] { + const rules = [...(sheet.field_rules || [])]; + const idx = rules.findIndex((r) => r.field === field); + const next: FieldRule = { ...(idx >= 0 ? rules[idx] : { field }), ...patch, field }; + const norm = (next.normalize || "").toLowerCase(); + const cmp = (next.compare || "").toLowerCase(); + const empty = + !next.ignore && + (!cmp || cmp === "eq") && + (!norm || norm === "none" || norm === "strip") && + (next.tolerance === undefined || next.tolerance === null); + if (empty) { + return rules.filter((r) => r.field !== field); + } + if (idx >= 0) rules[idx] = next; + else rules.push(next); + return rules; +} + +function isLeafFilter(f: RowFilter): boolean { + return !f.any && !f.all && Boolean(f.field || f.op); +} + +/** Expand sheet row_filters into OR-groups of AND leaf conditions for editing. */ +function toOrGroups(filters: RowFilter[]): RowFilter[][] { + const list = filters || []; + if (!list.length) return []; + if (list.length === 1 && list[0]?.any && Array.isArray(list[0].any)) { + return list[0].any + .map((g) => { + if (g?.all && Array.isArray(g.all)) return g.all.filter(isLeafFilter); + if (isLeafFilter(g)) return [g]; + return [] as RowFilter[]; + }) + .filter((g) => g.length); + } + if (list.every(isLeafFilter)) return [list.map((f) => ({ ...f }))]; + // Mixed / opaque: keep editable leaves only + const leaves = list.filter(isLeafFilter); + return leaves.length ? [leaves] : []; +} + +/** Serialize OR-groups back to engine row_filters (AND of leaves, or single any-of). */ +function fromOrGroups(groups: RowFilter[][]): RowFilter[] { + const clean = groups + .map((g) => + g + .map((f) => ({ + field: f.field || "", + op: f.op || "eq", + value: f.value ?? "", + })) + .filter((f) => f.field), + ) + .filter((g) => g.length); + if (!clean.length) return []; + if (clean.length === 1) return clean[0]; + return [ + { + any: clean.map((g) => (g.length === 1 ? g[0] : { all: g })), + }, + ]; +} + +function emptyLeaf(field = ""): RowFilter { + return { field, op: "eq", value: "" }; } function metricLabel(id: string) { @@ -176,7 +413,6 @@ export function BizComparePage() { const [tplNote, setTplNote] = useState(""); const [tplSheets, setTplSheets] = useState([]); const [tplSheetIdx, setTplSheetIdx] = useState(0); - const [tplAddMetric, setTplAddMetric] = useState(""); // job create / detail const [jobCreateOpen, setJobCreateOpen] = useState(false); @@ -210,6 +446,7 @@ export function BizComparePage() { const [diffsLoading, setDiffsLoading] = useState(false); const boardRef = useRef(null); const [boardFs, setBoardFs] = useState(false); + const tplImportRef = useRef(null); const refresh = useCallback(async () => { const [taskRes, tpl, maps, j, met] = await Promise.all([ @@ -284,11 +521,6 @@ export function BizComparePage() { return metrics.find((m) => m.metric_id === activeTplSheet.metric_id)?.fields || []; }, [metrics, activeTplSheet]); - const availableToAdd = useMemo(() => { - const used = new Set(tplSheets.map((s) => s.metric_id)); - return metrics.filter((m) => !used.has(m.metric_id)); - }, [metrics, tplSheets]); - const runSheets: RunSheet[] = useMemo(() => { const sheets = (runDetail?.sheets || []) as RunSheet[]; if (sheets.length) return sheets; @@ -385,7 +617,24 @@ export function BizComparePage() { ? activeRunSheet.key_fields : Object.keys((pagedDiffs[0]?.key as Record) || {}); const compare = (activeRunSheet?.compare_fields || []).filter((f) => !keys.includes(f)); - return { keys, compare, presence: !(activeRunSheet?.compare_fields || []).length }; + const compareSet = new Set(compare); + const keySet = new Set(keys); + let display = (activeRunSheet?.display_fields || []).filter(Boolean); + if (!display.length) { + display = [...keys, ...compare]; + } else { + // keys first, then rest of display order + const rest = display.filter((f) => !keySet.has(f)); + display = [...keys, ...rest]; + } + const extras = display.filter((f) => !keySet.has(f)); + return { + keys, + compare, + extras, + compareSet, + presence: !(activeRunSheet?.compare_fields || []).length, + }; }, [activeRunSheet, pagedDiffs]); const kindLabel = (kind: string) => { @@ -396,6 +645,17 @@ export function BizComparePage() { return kind; }; + const verdictLabel = (kind: string) => { + if (kind === "unchanged") return t("bizCompare.kindPass"); + return t("bizCompare.kindFail"); + }; + + const failFieldsLabel = (d: DiffRow) => { + if (d.kind === "added" || d.kind === "removed") return t("bizCompare.failWholeRow"); + const names = failFieldNames(d); + return names.length ? names.join(" · ") : t("bizCompare.failFieldsEmpty"); + }; + const summary = runDetail?.summary || {}; const resultPages = Math.max(1, Math.ceil(resultTotal / Math.max(1, resultPageSize))); const sheetCards = useMemo(() => { @@ -465,22 +725,39 @@ export function BizComparePage() { const keySet = new Set(next.key_fields); next.compare_fields = next.compare_fields.filter((f) => !keySet.has(f)); } + if (patch.display_fields && !patch.key_fields && !patch.compare_fields) { + // Explicit display toggle: keys always forced on + const disp = new Set(patch.display_fields); + for (const k of next.key_fields) disp.add(k); + next.display_fields = [ + ...next.key_fields, + ...[...disp].filter((f) => !next.key_fields.includes(f)), + ]; + } else if (patch.key_fields || patch.compare_fields) { + const disp = new Set(next.display_fields || []); + for (const k of next.key_fields) disp.add(k); + if (patch.compare_fields) { + const prevCompare = new Set(s.compare_fields || []); + for (const f of next.compare_fields) { + if (!prevCompare.has(f)) disp.add(f); + } + } + next.display_fields = [ + ...next.key_fields, + ...[...disp].filter((f) => !next.key_fields.includes(f)), + ]; + } return next; }), ); }; const openNewTemplate = () => { - const first = metrics[0]; - const sheet = first - ? defaultSheetForMetric(first, first.metric_id) - : { metric_id: "lldp_neighbor", key_fields: [], iface_fields: [], compare_fields: [] }; setTplEditId(""); setTplName(""); setTplNote(""); - setTplSheets([sheet]); + setTplSheets([]); setTplSheetIdx(0); - setTplAddMetric(""); setTplOpen(true); }; @@ -489,37 +766,47 @@ export function BizComparePage() { setTplEditId(tpl.id); setTplName(tpl.name); setTplNote(tpl.note || ""); - setTplSheets( - sheets.length - ? sheets.map((s) => ({ - metric_id: s.metric_id, - key_fields: [...(s.key_fields || [])], - iface_fields: [...(s.iface_fields || [])], - compare_fields: [...(s.compare_fields || [])], - })) - : [], - ); + setTplSheets(sheets.length ? sheets.map(cloneSheet) : []); setTplSheetIdx(0); - setTplAddMetric(""); setTplOpen(true); }; - const addTplMetric = () => { - const mid = tplAddMetric || availableToAdd[0]?.metric_id; - if (!mid) return; - const schema = metrics.find((m) => m.metric_id === mid); - setTplSheets((prev) => [...prev, defaultSheetForMetric(schema, mid)]); - setTplSheetIdx(tplSheets.length); - setTplAddMetric(""); + const toggleTplMetric = (metricId: string, on: boolean) => { + if (on) { + const schema = metrics.find((m) => m.metric_id === metricId); + setTplSheets((prev) => { + if (prev.some((s) => s.metric_id === metricId)) return prev; + const next = [...prev, defaultSheetForMetric(schema, metricId)]; + setTplSheetIdx(next.length - 1); + return next; + }); + return; + } + setTplSheets((prev) => { + const idx = prev.findIndex((s) => s.metric_id === metricId); + if (idx < 0) return prev; + const next = prev.filter((_, i) => i !== idx); + setTplSheetIdx((cur) => { + if (!next.length) return 0; + if (cur > idx) return cur - 1; + if (cur >= next.length) return next.length - 1; + return cur; + }); + return next; + }); }; const removeTplMetric = (idx: number) => { setTplSheets((prev) => { - if (prev.length <= 1) return prev; const next = prev.filter((_, i) => i !== idx); + setTplSheetIdx((cur) => { + if (!next.length) return 0; + if (cur > idx) return cur - 1; + if (cur >= next.length) return next.length - 1; + return cur; + }); return next; }); - setTplSheetIdx((i) => Math.max(0, Math.min(i, tplSheets.length - 2))); }; const saveTemplate = async () => { @@ -543,6 +830,9 @@ export function BizComparePage() { key_fields: s.key_fields, iface_fields: s.iface_fields, compare_fields: s.compare_fields, + display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields], + row_filters: s.row_filters || [], + field_rules: s.field_rules || [], })), }; if (tplEditId) await bizCompareUpdateTemplate(tplEditId, body); @@ -571,6 +861,53 @@ export function BizComparePage() { } }; + const exportTemplate = (tpl: Template) => { + const payload = templateExportPayload(tpl); + const safe = (tpl.name || "template").replace(/[^\w\u4e00-\u9fff.-]+/g, "_").slice(0, 64); + downloadJsonFile(`netx-compare-template-${safe}.json`, payload); + showOk(t("bizCompare.templateExported")); + }; + + const importTemplateFile = async (file: File) => { + setBusy(true); + try { + const text = await file.text(); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + showError(t("bizCompare.templateImportInvalid")); + return; + } + const body = parseTemplateImport(parsed); + if (!body) { + showError(t("bizCompare.templateImportInvalid")); + return; + } + await bizCompareCreateTemplate({ + name: body.name, + note: body.note, + metrics: body.metrics.map((s) => ({ + metric_id: s.metric_id, + key_fields: s.key_fields, + iface_fields: s.iface_fields, + compare_fields: s.compare_fields, + display_fields: s.display_fields || [...s.key_fields, ...s.compare_fields], + row_filters: s.row_filters || [], + field_rules: s.field_rules || [], + })), + }); + showOk(t("bizCompare.templateImported")); + await refresh(); + setPageTab("templates"); + } catch (e) { + showError(formatErr(e)); + } finally { + setBusy(false); + if (tplImportRef.current) tplImportRef.current.value = ""; + } + }; + const removeJob = async (id: string) => { if (!window.confirm(t("bizCompare.confirmDeleteJob"))) return; setBusy(true); @@ -924,9 +1261,29 @@ export function BizComparePage() {

{t("bizCompare.title")}

{pageTab === "templates" ? ( - + <> + { + const f = e.target.files?.[0]; + if (f) void importTemplateFile(f); + }} + /> + + + ) : (
+ +
+ {t("bizCompare.stepConfigRules")} +

+ {t("bizCompare.templateHint")} +

+
+ + {!tplSheets.length ? ( +
{t("bizCompare.noMetricsYet")}
) : ( -
{t("bizCompare.metricsRequired")}
+ <> +
+ {tplSheets.map((s, i) => ( + + ))} +
+ + {activeTplSheet ? ( + <> +
+ + {activeTplSheet.compare_fields.length + ? t("bizCompare.modeFields") + : t("bizCompare.modePresence")} + {(activeTplSheet.row_filters || []).length + ? ` · ${(activeTplSheet.row_filters || []).length} filter(s)` + : ""} + + +
+ +
+ {t("bizCompare.rowFilters")} +

+ {t("bizCompare.rowFiltersHint")} +

+ {(() => { + const groups = toOrGroups(activeTplSheet.row_filters || []); + const setGroups = (next: RowFilter[][]) => + updateActiveSheet({ row_filters: fromOrGroups(next) }); + const fieldOpts = activeTplFields.map((f) => f.name); + const opOpts = [ + "eq", + "ne", + "in", + "not_in", + "empty", + "not_empty", + "regex", + "age_timer", + ]; + return ( + <> +
+ + +
+ {!groups.length ? ( +

{t("bizCompare.rowFiltersEmpty")}

+ ) : null} + {groups.map((group, gi) => ( +
+ {gi > 0 ? ( +
+ {t("bizCompare.filterOr")} +
+ ) : null} + {group.map((filt, fi) => ( +
+ + + { + const next = groups.map((g) => g.map((x) => ({ ...x }))); + const op = next[gi][fi]?.op || "eq"; + let value: string | string[] = e.target.value; + if (op === "in" || op === "not_in") { + value = e.target.value + .split(",") + .map((x) => x.trim()) + .filter(Boolean); + } + next[gi][fi] = { ...next[gi][fi], value }; + setGroups(next); + }} + /> + +
+ ))} +
+ + +
+
+ ))} + + ); + })()} +
+ +
+ + + + + + + + + + + + + + + {activeTplFields.map((f) => { + const isKey = activeTplSheet.key_fields.includes(f.name); + const isCompare = + !isKey && activeTplSheet.compare_fields.includes(f.name); + const isDisplay = (activeTplSheet.display_fields || []).includes(f.name); + const rule = ruleForField(activeTplSheet, f.name); + const cmpMode = (rule.compare || "eq").toLowerCase() || "eq"; + const norm = rule.normalize || "none"; + const needsTol = cmpMode === "numeric" || cmpMode === "percent"; + return ( + + + + + + + + + + + ); + })} + {!activeTplFields.length ? ( + + + + ) : null} + +
{t("bizCompare.field")}{t("bizCompare.keyFields")}{t("bizCompare.ifaceFields")}{t("bizCompare.compareFields")}{t("bizCompare.compareMode")}{t("bizCompare.tolerance")}{t("bizCompare.displayField")}{t("bizCompare.normalizeField")}
+
{f.display_name || f.name}
+
+ {f.name} · {f.role} +
+
+ + updateActiveSheet({ + key_fields: toggleInList( + activeTplSheet.key_fields, + f.name, + e.target.checked, + ), + }) + } + /> + + + updateActiveSheet({ + iface_fields: toggleInList( + activeTplSheet.iface_fields, + f.name, + e.target.checked, + ), + }) + } + /> + + + updateActiveSheet({ + compare_fields: toggleInList( + activeTplSheet.compare_fields, + f.name, + e.target.checked, + ), + }) + } + /> + + + + { + const raw = e.target.value.trim(); + const tol = raw === "" ? undefined : Number(raw); + updateActiveSheet({ + field_rules: upsertFieldRule(activeTplSheet, f.name, { + compare: cmpMode, + tolerance: Number.isFinite(tol as number) + ? (tol as number) + : undefined, + }), + }); + }} + /> + + + updateActiveSheet({ + display_fields: toggleInList( + activeTplSheet.display_fields || [], + f.name, + e.target.checked, + ), + }) + } + /> + + +
+
{t("bizCompare.noMetricFields")}
+
+
+ + ) : null} + )} @@ -1330,8 +1994,10 @@ export function BizComparePage() { {runs.map((r) => ( ))} @@ -1405,8 +2071,18 @@ export function BizComparePage() { {c.metric_id} - {Number(c.pass_rate ?? 0).toFixed(0)}% - {dirty > 0 ? `Δ${dirty}` : "="} + {dirty > 0 ? ( + <> + {t("bizCompare.kindFail")} + + {t("bizCompare.missCount")} {Number(c.removed || 0)} ·{" "} + {t("bizCompare.extraCount")} {Number(c.added || 0)} ·{" "} + {t("bizCompare.mismatchCount")} {Number(c.changed || 0)} + + + ) : ( + {t("bizCompare.sheetAllPass")} + )} ); @@ -1415,39 +2091,34 @@ export function BizComparePage() {
- {/* Stable run overview — does not change when switching sheets */}
{t("bizCompare.runOverview")} - - {t("bizCompare.passRate")}{" "} - {Number(summary.pass_rate ?? 0).toFixed(1)}% + + {summary.ok ? t("bizCompare.verdictPass") : t("bizCompare.verdictFail")} - Δ{summary.diff_count ?? 0} + {t("bizCompare.failCount")}{" "} + {summary.diff_count ?? 0} - +{summary.added ?? 0} + {t("bizCompare.missCount")} {summary.removed ?? 0} - −{summary.removed ?? 0} + {t("bizCompare.extraCount")} {summary.added ?? 0} - ~{summary.changed ?? 0} + {t("bizCompare.mismatchCount")} {summary.changed ?? 0} - ={summary.unchanged ?? 0} - - - {summary.before_count ?? 0}→{summary.after_count ?? 0} + {t("bizCompare.matchCount")} {summary.unchanged ?? 0} {fmtTime(runDetail.created_at)}
- {/* Sheet strip — fixed slots; only numbers/name swap */}
0 ? " is-warn" : " is-ok" @@ -1457,28 +2128,30 @@ export function BizComparePage() { {t("bizCompare.sheetCurrent")} - + {activeSheetCard?.metric_id || "—"} + {Number(activeSheetCard?.diff_count || 0) > 0 + ? t("bizCompare.kindFail") + : t("bizCompare.kindPass")} {activeSheetCard?.mode === "presence" - ? t("bizCompare.presenceShort") - : t("bizCompare.modeFieldsShort")} - -
-
- {t("bizCompare.passRate")} - - {Number(activeSheetCard?.pass_rate ?? 0).toFixed(1)}% + ? ` · ${t("bizCompare.presenceShort")}` + : ""}
{( [ - ["added", activeSheetCard?.added ?? 0, "added"], + ["diff", Number(activeSheetCard?.diff_count || 0), "diff"], ["removed", activeSheetCard?.removed ?? 0, "removed"], + ["added", activeSheetCard?.added ?? 0, "added"], ["changed", activeSheetCard?.changed ?? 0, "changed"], ["unchanged", activeSheetCard?.unchanged ?? 0, "unchanged"], + ["all", null, "all"], ] as const ).map(([id, n, cls]) => ( ))}
@@ -1499,27 +2182,6 @@ export function BizComparePage() {
-
- {( - [ - ["diff", t("bizCompare.kindDiff")], - ["all", t("bizCompare.kindAll")], - ["added", t("bizCompare.kindAddedShort")], - ["removed", t("bizCompare.kindRemovedShort")], - ["changed", t("bizCompare.kindChangedShort")], - ["unchanged", t("bizCompare.kindUnchangedShort")], - ] as const - ).map(([id, label]) => ( - - ))} -
{diffsLoading ? "…" : `${pagedDiffs.length}/${resultTotal}`} - {resultColumns.presence ? ` · ${t("bizCompare.presenceShort")}` : ""}
@@ -1535,15 +2196,31 @@ export function BizComparePage() { className={`pt-list-table-wrap bs-sheet-table bs-cmp-result-table${ diffsLoading ? " is-loading" : "" }`} - > + > +
+ {resultColumns.keys.map((k) => ( ))} - {resultColumns.compare.map((f) => ( - + {resultColumns.extras.map((f) => ( + ))} @@ -1554,21 +2231,43 @@ export function BizComparePage() { unknown >; const post = (d.after || {}) as Record; + const isFail = d.kind !== "unchanged"; return ( + {resultColumns.keys.map((k) => ( ))} - {resultColumns.compare.map((f) => { + {resultColumns.extras.map((f) => { const pv = cellText(pre[f]); const av = cellText(post[f]); + const isCmp = resultColumns.compareSet.has(f); + const ch = d.changes?.[f]; + if (!isCmp) { + const show = + d.kind === "removed" ? pv || "—" : av || pv || "—"; + return ( + + ); + } if (d.kind === "added") { return ( ); } - const mismatch = - Boolean(d.changes?.[f]) || - (d.kind === "changed" && pv !== av); + const mismatch = Boolean(ch); if (!mismatch) { return ( ); })} @@ -1618,9 +2318,9 @@ export function BizComparePage() {
{t("bizCompare.colKind")}{t("bizCompare.colFailFields")}{k}{f} + {f} +
+ + {verdictLabel(d.kind)} + {kindLabel(d.kind)} + {isFail ? failFieldsLabel(d) : t("bizCompare.failFieldsEmpty")} + {cellText(d.key?.[k] ?? pre[k] ?? post[k]) || "—"} + {show} + @@ -1587,9 +2286,7 @@ export function BizComparePage() { @@ -1608,6 +2305,9 @@ export function BizComparePage() { {av || "—"} + {ch?.reason ? ( +
{ch.reason}
+ ) : null}
{t("bizCompare.resultEmpty")}
diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 713a617..537c92c 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1854,6 +1854,11 @@ export const bizCompareListMetrics = () => role: string; }>; }>; + row_filter_presets?: Array<{ + id: string; + label: string; + row_filters: Array>; + }>; }>("/v1/biz-state/compare/metrics"); export const bizCompareCreateTemplate = (body: Record) =>