mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 23:24:22 +08:00
Decouple cutover HF from portrait and tighten ops UI.
Portrait stays on biz-monitor; cutover create/ensure owns purpose=cutover_hf slots with project collect override, multi-interval bindings, window pause, and industrial detail UX. Also expand ZTE collect/config parsers and TextFSM for route/BGP/L2VPN/VRRP coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a65c9fc420
commit
285c19e5a3
67 changed files with 9510 additions and 723 deletions
|
|
@ -5,6 +5,11 @@ from __future__ import annotations
|
|||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .compare_rules import field_rule_map, values_equal, explain_diff
|
||||
from .iface_normalize import (
|
||||
apply_iface_normalize_rows,
|
||||
normalize_iface_rules,
|
||||
resolve_mapped_iface,
|
||||
)
|
||||
|
||||
|
||||
def apply_port_map(
|
||||
|
|
@ -13,11 +18,12 @@ def apply_port_map(
|
|||
iface_fields: list[str],
|
||||
port_map: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""Rewrite iface columns on a row (exact map or parent.subif inheritance)."""
|
||||
out = dict(row)
|
||||
for f in iface_fields:
|
||||
val = str(out.get(f) or "").strip()
|
||||
if val and val in port_map:
|
||||
out[f] = port_map[val]
|
||||
if val:
|
||||
out[f] = resolve_mapped_iface(val, port_map)
|
||||
return out
|
||||
|
||||
|
||||
|
|
@ -32,7 +38,11 @@ def mapping_stats(
|
|||
iface_fields: list[str],
|
||||
port_map: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""hit/miss/unused for port mapping validation."""
|
||||
"""hit/miss/unused for port mapping validation.
|
||||
|
||||
``hit_before``: map key appears as a normalized before iface, or as the
|
||||
parent of a before subinterface (so main-port-only maps still validate).
|
||||
"""
|
||||
before_ifaces: set[str] = set()
|
||||
after_ifaces: set[str] = set()
|
||||
for f in iface_fields:
|
||||
|
|
@ -45,13 +55,25 @@ def mapping_stats(
|
|||
if v:
|
||||
after_ifaces.add(v)
|
||||
|
||||
before_bases: set[str] = set()
|
||||
for v in before_ifaces:
|
||||
before_bases.add(v)
|
||||
if "." in v:
|
||||
before_bases.add(v.rsplit(".", 1)[0])
|
||||
|
||||
after_bases: set[str] = set()
|
||||
for v in after_ifaces:
|
||||
after_bases.add(v)
|
||||
if "." in v:
|
||||
after_bases.add(v.rsplit(".", 1)[0])
|
||||
|
||||
mapped_before = set(port_map.keys())
|
||||
mapped_after = set(port_map.values())
|
||||
hit_before = sorted(mapped_before & before_ifaces)
|
||||
miss_before = sorted(mapped_before - before_ifaces)
|
||||
unused = sorted(mapped_before - before_ifaces) # same as miss for before presence
|
||||
hit_after = sorted(mapped_after & after_ifaces)
|
||||
miss_after = sorted(mapped_after - after_ifaces)
|
||||
hit_before = sorted(mapped_before & before_bases)
|
||||
miss_before = sorted(mapped_before - before_bases)
|
||||
unused = list(miss_before)
|
||||
hit_after = sorted(mapped_after & after_bases)
|
||||
miss_after = sorted(mapped_after - after_bases)
|
||||
return {
|
||||
"before_iface_count": len(before_ifaces),
|
||||
"after_iface_count": len(after_ifaces),
|
||||
|
|
@ -74,13 +96,18 @@ def compare_rows(
|
|||
compare_fields: list[str],
|
||||
port_map: dict[str, str] | None = None,
|
||||
field_rules: Sequence[Mapping[str, Any]] | None = None,
|
||||
iface_normalize_rules: Sequence[Mapping[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return summary + diffs list.
|
||||
|
||||
Diff kinds: added | removed | changed | unchanged
|
||||
|
||||
Empty ``port_map`` means ignore port/iface changes: matching keys exclude
|
||||
``iface_fields`` (same-device batches compare without a rename map).
|
||||
Pipeline: iface normalize (both sides) → port map (before) → match.
|
||||
|
||||
Empty ``port_map``: try to ignore port renames by dropping ``iface_fields``
|
||||
from the match key (LLDP-style). If that would collapse distinct rows
|
||||
(OSPF/VRRP/config where the same id appears on many interfaces), keep
|
||||
iface columns so identity compare stays correct.
|
||||
|
||||
``field_rules`` drives normalize / numeric tolerance / per-field compare mode
|
||||
(template-driven; no metric-specific branches here).
|
||||
|
|
@ -89,24 +116,49 @@ def compare_rows(
|
|||
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.
|
||||
norm_rules = normalize_iface_rules(iface_normalize_rules)
|
||||
iface_list = [str(f) for f in (iface_fields or []) if str(f).strip()]
|
||||
iface_set = set(iface_list)
|
||||
|
||||
before_norm = apply_iface_normalize_rows(
|
||||
before_rows, iface_fields=iface_list, rules=norm_rules
|
||||
)
|
||||
after_norm = apply_iface_normalize_rows(
|
||||
after_rows, iface_fields=iface_list, rules=norm_rules
|
||||
)
|
||||
|
||||
ignore_port_changes = False
|
||||
# No map → optionally ignore port renames by dropping iface from match key.
|
||||
if not pmap and iface_set:
|
||||
match_keys = [k for k in key_fields if k not in iface_set]
|
||||
if not match_keys:
|
||||
candidate = [k for k in key_fields if k not in iface_set]
|
||||
if not candidate:
|
||||
match_keys = list(key_fields)
|
||||
else:
|
||||
before_c = [row_key(r, candidate) for r in before_norm]
|
||||
after_c = [row_key(r, candidate) for r in after_norm]
|
||||
if len(before_c) == len(set(before_c)) and len(after_c) == len(set(after_c)):
|
||||
match_keys = candidate
|
||||
ignore_port_changes = True
|
||||
else:
|
||||
# Remaining keys are not unique — iface is required for identity.
|
||||
match_keys = list(key_fields)
|
||||
else:
|
||||
match_keys = list(key_fields)
|
||||
|
||||
before_mapped: list[dict[str, Any]] = [
|
||||
apply_port_map(r, iface_fields=iface_fields, port_map=pmap) for r in before_rows
|
||||
apply_port_map(r, iface_fields=iface_list, port_map=pmap) for r in before_norm
|
||||
]
|
||||
|
||||
after_index: dict[tuple[str, ...], dict[str, Any]] = {}
|
||||
for r in after_rows:
|
||||
after_index[row_key(r, match_keys)] = r
|
||||
after_dup = 0
|
||||
for r in after_norm:
|
||||
k = row_key(r, match_keys)
|
||||
if k in after_index:
|
||||
after_dup += 1
|
||||
after_index[k] = r
|
||||
|
||||
before_keys: set[tuple[str, ...]] = set()
|
||||
before_dup = 0
|
||||
diffs: list[dict[str, Any]] = []
|
||||
added = removed = changed = unchanged = 0
|
||||
|
||||
|
|
@ -115,6 +167,8 @@ def compare_rows(
|
|||
|
||||
for orig, mapped in zip(before_rows, before_mapped):
|
||||
k = row_key(mapped, match_keys)
|
||||
if k in before_keys:
|
||||
before_dup += 1
|
||||
before_keys.add(k)
|
||||
after = after_index.get(k)
|
||||
if after is None:
|
||||
|
|
@ -184,13 +238,13 @@ def compare_rows(
|
|||
)
|
||||
|
||||
stats = mapping_stats(
|
||||
before_rows=before_rows,
|
||||
after_rows=after_rows,
|
||||
iface_fields=iface_fields,
|
||||
before_rows=before_norm,
|
||||
after_rows=after_norm,
|
||||
iface_fields=iface_list,
|
||||
port_map=pmap,
|
||||
)
|
||||
if not pmap:
|
||||
stats = {**stats, "ok": True, "ignore_port_changes": True}
|
||||
stats = {**stats, "ok": True, "ignore_port_changes": ignore_port_changes}
|
||||
return {
|
||||
"summary": {
|
||||
"before_count": len(before_rows),
|
||||
|
|
@ -200,6 +254,8 @@ def compare_rows(
|
|||
"changed": changed,
|
||||
"unchanged": unchanged,
|
||||
"match_key_fields": match_keys,
|
||||
"duplicate_keys_before": before_dup,
|
||||
"duplicate_keys_after": after_dup,
|
||||
},
|
||||
"diffs": diffs,
|
||||
"mapping_stats": stats,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue