netx/netx_api/biz_state/compare_rules.py
oliver 34a98fefcf Fix cutover compare tab switching and order detail columns Key/Compare/Display.
Remount BizComparePage per route so templates↔jobs no longer stuck; align column order.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 16:52:37 +08:00

293 lines
9.2 KiB
Python

"""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 == "contains":
needle = str(expect or "").strip().lower()
return bool(needle) and needle in raw.lower()
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 in Key → Compare → Display-only order.
Keys always lead; compare fields follow in template order; remaining
display (context) fields come last. Compare fields are included even if
the UI forgot to tick display.
"""
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() and str(x).strip() not in key_set
]
if display_fields is None:
return keys + compare
disp = [str(x).strip() for x in display_fields if str(x).strip()]
compare_set = set(compare)
out: list[str] = list(keys)
seen = set(keys)
for f in compare:
if f in seen:
continue
out.append(f)
seen.add(f)
for f in disp:
if f in seen or f in key_set or f in compare_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"}],
}