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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-18 01:30:57 +08:00
parent 21b254120e
commit d232044750
11 changed files with 1978 additions and 345 deletions

View file

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

View file

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

View file

@ -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 {}),
}

View file

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

View file

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

View file

@ -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()

View file

@ -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: {

View file

@ -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: {

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1854,6 +1854,11 @@ export const bizCompareListMetrics = () =>
role: string;
}>;
}>;
row_filter_presets?: Array<{
id: string;
label: string;
row_filters: Array<Record<string, unknown>>;
}>;
}>("/v1/biz-state/compare/metrics");
export const bizCompareCreateTemplate = (body: Record<string, unknown>) =>