mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Evaluate keeps match keys for normalize/map pairing while diffs, reds, and the board show collected ports plus show-command provenance; add monitor-context and command-raw read APIs with biz-monitor:read scope. Co-authored-by: Cursor <cursoragent@cursor.com>
1418 lines
48 KiB
Python
1418 lines
48 KiB
Python
"""Cutover monitor: relative-to-baseline drift + dual-device migration verdict."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from ..biz_state.compare_engine import apply_port_map, compare_rows
|
|
|
|
PORT_METRIC_ID = "interface_brief"
|
|
PORT_STATUS_FIELDS = ("admin", "phy", "prot")
|
|
|
|
|
|
def _key_str(key: tuple[str, ...] | list[str]) -> str:
|
|
return "|".join(str(x) for x in key)
|
|
|
|
|
|
def port_status_label(row: dict[str, Any] | None) -> str:
|
|
"""Compact admin/phy/prot for board (e.g. up/up/up)."""
|
|
if not row:
|
|
return "—"
|
|
parts = [str(row.get(f) or "-").lower() for f in PORT_STATUS_FIELDS]
|
|
if all(p == "-" for p in parts):
|
|
return "—"
|
|
return "/".join(parts)
|
|
|
|
|
|
def status_label(row: dict[str, Any] | None, sheet_override: dict[str, Any] | None = None) -> str:
|
|
"""Human status for board; uses sheet_override.status_fields when set."""
|
|
ov = sheet_override or {}
|
|
fields = [str(f) for f in (ov.get("status_fields") or []) if str(f).strip()]
|
|
if not fields:
|
|
return port_status_label(row)
|
|
if not row:
|
|
return "—"
|
|
parts = [str(row.get(f) or "-").lower() for f in fields]
|
|
if all(p == "-" for p in parts):
|
|
return "—"
|
|
return "/".join(parts)
|
|
|
|
|
|
def classify_status(row: dict[str, Any] | None, sheet_override: dict[str, Any] | None) -> str:
|
|
"""Classify current row as up|down|other|none from sheet_override status semantics."""
|
|
ov = sheet_override or {}
|
|
fields = [str(f) for f in (ov.get("status_fields") or []) if str(f).strip()]
|
|
if not fields or not row:
|
|
return "none"
|
|
down_vals = {str(x).lower() for x in (ov.get("down_values") or ["down"])}
|
|
up_vals = {str(x).lower() for x in (ov.get("up_values") or ["up"])}
|
|
vals = [str(row.get(f) or "").strip().lower() for f in fields]
|
|
vals = [v for v in vals if v]
|
|
if not vals:
|
|
return "none"
|
|
if any(v in down_vals for v in vals):
|
|
return "down"
|
|
if vals and all(v in up_vals for v in vals):
|
|
return "up"
|
|
return "other"
|
|
|
|
|
|
def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
|
|
"""Return metric_id → set of key strings (old-side / before-map identity).
|
|
|
|
Supports:
|
|
{"ports": ["gei-0/1/0/1", ...]} → interface_brief
|
|
{"items": [{"metric_id": "...", "key": "a|b"}, {"metric_id":"...", "keys":["a","b"]}]}
|
|
"""
|
|
out: dict[str, set[str]] = {}
|
|
data = raw or {}
|
|
ports = data.get("ports") or []
|
|
if isinstance(ports, list):
|
|
for p in ports:
|
|
s = str(p or "").strip()
|
|
if s:
|
|
out.setdefault(PORT_METRIC_ID, set()).add(s)
|
|
out.setdefault("_ports", set()).add(s)
|
|
items = data.get("items") or []
|
|
if isinstance(items, list):
|
|
for it in items:
|
|
if not isinstance(it, dict):
|
|
continue
|
|
mid = str(it.get("metric_id") or "").strip()
|
|
sid = str(it.get("sheet_id") or "").strip() or mid
|
|
if not sid:
|
|
continue
|
|
bucket = out.setdefault(sid, set())
|
|
if it.get("key") is not None:
|
|
k = it.get("key")
|
|
if isinstance(k, (list, tuple)):
|
|
joined = "|".join(str(p).strip() for p in k if str(p).strip())
|
|
if joined:
|
|
bucket.add(joined)
|
|
else:
|
|
s = str(k or "").strip()
|
|
if s:
|
|
bucket.add(s)
|
|
keys = it.get("keys")
|
|
if isinstance(keys, list):
|
|
# Flat list of segments → one composite key; else each entry is a key
|
|
# (string or nested list/tuple of segments).
|
|
if keys and all(not isinstance(x, (list, tuple, dict)) for x in keys):
|
|
joined = "|".join(str(x).strip() for x in keys if str(x).strip())
|
|
if joined:
|
|
bucket.add(joined)
|
|
else:
|
|
for x in keys:
|
|
if isinstance(x, (list, tuple)):
|
|
joined = "|".join(str(p).strip() for p in x if str(p).strip())
|
|
if joined:
|
|
bucket.add(joined)
|
|
else:
|
|
s = str(x or "").strip()
|
|
if s:
|
|
bucket.add(s)
|
|
return {k: {x for x in v if x} for k, v in out.items() if v}
|
|
|
|
|
|
def side_verdict(
|
|
*,
|
|
kind: str,
|
|
in_expect: bool,
|
|
window_active: bool,
|
|
) -> tuple[str, str]:
|
|
"""Map compare kind → (verdict, color) for one device vs its baseline."""
|
|
k = str(kind or "")
|
|
if k == "unchanged":
|
|
return "ok", "green"
|
|
if k == "removed":
|
|
if in_expect:
|
|
return "expected_gone", "yellow"
|
|
return "anomaly_gone", "red"
|
|
if k == "added":
|
|
if in_expect:
|
|
return "expected_new", "green"
|
|
return "unexpected_new", "yellow" if window_active else "yellow"
|
|
if k == "changed":
|
|
if in_expect:
|
|
return "expected_change", "yellow"
|
|
return "anomaly_change", "red"
|
|
return "ok", "gray"
|
|
|
|
|
|
def field_token(field: str, value: str) -> str:
|
|
"""Canonical field token used in success patterns: field:<name>:<value>."""
|
|
return f"field:{str(field).strip().lower()}:{str(value).strip().lower()}"
|
|
|
|
|
|
def _fields_for_tokens(
|
|
sheet_override: dict[str, Any] | None,
|
|
*pattern_lists: list[dict[str, Any]] | None,
|
|
) -> set[str]:
|
|
"""Fields whose current values should be emitted as field:* tokens."""
|
|
ov = sheet_override or {}
|
|
fields: set[str] = set()
|
|
for f in ov.get("status_fields") or []:
|
|
s = str(f or "").strip()
|
|
if s:
|
|
fields.add(s)
|
|
for ft in ov.get("field_tokens") or []:
|
|
if isinstance(ft, dict):
|
|
s = str(ft.get("field") or "").strip()
|
|
if s:
|
|
fields.add(s)
|
|
patterns: list[Any] = []
|
|
for pl in pattern_lists:
|
|
if pl:
|
|
patterns.extend(pl)
|
|
if not patterns:
|
|
for key in ("success", "anomaly"):
|
|
raw = ov.get(key)
|
|
if isinstance(raw, list):
|
|
patterns.extend(raw)
|
|
for pat in patterns:
|
|
if not isinstance(pat, dict):
|
|
continue
|
|
for side in ("old", "new"):
|
|
for tok in pat.get(side) or []:
|
|
t = str(tok or "")
|
|
if t.startswith("field:") and t.count(":") >= 2:
|
|
parts = t.split(":", 2)
|
|
if parts[1]:
|
|
fields.add(parts[1])
|
|
for cond in pat.get(f"{side}_conds") or []:
|
|
if isinstance(cond, dict) and str(cond.get("type") or "") in ("", "field", "value"):
|
|
s = str(cond.get("field") or "").strip()
|
|
if s:
|
|
fields.add(s)
|
|
for group in pat.get(f"{side}_groups") or []:
|
|
if not isinstance(group, list):
|
|
continue
|
|
for cond in group:
|
|
if isinstance(cond, dict) and str(cond.get("type") or "") in ("", "field", "value"):
|
|
s = str(cond.get("field") or "").strip()
|
|
if s:
|
|
fields.add(s)
|
|
return fields
|
|
|
|
|
|
def _field_tokens_from_row(row: dict[str, Any] | None, fields: set[str]) -> set[str]:
|
|
if not row or not fields:
|
|
return set()
|
|
out: set[str] = set()
|
|
for f in fields:
|
|
v = str(row.get(f) or "").strip()
|
|
if v:
|
|
out.add(field_token(f, v))
|
|
return out
|
|
|
|
|
|
def _side_tokens(
|
|
kind: str,
|
|
status: str,
|
|
*,
|
|
row: dict[str, Any] | None = None,
|
|
fields: set[str] | None = None,
|
|
) -> set[str]:
|
|
toks: set[str] = set()
|
|
k = str(kind or "").strip()
|
|
if k:
|
|
toks.add(k)
|
|
s = str(status or "").strip()
|
|
if s and s != "none":
|
|
toks.add(s)
|
|
toks |= _field_tokens_from_row(row, fields or set())
|
|
return toks
|
|
|
|
|
|
def _norm_list_values(raw: Any) -> list[str]:
|
|
if isinstance(raw, list):
|
|
return [str(x).strip().lower() for x in raw if str(x).strip()]
|
|
s = str(raw or "").strip()
|
|
if not s:
|
|
return []
|
|
if "," in s:
|
|
return [x.strip().lower() for x in s.split(",") if x.strip()]
|
|
return [s.lower()]
|
|
|
|
|
|
def _eval_field_op(
|
|
row: dict[str, Any] | None,
|
|
field: str,
|
|
op: str,
|
|
value: Any,
|
|
*,
|
|
base_row: dict[str, Any] | None = None,
|
|
) -> bool:
|
|
"""Evaluate a value condition on current row (optionally vs baseline)."""
|
|
f = str(field or "").strip()
|
|
if not f:
|
|
return False
|
|
raw = "" if not row else str(row.get(f) or "").strip()
|
|
val = raw.lower()
|
|
o = str(op or "eq").strip().lower()
|
|
if o in ("changed", "diff"):
|
|
base = "" if not base_row else str(base_row.get(f) or "").strip().lower()
|
|
if not row:
|
|
return False
|
|
return base != val
|
|
if o in ("unchanged", "same"):
|
|
if not row:
|
|
return False
|
|
base = "" if not base_row else str(base_row.get(f) or "").strip().lower()
|
|
return base == val
|
|
if o == "empty":
|
|
return not val
|
|
if o == "not_empty":
|
|
return bool(val)
|
|
need = _norm_list_values(value)
|
|
if o == "eq":
|
|
return bool(need) and val == need[0]
|
|
if o == "ne":
|
|
return bool(need) and val != need[0]
|
|
if o == "in":
|
|
return bool(need) and val in need
|
|
if o == "not_in":
|
|
return bool(need) and val not in need
|
|
return False
|
|
|
|
|
|
def _normalize_cond_type(cond: dict[str, Any]) -> str:
|
|
ctype = str(cond.get("type") or "").strip().lower()
|
|
if ctype in ("kind", "presence"):
|
|
return "presence"
|
|
if ctype in ("field", "value"):
|
|
return "value"
|
|
if ctype == "status":
|
|
# Legacy status class → treat as presence-of-status-token via status arg
|
|
return "status"
|
|
if cond.get("kind") is not None:
|
|
return "presence"
|
|
if cond.get("status") is not None:
|
|
return "status"
|
|
if cond.get("field"):
|
|
return "value"
|
|
return ctype
|
|
|
|
|
|
def _eval_condition(
|
|
cond: Any,
|
|
*,
|
|
kind: str,
|
|
status: str,
|
|
row: dict[str, Any] | None,
|
|
tokens: set[str],
|
|
base_row: dict[str, Any] | None = None,
|
|
) -> bool:
|
|
"""One leaf: presence (no field) or value (needs field + strategy)."""
|
|
if isinstance(cond, str):
|
|
t = str(cond).strip()
|
|
return bool(t) and t in tokens
|
|
if not isinstance(cond, dict):
|
|
return False
|
|
ctype = _normalize_cond_type(cond)
|
|
if ctype == "presence":
|
|
want = str(
|
|
cond.get("value")
|
|
if cond.get("value") is not None
|
|
else cond.get("kind")
|
|
or ""
|
|
).strip()
|
|
return bool(want) and want == str(kind or "").strip()
|
|
if ctype == "status":
|
|
want = str(
|
|
cond.get("value")
|
|
if cond.get("value") is not None
|
|
else cond.get("status")
|
|
or ""
|
|
).strip()
|
|
return bool(want) and want == str(status or "").strip()
|
|
if ctype == "value":
|
|
field = str(cond.get("field") or "").strip()
|
|
if not field:
|
|
# Legacy status token with no field bound yet.
|
|
need = _norm_list_values(cond.get("value"))
|
|
return bool(need) and str(status or "").strip().lower() in need
|
|
# Gone / missing row: value strategies do not apply (use presence instead).
|
|
if str(kind or "").strip() == "removed" or not row:
|
|
return str(cond.get("op") or "eq").strip().lower() == "empty"
|
|
return _eval_field_op(
|
|
row,
|
|
field,
|
|
str(cond.get("op") or "eq"),
|
|
cond.get("value"),
|
|
base_row=base_row,
|
|
)
|
|
return False
|
|
|
|
|
|
def _legacy_tokens_from_pat(pat: dict[str, Any], side: str) -> list[str]:
|
|
return [str(x) for x in (pat.get(side) or []) if str(x).strip()]
|
|
|
|
|
|
def _conds_list_for_side(pat: dict[str, Any], side: str) -> list[Any]:
|
|
key = f"{side}_conds"
|
|
raw = pat.get(key)
|
|
if isinstance(raw, list) and raw:
|
|
return list(raw)
|
|
return _legacy_tokens_from_pat(pat, side)
|
|
|
|
|
|
def _groups_for_side(pat: dict[str, Any], side: str) -> list[list[Any]]:
|
|
"""OR-of-AND groups. Prefer ``old_groups``/``new_groups``; migrate flat conds.
|
|
|
|
Explicit empty list means don't-care for that side (no groups to match).
|
|
"""
|
|
key = f"{side}_groups"
|
|
if key in pat and isinstance(pat.get(key), list):
|
|
raw = pat.get(key) or []
|
|
if not raw:
|
|
return []
|
|
groups: list[list[Any]] = []
|
|
for g in raw:
|
|
if isinstance(g, list) and g:
|
|
groups.append(list(g))
|
|
elif g is not None and not isinstance(g, list):
|
|
groups.append([g])
|
|
return groups
|
|
conds = _conds_list_for_side(pat, side)
|
|
if not conds:
|
|
return []
|
|
mode = str(pat.get(f"{side}_mode") or "any").strip().lower()
|
|
if mode == "all":
|
|
return [list(conds)]
|
|
# any → each cond is its own OR group
|
|
return [[c] for c in conds]
|
|
|
|
|
|
def _match_groups(
|
|
groups: list[list[Any]],
|
|
*,
|
|
kind: str,
|
|
status: str,
|
|
row: dict[str, Any] | None,
|
|
tokens: set[str],
|
|
base_row: dict[str, Any] | None = None,
|
|
) -> bool:
|
|
"""Group OR; within group AND."""
|
|
if not groups:
|
|
return False
|
|
for group in groups:
|
|
leaves = [c for c in group if c is not None]
|
|
if not leaves:
|
|
continue
|
|
if all(
|
|
_eval_condition(
|
|
c,
|
|
kind=kind,
|
|
status=status,
|
|
row=row,
|
|
tokens=tokens,
|
|
base_row=base_row,
|
|
)
|
|
for c in leaves
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _match_dual_pattern(
|
|
pat: dict[str, Any],
|
|
*,
|
|
old_kind: str,
|
|
new_kind: str,
|
|
old_status: str,
|
|
new_status: str,
|
|
old_row: dict[str, Any] | None,
|
|
new_row: dict[str, Any] | None,
|
|
old_tokens: set[str],
|
|
new_tokens: set[str],
|
|
old_base: dict[str, Any] | None,
|
|
new_base: dict[str, Any] | None,
|
|
require_both_sides: bool,
|
|
) -> bool:
|
|
"""Match one dual pattern. Empty side groups = don't-care when not requiring both."""
|
|
old_groups = _groups_for_side(pat, "old")
|
|
new_groups = _groups_for_side(pat, "new")
|
|
if require_both_sides:
|
|
if not old_groups or not new_groups:
|
|
return False
|
|
elif not old_groups and not new_groups:
|
|
return False
|
|
old_ok = True if not old_groups else _match_groups(
|
|
old_groups,
|
|
kind=old_kind,
|
|
status=old_status,
|
|
row=old_row,
|
|
tokens=old_tokens,
|
|
base_row=old_base,
|
|
)
|
|
new_ok = True if not new_groups else _match_groups(
|
|
new_groups,
|
|
kind=new_kind,
|
|
status=new_status,
|
|
row=new_row,
|
|
tokens=new_tokens,
|
|
base_row=new_base,
|
|
)
|
|
return old_ok and new_ok
|
|
|
|
|
|
def _pattern_both_sides(pat: dict[str, Any]) -> bool:
|
|
return bool(_groups_for_side(pat, "old")) and bool(_groups_for_side(pat, "new"))
|
|
|
|
|
|
def _match_patterns(
|
|
patterns: list[dict[str, Any]] | None,
|
|
*,
|
|
old_kind: str = "",
|
|
new_kind: str = "",
|
|
old_status: str = "none",
|
|
new_status: str = "none",
|
|
old_row: dict[str, Any] | None = None,
|
|
new_row: dict[str, Any] | None = None,
|
|
old_tokens: set[str] | None = None,
|
|
new_tokens: set[str] | None = None,
|
|
old_base: dict[str, Any] | None = None,
|
|
new_base: dict[str, Any] | None = None,
|
|
require_both_sides: bool = True,
|
|
) -> int:
|
|
"""Return 1-based matched pattern index, or 0 if none match."""
|
|
ot = old_tokens or set()
|
|
nt = new_tokens or set()
|
|
for i, pat in enumerate(patterns or [], start=1):
|
|
if not isinstance(pat, dict):
|
|
continue
|
|
if _match_dual_pattern(
|
|
pat,
|
|
old_kind=old_kind,
|
|
new_kind=new_kind,
|
|
old_status=old_status,
|
|
new_status=new_status,
|
|
old_row=old_row,
|
|
new_row=new_row,
|
|
old_tokens=ot,
|
|
new_tokens=nt,
|
|
old_base=old_base,
|
|
new_base=new_base,
|
|
require_both_sides=require_both_sides,
|
|
):
|
|
return i
|
|
return 0
|
|
|
|
|
|
def _match_success(
|
|
old_tokens: set[str],
|
|
new_tokens: set[str],
|
|
success_patterns: list[dict[str, Any]] | None,
|
|
*,
|
|
old_kind: str = "",
|
|
new_kind: str = "",
|
|
old_status: str = "none",
|
|
new_status: str = "none",
|
|
old_row: dict[str, Any] | None = None,
|
|
new_row: dict[str, Any] | None = None,
|
|
old_base: dict[str, Any] | None = None,
|
|
new_base: dict[str, Any] | None = None,
|
|
) -> bool:
|
|
return (
|
|
_match_patterns(
|
|
success_patterns,
|
|
old_kind=old_kind,
|
|
new_kind=new_kind,
|
|
old_status=old_status,
|
|
new_status=new_status,
|
|
old_row=old_row,
|
|
new_row=new_row,
|
|
old_tokens=old_tokens,
|
|
new_tokens=new_tokens,
|
|
old_base=old_base,
|
|
new_base=new_base,
|
|
require_both_sides=True,
|
|
)
|
|
> 0
|
|
)
|
|
|
|
|
|
def _match_field_token_rules(
|
|
old_row: dict[str, Any] | None,
|
|
new_row: dict[str, Any] | None,
|
|
field_tokens: list[dict[str, Any]] | None,
|
|
) -> bool:
|
|
"""Optional AND constraints: each {side, field, in:[...]} must hold."""
|
|
rules = [r for r in (field_tokens or []) if isinstance(r, dict)]
|
|
if not rules:
|
|
return True
|
|
for ft in rules:
|
|
side = str(ft.get("side") or "").strip().lower()
|
|
field = str(ft.get("field") or "").strip()
|
|
allowed = {str(x).strip().lower() for x in (ft.get("in") or []) if str(x).strip()}
|
|
if not field or not allowed:
|
|
continue
|
|
row = old_row if side == "old" else new_row if side == "new" else None
|
|
val = str((row or {}).get(field) or "").strip().lower()
|
|
if val not in allowed:
|
|
return False
|
|
return True
|
|
|
|
|
|
def dual_verdict(
|
|
*,
|
|
old_kind: str,
|
|
new_kind: str,
|
|
in_expect: bool,
|
|
window_active: bool,
|
|
acceptance: bool = False,
|
|
old_status: str = "none",
|
|
new_status: str = "none",
|
|
success_patterns: list[dict[str, Any]] | None = None,
|
|
anomaly_patterns: list[dict[str, Any]] | None = None,
|
|
out_of_expect: str = "strict",
|
|
old_row: dict[str, Any] | None = None,
|
|
new_row: dict[str, Any] | None = None,
|
|
old_base: dict[str, Any] | None = None,
|
|
new_base: dict[str, Any] | None = None,
|
|
sheet_override: dict[str, Any] | None = None,
|
|
) -> tuple[str, str]:
|
|
"""Synthesize old+new into migration board verdict. See dual_verdict_ex."""
|
|
verdict, color, _hit = dual_verdict_ex(
|
|
old_kind=old_kind,
|
|
new_kind=new_kind,
|
|
in_expect=in_expect,
|
|
window_active=window_active,
|
|
acceptance=acceptance,
|
|
old_status=old_status,
|
|
new_status=new_status,
|
|
success_patterns=success_patterns,
|
|
anomaly_patterns=anomaly_patterns,
|
|
out_of_expect=out_of_expect,
|
|
old_row=old_row,
|
|
new_row=new_row,
|
|
old_base=old_base,
|
|
new_base=new_base,
|
|
sheet_override=sheet_override,
|
|
)
|
|
return verdict, color
|
|
|
|
|
|
def dual_verdict_ex(
|
|
*,
|
|
old_kind: str,
|
|
new_kind: str,
|
|
in_expect: bool,
|
|
window_active: bool,
|
|
acceptance: bool = False,
|
|
old_status: str = "none",
|
|
new_status: str = "none",
|
|
success_patterns: list[dict[str, Any]] | None = None,
|
|
anomaly_patterns: list[dict[str, Any]] | None = None,
|
|
out_of_expect: str = "strict",
|
|
old_row: dict[str, Any] | None = None,
|
|
new_row: dict[str, Any] | None = None,
|
|
old_base: dict[str, Any] | None = None,
|
|
new_base: dict[str, Any] | None = None,
|
|
sheet_override: dict[str, Any] | None = None,
|
|
) -> tuple[str, str, str]:
|
|
"""Like dual_verdict, plus rule_hit label (success#N / anomaly#N / heuristic).
|
|
|
|
Patterns use OR-of-AND groups (``old_groups`` / ``new_groups``):
|
|
within a group = AND, between groups = OR. Leaf types:
|
|
- presence: row existence vs baseline (removed/added/unchanged/changed)
|
|
- value: field strategy (eq/ne/in/empty/changed/…)
|
|
|
|
Order (in expect): success → anomaly → heuristics.
|
|
Anomaly may leave a side empty (= don't-care).
|
|
During an active cutover window (not acceptance), side-only anomaly
|
|
patterns are deferred so mid-migration downs do not false-red.
|
|
When success or anomaly patterns are non-empty (rules mode), skip invented
|
|
green-migrate and catch-all anomaly; keep lost / migrating only.
|
|
"""
|
|
ov = sheet_override or {}
|
|
if anomaly_patterns is None and isinstance(ov.get("anomaly"), list):
|
|
anomaly_patterns = list(ov.get("anomaly") or [])
|
|
fields = _fields_for_tokens(ov, success_patterns, anomaly_patterns)
|
|
old_toks = _side_tokens(old_kind, old_status, row=old_row, fields=fields)
|
|
new_toks = _side_tokens(new_kind, new_status, row=new_row, fields=fields)
|
|
field_ok = _match_field_token_rules(old_row, new_row, list(ov.get("field_tokens") or []) or None)
|
|
|
|
match_kw = dict(
|
|
old_kind=old_kind,
|
|
new_kind=new_kind,
|
|
old_status=old_status,
|
|
new_status=new_status,
|
|
old_row=old_row,
|
|
new_row=new_row,
|
|
old_tokens=old_toks,
|
|
new_tokens=new_toks,
|
|
old_base=old_base,
|
|
new_base=new_base,
|
|
)
|
|
|
|
hit = _match_patterns(success_patterns, require_both_sides=True, **match_kw)
|
|
if in_expect and field_ok and hit:
|
|
return "migrated", "green", f"success#{hit}"
|
|
|
|
ooe = str(out_of_expect or "strict").strip().lower()
|
|
if not in_expect:
|
|
if ooe == "ignore":
|
|
return "not_involved", "gray", "out_of_expect:ignore"
|
|
if old_kind in ("removed", "changed") or new_kind in ("removed", "changed"):
|
|
# Both gone / old gone with no new → lost; any other drift (incl. new-only
|
|
# removed while old still present) → anomaly.
|
|
if old_kind == "removed" and new_kind in ("", "removed"):
|
|
label = "lost" if ooe == "warn" else "lost"
|
|
color = "yellow" if ooe == "warn" else "red"
|
|
return label, color, "out_of_expect:lost"
|
|
return (
|
|
("anomaly", "yellow", "out_of_expect:anomaly")
|
|
if ooe == "warn"
|
|
else ("anomaly", "red", "out_of_expect:anomaly")
|
|
)
|
|
if old_kind == "added" or new_kind == "added":
|
|
return "unexpected_new", "yellow", "out_of_expect:added"
|
|
return "not_involved", "gray", "out_of_expect:none"
|
|
|
|
# field_tokens gate success only — do not block anomaly on a gone side
|
|
anomaly_list = list(anomaly_patterns or [])
|
|
if window_active and not acceptance:
|
|
# Side-only anomaly = don't-care other side; defer until acceptance / window closed.
|
|
anomaly_list = [p for p in anomaly_list if isinstance(p, dict) and _pattern_both_sides(p)]
|
|
ahit = _match_patterns(anomaly_list, require_both_sides=False, **match_kw)
|
|
if in_expect and ahit:
|
|
return "anomaly", "red", f"anomaly#{ahit}"
|
|
|
|
# Explicit success/anomaly lists mean "rules mode": do not invent green migrate
|
|
# or catch-all anomaly; keep lost / migrating as mid-state fallbacks only.
|
|
rules_mode = bool(success_patterns) or bool(anomaly_patterns)
|
|
|
|
if (
|
|
not rules_mode
|
|
and old_kind in ("removed", "changed")
|
|
and new_kind in ("added", "unchanged", "changed")
|
|
):
|
|
return "migrated", "green", "heuristic:migrate"
|
|
if old_kind == "removed" and new_kind in ("", "removed"):
|
|
return "lost", "red", "heuristic:lost"
|
|
if old_kind in ("unchanged", "") and new_kind in ("unchanged", "added", "changed"):
|
|
if acceptance:
|
|
return "unfinished", "red", "heuristic:unfinished"
|
|
return "migrating", "yellow", "heuristic:migrating"
|
|
if old_kind == "unchanged" and new_kind in ("", "removed"):
|
|
if acceptance:
|
|
return "unfinished", "red", "heuristic:unfinished"
|
|
return "migrating", "yellow", "heuristic:migrating"
|
|
if not rules_mode:
|
|
side_v, side_c = side_verdict(
|
|
kind=old_kind or "unchanged", in_expect=True, window_active=window_active
|
|
)
|
|
if side_c == "red" or (new_kind in ("removed",) and old_kind != "removed"):
|
|
return "anomaly", "red", "heuristic:anomaly"
|
|
else:
|
|
side_v, _side_c = side_verdict(
|
|
kind=old_kind or "unchanged", in_expect=True, window_active=window_active
|
|
)
|
|
if acceptance:
|
|
return "unfinished", "red", "heuristic:unfinished"
|
|
if window_active or side_v.startswith("expected"):
|
|
return "migrating", "yellow", "heuristic:migrating"
|
|
return "migrating", "yellow", "heuristic:migrating"
|
|
|
|
|
|
def build_diff_index_from_compare(result: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
|
"""Normalize compare_rows result diffs → key_str → {kind, before, after, key}."""
|
|
out: dict[str, dict[str, Any]] = {}
|
|
for d in result.get("diffs") or []:
|
|
key = d.get("key")
|
|
if isinstance(key, dict):
|
|
key_list = [str(v) for v in key.values()]
|
|
ks = _key_str(key_list)
|
|
elif isinstance(key, (list, tuple)):
|
|
key_list = [str(x) for x in key]
|
|
ks = _key_str(key_list)
|
|
else:
|
|
ks = str(key or "")
|
|
key_list = [ks] if ks else []
|
|
before = dict(d.get("before") or {}) if d.get("before") else {}
|
|
after = dict(d.get("after") or {}) if d.get("after") else {}
|
|
out[ks] = {
|
|
"kind": str(d.get("kind") or ""),
|
|
"key": key_list,
|
|
"before": before,
|
|
"after": after,
|
|
"mapped_before": dict(d.get("mapped_before") or {}) if d.get("mapped_before") else {},
|
|
}
|
|
return out
|
|
|
|
|
|
def expect_keys_for_metric(
|
|
expect: dict[str, set[str]],
|
|
*,
|
|
metric_id: str,
|
|
iface_fields: list[str],
|
|
sheet_id: str = "",
|
|
) -> set[str]:
|
|
"""Resolve expect keys for a compare sheet.
|
|
|
|
Prefer ``sheet_id`` (split tables); fall back to ``metric_id`` for legacy
|
|
unsplit sheets / expect items that only carry metric_id.
|
|
"""
|
|
sid = str(sheet_id or "").strip()
|
|
mid = str(metric_id or "").strip()
|
|
keys = set(expect.get(sid) or ()) if sid else set()
|
|
if not keys and mid:
|
|
keys = set(expect.get(mid) or ())
|
|
# ports: shorthand is only for interface_brief, not every sheet with iface fields
|
|
if mid == PORT_METRIC_ID and expect.get("_ports"):
|
|
keys |= set(expect["_ports"])
|
|
return keys
|
|
|
|
|
|
def _map_defines_iface_expect(metric_id: str, iface_fields: list[str], port_map: dict[str, str]) -> bool:
|
|
"""Iface sheets: a non-empty port map is the expect scope.
|
|
|
|
BGP / PW keep the batch expect picker even if a column is marked iface.
|
|
"""
|
|
if not port_map or not iface_fields:
|
|
return False
|
|
mid = str(metric_id or "").strip().lower()
|
|
if "bgp" in mid or "pw" in mid or "pseudowire" in mid:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _iface_values(key_str: str, *, key_fields: list[str], iface_fields: list[str]) -> list[str]:
|
|
parts = str(key_str or "").split("|")
|
|
iface_set = {str(f) for f in iface_fields}
|
|
if key_fields and len(parts) == len(key_fields):
|
|
return [parts[i] for i, f in enumerate(key_fields) if f in iface_set and parts[i]]
|
|
if len(key_fields) == 1 and key_fields[0] in iface_set and key_str:
|
|
return [key_str]
|
|
return []
|
|
|
|
|
|
def _key_in_port_map(
|
|
key_str: str,
|
|
*,
|
|
key_fields: list[str],
|
|
iface_fields: list[str],
|
|
port_map: dict[str, str],
|
|
) -> bool:
|
|
"""True when every interface segment of the key is covered by the map.
|
|
|
|
Covered = exact map key, or parent (before last ``.``) is a map key.
|
|
"""
|
|
vals = _iface_values(key_str, key_fields=key_fields, iface_fields=iface_fields)
|
|
if not vals:
|
|
return False
|
|
for v in vals:
|
|
if v in port_map:
|
|
continue
|
|
parent = v.rsplit(".", 1)[0] if "." in v else ""
|
|
if parent and parent in port_map:
|
|
continue
|
|
return False
|
|
return True
|
|
|
|
|
|
def _port_identity(key_fields: list[str], iface_fields: list[str]) -> bool:
|
|
"""Key is the interface itself (interface_brief), not a business row hanging off it."""
|
|
if not key_fields or not iface_fields:
|
|
return False
|
|
iface_set = {str(f) for f in iface_fields}
|
|
return all(str(f) in iface_set for f in key_fields)
|
|
|
|
|
|
def _unmapped_same_iface_anomaly(
|
|
*,
|
|
old_kind: str,
|
|
new_kind: str,
|
|
old_status: str,
|
|
new_status: str,
|
|
) -> bool:
|
|
"""Same name, not in the map: old down/gone + new still up is not a migration."""
|
|
old_bad = old_kind in ("removed", "changed") or old_status == "down"
|
|
new_up = new_kind in ("added", "unchanged", "changed") and new_status != "down"
|
|
return old_bad and new_up
|
|
|
|
|
|
def _remap_key_str(
|
|
key_str: str,
|
|
*,
|
|
key_fields: list[str],
|
|
iface_fields: list[str],
|
|
port_map: dict[str, str],
|
|
) -> str:
|
|
"""Map old-side key_str → new-side using iface segments (not whole-string only)."""
|
|
from ..biz_state.iface_normalize import resolve_mapped_iface
|
|
|
|
if not key_str:
|
|
return key_str
|
|
if not port_map:
|
|
return key_str
|
|
if (not key_fields or len(key_fields) == 1) and (
|
|
key_str in port_map or ("." in key_str and key_str.rsplit(".", 1)[0] in port_map)
|
|
):
|
|
return resolve_mapped_iface(key_str, port_map)
|
|
parts = str(key_str).split("|")
|
|
if key_fields and len(parts) == len(key_fields):
|
|
iface_set = {str(f) for f in iface_fields}
|
|
out: list[str] = []
|
|
for i, f in enumerate(key_fields):
|
|
v = parts[i]
|
|
if f in iface_set:
|
|
out.append(resolve_mapped_iface(v, port_map))
|
|
else:
|
|
out.append(v)
|
|
return "|".join(out)
|
|
return resolve_mapped_iface(key_str, port_map)
|
|
|
|
|
|
def _reverse_remap_key_str(
|
|
key_str: str,
|
|
*,
|
|
key_fields: list[str],
|
|
iface_fields: list[str],
|
|
rev_map: dict[str, str],
|
|
) -> str:
|
|
"""Map new-side key_str → old-side canon identity."""
|
|
from ..biz_state.iface_normalize import resolve_mapped_iface
|
|
|
|
if not key_str:
|
|
return key_str
|
|
if not rev_map:
|
|
return key_str
|
|
if (not key_fields or len(key_fields) == 1) and (
|
|
key_str in rev_map or ("." in key_str and key_str.rsplit(".", 1)[0] in rev_map)
|
|
):
|
|
return resolve_mapped_iface(key_str, rev_map)
|
|
parts = str(key_str).split("|")
|
|
if key_fields and len(parts) == len(key_fields):
|
|
iface_set = {str(f) for f in iface_fields}
|
|
out: list[str] = []
|
|
for i, f in enumerate(key_fields):
|
|
v = parts[i]
|
|
if f in iface_set:
|
|
out.append(resolve_mapped_iface(v, rev_map))
|
|
else:
|
|
out.append(v)
|
|
return "|".join(out)
|
|
return resolve_mapped_iface(key_str, rev_map)
|
|
|
|
|
|
def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Current-side snapshot from a compare diff (empty if removed / missing)."""
|
|
if not diff:
|
|
return {}
|
|
kind = str(diff.get("kind") or "")
|
|
if kind == "removed":
|
|
return {}
|
|
return dict(diff.get("after") or {})
|
|
|
|
|
|
def override_for_sheet(
|
|
sheet_overrides: list[dict[str, Any]] | None,
|
|
*,
|
|
sheet_id: str,
|
|
metric_id: str,
|
|
) -> dict[str, Any]:
|
|
"""Match a monitor override. sheet_id wins; a legacy metric-only override applies to every split."""
|
|
sid = str(sheet_id or "").strip()
|
|
mid = str(metric_id or "").strip()
|
|
legacy: dict[str, Any] | None = None
|
|
for ov in sheet_overrides or []:
|
|
if not isinstance(ov, dict):
|
|
continue
|
|
ov_sid = str(ov.get("sheet_id") or "").strip()
|
|
ov_mid = str(ov.get("metric_id") or "").strip()
|
|
if ov_sid and ov_sid == sid:
|
|
return ov
|
|
if not ov_sid and ov_mid and ov_mid in (sid, mid) and legacy is None:
|
|
legacy = ov
|
|
return legacy or {}
|
|
|
|
|
|
def override_for_metric(
|
|
sheet_overrides: list[dict[str, Any]] | None,
|
|
metric_id: str,
|
|
) -> dict[str, Any]:
|
|
mid = str(metric_id or "").strip()
|
|
return override_for_sheet(sheet_overrides, sheet_id=mid, metric_id=mid)
|
|
|
|
|
|
def strip_netx(row: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Drop collector provenance key before compare / board row bodies."""
|
|
if not row:
|
|
return {}
|
|
return {k: v for k, v in row.items() if k != "_netx"}
|
|
|
|
|
|
def row_match_key(
|
|
row: dict[str, Any],
|
|
*,
|
|
key_fields: list[str],
|
|
iface_fields: list[str],
|
|
rules: list[dict[str, str]] | None,
|
|
) -> str:
|
|
from ..biz_state.iface_normalize import apply_iface_normalize
|
|
|
|
data = strip_netx(row)
|
|
if iface_fields and rules:
|
|
data = apply_iface_normalize(dict(data), iface_fields=iface_fields, rules=rules)
|
|
return "|".join(str(data.get(k) or "").strip() for k in key_fields)
|
|
|
|
|
|
def index_raw_by_match_key(
|
|
raw_rows: list[dict[str, Any]],
|
|
*,
|
|
key_fields: list[str],
|
|
iface_fields: list[str],
|
|
rules: list[dict[str, str]] | None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""First raw row per internal match key (normalized iface, raw elsewhere)."""
|
|
idx: dict[str, dict[str, Any]] = {}
|
|
for raw in raw_rows:
|
|
ks = row_match_key(raw, key_fields=key_fields, iface_fields=iface_fields, rules=rules)
|
|
if ks and ks not in idx:
|
|
idx[ks] = raw
|
|
return idx
|
|
|
|
|
|
def display_key_from_raw(raw: dict[str, Any] | None, key_fields: list[str]) -> str:
|
|
"""Board / AI facing identity: as collected on the device (no normalize / map)."""
|
|
if not raw or not key_fields:
|
|
return ""
|
|
data = strip_netx(raw)
|
|
return "|".join(str(data.get(k) or "").strip() for k in key_fields)
|
|
|
|
|
|
def iface_lineage(
|
|
raw: dict[str, Any] | None,
|
|
*,
|
|
iface_fields: list[str],
|
|
rules: list[dict[str, str]] | None,
|
|
port_map: dict[str, str] | None,
|
|
apply_map: bool,
|
|
) -> list[dict[str, Any]]:
|
|
"""Per iface field: raw → normalized → (optional) mapped."""
|
|
from ..biz_state.iface_normalize import normalize_iface_name, resolve_mapped_iface
|
|
|
|
if not raw or not iface_fields:
|
|
return []
|
|
data = strip_netx(raw)
|
|
pmap = port_map or {}
|
|
out: list[dict[str, Any]] = []
|
|
for f in iface_fields:
|
|
raw_v = str(data.get(f) or "").strip()
|
|
norm_v = normalize_iface_name(raw_v, rules) if rules else raw_v
|
|
mapped = False
|
|
map_to = ""
|
|
if apply_map and pmap and norm_v:
|
|
map_to = resolve_mapped_iface(norm_v, pmap)
|
|
mapped = bool(map_to) and map_to != norm_v
|
|
out.append(
|
|
{
|
|
"field": f,
|
|
"raw": raw_v,
|
|
"normalized": norm_v,
|
|
"mapped": mapped,
|
|
"map_from": norm_v if apply_map and pmap else "",
|
|
"map_to": map_to if apply_map and pmap else "",
|
|
}
|
|
)
|
|
return out
|
|
|
|
|
|
def evaluate_metric_dual(
|
|
*,
|
|
metric_id: str,
|
|
key_fields: list[str],
|
|
iface_fields: list[str],
|
|
compare_fields: list[str],
|
|
old_baseline_rows: list[dict[str, Any]],
|
|
old_current_rows: list[dict[str, Any]],
|
|
new_baseline_rows: list[dict[str, Any]] | None,
|
|
new_current_rows: list[dict[str, Any]],
|
|
port_map: dict[str, str],
|
|
expect: dict[str, set[str]],
|
|
window_active: bool,
|
|
acceptance: bool = False,
|
|
field_rules: list[dict[str, Any]] | None = None,
|
|
sheet_override: dict[str, Any] | None = None,
|
|
out_of_expect: str = "strict",
|
|
sheet_id: str = "",
|
|
iface_normalize_rules: list[dict[str, str]] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge.
|
|
|
|
Matching uses normalized (+ port-mapped) keys internally.
|
|
Board / AI fields ``old_key`` / ``new_key`` and ``old`` / ``new`` stay **raw**
|
|
device values (A/B), never the post-map BB form.
|
|
"""
|
|
from ..biz_state.iface_normalize import (
|
|
apply_iface_normalize_rows,
|
|
normalize_iface_rules,
|
|
)
|
|
|
|
sid = str(sheet_id or "").strip() or str(metric_id or "").strip()
|
|
expect_keys = expect_keys_for_metric(
|
|
expect, metric_id=metric_id, iface_fields=iface_fields, sheet_id=sid
|
|
)
|
|
ov = sheet_override or {}
|
|
if ov.get("skip_dual"):
|
|
return {
|
|
"metric_id": metric_id,
|
|
"old_summary": {},
|
|
"new_summary": {},
|
|
"progress_ok": 0,
|
|
"progress_total": 0,
|
|
"anomaly": 0,
|
|
"rows": [],
|
|
"skipped": True,
|
|
"new_baseline_mode": "skipped",
|
|
"new_baseline_missing": False,
|
|
"anomaly_in_expect": 0,
|
|
}
|
|
success_patterns = list(ov.get("success") or []) if isinstance(ov.get("success"), list) else []
|
|
anomaly_patterns = list(ov.get("anomaly") or []) if isinstance(ov.get("anomaly"), list) else []
|
|
|
|
# Keep collector originals for display / evidence; compare on normalized copies.
|
|
raw_old_base = [dict(r) for r in (old_baseline_rows or [])]
|
|
raw_old_cur = [dict(r) for r in (old_current_rows or [])]
|
|
raw_new_cur = [dict(r) for r in (new_current_rows or [])]
|
|
raw_new_base = (
|
|
[dict(r) for r in new_baseline_rows] if new_baseline_rows is not None else None
|
|
)
|
|
|
|
norm_rules = normalize_iface_rules(iface_normalize_rules)
|
|
old_baseline_rows = apply_iface_normalize_rows(
|
|
[strip_netx(r) for r in raw_old_base], iface_fields=iface_fields, rules=norm_rules
|
|
)
|
|
old_current_rows = apply_iface_normalize_rows(
|
|
[strip_netx(r) for r in raw_old_cur], iface_fields=iface_fields, rules=norm_rules
|
|
)
|
|
new_current_rows = apply_iface_normalize_rows(
|
|
[strip_netx(r) for r in raw_new_cur], iface_fields=iface_fields, rules=norm_rules
|
|
)
|
|
if raw_new_base is not None:
|
|
new_baseline_rows = apply_iface_normalize_rows(
|
|
[strip_netx(r) for r in raw_new_base],
|
|
iface_fields=iface_fields,
|
|
rules=norm_rules,
|
|
)
|
|
else:
|
|
new_baseline_rows = None
|
|
|
|
old_raw_cur_idx = index_raw_by_match_key(
|
|
raw_old_cur, key_fields=key_fields, iface_fields=iface_fields, rules=norm_rules
|
|
)
|
|
old_raw_base_idx = index_raw_by_match_key(
|
|
raw_old_base, key_fields=key_fields, iface_fields=iface_fields, rules=norm_rules
|
|
)
|
|
new_raw_cur_idx = index_raw_by_match_key(
|
|
raw_new_cur, key_fields=key_fields, iface_fields=iface_fields, rules=norm_rules
|
|
)
|
|
new_raw_base_idx = index_raw_by_match_key(
|
|
raw_new_base or [],
|
|
key_fields=key_fields,
|
|
iface_fields=iface_fields,
|
|
rules=norm_rules,
|
|
)
|
|
|
|
old_cmp = compare_rows(
|
|
before_rows=old_baseline_rows,
|
|
after_rows=old_current_rows,
|
|
key_fields=key_fields,
|
|
iface_fields=[],
|
|
compare_fields=compare_fields,
|
|
port_map=None,
|
|
field_rules=field_rules,
|
|
iface_normalize_rules=None, # already applied
|
|
)
|
|
old_idx = build_diff_index_from_compare(old_cmp)
|
|
|
|
new_baseline_mode = "provided"
|
|
new_baseline_missing = False
|
|
if new_baseline_rows is not None:
|
|
new_before = list(new_baseline_rows)
|
|
new_baseline_mode = "provided"
|
|
# Explicit empty batch is as unusable as "no baseline" for presence semantics.
|
|
if not new_before and (old_baseline_rows or new_current_rows):
|
|
if port_map and iface_fields:
|
|
new_before = [
|
|
apply_port_map(r, iface_fields=iface_fields, port_map=port_map)
|
|
for r in old_baseline_rows
|
|
]
|
|
new_baseline_mode = "port_mapped"
|
|
new_baseline_missing = False
|
|
else:
|
|
new_baseline_missing = True
|
|
new_baseline_mode = "empty"
|
|
elif port_map and iface_fields:
|
|
new_before = [
|
|
apply_port_map(r, iface_fields=iface_fields, port_map=port_map)
|
|
for r in old_baseline_rows
|
|
]
|
|
new_baseline_mode = "port_mapped"
|
|
else:
|
|
new_before = []
|
|
new_baseline_mode = "empty"
|
|
# No new baseline and no map → every current new row looks like "added".
|
|
new_baseline_missing = bool(old_baseline_rows or new_current_rows)
|
|
|
|
new_cmp = compare_rows(
|
|
before_rows=new_before,
|
|
after_rows=new_current_rows,
|
|
key_fields=key_fields,
|
|
iface_fields=[],
|
|
compare_fields=compare_fields,
|
|
port_map=None,
|
|
field_rules=field_rules,
|
|
)
|
|
new_idx = build_diff_index_from_compare(new_cmp)
|
|
|
|
rev_map = {v: k for k, v in port_map.items()}
|
|
map_scoped = _map_defines_iface_expect(metric_id, iface_fields, port_map)
|
|
if map_scoped:
|
|
scoped: set[str] = set()
|
|
for ks in old_idx:
|
|
if _key_in_port_map(
|
|
ks, key_fields=key_fields, iface_fields=iface_fields, port_map=port_map
|
|
):
|
|
scoped.add(ks)
|
|
for ks in new_idx:
|
|
canon = _reverse_remap_key_str(
|
|
ks,
|
|
key_fields=key_fields,
|
|
iface_fields=iface_fields,
|
|
rev_map=rev_map,
|
|
)
|
|
if _key_in_port_map(
|
|
canon, key_fields=key_fields, iface_fields=iface_fields, port_map=port_map
|
|
):
|
|
scoped.add(canon)
|
|
expect_keys = scoped
|
|
|
|
canon_keys: list[str] = []
|
|
seen: set[str] = set()
|
|
|
|
def _add(canon: str) -> None:
|
|
if canon and canon not in seen:
|
|
seen.add(canon)
|
|
canon_keys.append(canon)
|
|
|
|
for ok in sorted(expect_keys):
|
|
_add(ok)
|
|
for ks in sorted(old_idx):
|
|
_add(ks)
|
|
for ks in sorted(new_idx):
|
|
_add(
|
|
_reverse_remap_key_str(
|
|
ks,
|
|
key_fields=key_fields,
|
|
iface_fields=iface_fields,
|
|
rev_map=rev_map,
|
|
)
|
|
)
|
|
|
|
rows_out: list[dict[str, Any]] = []
|
|
progress_ok = 0
|
|
progress_total = 0
|
|
anomaly = 0
|
|
anomaly_in_expect = 0
|
|
|
|
for old_ks in canon_keys:
|
|
new_ks = _remap_key_str(
|
|
old_ks,
|
|
key_fields=key_fields,
|
|
iface_fields=iface_fields,
|
|
port_map=port_map,
|
|
)
|
|
in_exp = old_ks in expect_keys
|
|
if in_exp:
|
|
progress_total += 1
|
|
|
|
od = old_idx.get(old_ks)
|
|
nd = new_idx.get(new_ks) or new_idx.get(old_ks)
|
|
|
|
old_kind = str((od or {}).get("kind") or "")
|
|
new_kind = str((nd or {}).get("kind") or "")
|
|
if od is None and nd is None and not in_exp:
|
|
continue
|
|
if od is None:
|
|
old_kind = ""
|
|
if nd is None:
|
|
new_kind = ""
|
|
|
|
old_cur = _current_row(od)
|
|
new_cur = _current_row(nd)
|
|
old_base = dict((od or {}).get("before") or {})
|
|
new_base = dict((nd or {}).get("before") or {})
|
|
old_st = classify_status(old_cur, ov)
|
|
new_st = classify_status(new_cur, ov)
|
|
|
|
# Mapped iface rows are the expect set. Unmapped rows stay out of the
|
|
# migration compare, except same-name ports where old went down and the
|
|
# new device's same port is up — that is anomaly, not a successful cutover.
|
|
if map_scoped and not in_exp:
|
|
if not (
|
|
_port_identity(key_fields, iface_fields)
|
|
and _unmapped_same_iface_anomaly(
|
|
old_kind=old_kind,
|
|
new_kind=new_kind,
|
|
old_status=old_st,
|
|
new_status=new_st,
|
|
)
|
|
):
|
|
continue
|
|
verdict, color, rule_hit = "anomaly", "red", "unmapped_same_iface"
|
|
else:
|
|
verdict, color, rule_hit = dual_verdict_ex(
|
|
old_kind=old_kind or "",
|
|
new_kind=new_kind or "",
|
|
in_expect=in_exp,
|
|
window_active=window_active,
|
|
acceptance=acceptance,
|
|
old_status=old_st,
|
|
new_status=new_st,
|
|
success_patterns=success_patterns or None,
|
|
anomaly_patterns=anomaly_patterns or None,
|
|
out_of_expect=out_of_expect,
|
|
old_row=old_cur or None,
|
|
new_row=new_cur or None,
|
|
old_base=old_base or None,
|
|
new_base=new_base or None,
|
|
sheet_override=ov,
|
|
)
|
|
if in_exp and verdict == "migrated" and color == "green":
|
|
progress_ok += 1
|
|
if color == "red":
|
|
anomaly += 1
|
|
if in_exp:
|
|
anomaly_in_expect += 1
|
|
|
|
# Prefer live raw rows; fall back to baseline raw. Never use port-mapped
|
|
# synthetic "before" as the new-side display row.
|
|
raw_old = old_raw_cur_idx.get(old_ks) or old_raw_base_idx.get(old_ks)
|
|
raw_new = new_raw_cur_idx.get(new_ks) or new_raw_cur_idx.get(old_ks)
|
|
if not raw_new:
|
|
raw_new = new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks)
|
|
|
|
display_old = display_key_from_raw(raw_old, key_fields) or old_ks
|
|
display_new = display_key_from_raw(raw_new, key_fields) or (
|
|
new_ks if raw_new is not None else ""
|
|
)
|
|
# If new side missing entirely, still show map target as hint only in match_*
|
|
if not display_new and new_ks and new_ks != old_ks:
|
|
display_new = ""
|
|
|
|
old_disp = strip_netx(raw_old) if raw_old else {}
|
|
new_disp = strip_netx(raw_new) if raw_new else {}
|
|
old_base_raw = strip_netx(old_raw_base_idx.get(old_ks)) if old_raw_base_idx.get(old_ks) else {}
|
|
new_base_raw = (
|
|
strip_netx(new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks))
|
|
if (new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks))
|
|
else {}
|
|
)
|
|
|
|
evidence = {
|
|
"old": {
|
|
"netx": dict((raw_old or {}).get("_netx") or {}),
|
|
"iface": iface_lineage(
|
|
raw_old,
|
|
iface_fields=iface_fields,
|
|
rules=norm_rules,
|
|
port_map=port_map,
|
|
apply_map=True,
|
|
),
|
|
},
|
|
"new": {
|
|
"netx": dict((raw_new or {}).get("_netx") or {}),
|
|
"iface": iface_lineage(
|
|
raw_new,
|
|
iface_fields=iface_fields,
|
|
rules=norm_rules,
|
|
port_map=None,
|
|
apply_map=False,
|
|
),
|
|
},
|
|
"port_map": {
|
|
"applied": bool(port_map and iface_fields and old_ks != new_ks),
|
|
"match_before": old_ks,
|
|
"match_after": new_ks,
|
|
"display_before": display_old,
|
|
"display_after": display_new,
|
|
},
|
|
}
|
|
|
|
rows_out.append(
|
|
{
|
|
"metric_id": metric_id,
|
|
"key": (od or nd or {}).get("key") or [old_ks],
|
|
# Display identity (A/B as collected) — board & MCP primary
|
|
"old_key": display_old,
|
|
"new_key": display_new,
|
|
# Internal match keys (normalized / remapped)
|
|
"match_old_key": old_ks,
|
|
"match_new_key": new_ks,
|
|
# Aliases: key_str = display old; new_key_str = display new
|
|
"key_str": display_old,
|
|
"new_key_str": display_new,
|
|
"verdict": verdict,
|
|
"color": color,
|
|
"rule_hit": rule_hit,
|
|
"in_expect": in_exp,
|
|
"old_kind": old_kind,
|
|
"new_kind": new_kind,
|
|
"old": old_disp,
|
|
"new": new_disp,
|
|
"old_baseline": old_base_raw,
|
|
"new_baseline": new_base_raw,
|
|
"old_status": status_label(old_cur, ov)
|
|
if old_cur
|
|
else ("gone" if old_kind == "removed" else "—"),
|
|
"new_status": status_label(new_cur, ov)
|
|
if new_cur
|
|
else ("gone" if new_kind == "removed" else "—"),
|
|
"old_side": side_verdict(
|
|
kind=old_kind or "unchanged",
|
|
in_expect=in_exp,
|
|
window_active=window_active,
|
|
)
|
|
if old_kind
|
|
else ("", "gray"),
|
|
"new_side": side_verdict(
|
|
kind=new_kind or "unchanged",
|
|
in_expect=in_exp,
|
|
window_active=window_active,
|
|
)
|
|
if new_kind
|
|
else ("", "gray"),
|
|
"evidence": evidence,
|
|
}
|
|
)
|
|
|
|
return {
|
|
"metric_id": metric_id,
|
|
"old_summary": old_cmp.get("summary") or {},
|
|
"new_summary": new_cmp.get("summary") or {},
|
|
"progress_ok": progress_ok,
|
|
"progress_total": progress_total if progress_total else len(expect_keys),
|
|
"anomaly": anomaly,
|
|
"anomaly_in_expect": anomaly_in_expect,
|
|
"rows": rows_out,
|
|
"new_baseline_mode": new_baseline_mode,
|
|
"new_baseline_missing": new_baseline_missing,
|
|
}
|
|
|
|
|
|
def port_sheet_def() -> dict[str, Any]:
|
|
"""Default sheet for port-status cutover monitor (interface_brief only)."""
|
|
return {
|
|
"sheet_id": PORT_METRIC_ID,
|
|
"title": PORT_METRIC_ID,
|
|
"metric_id": PORT_METRIC_ID,
|
|
"key_fields": ["interface"],
|
|
"iface_fields": ["interface"],
|
|
"compare_fields": ["admin", "phy", "prot"],
|
|
"row_filters": [],
|
|
"field_rules": [],
|
|
}
|