mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
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:
parent
21b254120e
commit
d232044750
11 changed files with 1978 additions and 345 deletions
|
|
@ -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(
|
||||
|
|
|
|||
289
netx_api/biz_state/compare_rules.py
Normal file
289
netx_api/biz_state/compare_rules.py
Normal 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"}],
|
||||
}
|
||||
|
|
@ -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 {}),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue