Allow compare templates to split one collected metric into filtered sheets.

BGP/ISIS (and any other metric) can share a metric_id with distinct sheet_id + row_filters; drop conflicting bgp state_or_pfx and keep state/pfx_rcd.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-19 01:46:03 +08:00
parent 88b7558a48
commit a65c9fc420
17 changed files with 745 additions and 230 deletions

View file

@ -79,9 +79,10 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
if not isinstance(it, dict): if not isinstance(it, dict):
continue continue
mid = str(it.get("metric_id") or "").strip() mid = str(it.get("metric_id") or "").strip()
if not mid: sid = str(it.get("sheet_id") or "").strip() or mid
if not sid:
continue continue
bucket = out.setdefault(mid, set()) bucket = out.setdefault(sid, set())
if it.get("key") is not None: if it.get("key") is not None:
k = it.get("key") k = it.get("key")
if isinstance(k, (list, tuple)): if isinstance(k, (list, tuple)):
@ -747,10 +748,20 @@ def expect_keys_for_metric(
*, *,
metric_id: str, metric_id: str,
iface_fields: list[str], iface_fields: list[str],
sheet_id: str = "",
) -> set[str]: ) -> set[str]:
keys = set(expect.get(metric_id) or ()) """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 # ports: shorthand is only for interface_brief, not every sheet with iface fields
if metric_id == PORT_METRIC_ID and expect.get("_ports"): if mid == PORT_METRIC_ID and expect.get("_ports"):
keys |= set(expect["_ports"]) keys |= set(expect["_ports"])
return keys return keys
@ -879,15 +890,34 @@ def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]:
return dict(diff.get("after") or {}) 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( def override_for_metric(
sheet_overrides: list[dict[str, Any]] | None, sheet_overrides: list[dict[str, Any]] | None,
metric_id: str, metric_id: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
mid = str(metric_id or "").strip() mid = str(metric_id or "").strip()
for ov in sheet_overrides or []: return override_for_sheet(sheet_overrides, sheet_id=mid, metric_id=mid)
if isinstance(ov, dict) and str(ov.get("metric_id") or "").strip() == mid:
return ov
return {}
def evaluate_metric_dual( def evaluate_metric_dual(
@ -907,9 +937,13 @@ def evaluate_metric_dual(
field_rules: list[dict[str, Any]] | None = None, field_rules: list[dict[str, Any]] | None = None,
sheet_override: dict[str, Any] | None = None, sheet_override: dict[str, Any] | None = None,
out_of_expect: str = "strict", out_of_expect: str = "strict",
sheet_id: str = "",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge.""" """Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge."""
expect_keys = expect_keys_for_metric(expect, metric_id=metric_id, iface_fields=iface_fields) 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 {} ov = sheet_override or {}
if ov.get("skip_dual"): if ov.get("skip_dual"):
return { return {
@ -1155,6 +1189,8 @@ def evaluate_metric_dual(
def port_sheet_def() -> dict[str, Any]: def port_sheet_def() -> dict[str, Any]:
"""Default sheet for port-status cutover monitor (interface_brief only).""" """Default sheet for port-status cutover monitor (interface_brief only)."""
return { return {
"sheet_id": PORT_METRIC_ID,
"title": PORT_METRIC_ID,
"metric_id": PORT_METRIC_ID, "metric_id": PORT_METRIC_ID,
"key_fields": ["interface"], "key_fields": ["interface"],
"iface_fields": ["interface"], "iface_fields": ["interface"],

View file

@ -127,68 +127,89 @@ def _success_stateful(
} }
def preset_override_for_metric(metric_id: str) -> dict[str, Any]: def preset_override_for_metric(metric_id: str, *, sheet_id: str = "") -> dict[str, Any]:
"""Default dual-verdict overlay for a compare-template sheet.""" """Default dual-verdict overlay for a compare-template sheet.
``metric_id`` is the collected source table; ``sheet_id`` identifies a
filtered split (e.g. bgp_peer.vpnv4). Presets key off the source metric.
"""
mid = str(metric_id or "").strip() mid = str(metric_id or "").strip()
if mid == PORT_METRIC_ID: sid = str(sheet_id or "").strip()
# Callers may pass only a split id like "bgp_peer.vpnv4"
if not mid and sid:
mid = sid.split(".", 1)[0]
if not sid:
sid = mid
look = mid.split(".", 1)[0] if "." in mid else mid
def _out(body: dict[str, Any]) -> dict[str, Any]:
body["metric_id"] = mid or look
if sid and sid != (mid or look):
body["sheet_id"] = sid
return body
if look == PORT_METRIC_ID:
up = [ up = [
{"type": "value", "field": "admin", "op": "in", "value": ["up"]}, {"type": "value", "field": "admin", "op": "in", "value": ["up"]},
{"type": "value", "field": "phy", "op": "in", "value": ["up"]}, {"type": "value", "field": "phy", "op": "in", "value": ["up"]},
] ]
return { return _out(
"metric_id": mid, {
"status_fields": list(PORT_STATUS_FIELDS), "status_fields": list(PORT_STATUS_FIELDS),
"down_values": ["down"], "down_values": ["down"],
"up_values": ["up"], "up_values": ["up"],
"success": [ "success": [
_success_stateful( _success_stateful(
old_down_groups=[ old_down_groups=[
[ [
{"type": "value", "field": "admin", "op": "in", "value": ["down"]}, {"type": "value", "field": "admin", "op": "in", "value": ["down"]},
{"type": "value", "field": "phy", "op": "in", "value": ["down"]}, {"type": "value", "field": "phy", "op": "in", "value": ["down"]},
] ]
], ],
new_up_conds=up, new_up_conds=up,
) )
], ],
"anomaly": _default_anomaly_for_state("admin", ["down"]), "anomaly": _default_anomaly_for_state("admin", ["down"]),
} }
if mid == "bgp_peer": )
if look == "bgp_peer":
up = [{"type": "value", "field": "state", "op": "eq", "value": "established"}] up = [{"type": "value", "field": "state", "op": "eq", "value": "established"}]
return { return _out(
"metric_id": mid, {
"status_fields": ["state"], "status_fields": ["state"],
"down_values": ["idle", "active", "connect", "down"], "down_values": ["idle", "active", "connect", "down"],
"up_values": ["established"], "up_values": ["established"],
"success": [ "success": [
_success_stateful( _success_stateful(
old_down_groups=[ old_down_groups=[
[ [
{ {
"type": "value", "type": "value",
"field": "state", "field": "state",
"op": "in", "op": "in",
"value": ["idle", "active", "connect", "down"], "value": ["idle", "active", "connect", "down"],
} }
] ]
], ],
new_up_conds=up, new_up_conds=up,
) )
], ],
"anomaly": _default_anomaly_for_state( "anomaly": _default_anomaly_for_state(
"state", ["idle", "active", "connect", "down"] "state", ["idle", "active", "connect", "down"]
), ),
} }
if mid in ("arp", "nd6_cache", "lldp_neighbor"): )
return { if look in ("arp", "nd6_cache", "lldp_neighbor"):
"metric_id": mid, return _out(
"status_fields": [], {
"down_values": [], "status_fields": [],
"up_values": [], "down_values": [],
"success": [_success_presence_migrate()], "up_values": [],
"anomaly": _default_anomaly_presence_only(), "success": [_success_presence_migrate()],
} "anomaly": _default_anomaly_presence_only(),
if "isis" in mid or "ospf" in mid or "adjacency" in mid: }
)
if "isis" in look or "ospf" in look or "adjacency" in look:
up = [ up = [
{ {
"type": "value", "type": "value",
@ -197,45 +218,48 @@ def preset_override_for_metric(metric_id: str) -> dict[str, Any]:
"value": ["up", "full", "2way"], "value": ["up", "full", "2way"],
} }
] ]
return { return _out(
"metric_id": mid, {
"status_fields": ["state"], "status_fields": ["state"],
"down_values": ["down", "init", "idle"], "down_values": ["down", "init", "idle"],
"up_values": ["up", "full", "2way"], "up_values": ["up", "full", "2way"],
"success": [ "success": [
_success_stateful( _success_stateful(
old_down_groups=[ old_down_groups=[
[ [
{ {
"type": "value", "type": "value",
"field": "state", "field": "state",
"op": "in", "op": "in",
"value": ["down", "init", "idle"], "value": ["down", "init", "idle"],
} }
] ]
], ],
new_up_conds=up, new_up_conds=up,
) )
], ],
"anomaly": _default_anomaly_for_state("state", ["down", "init", "idle"]), "anomaly": _default_anomaly_for_state("state", ["down", "init", "idle"]),
} }
if "route" in mid or "vrf" in mid: )
return { if "route" in look or "vrf" in look:
"metric_id": mid, return _out(
{
"status_fields": [],
"down_values": [],
"up_values": [],
"success": [_success_presence_migrate()],
"anomaly": _default_anomaly_presence_only(),
}
)
return _out(
{
"status_fields": [], "status_fields": [],
"down_values": [], "down_values": [],
"up_values": [], "up_values": [],
"success": [_success_presence_migrate()], "success": [_success_presence_migrate()],
"anomaly": _default_anomaly_presence_only(), "anomaly": _default_anomaly_presence_only(),
} }
return { )
"metric_id": mid,
"status_fields": [],
"down_values": [],
"up_values": [],
"success": [_success_presence_migrate()],
"anomaly": _default_anomaly_presence_only(),
}
def ensure_default_monitor_templates(db: Session) -> None: def ensure_default_monitor_templates(db: Session) -> None:
@ -264,7 +288,14 @@ def ensure_default_monitor_templates(db: Session) -> None:
] ]
if zte: if zte:
zte_sheets = cmp_svc.template_metrics(zte) zte_sheets = cmp_svc.template_metrics(zte)
zte_overrides = [preset_override_for_metric(str(s.get("metric_id") or "")) for s in zte_sheets if s.get("metric_id")] zte_overrides = [
preset_override_for_metric(
str(s.get("metric_id") or ""),
sheet_id=str(s.get("sheet_id") or s.get("metric_id") or ""),
)
for s in zte_sheets
if s.get("metric_id")
]
seeds.append( seeds.append(
BizMonitorTemplate( BizMonitorTemplate(
id=uuid4().hex, id=uuid4().hex,

View file

@ -9,7 +9,7 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..biz_state.compare_rules import apply_row_filters from ..biz_state.compare_rules import apply_row_filters
from ..biz_state.compare_service import _load_metric_rows, _port_map_dict, template_metrics from ..biz_state.compare_service import _load_metric_rows, _port_map_dict, sheet_key, sheet_title, template_metrics
from ..models import ( from ..models import (
BizCompareTemplate, BizCompareTemplate,
BizMigrationBatch, BizMigrationBatch,
@ -27,7 +27,7 @@ from . import monitor_templates as mon_tpl
from .evaluate import ( from .evaluate import (
PORT_METRIC_ID, PORT_METRIC_ID,
evaluate_metric_dual, evaluate_metric_dual,
override_for_metric, override_for_sheet,
parse_expect_set, parse_expect_set,
port_sheet_def, port_sheet_def,
) )
@ -124,7 +124,12 @@ def resolve_collect_metric_ids(db: Session, proj: BizMigrationProject) -> list[s
if collect: if collect:
return collect return collect
sheets, _, _ = resolve_evaluate_sheets(db, mt) sheets, _, _ = resolve_evaluate_sheets(db, mt)
return [str(s.get("metric_id") or "").strip() for s in sheets if str(s.get("metric_id") or "").strip()] seen: list[str] = []
for s in sheets:
mid = str(s.get("metric_id") or "").strip()
if mid and mid not in seen:
seen.append(mid)
return seen
def project_to_dict(db: Session, p: BizMigrationProject) -> dict[str, Any]: def project_to_dict(db: Session, p: BizMigrationProject) -> dict[str, Any]:
@ -372,6 +377,7 @@ def run_evaluate(
for sheet in sheets: for sheet in sheets:
mid = str(sheet.get("metric_id") or "").strip() mid = str(sheet.get("metric_id") or "").strip()
sid = sheet_key(sheet)
key_fields = list(sheet.get("key_fields") or []) key_fields = list(sheet.get("key_fields") or [])
if not mid or not key_fields: if not mid or not key_fields:
continue continue
@ -379,7 +385,7 @@ def run_evaluate(
compare_fields = list(sheet.get("compare_fields") or []) compare_fields = list(sheet.get("compare_fields") or [])
row_filters = list(sheet.get("row_filters") or []) row_filters = list(sheet.get("row_filters") or [])
field_rules = list(sheet.get("field_rules") or []) field_rules = list(sheet.get("field_rules") or [])
sheet_ov = override_for_metric(sheet_overrides, mid) sheet_ov = override_for_sheet(sheet_overrides, sheet_id=sid, metric_id=mid)
if sheet_ov.get("skip_dual"): if sheet_ov.get("skip_dual"):
continue continue
@ -402,6 +408,7 @@ def run_evaluate(
one = evaluate_metric_dual( one = evaluate_metric_dual(
metric_id=mid, metric_id=mid,
sheet_id=sid,
key_fields=key_fields, key_fields=key_fields,
iface_fields=iface_fields, iface_fields=iface_fields,
compare_fields=compare_fields, compare_fields=compare_fields,
@ -420,7 +427,8 @@ def run_evaluate(
sheet_cards.append( sheet_cards.append(
{ {
"metric_id": mid, "metric_id": mid,
"title": mid, "sheet_id": sid,
"title": sheet_title(sheet),
"progress_ok": one["progress_ok"], "progress_ok": one["progress_ok"],
"progress_total": one["progress_total"], "progress_total": one["progress_total"],
"anomaly": one["anomaly"], "anomaly": one["anomaly"],
@ -433,6 +441,7 @@ def run_evaluate(
) )
for r in one["rows"]: for r in one["rows"]:
r["seq"] = seq r["seq"] = seq
r["sheet_id"] = sid
seq += 1 seq += 1
all_rows.append(r) all_rows.append(r)
v = str(r.get("verdict") or "") v = str(r.get("verdict") or "")
@ -460,7 +469,7 @@ def run_evaluate(
"anomaly": sum(c["anomaly"] for c in sheet_cards), "anomaly": sum(c["anomaly"] for c in sheet_cards),
"new_baseline_missing": any(bool(c.get("new_baseline_missing")) for c in sheet_cards), "new_baseline_missing": any(bool(c.get("new_baseline_missing")) for c in sheet_cards),
"missing_metrics": [ "missing_metrics": [
str(c.get("metric_id") or "") str(c.get("title") or c.get("sheet_id") or c.get("metric_id") or "")
for c in sheet_cards for c in sheet_cards
if c.get("new_baseline_missing") if c.get("new_baseline_missing")
], ],
@ -499,6 +508,7 @@ def run_evaluate(
"old_status": r.get("old_status"), "old_status": r.get("old_status"),
"new_status": r.get("new_status"), "new_status": r.get("new_status"),
"rule_hit": r.get("rule_hit") or "", "rule_hit": r.get("rule_hit") or "",
"sheet_id": r.get("sheet_id") or "",
}, },
old_kind=str(r.get("old_kind") or ""), old_kind=str(r.get("old_kind") or ""),
new_kind=str(r.get("new_kind") or ""), new_kind=str(r.get("new_kind") or ""),
@ -545,6 +555,7 @@ def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]:
return { return {
"id": d.id, "id": d.id,
"metric_id": d.metric_id, "metric_id": d.metric_id,
"sheet_id": kj.get("sheet_id") or d.metric_id or "",
"seq": d.seq, "seq": d.seq,
"verdict": d.verdict, "verdict": d.verdict,
"color": d.color, "color": d.color,
@ -610,10 +621,11 @@ def list_baseline_expect_objects(db: Session, project_id: str) -> dict[str, Any]
out_sheets: list[dict[str, Any]] = [] out_sheets: list[dict[str, Any]] = []
for sheet in sheets: for sheet in sheets:
mid = str(sheet.get("metric_id") or "").strip() mid = str(sheet.get("metric_id") or "").strip()
sid = sheet_key(sheet)
key_fields = [str(k) for k in (sheet.get("key_fields") or []) if str(k).strip()] key_fields = [str(k) for k in (sheet.get("key_fields") or []) if str(k).strip()]
if not mid or not key_fields: if not mid or not key_fields:
continue continue
sheet_ov = override_for_metric(sheet_overrides, mid) sheet_ov = override_for_sheet(sheet_overrides, sheet_id=sid, metric_id=mid)
if sheet_ov.get("skip_dual"): if sheet_ov.get("skip_dual"):
continue continue
iface_fields = [str(k) for k in (sheet.get("iface_fields") or []) if str(k).strip()] iface_fields = [str(k) for k in (sheet.get("iface_fields") or []) if str(k).strip()]
@ -643,6 +655,8 @@ def list_baseline_expect_objects(db: Session, project_id: str) -> dict[str, Any]
out_sheets.append( out_sheets.append(
{ {
"metric_id": mid, "metric_id": mid,
"sheet_id": sid,
"title": sheet_title(sheet),
"key_fields": key_fields, "key_fields": key_fields,
"iface_fields": iface_fields, "iface_fields": iface_fields,
"items": items, "items": items,

View file

@ -44,6 +44,9 @@ def eval_leaf_filter(row: Mapping[str, Any], filt: Mapping[str, Any]) -> bool:
if op in ("not_in", "nin"): if op in ("not_in", "nin"):
opts = {str(x).strip().lower() for x in _as_list(expect) if str(x).strip()} opts = {str(x).strip().lower() for x in _as_list(expect) if str(x).strip()}
return raw.lower() not in opts 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": if op == "empty":
return not raw return not raw
if op in ("not_empty", "nonempty"): if op in ("not_empty", "nonempty"):

View file

@ -225,6 +225,8 @@ def _sheet_meta_from_summary(summary: dict[str, Any], run: BizCompareRun, tpl: A
return [ return [
{ {
"metric_id": run.metric_id, "metric_id": run.metric_id,
"sheet_id": run.metric_id,
"title": run.metric_id,
"key_fields": list((tpl.key_fields if tpl else None) or []), "key_fields": list((tpl.key_fields if tpl else None) or []),
"iface_fields": list((tpl.iface_fields if tpl else None) or []), "iface_fields": list((tpl.iface_fields if tpl else None) or []),
"compare_fields": list((tpl.compare_fields if tpl else None) or []), "compare_fields": list((tpl.compare_fields if tpl else None) or []),
@ -285,10 +287,23 @@ def _normalize_field_rules(raw: Any) -> list[dict[str, Any]]:
return out return out
def sheet_key(sheet: dict[str, Any] | None) -> str:
"""Unique compare-item id. Falls back to metric_id so old sheets stay valid."""
data = sheet or {}
return str(data.get("sheet_id") or data.get("metric_id") or "").strip()
def sheet_title(sheet: dict[str, Any] | None) -> str:
data = sheet or {}
return str(data.get("title") or "").strip() or sheet_key(data)
def _sheet_def( def _sheet_def(
*, *,
metric_id: str, metric_id: str,
key_fields: list[str], key_fields: list[str],
sheet_id: str | None = None,
title: str | None = None,
iface_fields: list[str] | None = None, iface_fields: list[str] | None = None,
compare_fields: list[str] | None = None, compare_fields: list[str] | None = None,
display_fields: list[str] | None = None, display_fields: list[str] | None = None,
@ -296,6 +311,8 @@ def _sheet_def(
field_rules: list[dict[str, Any]] | None = None, field_rules: list[dict[str, Any]] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
mid = str(metric_id or "").strip() mid = str(metric_id or "").strip()
sid = str(sheet_id or "").strip() or mid
ttl = str(title or "").strip() or sid
keys = _str_list(key_fields) keys = _str_list(key_fields)
ifaces = _str_list(iface_fields) ifaces = _str_list(iface_fields)
# compare_fields empty → presence-only mode (intentional) # compare_fields empty → presence-only mode (intentional)
@ -320,6 +337,8 @@ def _sheet_def(
display_fields=_str_list(display_fields), display_fields=_str_list(display_fields),
) )
sheet: dict[str, Any] = { sheet: dict[str, Any] = {
"sheet_id": sid,
"title": ttl,
"metric_id": mid, "metric_id": mid,
"key_fields": keys, "key_fields": keys,
"iface_fields": ifaces, "iface_fields": ifaces,
@ -389,13 +408,78 @@ def _default_sheet_for_metric(metric_id: str, *, compare_roles: tuple[str, ...]
) )
def _sheets_split_by_field(
metric_id: str,
field: str,
slices: tuple[tuple[str, str, str], ...],
*,
op: str = "eq",
compare_roles: tuple[str, ...] = ("state",),
) -> list[dict[str, Any]]:
"""One collected metric → many compare sheets, each a row_filter slice.
``slices`` is ``(sheet_id, title, filter_value)``. Any metric can be split
this way (BGP afi, ISIS af, …); the engine does not special-case names.
"""
out: list[dict[str, Any]] = []
for sid, title, value in slices:
base = _default_sheet_for_metric(metric_id, compare_roles=compare_roles)
out.append(
_sheet_def(
metric_id=metric_id,
sheet_id=sid,
title=title,
key_fields=list(base.get("key_fields") or []),
iface_fields=list(base.get("iface_fields") or []),
compare_fields=list(base.get("compare_fields") or []),
display_fields=list(base.get("display_fields") or []),
row_filters=[{"field": field, "op": op, "value": value}],
field_rules=list(base.get("field_rules") or []),
)
)
return out
def _bgp_afi_sheets() -> list[dict[str, Any]]:
return _sheets_split_by_field(
"bgp_peer",
"afi",
(
("bgp_peer.ipv4", "BGP IPv4", "ipv4"),
("bgp_peer.ipv6", "BGP IPv6", "ipv6"),
("bgp_peer.vpnv4", "BGP VPNv4", "vpnv4"),
("bgp_peer.vpnv6", "BGP VPNv6", "vpnv6"),
),
op="eq",
)
def _isis_af_sheets() -> list[dict[str, Any]]:
return _sheets_split_by_field(
"isis_adjacency",
"af",
(
("isis_adjacency.ipv4", "ISIS IPv4", "IPv4"),
("isis_adjacency.ipv6", "ISIS IPv6", "IPv6"),
),
op="contains",
)
def _builtin_source_splits() -> dict[str, list[dict[str, Any]]]:
return {
"bgp_peer": _bgp_afi_sheets(),
"isis_adjacency": _isis_af_sheets(),
}
def _default_zte_status_sheets() -> list[dict[str, Any]]: def _default_zte_status_sheets() -> list[dict[str, Any]]:
return [ return [
_default_sheet_for_metric("isis_adjacency", compare_roles=("state",)), *_isis_af_sheets(),
_default_sheet_for_metric("interface_brief", compare_roles=("state",)), _default_sheet_for_metric("interface_brief", compare_roles=("state",)),
_default_sheet_for_metric("arp", compare_roles=("state",)), _default_sheet_for_metric("arp", compare_roles=("state",)),
_default_sheet_for_metric("nd6_cache", compare_roles=("state",)), _default_sheet_for_metric("nd6_cache", compare_roles=("state",)),
_default_sheet_for_metric("bgp_peer", compare_roles=("state",)), *_bgp_afi_sheets(),
] ]
@ -421,6 +505,8 @@ def _normalize_sheet(raw: Any) -> dict[str, Any] | None:
disp_arg = None disp_arg = None
return _sheet_def( return _sheet_def(
metric_id=mid, metric_id=mid,
sheet_id=str(raw.get("sheet_id") or "").strip() or mid,
title=str(raw.get("title") or "").strip() or None,
key_fields=keys, key_fields=keys,
iface_fields=_str_list(raw.get("iface_fields")), iface_fields=_str_list(raw.get("iface_fields")),
compare_fields=_str_list(raw.get("compare_fields")), compare_fields=_str_list(raw.get("compare_fields")),
@ -458,10 +544,10 @@ def template_metrics(t: BizCompareTemplate) -> list[dict[str, Any]]:
sheet = _normalize_sheet(item) sheet = _normalize_sheet(item)
if not sheet: if not sheet:
continue continue
mid = sheet["metric_id"] sid = sheet_key(sheet)
if mid in seen: if sid in seen:
continue continue
seen.add(mid) seen.add(sid)
out.append(sheet) out.append(sheet)
if out: if out:
return out return out
@ -494,10 +580,10 @@ def _parse_metrics_body(body: dict[str, Any]) -> list[dict[str, Any]]:
sheet = _normalize_sheet(raw) sheet = _normalize_sheet(raw)
if not sheet: if not sheet:
continue continue
mid = sheet["metric_id"] sid = sheet_key(sheet)
if mid in seen: if sid in seen:
raise HTTPException(status_code=400, detail=f"duplicate_metric:{mid}") raise HTTPException(status_code=400, detail=f"duplicate_sheet:{sid}")
seen.add(mid) seen.add(sid)
sheets.append(sheet) sheets.append(sheet)
if not sheets: if not sheets:
raise HTTPException(status_code=400, detail="metrics_required") raise HTTPException(status_code=400, detail="metrics_required")
@ -524,6 +610,8 @@ def _parse_metrics_body(body: dict[str, Any]) -> list[dict[str, Any]]:
return [ return [
_sheet_def( _sheet_def(
metric_id=mid, metric_id=mid,
sheet_id=str(body.get("sheet_id") or "").strip() or mid,
title=str(body.get("title") or "").strip() or None,
key_fields=keys, key_fields=keys,
iface_fields=_str_list(body.get("iface_fields")), iface_fields=_str_list(body.get("iface_fields")),
compare_fields=compare, compare_fields=compare,
@ -541,7 +629,7 @@ def _template_out(t: BizCompareTemplate) -> dict[str, Any]:
"id": t.id, "id": t.id,
"name": t.name, "name": t.name,
"metrics": sheets, "metrics": sheets,
"metric_ids": [s["metric_id"] for s in sheets], "metric_ids": list(dict.fromkeys(s["metric_id"] for s in sheets if s.get("metric_id"))),
# legacy mirrors (first sheet) # legacy mirrors (first sheet)
"metric_id": (first or {}).get("metric_id") or t.metric_id or "", "metric_id": (first or {}).get("metric_id") or t.metric_id or "",
"key_fields": list((first or {}).get("key_fields") or t.key_fields or []), "key_fields": list((first or {}).get("key_fields") or t.key_fields or []),
@ -698,8 +786,44 @@ def ensure_default_templates(db: Session) -> None:
ensure_default_zte_status_template(db) ensure_default_zte_status_template(db)
def upgrade_builtin_split_sheets(db: Session) -> None:
"""Split unfiltered whole-table sheets on the built-in ZTE template only.
A sheet is replaced when its id is still the source metric and it has no
row filters. Custom templates and already-split sheets are left alone.
"""
row = (
db.query(BizCompareTemplate)
.filter(BizCompareTemplate.name == "ZTE status default")
.one_or_none()
)
if not row:
return
existing = template_metrics(row)
splits = _builtin_source_splits()
out: list[dict[str, Any]] = []
changed = False
replaced: set[str] = set()
for s in existing:
mid = str(s.get("metric_id") or "")
if mid in splits and sheet_key(s) == mid and not list(s.get("row_filters") or []):
if mid not in replaced:
out.extend(splits[mid])
replaced.add(mid)
changed = True
continue
out.append(s)
if not changed or not out:
return
_apply_sheets_to_row(row, out)
row.note = "Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP, address-family sheets)"
row.updated_at = _utcnow()
db.commit()
def list_templates(db: Session) -> list[dict[str, Any]]: def list_templates(db: Session) -> list[dict[str, Any]]:
ensure_default_templates(db) ensure_default_templates(db)
upgrade_builtin_split_sheets(db)
rows = db.query(BizCompareTemplate).order_by(BizCompareTemplate.name.asc()).all() rows = db.query(BizCompareTemplate).order_by(BizCompareTemplate.name.asc()).all()
return [_template_out(t) for t in rows] return [_template_out(t) for t in rows]
@ -1143,6 +1267,8 @@ def _run_sheet(
summary["after_raw_count"] = len(after_raw) summary["after_raw_count"] = len(after_raw)
summary["row_filters"] = len(row_filters) summary["row_filters"] = len(row_filters)
return { return {
"sheet_id": sheet_key(sheet),
"title": sheet_title(sheet),
"metric_id": sheet["metric_id"], "metric_id": sheet["metric_id"],
"key_fields": key_fields, "key_fields": key_fields,
"iface_fields": iface_fields, "iface_fields": iface_fields,
@ -1198,7 +1324,7 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
s = one["summary"] s = one["summary"]
for k in agg: for k in agg:
agg[k] += int(s.get(k) or 0) agg[k] += int(s.get(k) or 0)
mapping_by_metric[one["metric_id"]] = one["mapping_stats"] mapping_by_metric[sheet_key(one)] = one["mapping_stats"]
first = sheet_results[0] first = sheet_results[0]
field_counts: dict[str, int] = {} field_counts: dict[str, int] = {}
@ -1221,6 +1347,8 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
"top_changed_fields": top_fields, "top_changed_fields": top_fields,
"sheets": [ "sheets": [
{ {
"sheet_id": s.get("sheet_id") or s["metric_id"],
"title": s.get("title") or s.get("sheet_id") or s["metric_id"],
"metric_id": s["metric_id"], "metric_id": s["metric_id"],
"key_fields": s["key_fields"], "key_fields": s["key_fields"],
"iface_fields": s["iface_fields"], "iface_fields": s["iface_fields"],
@ -1255,7 +1383,7 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
_persist_sheet_diffs( _persist_sheet_diffs(
db, db,
run_id=run_id, run_id=run_id,
metric_id=str(s["metric_id"]), metric_id=sheet_key(s),
diffs=list(s.get("diffs") or []), diffs=list(s.get("diffs") or []),
) )
j.updated_at = _utcnow() j.updated_at = _utcnow()
@ -1352,6 +1480,8 @@ def _enrich_summary(summary: dict[str, Any], sheets: list[dict[str, Any]]) -> di
sj = sf + su sj = sf + su
sheet_cards.append( sheet_cards.append(
{ {
"sheet_id": sheet_key(sh),
"title": sheet_title(sh),
"metric_id": sh.get("metric_id") or "", "metric_id": sh.get("metric_id") or "",
"mode": sh.get("mode") or ("presence" if not sh.get("compare_fields") else "fields"), "mode": sh.get("mode") or ("presence" if not sh.get("compare_fields") else "fields"),
"added": sa, "added": sa,
@ -1413,6 +1543,8 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]:
# Never return full diffs in run detail (million-row safe) # Never return full diffs in run detail (million-row safe)
sheets = [ sheets = [
{ {
"sheet_id": sh.get("sheet_id") or sh.get("metric_id") or "",
"title": sh.get("title") or sh.get("sheet_id") or sh.get("metric_id") or "",
"metric_id": sh.get("metric_id") or "", "metric_id": sh.get("metric_id") or "",
"key_fields": list(sh.get("key_fields") or []), "key_fields": list(sh.get("key_fields") or []),
"iface_fields": list(sh.get("iface_fields") or []), "iface_fields": list(sh.get("iface_fields") or []),
@ -1459,6 +1591,20 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]:
} }
def _lookup_sheet(sheets: list[dict[str, Any]], key: str) -> dict[str, Any] | None:
"""Find a result sheet by sheet_id, or by metric_id when that source is unique."""
k = str(key or "").strip()
if not k:
return sheets[0] if sheets else None
for s in sheets:
if sheet_key(s) == k:
return s
hits = [s for s in sheets if str(s.get("metric_id") or "") == k]
if len(hits) == 1:
return hits[0]
return None
def list_run_diffs( def list_run_diffs(
db: Session, db: Session,
run_id: str, run_id: str,
@ -1480,7 +1626,9 @@ def list_run_diffs(
summary = dict(r.summary_json or {}) summary = dict(r.summary_json or {})
tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None
sheets = _sheet_meta_from_summary(summary, r, tpl) sheets = _sheet_meta_from_summary(summary, r, tpl)
mid = (metric_id or "").strip() or (sheets[0].get("metric_id") if sheets else r.metric_id) or "" asked = (metric_id or "").strip()
sheet = _lookup_sheet(sheets, asked) if asked else (sheets[0] if sheets else None)
mid = sheet_key(sheet) if sheet else (asked or str(r.metric_id or ""))
if _run_has_diff_rows(db, run_id): if _run_has_diff_rows(db, run_id):
q = db.query(BizCompareDiff).filter( q = db.query(BizCompareDiff).filter(
@ -1509,10 +1657,9 @@ def list_run_diffs(
} }
# Legacy: diffs embedded in summary_json / diffs_json # Legacy: diffs embedded in summary_json / diffs_json
sheet = next((s for s in sheets if str(s.get("metric_id") or "") == mid), None)
if sheet is None and sheets: if sheet is None and sheets:
sheet = sheets[0] sheet = sheets[0]
mid = str(sheet.get("metric_id") or mid) mid = sheet_key(sheet)
inline = list((sheet or {}).get("diffs") or []) inline = list((sheet or {}).get("diffs") or [])
if not inline and mid == r.metric_id: if not inline and mid == r.metric_id:
inline = list(r.diffs_json or []) inline = list(r.diffs_json or [])
@ -1556,7 +1703,9 @@ def _iter_sheet_diffs(db: Session, run_id: str, metric_id: str) -> list[dict[str
summary = dict(r.summary_json or {}) summary = dict(r.summary_json or {})
sheets = list(summary.get("sheets") or []) sheets = list(summary.get("sheets") or [])
for sh in sheets: for sh in sheets:
if str(sh.get("metric_id") or "") == metric_id: if sheet_key(sh) == metric_id or (
str(sh.get("metric_id") or "") == metric_id and sheet_key(sh) == metric_id
):
return list(sh.get("diffs") or []) return list(sh.get("diffs") or [])
if metric_id == r.metric_id: if metric_id == r.metric_id:
return list(r.diffs_json or []) return list(r.diffs_json or [])
@ -1584,17 +1733,17 @@ def export_run_zip(db: Session, run_id: str) -> bytes:
] ]
for card in list(s.get("sheet_cards") or []): for card in list(s.get("sheet_cards") or []):
manifest.append( manifest.append(
f"- {card.get('metric_id')}: diff={card.get('diff_count')} " f"- {card.get('title') or card.get('sheet_id') or card.get('metric_id')}: diff={card.get('diff_count')} "
f"pass={card.get('pass_rate')}% " f"pass={card.get('pass_rate')}% "
f"+{card.get('added')}/-{card.get('removed')}/~{card.get('changed')}/= {card.get('unchanged')}" f"+{card.get('added')}/-{card.get('removed')}/~{card.get('changed')}/= {card.get('unchanged')}"
) )
zf.writestr("manifest.txt", "\n".join(manifest) + "\n") zf.writestr("manifest.txt", "\n".join(manifest) + "\n")
for sheet in list(detail.get("sheets") or []): for sheet in list(detail.get("sheets") or []):
mid = str(sheet.get("metric_id") or "sheet") sid = sheet_key(sheet) or "sheet"
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in mid)[:80] or "sheet" safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in sid)[:80] or "sheet"
sheet_full = { sheet_full = {
**sheet, **sheet,
"diffs": _iter_sheet_diffs(db, run_id, mid), "diffs": _iter_sheet_diffs(db, run_id, sid),
} }
zf.writestr(f"tables/{safe}.csv", _sheet_csv(sheet_full)) zf.writestr(f"tables/{safe}.csv", _sheet_csv(sheet_full))
sum_lines = ["metric_id,mode,before,after,added,removed,changed,unchanged,diff_count,pass_rate"] sum_lines = ["metric_id,mode,before,after,added,removed,changed,unchanged,diff_count,pass_rate"]
@ -1603,7 +1752,7 @@ def export_run_zip(db: Session, run_id: str) -> bytes:
",".join( ",".join(
_csv_cell(x) _csv_cell(x)
for x in ( for x in (
card.get("metric_id"), card.get("title") or card.get("sheet_id") or card.get("metric_id"),
card.get("mode"), card.get("mode"),
card.get("before_count"), card.get("before_count"),
card.get("after_count"), card.get("after_count"),

View file

@ -76,7 +76,6 @@ def normalize_bgp_peer(
"up_down": m.group("up")[:32], "up_down": m.group("up")[:32],
"state": state[:64], "state": state[:64],
"pfx_rcd": pfx[:32], "pfx_rcd": pfx[:32],
"state_or_pfx": state_raw[:64],
} }
) )
return out return out

View file

@ -298,7 +298,6 @@ _BGP_PEER_FIELDS: list[FieldDef] = [
FieldDef("as_num", length=16, role="state", display_name="AS"), FieldDef("as_num", length=16, role="state", display_name="AS"),
FieldDef("state", length=64, role="state", display_name="State"), FieldDef("state", length=64, role="state", display_name="State"),
FieldDef("pfx_rcd", length=32, role="state", display_name="PfxRcd"), FieldDef("pfx_rcd", length=32, role="state", display_name="PfxRcd"),
FieldDef("state_or_pfx", length=64, role="meta", display_name="State/PfxRcd"),
FieldDef("ver", length=8, role="meta", display_name="Ver"), FieldDef("ver", length=8, role="meta", display_name="Ver"),
FieldDef("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"), FieldDef("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"),
FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"), FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"),

View file

@ -44,7 +44,7 @@ class BizMigrationBatch(Base):
batch_label: Mapped[str] = mapped_column(String(128), default="") batch_label: Mapped[str] = mapped_column(String(128), default="")
# pending | active | review | done # pending | active | review | done
status: Mapped[str] = mapped_column(String(32), default="pending", index=True) status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
# {"ports": ["gei-..."], "items": [{"metric_id":"bgp_peer","key":"..."}]} # {"ports": ["gei-..."], "items": [{"metric_id":"bgp_peer","sheet_id":"bgp_peer.vpnv4","key":"..."}]}
expect_set_json: Mapped[dict] = mapped_column(_JsonType, default=dict) expect_set_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View file

@ -205,8 +205,11 @@ class BizStateCommandOverride(Base):
class BizCompareTemplate(Base): class BizCompareTemplate(Base):
"""Compare template: one or more metric sheets with key/iface/compare roles. """Compare template: one or more metric sheets with key/iface/compare roles.
``metrics_json`` is the source of truth (list of sheet defs). Legacy ``metrics_json`` is the source of truth (list of sheet defs). Each sheet has
``metric_id`` / ``key_fields`` / … mirror the first sheet for older rows. ``metric_id`` (collected source table) and optional ``sheet_id`` / ``title`` /
``row_filters`` so one source can be split into multiple compare items
(e.g. bgp_peer → vpnv4/vpnv6). Legacy ``metric_id`` / ``key_fields`` / …
mirror the first sheet for older rows.
Empty ``compare_fields`` on a sheet = presence-only (entry set match). Empty ``compare_fields`` on a sheet = presence-only (entry set match).
""" """

View file

@ -27,6 +27,55 @@ PORT_OVERRIDE = {
class ParseExpectSetTests(unittest.TestCase): class ParseExpectSetTests(unittest.TestCase):
def test_items_sheet_id_bucket(self):
got = parse_expect_set(
{
"items": [
{"metric_id": "bgp_peer", "sheet_id": "bgp_peer.vpnv4", "key": "1.1.1.1"},
{"metric_id": "bgp_peer", "sheet_id": "bgp_peer.ipv4", "key": "2.2.2.2"},
]
}
)
self.assertEqual(got["bgp_peer.vpnv4"], {"1.1.1.1"})
self.assertEqual(got["bgp_peer.ipv4"], {"2.2.2.2"})
self.assertNotIn("bgp_peer", got)
def test_expect_keys_prefer_sheet_id(self):
expect = parse_expect_set(
{
"items": [
{"metric_id": "bgp_peer", "sheet_id": "bgp_peer.vpnv4", "key": "1.1.1.1"},
{"metric_id": "bgp_peer", "key": "9.9.9.9"},
]
}
)
self.assertEqual(
expect_keys_for_metric(
expect, metric_id="bgp_peer", iface_fields=[], sheet_id="bgp_peer.vpnv4"
),
{"1.1.1.1"},
)
self.assertEqual(
expect_keys_for_metric(expect, metric_id="bgp_peer", iface_fields=[]),
{"9.9.9.9"},
)
def test_override_for_sheet_prefers_sheet_id(self):
from netx_api.biz_migration.evaluate import override_for_sheet
overrides = [
{"metric_id": "bgp_peer", "status_fields": ["state"]},
{"metric_id": "bgp_peer", "sheet_id": "bgp_peer.vpnv4", "status_fields": ["state", "pfx_rcd"]},
]
hit = override_for_sheet(
overrides, sheet_id="bgp_peer.vpnv4", metric_id="bgp_peer"
)
self.assertEqual(hit["status_fields"], ["state", "pfx_rcd"])
legacy = override_for_sheet(
overrides, sheet_id="bgp_peer.ipv4", metric_id="bgp_peer"
)
self.assertEqual(legacy["status_fields"], ["state"])
def test_ports_go_to_interface_brief(self): def test_ports_go_to_interface_brief(self):
got = parse_expect_set({"ports": ["gei-1", "gei-2", ""]}) got = parse_expect_set({"ports": ["gei-1", "gei-2", ""]})
self.assertEqual(got["interface_brief"], {"gei-1", "gei-2"}) self.assertEqual(got["interface_brief"], {"gei-1", "gei-2"})

View file

@ -10,6 +10,7 @@ from netx_api.biz_state.compare_rules import (
arp_dynamic_row_filters, arp_dynamic_row_filters,
effective_compare_fields, effective_compare_fields,
effective_display_fields, effective_display_fields,
eval_leaf_filter,
normalize_value, normalize_value,
values_equal, values_equal,
) )
@ -314,6 +315,57 @@ class CompareDiffPagingTests(unittest.TestCase):
class CompareSheetDefaultsTests(unittest.TestCase): class CompareSheetDefaultsTests(unittest.TestCase):
def test_contains_filter_op(self) -> None:
row = {"af": "IPv4,IPv6"}
self.assertTrue(eval_leaf_filter(row, {"field": "af", "op": "contains", "value": "IPv4"}))
self.assertTrue(eval_leaf_filter(row, {"field": "af", "op": "contains", "value": "ipv6"}))
self.assertFalse(eval_leaf_filter(row, {"field": "af", "op": "contains", "value": "vpn"}))
def test_zte_default_splits_bgp_and_isis(self) -> None:
from netx_api.biz_state.compare_service import _default_zte_status_sheets, sheet_key
sheets = _default_zte_status_sheets()
ids = [sheet_key(s) for s in sheets]
self.assertIn("bgp_peer.ipv4", ids)
self.assertIn("bgp_peer.vpnv4", ids)
self.assertIn("bgp_peer.vpnv6", ids)
self.assertIn("isis_adjacency.ipv4", ids)
self.assertIn("isis_adjacency.ipv6", ids)
# Same source metric may appear multiple times
self.assertEqual(sum(1 for s in sheets if s["metric_id"] == "bgp_peer"), 4)
vpnv4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.vpnv4")
self.assertEqual(vpnv4["row_filters"], [{"field": "afi", "op": "eq", "value": "vpnv4"}])
isis4 = next(s for s in sheets if sheet_key(s) == "isis_adjacency.ipv4")
self.assertEqual(isis4["row_filters"][0]["op"], "contains")
def test_normalize_allows_duplicate_metric_with_distinct_sheet_id(self) -> None:
from netx_api.biz_state.compare_service import _normalize_sheet, sheet_key
a = _normalize_sheet(
{
"sheet_id": "bgp_peer.vpnv4",
"title": "BGP VPNv4",
"metric_id": "bgp_peer",
"key_fields": ["afi", "neighbor"],
"compare_fields": ["state"],
"row_filters": [{"field": "afi", "op": "eq", "value": "vpnv4"}],
}
)
b = _normalize_sheet(
{
"sheet_id": "bgp_peer.ipv4",
"title": "BGP IPv4",
"metric_id": "bgp_peer",
"key_fields": ["afi", "neighbor"],
"compare_fields": ["state"],
"row_filters": [{"field": "afi", "op": "eq", "value": "ipv4"}],
}
)
self.assertIsNotNone(a)
self.assertIsNotNone(b)
self.assertEqual(a["metric_id"], b["metric_id"])
self.assertNotEqual(sheet_key(a), sheet_key(b))
def test_arp_default_sheet_has_row_filters(self) -> None: def test_arp_default_sheet_has_row_filters(self) -> None:
from netx_api.biz_state.compare_service import _default_sheet_for_metric from netx_api.biz_state.compare_service import _default_sheet_for_metric

View file

@ -145,6 +145,12 @@ class ZteStatusParserTests(unittest.TestCase):
conn = [r for r in rows if r["state"] == "Connect"] conn = [r for r in rows if r["state"] == "Connect"]
self.assertGreaterEqual(len(est), 3) self.assertGreaterEqual(len(est), 3)
self.assertGreaterEqual(len(conn), 3) self.assertGreaterEqual(len(conn), 3)
self.assertTrue(all("state_or_pfx" not in r for r in rows))
self.assertTrue(all(r["pfx_rcd"].isdigit() for r in est))
self.assertTrue(all(r["pfx_rcd"] == "" for r in conn))
one = next(r for r in rows if r["neighbor"] == "10.206.63.205")
self.assertEqual(one["state"], "Established")
self.assertEqual(one["pfx_rcd"], "642")
ipv4 = _section(self.log, "show bgp ipv4 unicast summary", ("show bgp vpnv6",)) ipv4 = _section(self.log, "show bgp ipv4 unicast summary", ("show bgp vpnv6",))
rows2 = normalize_bgp_peer(raw_text=ipv4, command="show bgp ipv4 unicast summary") rows2 = normalize_bgp_peer(raw_text=ipv4, command="show bgp ipv4 unicast summary")

View file

@ -345,6 +345,11 @@ const en = {
pickMetric: "Pick metric…", pickMetric: "Pick metric…",
addSheet: "Add to template", addSheet: "Add to template",
removeSheet: "Remove sheet", removeSheet: "Remove sheet",
sheetIdRequired: "sheet_id is required (unique per split)",
duplicateSheetId: "sheet_id must be unique",
sheetTitle: "Sheet title",
sheetId: "sheet_id",
splitSheet: "Split / duplicate",
colSheets: "Sheets", colSheets: "Sheets",
sheetsUnit: "sheets", sheetsUnit: "sheets",
presenceShort: "presence", presenceShort: "presence",

View file

@ -344,6 +344,11 @@ const zh = {
pickMetric: "选择监控项…", pickMetric: "选择监控项…",
addSheet: "加入模板", addSheet: "加入模板",
removeSheet: "移出本表", removeSheet: "移出本表",
sheetIdRequired: "请填写 sheet_id(拆表唯一标识)",
duplicateSheetId: "sheet_id 不能重复",
sheetTitle: "表标题",
sheetId: "sheet_id",
splitSheet: "拆表复制",
colSheets: "监控表", colSheets: "监控表",
sheetsUnit: "张表", sheetsUnit: "张表",
presenceShort: "仅条目", presenceShort: "仅条目",

View file

@ -49,6 +49,8 @@ type MetricField = {
type MetricSchema = { metric_id: string; fields: MetricField[] }; type MetricSchema = { metric_id: string; fields: MetricField[] };
type MetricSheet = { type MetricSheet = {
sheet_id?: string;
title?: string;
metric_id: string; metric_id: string;
key_fields: string[]; key_fields: string[];
iface_fields: string[]; iface_fields: string[];
@ -111,6 +113,8 @@ type DiffRow = {
}; };
type RunSheet = { type RunSheet = {
sheet_id?: string;
title?: string;
metric_id: string; metric_id: string;
key_fields: string[]; key_fields: string[];
iface_fields: string[]; iface_fields: string[];
@ -285,12 +289,22 @@ function taskLabel(row: TaskOpt) {
return `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`; return `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`;
} }
function sheetIdentity(s: { sheet_id?: string; metric_id?: string } | null | undefined): string {
return String(s?.sheet_id || s?.metric_id || "").trim();
}
function sheetLabel(s: { title?: string; sheet_id?: string; metric_id?: string } | null | undefined): string {
return String(s?.title || s?.sheet_id || s?.metric_id || "").trim() || "—";
}
function templateSheets(tpl?: Template | null): MetricSheet[] { function templateSheets(tpl?: Template | null): MetricSheet[] {
if (!tpl) return []; if (!tpl) return [];
if (tpl.metrics?.length) return tpl.metrics; if (tpl.metrics?.length) return tpl.metrics;
if (tpl.metric_id) { if (tpl.metric_id) {
return [ return [
{ {
sheet_id: tpl.metric_id,
title: tpl.metric_id,
metric_id: tpl.metric_id, metric_id: tpl.metric_id,
key_fields: [...(tpl.key_fields || [])], key_fields: [...(tpl.key_fields || [])],
iface_fields: [...(tpl.iface_fields || [])], iface_fields: [...(tpl.iface_fields || [])],
@ -311,6 +325,8 @@ function defaultSheetForMetric(schema: MetricSchema | undefined, metricId: strin
.filter((f) => !f.is_key && (f.role === "state" || f.role === "meta")) .filter((f) => !f.is_key && (f.role === "state" || f.role === "meta"))
.map((f) => f.name); .map((f) => f.name);
const sheet: MetricSheet = { const sheet: MetricSheet = {
sheet_id: metricId,
title: metricId,
metric_id: metricId, metric_id: metricId,
key_fields, key_fields,
iface_fields: fields.filter((f) => f.is_interface).map((f) => f.name), iface_fields: fields.filter((f) => f.is_interface).map((f) => f.name),
@ -353,6 +369,8 @@ function cloneSheet(s: MetricSheet): MetricSheet {
? [...s.display_fields] ? [...s.display_fields]
: [...keys, ...compare]; : [...keys, ...compare];
return { return {
sheet_id: s.sheet_id || s.metric_id,
title: s.title || s.sheet_id || s.metric_id,
metric_id: s.metric_id, metric_id: s.metric_id,
key_fields: keys, key_fields: keys,
iface_fields: [...(s.iface_fields || [])], iface_fields: [...(s.iface_fields || [])],
@ -368,6 +386,8 @@ const TEMPLATE_EXPORT_VERSION = 1;
function templateExportPayload(tpl: Template) { function templateExportPayload(tpl: Template) {
const metrics = templateSheets(tpl).map((s) => ({ const metrics = templateSheets(tpl).map((s) => ({
sheet_id: s.sheet_id || s.metric_id,
title: s.title || s.sheet_id || s.metric_id,
metric_id: s.metric_id, metric_id: s.metric_id,
key_fields: [...(s.key_fields || [])], key_fields: [...(s.key_fields || [])],
iface_fields: [...(s.iface_fields || [])], iface_fields: [...(s.iface_fields || [])],
@ -424,6 +444,8 @@ function parseTemplateImport(raw: unknown): {
: []; : [];
if (!mid || !keys.length) continue; if (!mid || !keys.length) continue;
metrics.push({ metrics.push({
sheet_id: String(m.sheet_id || mid).trim() || mid,
title: String(m.title || m.sheet_id || mid).trim() || mid,
metric_id: mid, metric_id: mid,
key_fields: keys, key_fields: keys,
iface_fields: Array.isArray(m.iface_fields) iface_fields: Array.isArray(m.iface_fields)
@ -530,6 +552,7 @@ const FILTER_OPS = [
"ne", "ne",
"in", "in",
"not_in", "not_in",
"contains",
"empty", "empty",
"not_empty", "not_empty",
"regex", "regex",
@ -834,13 +857,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setResultSheetId(""); setResultSheetId("");
return; return;
} }
if (!resultSheetId || !runSheets.some((s) => s.metric_id === resultSheetId)) { if (!resultSheetId || !runSheets.some((s) => sheetIdentity(s) === resultSheetId)) {
setResultSheetId(runSheets[0].metric_id); setResultSheetId(sheetIdentity(runSheets[0]));
} }
}, [runSheets, resultSheetId]); }, [runSheets, resultSheetId]);
const activeRunSheet = useMemo( const activeRunSheet = useMemo(
() => runSheets.find((s) => s.metric_id === resultSheetId) || runSheets[0] || null, () => runSheets.find((s) => sheetIdentity(s) === resultSheetId) || runSheets[0] || null,
[runSheets, resultSheetId], [runSheets, resultSheetId],
); );
@ -851,7 +874,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
useEffect(() => { useEffect(() => {
const runId = String(runDetail?.id || ""); const runId = String(runDetail?.id || "");
const mid = resultSheetId || activeRunSheet?.metric_id || ""; const mid = resultSheetId || sheetIdentity(activeRunSheet) || "";
if (!runId || !mid || jobDetailTab !== "result") { if (!runId || !mid || jobDetailTab !== "result") {
setPagedDiffs([]); setPagedDiffs([]);
setResultTotal(0); setResultTotal(0);
@ -1002,6 +1025,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const resultPages = Math.max(1, Math.ceil(resultTotal / Math.max(1, resultPageSize))); const resultPages = Math.max(1, Math.ceil(resultTotal / Math.max(1, resultPageSize)));
const sheetCards = useMemo(() => { const sheetCards = useMemo(() => {
const raw = (summary.sheet_cards || []) as Array<{ const raw = (summary.sheet_cards || []) as Array<{
sheet_id?: string;
title?: string;
metric_id: string; metric_id: string;
mode?: string; mode?: string;
added?: number; added?: number;
@ -1023,7 +1048,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
return String(a.metric_id).localeCompare(String(b.metric_id)); return String(a.metric_id).localeCompare(String(b.metric_id));
}); });
}, [summary.sheet_cards]); }, [summary.sheet_cards]);
const activeSheetCard = sheetCards.find((c) => c.metric_id === resultSheetId) || sheetCards[0]; const activeSheetCard = sheetCards.find((c) => sheetIdentity(c) === resultSheetId) || sheetCards[0];
const activeFail = sheetFailOf(activeSheetCard || {}); const activeFail = sheetFailOf(activeSheetCard || {});
const activeSuccess = sheetSuccessOf(activeSheetCard || {}); const activeSuccess = sheetSuccessOf(activeSheetCard || {});
const activePassRate = sheetPassRateOf(activeSheetCard || {}); const activePassRate = sheetPassRateOf(activeSheetCard || {});
@ -1125,7 +1150,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
if (on) { if (on) {
const schema = metrics.find((m) => m.metric_id === metricId); const schema = metrics.find((m) => m.metric_id === metricId);
setTplSheets((prev) => { setTplSheets((prev) => {
if (prev.some((s) => s.metric_id === metricId)) return prev;
const next = [...prev, defaultSheetForMetric(schema, metricId)]; const next = [...prev, defaultSheetForMetric(schema, metricId)];
setTplSheetIdx(next.length - 1); setTplSheetIdx(next.length - 1);
if (!prev.length) setShowMetricPicker(false); if (!prev.length) setShowMetricPicker(false);
@ -1133,13 +1157,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}); });
return; return;
} }
// Uncheck removes ALL sheets of this source metric
setTplSheets((prev) => { setTplSheets((prev) => {
const idx = prev.findIndex((s) => s.metric_id === metricId); const next = prev.filter((s) => s.metric_id !== metricId);
if (idx < 0) return prev;
const next = prev.filter((_, i) => i !== idx);
setTplSheetIdx((cur) => { setTplSheetIdx((cur) => {
if (!next.length) return 0; if (!next.length) return 0;
if (cur > idx) return cur - 1;
if (cur >= next.length) return next.length - 1; if (cur >= next.length) return next.length - 1;
return cur; return cur;
}); });
@ -1147,6 +1169,28 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}); });
}; };
const duplicateTplSheet = (idx: number) => {
setTplSheets((prev) => {
const src = prev[idx];
if (!src) return prev;
const baseId = sheetIdentity(src);
let n = 2;
let candidate = `${baseId}.${n}`;
const used = new Set(prev.map((s) => sheetIdentity(s)));
while (used.has(candidate)) {
n += 1;
candidate = `${baseId}.${n}`;
}
const copy = cloneSheet(src);
copy.sheet_id = candidate;
copy.title = `${sheetLabel(src)} (${n})`;
const next = [...prev];
next.splice(idx + 1, 0, copy);
setTplSheetIdx(idx + 1);
return next;
});
};
const removeTplMetric = (idx: number) => { const removeTplMetric = (idx: number) => {
setTplSheets((prev) => { setTplSheets((prev) => {
const next = prev.filter((_, i) => i !== idx); const next = prev.filter((_, i) => i !== idx);
@ -1167,16 +1211,27 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
} }
for (const s of tplSheets) { for (const s of tplSheets) {
if (!s.key_fields.length) { if (!s.key_fields.length) {
showError(`${metricLabel(s.metric_id)}: ${t("bizCompare.keyRequired")}`); showError(`${sheetLabel(s)}: ${t("bizCompare.keyRequired")}`);
return; return;
} }
if (!sheetIdentity(s)) {
showError(`${s.metric_id}: ${t("bizCompare.sheetIdRequired")}`);
return;
}
}
const ids = tplSheets.map((s) => sheetIdentity(s));
if (new Set(ids).size !== ids.length) {
showError(t("bizCompare.duplicateSheetId"));
return;
} }
setBusy(true); setBusy(true);
try { try {
const body = { const body = {
name: tplName || tplSheets.map((s) => s.metric_id).join("+"), name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"),
note: tplNote, note: tplNote,
metrics: tplSheets.map((s) => ({ metrics: tplSheets.map((s) => ({
sheet_id: sheetIdentity(s),
title: s.title || sheetIdentity(s),
metric_id: s.metric_id, metric_id: s.metric_id,
key_fields: s.key_fields, key_fields: s.key_fields,
iface_fields: s.iface_fields, iface_fields: s.iface_fields,
@ -1239,6 +1294,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
name: body.name, name: body.name,
note: body.note, note: body.note,
metrics: body.metrics.map((s) => ({ metrics: body.metrics.map((s) => ({
sheet_id: sheetIdentity(s),
title: s.title || sheetIdentity(s),
metric_id: s.metric_id, metric_id: s.metric_id,
key_fields: s.key_fields, key_fields: s.key_fields,
iface_fields: s.iface_fields, iface_fields: s.iface_fields,
@ -1701,9 +1758,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</td> </td>
<td> <td>
<div className="bs-cmp-metric-chips"> <div className="bs-cmp-metric-chips">
{sheets.map((s) => ( {sheets.map((s, i) => (
<code key={s.metric_id} className="bs-cmp-metric-chip"> <code key={`${sheetIdentity(s)}-${i}`} className="bs-cmp-metric-chip">
{s.metric_id} {sheetLabel(s)}
{!s.compare_fields?.length ? ( {!s.compare_fields?.length ? (
<span className="muted"> · {t("bizCompare.presenceShort")}</span> <span className="muted"> · {t("bizCompare.presenceShort")}</span>
) : null} ) : null}
@ -1885,7 +1942,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const filterN = toOrGroups(s.row_filters || []).length; const filterN = toOrGroups(s.row_filters || []).length;
return ( return (
<button <button
key={s.metric_id} key={`${sheetIdentity(s)}-${i}`}
type="button" type="button"
role="tab" role="tab"
aria-selected={tplSheetIdx === i} aria-selected={tplSheetIdx === i}
@ -1895,7 +1952,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
setTplPaneTab("fields"); setTplPaneTab("fields");
}} }}
> >
<span className="ct-editor__nav-name">{s.metric_id}</span> <span className="ct-editor__nav-name">{sheetLabel(s)}</span>
<span className="ct-editor__nav-tag"> <span className="ct-editor__nav-tag">
{!s.compare_fields.length {!s.compare_fields.length
? t("bizCompare.presenceShort") ? t("bizCompare.presenceShort")
@ -1911,9 +1968,26 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
{activeTplSheet ? ( {activeTplSheet ? (
<div className="ct-editor__pane"> <div className="ct-editor__pane">
<div className="ct-editor__pane-head"> <div className="ct-editor__pane-head">
<div> <div style={{ flex: 1, minWidth: 0 }}>
<div className="ct-editor__metric">{activeTplSheet.metric_id}</div> <div className="ct-editor__metric-edit" style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Input
value={activeTplSheet.title || ""}
placeholder={t("bizCompare.sheetTitle")}
aria-label={t("bizCompare.sheetTitle")}
onChange={(e) => updateActiveSheet({ title: e.target.value })}
style={{ maxWidth: 220 }}
/>
<Input
value={activeTplSheet.sheet_id || activeTplSheet.metric_id}
placeholder={t("bizCompare.sheetId")}
aria-label={t("bizCompare.sheetId")}
onChange={(e) => updateActiveSheet({ sheet_id: e.target.value.trim() })}
style={{ maxWidth: 220 }}
/>
</div>
<div className="ct-editor__meta muted"> <div className="ct-editor__meta muted">
{activeTplSheet.metric_id}
{" · "}
{activeTplSheet.compare_fields.length {activeTplSheet.compare_fields.length
? t("bizCompare.modeFieldsShort") ? t("bizCompare.modeFieldsShort")
: t("bizCompare.presenceShort")} : t("bizCompare.presenceShort")}
@ -1921,9 +1995,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
Key {(activeTplSheet.key_fields || []).join(" · ") || "—"} Key {(activeTplSheet.key_fields || []).join(" · ") || "—"}
</div> </div>
</div> </div>
<Button size="sm" variant="ghost" onPress={() => removeTplMetric(tplSheetIdx)}> <div className="btn-row" style={{ gap: 6 }}>
{t("bizCompare.removeSheet")} <Button size="sm" variant="secondary" onPress={() => duplicateTplSheet(tplSheetIdx)}>
</Button> {t("bizCompare.splitSheet")}
</Button>
<Button size="sm" variant="ghost" onPress={() => removeTplMetric(tplSheetIdx)}>
{t("bizCompare.removeSheet")}
</Button>
</div>
</div> </div>
<div className="mt-rule-tabs" role="tablist"> <div className="mt-rule-tabs" role="tablist">
@ -2354,6 +2433,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const fail = removed + changed; const fail = removed + changed;
const judged = fail + unchanged; const judged = fail + unchanged;
return { return {
sheet_id: sheetIdentity(s),
title: sheetLabel(s),
metric_id: s.metric_id, metric_id: s.metric_id,
mode: s.mode, mode: s.mode,
added: s.summary?.added, added: s.summary?.added,
@ -2374,21 +2455,23 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const fail = sheetFailOf(c); const fail = sheetFailOf(c);
const ok = sheetSuccessOf(c); const ok = sheetSuccessOf(c);
const rate = sheetPassRateOf(c); const rate = sheetPassRateOf(c);
const active = resultSheetId === c.metric_id; const id = sheetIdentity(c);
const label = sheetLabel(c);
const active = resultSheetId === id;
return ( return (
<button <button
key={c.metric_id} key={id}
type="button" type="button"
role="tab" role="tab"
aria-selected={active} aria-selected={active}
className={`bs-cmp-nav__item${active ? " is-active" : ""}${ className={`bs-cmp-nav__item${active ? " is-active" : ""}${
fail > 0 ? " has-diff" : " is-clean" fail > 0 ? " has-diff" : " is-clean"
}`} }`}
onClick={() => setResultSheetId(c.metric_id)} onClick={() => setResultSheetId(id)}
title={`${c.metric_id} · ${t("bizCompare.kindFail")} ${fail} · ${t("bizCompare.kindSuccess")} ${ok} · ${t("bizCompare.passRateShort")} ${rate}%`} title={`${label} · ${t("bizCompare.kindFail")} ${fail} · ${t("bizCompare.kindSuccess")} ${ok} · ${t("bizCompare.passRateShort")} ${rate}%`}
> >
<span className="bs-cmp-nav__dot" aria-hidden /> <span className="bs-cmp-nav__dot" aria-hidden />
<span className="bs-cmp-nav__name">{c.metric_id}</span> <span className="bs-cmp-nav__name">{label}</span>
<span className="bs-cmp-nav__stats"> <span className="bs-cmp-nav__stats">
<span <span
className={`bs-cmp-nav__num bs-cmp-nav__num--fail${ className={`bs-cmp-nav__num bs-cmp-nav__num--fail${
@ -2420,9 +2503,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</span> </span>
<strong <strong
className="bs-cmp-strip__sheet-name" className="bs-cmp-strip__sheet-name"
title={activeSheetCard?.metric_id || ""} title={sheetLabel(activeSheetCard)}
> >
{activeSheetCard?.metric_id || "—"} {sheetLabel(activeSheetCard)}
</strong> </strong>
<span className="muted bs-cmp-strip__sheet-mode"> <span className="muted bs-cmp-strip__sheet-mode">
{activeFail > 0 ? t("bizCompare.kindFail") : t("bizCompare.kindPass")} {activeFail > 0 ? t("bizCompare.kindFail") : t("bizCompare.kindPass")}

View file

@ -68,7 +68,10 @@ type MigBatch = {
id: string; id: string;
batch_label: string; batch_label: string;
status: string; status: string;
expect_set?: { ports?: string[]; items?: Array<{ metric_id: string; key?: string; keys?: string[] }> }; expect_set?: {
ports?: string[];
items?: Array<{ metric_id: string; sheet_id?: string; key?: string; keys?: string[] }>;
};
accept_status?: string; accept_status?: string;
accept_run_id?: string; accept_run_id?: string;
accept_summary?: { accept_summary?: {
@ -91,6 +94,7 @@ type RedTicket = {
}; };
type SheetCard = { type SheetCard = {
metric_id: string; metric_id: string;
sheet_id?: string;
title?: string; title?: string;
progress_ok: number; progress_ok: number;
progress_total: number; progress_total: number;
@ -101,6 +105,7 @@ type SheetCard = {
type DiffRow = { type DiffRow = {
id: string; id: string;
metric_id: string; metric_id: string;
sheet_id?: string;
verdict: string; verdict: string;
color: string; color: string;
old_kind: string; old_kind: string;
@ -129,11 +134,21 @@ type ExpectSheetItem = {
}; };
type ExpectSheet = { type ExpectSheet = {
metric_id: string; metric_id: string;
sheet_id?: string;
title?: string;
key_fields: string[]; key_fields: string[];
iface_fields: string[]; iface_fields: string[];
items: ExpectSheetItem[]; items: ExpectSheetItem[];
}; };
function sheetIdentity(s: { sheet_id?: string; metric_id?: string } | null | undefined): string {
return String(s?.sheet_id || s?.metric_id || "").trim();
}
function sheetLabel(s: { title?: string; sheet_id?: string; metric_id?: string } | null | undefined): string {
return String(s?.title || s?.sheet_id || s?.metric_id || "").trim() || "—";
}
type DetailTab = "setup" | "batches" | "board"; type DetailTab = "setup" | "batches" | "board";
const COLOR: Record<string, string> = { const COLOR: Record<string, string> = {
@ -237,7 +252,11 @@ export function BizMigrationPage() {
const sheetCards = board?.run?.summary?.sheet_cards || []; const sheetCards = board?.run?.summary?.sheet_cards || [];
const visibleDiffs = useMemo(() => { const visibleDiffs = useMemo(() => {
let rows = diffs; let rows = diffs;
if (boardMetricId) rows = rows.filter((d) => d.metric_id === boardMetricId); if (boardMetricId) {
rows = rows.filter(
(d) => sheetIdentity(d) === boardMetricId || d.metric_id === boardMetricId,
);
}
if (onlyExpect) rows = rows.filter((d) => d.in_expect); if (onlyExpect) rows = rows.filter((d) => d.in_expect);
return rows; return rows;
}, [diffs, onlyExpect, boardMetricId]); }, [diffs, onlyExpect, boardMetricId]);
@ -245,10 +264,12 @@ export function BizMigrationPage() {
const boardMetricOptions = useMemo(() => { const boardMetricOptions = useMemo(() => {
const ids = new Set<string>(); const ids = new Set<string>();
for (const c of sheetCards) { for (const c of sheetCards) {
if (c.metric_id) ids.add(c.metric_id); const id = sheetIdentity(c);
if (id) ids.add(id);
} }
for (const d of diffs) { for (const d of diffs) {
if (d.metric_id) ids.add(d.metric_id); const id = sheetIdentity(d);
if (id) ids.add(id);
} }
return [...ids]; return [...ids];
}, [sheetCards, diffs]); }, [sheetCards, diffs]);
@ -288,7 +309,8 @@ export function BizMigrationPage() {
}, [batch]); }, [batch]);
const filteredExpectItems = useMemo(() => { const filteredExpectItems = useMemo(() => {
const sheet = expectSheets.find((s) => s.metric_id === expectMetricId) || expectSheets[0]; const sheet =
expectSheets.find((s) => sheetIdentity(s) === expectMetricId) || expectSheets[0];
const items = sheet?.items || []; const items = sheet?.items || [];
const kw = portFilter.trim().toLowerCase(); const kw = portFilter.trim().toLowerCase();
if (!kw) return items; if (!kw) return items;
@ -378,8 +400,8 @@ export function BizMigrationPage() {
const sheets = (res.sheets || []) as ExpectSheet[]; const sheets = (res.sheets || []) as ExpectSheet[];
setExpectSheets(sheets); setExpectSheets(sheets);
setExpectMetricId((prev) => { setExpectMetricId((prev) => {
if (prev && sheets.some((s) => s.metric_id === prev)) return prev; if (prev && sheets.some((s) => sheetIdentity(s) === prev)) return prev;
return sheets[0]?.metric_id || ""; return sheets[0] ? sheetIdentity(sheets[0]) : "";
}); });
} catch { } catch {
setExpectSheets([]); setExpectSheets([]);
@ -712,11 +734,14 @@ export function BizMigrationPage() {
async function onCreateBatch() { async function onCreateBatch() {
if (!projectId) return; if (!projectId) return;
// One item per selected key. A flat `keys: [a, b]` is parsed as one composite key. // One item per selected key. A flat `keys: [a, b]` is parsed as one composite key.
const items: Array<{ metric_id: string; key: string }> = []; const items: Array<{ metric_id: string; sheet_id?: string; key: string }> = [];
const ports: string[] = []; const ports: string[] = [];
for (const sheet of expectSheets) { for (const sheet of expectSheets) {
const keys = sheet.items.map((it) => it.key).filter((k) => selectedExpectKeys.has(k)); const keys = sheet.items.map((it) => it.key).filter((k) => selectedExpectKeys.has(k));
for (const key of keys) items.push({ metric_id: sheet.metric_id, key }); const sid = sheetIdentity(sheet);
for (const key of keys) {
items.push({ metric_id: sheet.metric_id, sheet_id: sid, key });
}
if (sheet.metric_id === "interface_brief") ports.push(...keys); if (sheet.metric_id === "interface_brief") ports.push(...keys);
} }
if (!items.length && !ports.length) { if (!items.length && !ports.length) {
@ -1416,20 +1441,30 @@ export function BizMigrationPage() {
<div className="ct-editor__nav-list" role="tablist"> <div className="ct-editor__nav-list" role="tablist">
{(expectSheets.length {(expectSheets.length
? expectSheets ? expectSheets
: [{ metric_id: "—", items: [] as ExpectSheetItem[], key_fields: [] as string[], iface_fields: [] as string[] }] : [
{
metric_id: "—",
sheet_id: "—",
title: "—",
items: [] as ExpectSheetItem[],
key_fields: [] as string[],
iface_fields: [] as string[],
},
]
).map((s) => { ).map((s) => {
const sid = sheetIdentity(s);
const active = const active =
(expectMetricId || expectSheets[0]?.metric_id) === s.metric_id; (expectMetricId || sheetIdentity(expectSheets[0])) === sid;
return ( return (
<button <button
key={s.metric_id} key={sid}
type="button" type="button"
role="tab" role="tab"
aria-selected={active} aria-selected={active}
className={`ct-editor__nav-item${active ? " is-active" : ""}`} className={`ct-editor__nav-item${active ? " is-active" : ""}`}
onClick={() => setExpectMetricId(s.metric_id)} onClick={() => setExpectMetricId(sid)}
> >
<span className="ct-editor__nav-name">{s.metric_id}</span> <span className="ct-editor__nav-name">{sheetLabel(s)}</span>
<span className="ct-editor__nav-tag">{s.items.length}</span> <span className="ct-editor__nav-tag">{s.items.length}</span>
</button> </button>
); );
@ -1605,7 +1640,7 @@ export function BizMigrationPage() {
{t("bizMigration.newBaselineMissingBanner", { {t("bizMigration.newBaselineMissingBanner", {
metrics: sheetCards metrics: sheetCards
.filter((c) => c.new_baseline_missing) .filter((c) => c.new_baseline_missing)
.map((c) => c.metric_id) .map((c) => sheetLabel(c))
.join(", "), .join(", "),
})} })}
</div> </div>
@ -1703,22 +1738,23 @@ export function BizMigrationPage() {
<span className="bs-cmp-nav__name">{t("bizMigration.allMetrics")}</span> <span className="bs-cmp-nav__name">{t("bizMigration.allMetrics")}</span>
</button> </button>
{sheetCards.map((c) => { {sheetCards.map((c) => {
const active = boardMetricId === c.metric_id; const sid = sheetIdentity(c);
const active = boardMetricId === sid;
const hot = (c.anomaly || 0) > 0 || Boolean(c.new_baseline_missing); const hot = (c.anomaly || 0) > 0 || Boolean(c.new_baseline_missing);
return ( return (
<button <button
key={c.metric_id} key={sid}
type="button" type="button"
role="tab" role="tab"
aria-selected={active} aria-selected={active}
className={`bs-cmp-nav__item${active ? " is-active" : ""}${ className={`bs-cmp-nav__item${active ? " is-active" : ""}${
hot ? " has-diff" : " is-clean" hot ? " has-diff" : " is-clean"
}`} }`}
onClick={() => setBoardMetricId(c.metric_id)} onClick={() => setBoardMetricId(sid)}
> >
<span className="bs-cmp-nav__dot" /> <span className="bs-cmp-nav__dot" />
<span className="bs-cmp-nav__name"> <span className="bs-cmp-nav__name">
{c.metric_id} {sheetLabel(c)}
{c.new_baseline_missing ? " · baseline" : ""} {c.new_baseline_missing ? " · baseline" : ""}
</span> </span>
<span className="bs-cmp-nav__stats"> <span className="bs-cmp-nav__stats">
@ -1754,7 +1790,7 @@ export function BizMigrationPage() {
<tbody> <tbody>
{visibleDiffs.map((d) => ( {visibleDiffs.map((d) => (
<tr key={d.id}> <tr key={d.id}>
<td>{d.metric_id || "—"}</td> <td>{sheetLabel(d)}</td>
<td> <td>
<code>{d.key_str || "—"}</code> <code>{d.key_str || "—"}</code>
</td> </td>

View file

@ -19,6 +19,8 @@ import {
type MetricField = { name: string; display_name?: string }; type MetricField = { name: string; display_name?: string };
type MetricSchema = { metric_id: string; fields: MetricField[] }; type MetricSchema = { metric_id: string; fields: MetricField[] };
type CompareSheet = { type CompareSheet = {
sheet_id?: string;
title?: string;
metric_id: string; metric_id: string;
key_fields?: string[]; key_fields?: string[];
iface_fields?: string[]; iface_fields?: string[];
@ -48,6 +50,7 @@ type DualPat = {
}; };
type SheetOverride = { type SheetOverride = {
metric_id: string; metric_id: string;
sheet_id?: string;
status_fields?: string[]; status_fields?: string[];
down_values?: string[]; down_values?: string[];
up_values?: string[]; up_values?: string[];
@ -237,9 +240,10 @@ function defaultAnomalyPresenceOnly(): DualPat[] {
]; ];
} }
function emptyOverride(metricId: string): SheetOverride { function emptyOverride(metricId: string, sheetId = ""): SheetOverride {
return { return {
metric_id: metricId, metric_id: metricId,
sheet_id: sheetId || metricId,
status_fields: [], status_fields: [],
down_values: [], down_values: [],
up_values: [], up_values: [],
@ -249,14 +253,28 @@ function emptyOverride(metricId: string): SheetOverride {
}; };
} }
export function presetForMetric(metricId: string): SheetOverride { function sheetIdentity(s: { sheet_id?: string; metric_id?: string } | null | undefined): string {
return String(s?.sheet_id || s?.metric_id || "").trim();
}
function sheetLabel(s: { title?: string; sheet_id?: string; metric_id?: string } | null | undefined): string {
return String(s?.title || s?.sheet_id || s?.metric_id || "").trim() || "—";
}
export function presetForMetric(metricId: string, sheetId = ""): SheetOverride {
const mid = metricId; const mid = metricId;
const sid = sheetId || mid;
const withId = (ov: SheetOverride): SheetOverride => ({
...ov,
metric_id: mid,
sheet_id: sid,
});
if (mid === "interface_brief") { if (mid === "interface_brief") {
const upConds: RuleCond[] = [ const upConds: RuleCond[] = [
{ type: "value", field: "admin", op: "in", value: ["up"] }, { type: "value", field: "admin", op: "in", value: ["up"] },
{ type: "value", field: "phy", op: "in", value: ["up"] }, { type: "value", field: "phy", op: "in", value: ["up"] },
]; ];
return { return withId({
metric_id: mid, metric_id: mid,
status_fields: ["admin", "phy", "prot"], status_fields: ["admin", "phy", "prot"],
down_values: ["down"], down_values: ["down"],
@ -279,11 +297,11 @@ export function presetForMetric(metricId: string): SheetOverride {
], ],
anomaly: defaultAnomalyForState("admin", ["down"]), anomaly: defaultAnomalyForState("admin", ["down"]),
skip_dual: false, skip_dual: false,
}; });
} }
if (mid === "bgp_peer") { if (mid === "bgp_peer" || mid.startsWith("bgp_peer")) {
const upConds: RuleCond[] = [{ type: "value", field: "state", op: "eq", value: "established" }]; const upConds: RuleCond[] = [{ type: "value", field: "state", op: "eq", value: "established" }];
return { return withId({
metric_id: mid, metric_id: mid,
status_fields: ["state"], status_fields: ["state"],
down_values: ["idle", "active", "connect", "down"], down_values: ["idle", "active", "connect", "down"],
@ -310,10 +328,10 @@ export function presetForMetric(metricId: string): SheetOverride {
], ],
anomaly: defaultAnomalyForState("state", ["idle", "active", "connect", "down"]), anomaly: defaultAnomalyForState("state", ["idle", "active", "connect", "down"]),
skip_dual: false, skip_dual: false,
}; });
} }
if (mid === "arp" || mid === "nd6_cache" || mid === "lldp_neighbor") { if (mid === "arp" || mid === "nd6_cache" || mid === "lldp_neighbor") {
return { return withId({
metric_id: mid, metric_id: mid,
status_fields: [], status_fields: [],
down_values: [], down_values: [],
@ -321,13 +339,13 @@ export function presetForMetric(metricId: string): SheetOverride {
success: [successFromPresence(["removed"], ["added"])], success: [successFromPresence(["removed"], ["added"])],
anomaly: defaultAnomalyPresenceOnly(), anomaly: defaultAnomalyPresenceOnly(),
skip_dual: false, skip_dual: false,
}; });
} }
if (mid.includes("isis") || mid.includes("ospf") || mid.includes("adjacency")) { if (mid.includes("isis") || mid.includes("ospf") || mid.includes("adjacency")) {
const upConds: RuleCond[] = [ const upConds: RuleCond[] = [
{ type: "value", field: "state", op: "in", value: ["up", "full", "2way"] }, { type: "value", field: "state", op: "in", value: ["up", "full", "2way"] },
]; ];
return { return withId({
metric_id: mid, metric_id: mid,
status_fields: ["state"], status_fields: ["state"],
down_values: ["down", "init", "idle"], down_values: ["down", "init", "idle"],
@ -354,10 +372,10 @@ export function presetForMetric(metricId: string): SheetOverride {
], ],
anomaly: defaultAnomalyForState("state", ["down", "init", "idle"]), anomaly: defaultAnomalyForState("state", ["down", "init", "idle"]),
skip_dual: false, skip_dual: false,
}; });
} }
if (mid.includes("route") || mid.includes("vrf")) { if (mid.includes("route") || mid.includes("vrf")) {
return { return withId({
metric_id: mid, metric_id: mid,
status_fields: [], status_fields: [],
down_values: [], down_values: [],
@ -365,18 +383,23 @@ export function presetForMetric(metricId: string): SheetOverride {
success: [successFromPresence(["removed"], ["added"])], success: [successFromPresence(["removed"], ["added"])],
anomaly: defaultAnomalyPresenceOnly(), anomaly: defaultAnomalyPresenceOnly(),
skip_dual: false, skip_dual: false,
}; });
} }
return emptyOverride(mid); return emptyOverride(mid, sid);
} }
function skipPreset(metricId: string): SheetOverride { function skipPreset(metricId: string, sheetId = ""): SheetOverride {
return { ...emptyOverride(metricId), skip_dual: true, success: [], anomaly: [] }; return { ...emptyOverride(metricId, sheetId), skip_dual: true, success: [], anomaly: [] };
} }
function overrideFor(overrides: SheetOverride[], metricId: string): SheetOverride { function overrideFor(overrides: SheetOverride[], metricId: string, sheetId = ""): SheetOverride {
const found = overrides.find((o) => o.metric_id === metricId); const sid = sheetId || metricId;
const merged = found ? { ...emptyOverride(metricId), ...found, metric_id: metricId } : emptyOverride(metricId); const found =
overrides.find((o) => (o.sheet_id || o.metric_id) === sid) ||
overrides.find((o) => !o.sheet_id && o.metric_id === metricId);
const merged = found
? { ...emptyOverride(metricId, sid), ...found, metric_id: metricId, sheet_id: sid }
: emptyOverride(metricId, sid);
return { return {
...merged, ...merged,
success: (merged.success || []).map((p) => normalizePat(p)), success: (merged.success || []).map((p) => normalizePat(p)),
@ -788,16 +811,20 @@ export function BizMonitorTemplatesPage() {
const sheets = useMemo(() => selectedCompare?.metrics || [], [selectedCompare]); const sheets = useMemo(() => selectedCompare?.metrics || [], [selectedCompare]);
const activeSheet = sheets[activeSheetIdx] || sheets[0] || null; const activeSheet = sheets[activeSheetIdx] || sheets[0] || null;
const activeMetricId = activeSheet?.metric_id || ""; const activeMetricId = activeSheet?.metric_id || "";
const activeSheetId = activeSheet ? sheetIdentity(activeSheet) : "";
const activeOverride = useMemo( const activeOverride = useMemo(
() => (activeMetricId ? overrideFor(overrides, activeMetricId) : emptyOverride("")), () =>
[overrides, activeMetricId], activeMetricId
? overrideFor(overrides, activeMetricId, activeSheetId)
: emptyOverride(""),
[overrides, activeMetricId, activeSheetId],
); );
const activeFields = useMemo(() => { const activeFields = useMemo(() => {
const schema = metricSchemas.find((m) => m.metric_id === activeMetricId); const schema = metricSchemas.find((m) => m.metric_id === activeMetricId);
return schema?.fields || []; return schema?.fields || [];
}, [metricSchemas, activeMetricId]); }, [metricSchemas, activeMetricId]);
const sheetKey = useMemo(() => sheets.map((s) => s.metric_id).join("|"), [sheets]); const sheetKey = useMemo(() => sheets.map((s) => sheetIdentity(s)).join("|"), [sheets]);
useEffect(() => { useEffect(() => {
if (!sheets.length) { if (!sheets.length) {
@ -810,16 +837,21 @@ export function BizMonitorTemplatesPage() {
useEffect(() => { useEffect(() => {
if (!sheetKey) return; if (!sheetKey) return;
setOverrides((prev) => { setOverrides((prev) => {
const byId = new Map(prev.map((o) => [o.metric_id, o])); const byId = new Map(prev.map((o) => [o.sheet_id || o.metric_id, o]));
return sheets.map((s) => byId.get(s.metric_id) || emptyOverride(s.metric_id)); return sheets.map(
(s) =>
byId.get(sheetIdentity(s)) ||
emptyOverride(s.metric_id, sheetIdentity(s)),
);
}); });
}, [compareId, sheetKey]); }, [compareId, sheetKey]);
const syncOverride = (metricId: string, patch: Partial<SheetOverride>) => { const syncOverride = (metricId: string, patch: Partial<SheetOverride>, sheetId = "") => {
const sid = sheetId || sheetIdentity({ metric_id: metricId, sheet_id: sheetId });
setOverrides((prev) => { setOverrides((prev) => {
const others = prev.filter((o) => o.metric_id !== metricId); const others = prev.filter((o) => (o.sheet_id || o.metric_id) !== sid);
const base = overrideFor(prev, metricId); const base = overrideFor(prev, metricId, sid);
return [...others, { ...base, ...patch, metric_id: metricId }]; return [...others, { ...base, ...patch, metric_id: metricId, sheet_id: sid }];
}); });
}; };
@ -858,15 +890,18 @@ export function BizMonitorTemplatesPage() {
const closeEdit = () => setEditOpen(false); const closeEdit = () => setEditOpen(false);
const applyPreset = (kind: "auto" | "skip") => { const applyPreset = (kind: "auto" | "skip") => {
if (!activeMetricId) return; if (!activeMetricId || !activeSheetId) return;
syncOverride( syncOverride(
activeMetricId, activeMetricId,
kind === "skip" ? skipPreset(activeMetricId) : presetForMetric(activeMetricId), kind === "skip"
? skipPreset(activeMetricId, activeSheetId)
: presetForMetric(activeMetricId, activeSheetId),
activeSheetId,
); );
}; };
const applyAllPresets = () => { const applyAllPresets = () => {
setOverrides(sheets.map((s) => presetForMetric(s.metric_id))); setOverrides(sheets.map((s) => presetForMetric(s.metric_id, sheetIdentity(s))));
}; };
const save = async () => { const save = async () => {
@ -1080,10 +1115,11 @@ export function BizMonitorTemplatesPage() {
</div> </div>
<div className="mt-editor__nav-list" role="tablist"> <div className="mt-editor__nav-list" role="tablist">
{sheets.map((s, i) => { {sheets.map((s, i) => {
const ov = overrideFor(overrides, s.metric_id); const sid = sheetIdentity(s);
const ov = overrideFor(overrides, s.metric_id, sid);
return ( return (
<button <button
key={s.metric_id} key={sid}
type="button" type="button"
role="tab" role="tab"
aria-selected={activeSheetIdx === i} aria-selected={activeSheetIdx === i}
@ -1093,7 +1129,7 @@ export function BizMonitorTemplatesPage() {
setRuleTab("success"); setRuleTab("success");
}} }}
> >
<span className="mt-editor__nav-name">{s.metric_id}</span> <span className="mt-editor__nav-name">{sheetLabel(s)}</span>
<span className="mt-editor__nav-tag"> <span className="mt-editor__nav-tag">
{ov.skip_dual {ov.skip_dual
? t("bizMonitorTpl.tagSkip") ? t("bizMonitorTpl.tagSkip")
@ -1116,9 +1152,9 @@ export function BizMonitorTemplatesPage() {
<div className="mt-editor__pane"> <div className="mt-editor__pane">
<div className="mt-editor__pane-head"> <div className="mt-editor__pane-head">
<div> <div>
<div className="mt-editor__metric">{activeMetricId}</div> <div className="mt-editor__metric">{sheetLabel(activeSheet)}</div>
<div className="mt-editor__meta muted"> <div className="mt-editor__meta muted">
<span>Key {(activeSheet.key_fields || []).join(" · ") || "—"}</span> <span>{activeMetricId}</span>
<span> <span>
{t("bizMonitorTpl.compare")}{" "} {t("bizMonitorTpl.compare")}{" "}
{(activeSheet.compare_fields || []).length {(activeSheet.compare_fields || []).length
@ -1142,7 +1178,9 @@ export function BizMonitorTemplatesPage() {
<input <input
type="checkbox" type="checkbox"
checked={Boolean(activeOverride.skip_dual)} checked={Boolean(activeOverride.skip_dual)}
onChange={(e) => syncOverride(activeMetricId, { skip_dual: e.target.checked })} onChange={(e) =>
syncOverride(activeMetricId, { skip_dual: e.target.checked }, activeSheetId)
}
/> />
<span>{t("bizMonitorTpl.skipDual")}</span> <span>{t("bizMonitorTpl.skipDual")}</span>
</label> </label>
@ -1177,7 +1215,9 @@ export function BizMonitorTemplatesPage() {
patterns={activeOverride.success || []} patterns={activeOverride.success || []}
fieldChoices={fieldChoices} fieldChoices={fieldChoices}
t={t} t={t}
onChange={(success) => syncOverride(activeMetricId, { success })} onChange={(success) =>
syncOverride(activeMetricId, { success }, activeSheetId)
}
/> />
) : ( ) : (
<DualPatternList <DualPatternList
@ -1185,7 +1225,9 @@ export function BizMonitorTemplatesPage() {
patterns={activeOverride.anomaly || []} patterns={activeOverride.anomaly || []}
fieldChoices={fieldChoices} fieldChoices={fieldChoices}
t={t} t={t}
onChange={(anomaly) => syncOverride(activeMetricId, { anomaly })} onChange={(anomaly) =>
syncOverride(activeMetricId, { anomaly }, activeSheetId)
}
/> />
)} )}
</> </>
@ -1217,26 +1259,29 @@ export function BizMonitorTemplatesPage() {
{t("bizMonitorTpl.collectHint")} {t("bizMonitorTpl.collectHint")}
</p> </p>
<div className="mt-chip-row"> <div className="mt-chip-row">
{sheets.map((s) => { {Array.from(new Set(sheets.map((s) => s.metric_id).filter(Boolean))).map((mid) => {
const checked = collectIds.length === 0 || collectIds.includes(s.metric_id); const checked = collectIds.length === 0 || collectIds.includes(mid);
const explicit = collectIds.length > 0; const explicit = collectIds.length > 0;
const allMids = Array.from(
new Set(sheets.map((s) => s.metric_id).filter(Boolean)),
);
return ( return (
<Chip <Chip
key={s.metric_id} key={mid}
active={checked} active={checked}
label={s.metric_id} label={mid}
onClick={() => { onClick={() => {
if (!explicit) { if (!explicit) {
setCollectIds(sheets.map((x) => x.metric_id).filter((id) => id !== s.metric_id)); setCollectIds(allMids.filter((id) => id !== mid));
return; return;
} }
setCollectIds((prev) => { setCollectIds((prev) => {
if (prev.includes(s.metric_id)) { if (prev.includes(mid)) {
const next = prev.filter((x) => x !== s.metric_id); const next = prev.filter((x) => x !== mid);
return next.length === sheets.length ? [] : next; return next.length === allMids.length ? [] : next;
} }
const next = [...prev, s.metric_id]; const next = [...prev, mid];
return next.length === sheets.length ? [] : next; return next.length === allMids.length ? [] : next;
}); });
}} }}
/> />