mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
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:
parent
88b7558a48
commit
a65c9fc420
17 changed files with 745 additions and 230 deletions
|
|
@ -79,9 +79,10 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
|
|||
if not isinstance(it, dict):
|
||||
continue
|
||||
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
|
||||
bucket = out.setdefault(mid, set())
|
||||
bucket = out.setdefault(sid, set())
|
||||
if it.get("key") is not None:
|
||||
k = it.get("key")
|
||||
if isinstance(k, (list, tuple)):
|
||||
|
|
@ -747,10 +748,20 @@ def expect_keys_for_metric(
|
|||
*,
|
||||
metric_id: str,
|
||||
iface_fields: list[str],
|
||||
sheet_id: 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
|
||||
if metric_id == PORT_METRIC_ID and expect.get("_ports"):
|
||||
if mid == PORT_METRIC_ID and expect.get("_ports"):
|
||||
keys |= set(expect["_ports"])
|
||||
return keys
|
||||
|
||||
|
|
@ -879,15 +890,34 @@ def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]:
|
|||
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()
|
||||
for ov in sheet_overrides or []:
|
||||
if isinstance(ov, dict) and str(ov.get("metric_id") or "").strip() == mid:
|
||||
return ov
|
||||
return {}
|
||||
return override_for_sheet(sheet_overrides, sheet_id=mid, metric_id=mid)
|
||||
|
||||
|
||||
def evaluate_metric_dual(
|
||||
|
|
@ -907,9 +937,13 @@ def evaluate_metric_dual(
|
|||
field_rules: list[dict[str, Any]] | None = None,
|
||||
sheet_override: dict[str, Any] | None = None,
|
||||
out_of_expect: str = "strict",
|
||||
sheet_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""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 {}
|
||||
if ov.get("skip_dual"):
|
||||
return {
|
||||
|
|
@ -1155,6 +1189,8 @@ def evaluate_metric_dual(
|
|||
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"],
|
||||
|
|
|
|||
|
|
@ -127,68 +127,89 @@ def _success_stateful(
|
|||
}
|
||||
|
||||
|
||||
def preset_override_for_metric(metric_id: str) -> dict[str, Any]:
|
||||
"""Default dual-verdict overlay for a compare-template sheet."""
|
||||
def preset_override_for_metric(metric_id: str, *, sheet_id: str = "") -> dict[str, Any]:
|
||||
"""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()
|
||||
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 = [
|
||||
{"type": "value", "field": "admin", "op": "in", "value": ["up"]},
|
||||
{"type": "value", "field": "phy", "op": "in", "value": ["up"]},
|
||||
]
|
||||
return {
|
||||
"metric_id": mid,
|
||||
"status_fields": list(PORT_STATUS_FIELDS),
|
||||
"down_values": ["down"],
|
||||
"up_values": ["up"],
|
||||
"success": [
|
||||
_success_stateful(
|
||||
old_down_groups=[
|
||||
[
|
||||
{"type": "value", "field": "admin", "op": "in", "value": ["down"]},
|
||||
{"type": "value", "field": "phy", "op": "in", "value": ["down"]},
|
||||
]
|
||||
],
|
||||
new_up_conds=up,
|
||||
)
|
||||
],
|
||||
"anomaly": _default_anomaly_for_state("admin", ["down"]),
|
||||
}
|
||||
if mid == "bgp_peer":
|
||||
return _out(
|
||||
{
|
||||
"status_fields": list(PORT_STATUS_FIELDS),
|
||||
"down_values": ["down"],
|
||||
"up_values": ["up"],
|
||||
"success": [
|
||||
_success_stateful(
|
||||
old_down_groups=[
|
||||
[
|
||||
{"type": "value", "field": "admin", "op": "in", "value": ["down"]},
|
||||
{"type": "value", "field": "phy", "op": "in", "value": ["down"]},
|
||||
]
|
||||
],
|
||||
new_up_conds=up,
|
||||
)
|
||||
],
|
||||
"anomaly": _default_anomaly_for_state("admin", ["down"]),
|
||||
}
|
||||
)
|
||||
if look == "bgp_peer":
|
||||
up = [{"type": "value", "field": "state", "op": "eq", "value": "established"}]
|
||||
return {
|
||||
"metric_id": mid,
|
||||
"status_fields": ["state"],
|
||||
"down_values": ["idle", "active", "connect", "down"],
|
||||
"up_values": ["established"],
|
||||
"success": [
|
||||
_success_stateful(
|
||||
old_down_groups=[
|
||||
[
|
||||
{
|
||||
"type": "value",
|
||||
"field": "state",
|
||||
"op": "in",
|
||||
"value": ["idle", "active", "connect", "down"],
|
||||
}
|
||||
]
|
||||
],
|
||||
new_up_conds=up,
|
||||
)
|
||||
],
|
||||
"anomaly": _default_anomaly_for_state(
|
||||
"state", ["idle", "active", "connect", "down"]
|
||||
),
|
||||
}
|
||||
if mid in ("arp", "nd6_cache", "lldp_neighbor"):
|
||||
return {
|
||||
"metric_id": mid,
|
||||
"status_fields": [],
|
||||
"down_values": [],
|
||||
"up_values": [],
|
||||
"success": [_success_presence_migrate()],
|
||||
"anomaly": _default_anomaly_presence_only(),
|
||||
}
|
||||
if "isis" in mid or "ospf" in mid or "adjacency" in mid:
|
||||
return _out(
|
||||
{
|
||||
"status_fields": ["state"],
|
||||
"down_values": ["idle", "active", "connect", "down"],
|
||||
"up_values": ["established"],
|
||||
"success": [
|
||||
_success_stateful(
|
||||
old_down_groups=[
|
||||
[
|
||||
{
|
||||
"type": "value",
|
||||
"field": "state",
|
||||
"op": "in",
|
||||
"value": ["idle", "active", "connect", "down"],
|
||||
}
|
||||
]
|
||||
],
|
||||
new_up_conds=up,
|
||||
)
|
||||
],
|
||||
"anomaly": _default_anomaly_for_state(
|
||||
"state", ["idle", "active", "connect", "down"]
|
||||
),
|
||||
}
|
||||
)
|
||||
if look in ("arp", "nd6_cache", "lldp_neighbor"):
|
||||
return _out(
|
||||
{
|
||||
"status_fields": [],
|
||||
"down_values": [],
|
||||
"up_values": [],
|
||||
"success": [_success_presence_migrate()],
|
||||
"anomaly": _default_anomaly_presence_only(),
|
||||
}
|
||||
)
|
||||
if "isis" in look or "ospf" in look or "adjacency" in look:
|
||||
up = [
|
||||
{
|
||||
"type": "value",
|
||||
|
|
@ -197,45 +218,48 @@ def preset_override_for_metric(metric_id: str) -> dict[str, Any]:
|
|||
"value": ["up", "full", "2way"],
|
||||
}
|
||||
]
|
||||
return {
|
||||
"metric_id": mid,
|
||||
"status_fields": ["state"],
|
||||
"down_values": ["down", "init", "idle"],
|
||||
"up_values": ["up", "full", "2way"],
|
||||
"success": [
|
||||
_success_stateful(
|
||||
old_down_groups=[
|
||||
[
|
||||
{
|
||||
"type": "value",
|
||||
"field": "state",
|
||||
"op": "in",
|
||||
"value": ["down", "init", "idle"],
|
||||
}
|
||||
]
|
||||
],
|
||||
new_up_conds=up,
|
||||
)
|
||||
],
|
||||
"anomaly": _default_anomaly_for_state("state", ["down", "init", "idle"]),
|
||||
}
|
||||
if "route" in mid or "vrf" in mid:
|
||||
return {
|
||||
"metric_id": mid,
|
||||
return _out(
|
||||
{
|
||||
"status_fields": ["state"],
|
||||
"down_values": ["down", "init", "idle"],
|
||||
"up_values": ["up", "full", "2way"],
|
||||
"success": [
|
||||
_success_stateful(
|
||||
old_down_groups=[
|
||||
[
|
||||
{
|
||||
"type": "value",
|
||||
"field": "state",
|
||||
"op": "in",
|
||||
"value": ["down", "init", "idle"],
|
||||
}
|
||||
]
|
||||
],
|
||||
new_up_conds=up,
|
||||
)
|
||||
],
|
||||
"anomaly": _default_anomaly_for_state("state", ["down", "init", "idle"]),
|
||||
}
|
||||
)
|
||||
if "route" in look or "vrf" in look:
|
||||
return _out(
|
||||
{
|
||||
"status_fields": [],
|
||||
"down_values": [],
|
||||
"up_values": [],
|
||||
"success": [_success_presence_migrate()],
|
||||
"anomaly": _default_anomaly_presence_only(),
|
||||
}
|
||||
)
|
||||
return _out(
|
||||
{
|
||||
"status_fields": [],
|
||||
"down_values": [],
|
||||
"up_values": [],
|
||||
"success": [_success_presence_migrate()],
|
||||
"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:
|
||||
|
|
@ -264,7 +288,14 @@ def ensure_default_monitor_templates(db: Session) -> None:
|
|||
]
|
||||
if 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(
|
||||
BizMonitorTemplate(
|
||||
id=uuid4().hex,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from fastapi import HTTPException
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
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 (
|
||||
BizCompareTemplate,
|
||||
BizMigrationBatch,
|
||||
|
|
@ -27,7 +27,7 @@ from . import monitor_templates as mon_tpl
|
|||
from .evaluate import (
|
||||
PORT_METRIC_ID,
|
||||
evaluate_metric_dual,
|
||||
override_for_metric,
|
||||
override_for_sheet,
|
||||
parse_expect_set,
|
||||
port_sheet_def,
|
||||
)
|
||||
|
|
@ -124,7 +124,12 @@ def resolve_collect_metric_ids(db: Session, proj: BizMigrationProject) -> list[s
|
|||
if collect:
|
||||
return collect
|
||||
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]:
|
||||
|
|
@ -372,6 +377,7 @@ def run_evaluate(
|
|||
|
||||
for sheet in sheets:
|
||||
mid = str(sheet.get("metric_id") or "").strip()
|
||||
sid = sheet_key(sheet)
|
||||
key_fields = list(sheet.get("key_fields") or [])
|
||||
if not mid or not key_fields:
|
||||
continue
|
||||
|
|
@ -379,7 +385,7 @@ def run_evaluate(
|
|||
compare_fields = list(sheet.get("compare_fields") or [])
|
||||
row_filters = list(sheet.get("row_filters") 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"):
|
||||
continue
|
||||
|
||||
|
|
@ -402,6 +408,7 @@ def run_evaluate(
|
|||
|
||||
one = evaluate_metric_dual(
|
||||
metric_id=mid,
|
||||
sheet_id=sid,
|
||||
key_fields=key_fields,
|
||||
iface_fields=iface_fields,
|
||||
compare_fields=compare_fields,
|
||||
|
|
@ -420,7 +427,8 @@ def run_evaluate(
|
|||
sheet_cards.append(
|
||||
{
|
||||
"metric_id": mid,
|
||||
"title": mid,
|
||||
"sheet_id": sid,
|
||||
"title": sheet_title(sheet),
|
||||
"progress_ok": one["progress_ok"],
|
||||
"progress_total": one["progress_total"],
|
||||
"anomaly": one["anomaly"],
|
||||
|
|
@ -433,6 +441,7 @@ def run_evaluate(
|
|||
)
|
||||
for r in one["rows"]:
|
||||
r["seq"] = seq
|
||||
r["sheet_id"] = sid
|
||||
seq += 1
|
||||
all_rows.append(r)
|
||||
v = str(r.get("verdict") or "")
|
||||
|
|
@ -460,7 +469,7 @@ def run_evaluate(
|
|||
"anomaly": sum(c["anomaly"] for c in sheet_cards),
|
||||
"new_baseline_missing": any(bool(c.get("new_baseline_missing")) for c in sheet_cards),
|
||||
"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
|
||||
if c.get("new_baseline_missing")
|
||||
],
|
||||
|
|
@ -499,6 +508,7 @@ def run_evaluate(
|
|||
"old_status": r.get("old_status"),
|
||||
"new_status": r.get("new_status"),
|
||||
"rule_hit": r.get("rule_hit") or "",
|
||||
"sheet_id": r.get("sheet_id") or "",
|
||||
},
|
||||
old_kind=str(r.get("old_kind") or ""),
|
||||
new_kind=str(r.get("new_kind") or ""),
|
||||
|
|
@ -545,6 +555,7 @@ def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]:
|
|||
return {
|
||||
"id": d.id,
|
||||
"metric_id": d.metric_id,
|
||||
"sheet_id": kj.get("sheet_id") or d.metric_id or "",
|
||||
"seq": d.seq,
|
||||
"verdict": d.verdict,
|
||||
"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]] = []
|
||||
for sheet in sheets:
|
||||
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()]
|
||||
if not mid or not key_fields:
|
||||
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"):
|
||||
continue
|
||||
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(
|
||||
{
|
||||
"metric_id": mid,
|
||||
"sheet_id": sid,
|
||||
"title": sheet_title(sheet),
|
||||
"key_fields": key_fields,
|
||||
"iface_fields": iface_fields,
|
||||
"items": items,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ def eval_leaf_filter(row: Mapping[str, Any], filt: Mapping[str, Any]) -> bool:
|
|||
if op in ("not_in", "nin"):
|
||||
opts = {str(x).strip().lower() for x in _as_list(expect) if str(x).strip()}
|
||||
return raw.lower() not in opts
|
||||
if op == "contains":
|
||||
needle = str(expect or "").strip().lower()
|
||||
return bool(needle) and needle in raw.lower()
|
||||
if op == "empty":
|
||||
return not raw
|
||||
if op in ("not_empty", "nonempty"):
|
||||
|
|
|
|||
|
|
@ -225,6 +225,8 @@ def _sheet_meta_from_summary(summary: dict[str, Any], run: BizCompareRun, tpl: A
|
|||
return [
|
||||
{
|
||||
"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 []),
|
||||
"iface_fields": list((tpl.iface_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
|
||||
|
||||
|
||||
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(
|
||||
*,
|
||||
metric_id: str,
|
||||
key_fields: list[str],
|
||||
sheet_id: str | None = None,
|
||||
title: str | None = None,
|
||||
iface_fields: list[str] | None = None,
|
||||
compare_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,
|
||||
) -> dict[str, Any]:
|
||||
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)
|
||||
ifaces = _str_list(iface_fields)
|
||||
# compare_fields empty → presence-only mode (intentional)
|
||||
|
|
@ -320,6 +337,8 @@ def _sheet_def(
|
|||
display_fields=_str_list(display_fields),
|
||||
)
|
||||
sheet: dict[str, Any] = {
|
||||
"sheet_id": sid,
|
||||
"title": ttl,
|
||||
"metric_id": mid,
|
||||
"key_fields": keys,
|
||||
"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]]:
|
||||
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("arp", 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
|
||||
return _sheet_def(
|
||||
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,
|
||||
iface_fields=_str_list(raw.get("iface_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)
|
||||
if not sheet:
|
||||
continue
|
||||
mid = sheet["metric_id"]
|
||||
if mid in seen:
|
||||
sid = sheet_key(sheet)
|
||||
if sid in seen:
|
||||
continue
|
||||
seen.add(mid)
|
||||
seen.add(sid)
|
||||
out.append(sheet)
|
||||
if out:
|
||||
return out
|
||||
|
|
@ -494,10 +580,10 @@ def _parse_metrics_body(body: dict[str, Any]) -> list[dict[str, Any]]:
|
|||
sheet = _normalize_sheet(raw)
|
||||
if not sheet:
|
||||
continue
|
||||
mid = sheet["metric_id"]
|
||||
if mid in seen:
|
||||
raise HTTPException(status_code=400, detail=f"duplicate_metric:{mid}")
|
||||
seen.add(mid)
|
||||
sid = sheet_key(sheet)
|
||||
if sid in seen:
|
||||
raise HTTPException(status_code=400, detail=f"duplicate_sheet:{sid}")
|
||||
seen.add(sid)
|
||||
sheets.append(sheet)
|
||||
if not sheets:
|
||||
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 [
|
||||
_sheet_def(
|
||||
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,
|
||||
iface_fields=_str_list(body.get("iface_fields")),
|
||||
compare_fields=compare,
|
||||
|
|
@ -541,7 +629,7 @@ def _template_out(t: BizCompareTemplate) -> dict[str, Any]:
|
|||
"id": t.id,
|
||||
"name": t.name,
|
||||
"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)
|
||||
"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 []),
|
||||
|
|
@ -698,8 +786,44 @@ def ensure_default_templates(db: Session) -> None:
|
|||
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]]:
|
||||
ensure_default_templates(db)
|
||||
upgrade_builtin_split_sheets(db)
|
||||
rows = db.query(BizCompareTemplate).order_by(BizCompareTemplate.name.asc()).all()
|
||||
return [_template_out(t) for t in rows]
|
||||
|
||||
|
|
@ -1143,6 +1267,8 @@ def _run_sheet(
|
|||
summary["after_raw_count"] = len(after_raw)
|
||||
summary["row_filters"] = len(row_filters)
|
||||
return {
|
||||
"sheet_id": sheet_key(sheet),
|
||||
"title": sheet_title(sheet),
|
||||
"metric_id": sheet["metric_id"],
|
||||
"key_fields": key_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"]
|
||||
for k in agg:
|
||||
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]
|
||||
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,
|
||||
"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"],
|
||||
"key_fields": s["key_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(
|
||||
db,
|
||||
run_id=run_id,
|
||||
metric_id=str(s["metric_id"]),
|
||||
metric_id=sheet_key(s),
|
||||
diffs=list(s.get("diffs") or []),
|
||||
)
|
||||
j.updated_at = _utcnow()
|
||||
|
|
@ -1352,6 +1480,8 @@ def _enrich_summary(summary: dict[str, Any], sheets: list[dict[str, Any]]) -> di
|
|||
sj = sf + su
|
||||
sheet_cards.append(
|
||||
{
|
||||
"sheet_id": sheet_key(sh),
|
||||
"title": sheet_title(sh),
|
||||
"metric_id": sh.get("metric_id") or "",
|
||||
"mode": sh.get("mode") or ("presence" if not sh.get("compare_fields") else "fields"),
|
||||
"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)
|
||||
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 "",
|
||||
"key_fields": list(sh.get("key_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(
|
||||
db: Session,
|
||||
run_id: str,
|
||||
|
|
@ -1480,7 +1626,9 @@ def list_run_diffs(
|
|||
summary = dict(r.summary_json or {})
|
||||
tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None
|
||||
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):
|
||||
q = db.query(BizCompareDiff).filter(
|
||||
|
|
@ -1509,10 +1657,9 @@ def list_run_diffs(
|
|||
}
|
||||
|
||||
# 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:
|
||||
sheet = sheets[0]
|
||||
mid = str(sheet.get("metric_id") or mid)
|
||||
mid = sheet_key(sheet)
|
||||
inline = list((sheet or {}).get("diffs") or [])
|
||||
if not inline and mid == r.metric_id:
|
||||
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 {})
|
||||
sheets = list(summary.get("sheets") or [])
|
||||
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 [])
|
||||
if metric_id == r.metric_id:
|
||||
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 []):
|
||||
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"+{card.get('added')}/-{card.get('removed')}/~{card.get('changed')}/= {card.get('unchanged')}"
|
||||
)
|
||||
zf.writestr("manifest.txt", "\n".join(manifest) + "\n")
|
||||
for sheet in list(detail.get("sheets") or []):
|
||||
mid = str(sheet.get("metric_id") or "sheet")
|
||||
safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in mid)[:80] or "sheet"
|
||||
sid = sheet_key(sheet) or "sheet"
|
||||
safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in sid)[:80] or "sheet"
|
||||
sheet_full = {
|
||||
**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))
|
||||
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(
|
||||
_csv_cell(x)
|
||||
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("before_count"),
|
||||
card.get("after_count"),
|
||||
|
|
|
|||
|
|
@ -76,7 +76,6 @@ def normalize_bgp_peer(
|
|||
"up_down": m.group("up")[:32],
|
||||
"state": state[:64],
|
||||
"pfx_rcd": pfx[:32],
|
||||
"state_or_pfx": state_raw[:64],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -298,7 +298,6 @@ _BGP_PEER_FIELDS: list[FieldDef] = [
|
|||
FieldDef("as_num", length=16, role="state", display_name="AS"),
|
||||
FieldDef("state", length=64, role="state", display_name="State"),
|
||||
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("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"),
|
||||
FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"),
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class BizMigrationBatch(Base):
|
|||
batch_label: Mapped[str] = mapped_column(String(128), default="")
|
||||
# pending | active | review | done
|
||||
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)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
|
|
|||
|
|
@ -205,8 +205,11 @@ class BizStateCommandOverride(Base):
|
|||
class BizCompareTemplate(Base):
|
||||
"""Compare template: one or more metric sheets with key/iface/compare roles.
|
||||
|
||||
``metrics_json`` is the source of truth (list of sheet defs). Legacy
|
||||
``metric_id`` / ``key_fields`` / … mirror the first sheet for older rows.
|
||||
``metrics_json`` is the source of truth (list of sheet defs). Each sheet has
|
||||
``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).
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,55 @@ PORT_OVERRIDE = {
|
|||
|
||||
|
||||
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):
|
||||
got = parse_expect_set({"ports": ["gei-1", "gei-2", ""]})
|
||||
self.assertEqual(got["interface_brief"], {"gei-1", "gei-2"})
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from netx_api.biz_state.compare_rules import (
|
|||
arp_dynamic_row_filters,
|
||||
effective_compare_fields,
|
||||
effective_display_fields,
|
||||
eval_leaf_filter,
|
||||
normalize_value,
|
||||
values_equal,
|
||||
)
|
||||
|
|
@ -314,6 +315,57 @@ class CompareDiffPagingTests(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:
|
||||
from netx_api.biz_state.compare_service import _default_sheet_for_metric
|
||||
|
||||
|
|
|
|||
|
|
@ -145,6 +145,12 @@ class ZteStatusParserTests(unittest.TestCase):
|
|||
conn = [r for r in rows if r["state"] == "Connect"]
|
||||
self.assertGreaterEqual(len(est), 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",))
|
||||
rows2 = normalize_bgp_peer(raw_text=ipv4, command="show bgp ipv4 unicast summary")
|
||||
|
|
|
|||
|
|
@ -345,6 +345,11 @@ const en = {
|
|||
pickMetric: "Pick metric…",
|
||||
addSheet: "Add to template",
|
||||
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",
|
||||
sheetsUnit: "sheets",
|
||||
presenceShort: "presence",
|
||||
|
|
|
|||
|
|
@ -344,6 +344,11 @@ const zh = {
|
|||
pickMetric: "选择监控项…",
|
||||
addSheet: "加入模板",
|
||||
removeSheet: "移出本表",
|
||||
sheetIdRequired: "请填写 sheet_id(拆表唯一标识)",
|
||||
duplicateSheetId: "sheet_id 不能重复",
|
||||
sheetTitle: "表标题",
|
||||
sheetId: "sheet_id",
|
||||
splitSheet: "拆表复制",
|
||||
colSheets: "监控表",
|
||||
sheetsUnit: "张表",
|
||||
presenceShort: "仅条目",
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ type MetricField = {
|
|||
type MetricSchema = { metric_id: string; fields: MetricField[] };
|
||||
|
||||
type MetricSheet = {
|
||||
sheet_id?: string;
|
||||
title?: string;
|
||||
metric_id: string;
|
||||
key_fields: string[];
|
||||
iface_fields: string[];
|
||||
|
|
@ -111,6 +113,8 @@ type DiffRow = {
|
|||
};
|
||||
|
||||
type RunSheet = {
|
||||
sheet_id?: string;
|
||||
title?: string;
|
||||
metric_id: string;
|
||||
key_fields: string[];
|
||||
iface_fields: string[];
|
||||
|
|
@ -285,12 +289,22 @@ function taskLabel(row: TaskOpt) {
|
|||
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[] {
|
||||
if (!tpl) return [];
|
||||
if (tpl.metrics?.length) return tpl.metrics;
|
||||
if (tpl.metric_id) {
|
||||
return [
|
||||
{
|
||||
sheet_id: tpl.metric_id,
|
||||
title: tpl.metric_id,
|
||||
metric_id: tpl.metric_id,
|
||||
key_fields: [...(tpl.key_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"))
|
||||
.map((f) => f.name);
|
||||
const sheet: MetricSheet = {
|
||||
sheet_id: metricId,
|
||||
title: metricId,
|
||||
metric_id: metricId,
|
||||
key_fields,
|
||||
iface_fields: fields.filter((f) => f.is_interface).map((f) => f.name),
|
||||
|
|
@ -353,6 +369,8 @@ function cloneSheet(s: MetricSheet): MetricSheet {
|
|||
? [...s.display_fields]
|
||||
: [...keys, ...compare];
|
||||
return {
|
||||
sheet_id: s.sheet_id || s.metric_id,
|
||||
title: s.title || s.sheet_id || s.metric_id,
|
||||
metric_id: s.metric_id,
|
||||
key_fields: keys,
|
||||
iface_fields: [...(s.iface_fields || [])],
|
||||
|
|
@ -368,6 +386,8 @@ const TEMPLATE_EXPORT_VERSION = 1;
|
|||
|
||||
function templateExportPayload(tpl: Template) {
|
||||
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,
|
||||
key_fields: [...(s.key_fields || [])],
|
||||
iface_fields: [...(s.iface_fields || [])],
|
||||
|
|
@ -424,6 +444,8 @@ function parseTemplateImport(raw: unknown): {
|
|||
: [];
|
||||
if (!mid || !keys.length) continue;
|
||||
metrics.push({
|
||||
sheet_id: String(m.sheet_id || mid).trim() || mid,
|
||||
title: String(m.title || m.sheet_id || mid).trim() || mid,
|
||||
metric_id: mid,
|
||||
key_fields: keys,
|
||||
iface_fields: Array.isArray(m.iface_fields)
|
||||
|
|
@ -530,6 +552,7 @@ const FILTER_OPS = [
|
|||
"ne",
|
||||
"in",
|
||||
"not_in",
|
||||
"contains",
|
||||
"empty",
|
||||
"not_empty",
|
||||
"regex",
|
||||
|
|
@ -834,13 +857,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
setResultSheetId("");
|
||||
return;
|
||||
}
|
||||
if (!resultSheetId || !runSheets.some((s) => s.metric_id === resultSheetId)) {
|
||||
setResultSheetId(runSheets[0].metric_id);
|
||||
if (!resultSheetId || !runSheets.some((s) => sheetIdentity(s) === resultSheetId)) {
|
||||
setResultSheetId(sheetIdentity(runSheets[0]));
|
||||
}
|
||||
}, [runSheets, resultSheetId]);
|
||||
|
||||
const activeRunSheet = useMemo(
|
||||
() => runSheets.find((s) => s.metric_id === resultSheetId) || runSheets[0] || null,
|
||||
() => runSheets.find((s) => sheetIdentity(s) === resultSheetId) || runSheets[0] || null,
|
||||
[runSheets, resultSheetId],
|
||||
);
|
||||
|
||||
|
|
@ -851,7 +874,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
|
||||
useEffect(() => {
|
||||
const runId = String(runDetail?.id || "");
|
||||
const mid = resultSheetId || activeRunSheet?.metric_id || "";
|
||||
const mid = resultSheetId || sheetIdentity(activeRunSheet) || "";
|
||||
if (!runId || !mid || jobDetailTab !== "result") {
|
||||
setPagedDiffs([]);
|
||||
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 sheetCards = useMemo(() => {
|
||||
const raw = (summary.sheet_cards || []) as Array<{
|
||||
sheet_id?: string;
|
||||
title?: string;
|
||||
metric_id: string;
|
||||
mode?: string;
|
||||
added?: number;
|
||||
|
|
@ -1023,7 +1048,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
return String(a.metric_id).localeCompare(String(b.metric_id));
|
||||
});
|
||||
}, [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 activeSuccess = sheetSuccessOf(activeSheetCard || {});
|
||||
const activePassRate = sheetPassRateOf(activeSheetCard || {});
|
||||
|
|
@ -1125,7 +1150,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
if (on) {
|
||||
const schema = metrics.find((m) => m.metric_id === metricId);
|
||||
setTplSheets((prev) => {
|
||||
if (prev.some((s) => s.metric_id === metricId)) return prev;
|
||||
const next = [...prev, defaultSheetForMetric(schema, metricId)];
|
||||
setTplSheetIdx(next.length - 1);
|
||||
if (!prev.length) setShowMetricPicker(false);
|
||||
|
|
@ -1133,13 +1157,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
});
|
||||
return;
|
||||
}
|
||||
// Uncheck removes ALL sheets of this source metric
|
||||
setTplSheets((prev) => {
|
||||
const idx = prev.findIndex((s) => s.metric_id === metricId);
|
||||
if (idx < 0) return prev;
|
||||
const next = prev.filter((_, i) => i !== idx);
|
||||
const next = prev.filter((s) => s.metric_id !== metricId);
|
||||
setTplSheetIdx((cur) => {
|
||||
if (!next.length) return 0;
|
||||
if (cur > idx) return cur - 1;
|
||||
if (cur >= next.length) return next.length - 1;
|
||||
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) => {
|
||||
setTplSheets((prev) => {
|
||||
const next = prev.filter((_, i) => i !== idx);
|
||||
|
|
@ -1167,16 +1211,27 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
}
|
||||
for (const s of tplSheets) {
|
||||
if (!s.key_fields.length) {
|
||||
showError(`${metricLabel(s.metric_id)}: ${t("bizCompare.keyRequired")}`);
|
||||
showError(`${sheetLabel(s)}: ${t("bizCompare.keyRequired")}`);
|
||||
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);
|
||||
try {
|
||||
const body = {
|
||||
name: tplName || tplSheets.map((s) => s.metric_id).join("+"),
|
||||
name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"),
|
||||
note: tplNote,
|
||||
metrics: tplSheets.map((s) => ({
|
||||
sheet_id: sheetIdentity(s),
|
||||
title: s.title || sheetIdentity(s),
|
||||
metric_id: s.metric_id,
|
||||
key_fields: s.key_fields,
|
||||
iface_fields: s.iface_fields,
|
||||
|
|
@ -1239,6 +1294,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
name: body.name,
|
||||
note: body.note,
|
||||
metrics: body.metrics.map((s) => ({
|
||||
sheet_id: sheetIdentity(s),
|
||||
title: s.title || sheetIdentity(s),
|
||||
metric_id: s.metric_id,
|
||||
key_fields: s.key_fields,
|
||||
iface_fields: s.iface_fields,
|
||||
|
|
@ -1701,9 +1758,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</td>
|
||||
<td>
|
||||
<div className="bs-cmp-metric-chips">
|
||||
{sheets.map((s) => (
|
||||
<code key={s.metric_id} className="bs-cmp-metric-chip">
|
||||
{s.metric_id}
|
||||
{sheets.map((s, i) => (
|
||||
<code key={`${sheetIdentity(s)}-${i}`} className="bs-cmp-metric-chip">
|
||||
{sheetLabel(s)}
|
||||
{!s.compare_fields?.length ? (
|
||||
<span className="muted"> · {t("bizCompare.presenceShort")}</span>
|
||||
) : null}
|
||||
|
|
@ -1885,7 +1942,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const filterN = toOrGroups(s.row_filters || []).length;
|
||||
return (
|
||||
<button
|
||||
key={s.metric_id}
|
||||
key={`${sheetIdentity(s)}-${i}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tplSheetIdx === i}
|
||||
|
|
@ -1895,7 +1952,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
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">
|
||||
{!s.compare_fields.length
|
||||
? t("bizCompare.presenceShort")
|
||||
|
|
@ -1911,9 +1968,26 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
{activeTplSheet ? (
|
||||
<div className="ct-editor__pane">
|
||||
<div className="ct-editor__pane-head">
|
||||
<div>
|
||||
<div className="ct-editor__metric">{activeTplSheet.metric_id}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<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">
|
||||
{activeTplSheet.metric_id}
|
||||
{" · "}
|
||||
{activeTplSheet.compare_fields.length
|
||||
? t("bizCompare.modeFieldsShort")
|
||||
: t("bizCompare.presenceShort")}
|
||||
|
|
@ -1921,9 +1995,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
Key {(activeTplSheet.key_fields || []).join(" · ") || "—"}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onPress={() => removeTplMetric(tplSheetIdx)}>
|
||||
{t("bizCompare.removeSheet")}
|
||||
</Button>
|
||||
<div className="btn-row" style={{ gap: 6 }}>
|
||||
<Button size="sm" variant="secondary" onPress={() => duplicateTplSheet(tplSheetIdx)}>
|
||||
{t("bizCompare.splitSheet")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onPress={() => removeTplMetric(tplSheetIdx)}>
|
||||
{t("bizCompare.removeSheet")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-rule-tabs" role="tablist">
|
||||
|
|
@ -2354,6 +2433,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const fail = removed + changed;
|
||||
const judged = fail + unchanged;
|
||||
return {
|
||||
sheet_id: sheetIdentity(s),
|
||||
title: sheetLabel(s),
|
||||
metric_id: s.metric_id,
|
||||
mode: s.mode,
|
||||
added: s.summary?.added,
|
||||
|
|
@ -2374,21 +2455,23 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
const fail = sheetFailOf(c);
|
||||
const ok = sheetSuccessOf(c);
|
||||
const rate = sheetPassRateOf(c);
|
||||
const active = resultSheetId === c.metric_id;
|
||||
const id = sheetIdentity(c);
|
||||
const label = sheetLabel(c);
|
||||
const active = resultSheetId === id;
|
||||
return (
|
||||
<button
|
||||
key={c.metric_id}
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`bs-cmp-nav__item${active ? " is-active" : ""}${
|
||||
fail > 0 ? " has-diff" : " is-clean"
|
||||
}`}
|
||||
onClick={() => setResultSheetId(c.metric_id)}
|
||||
title={`${c.metric_id} · ${t("bizCompare.kindFail")} ${fail} · ${t("bizCompare.kindSuccess")} ${ok} · ${t("bizCompare.passRateShort")} ${rate}%`}
|
||||
onClick={() => setResultSheetId(id)}
|
||||
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__name">{c.metric_id}</span>
|
||||
<span className="bs-cmp-nav__name">{label}</span>
|
||||
<span className="bs-cmp-nav__stats">
|
||||
<span
|
||||
className={`bs-cmp-nav__num bs-cmp-nav__num--fail${
|
||||
|
|
@ -2420,9 +2503,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
|||
</span>
|
||||
<strong
|
||||
className="bs-cmp-strip__sheet-name"
|
||||
title={activeSheetCard?.metric_id || ""}
|
||||
title={sheetLabel(activeSheetCard)}
|
||||
>
|
||||
{activeSheetCard?.metric_id || "—"}
|
||||
{sheetLabel(activeSheetCard)}
|
||||
</strong>
|
||||
<span className="muted bs-cmp-strip__sheet-mode">
|
||||
{activeFail > 0 ? t("bizCompare.kindFail") : t("bizCompare.kindPass")}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,10 @@ type MigBatch = {
|
|||
id: string;
|
||||
batch_label: 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_run_id?: string;
|
||||
accept_summary?: {
|
||||
|
|
@ -91,6 +94,7 @@ type RedTicket = {
|
|||
};
|
||||
type SheetCard = {
|
||||
metric_id: string;
|
||||
sheet_id?: string;
|
||||
title?: string;
|
||||
progress_ok: number;
|
||||
progress_total: number;
|
||||
|
|
@ -101,6 +105,7 @@ type SheetCard = {
|
|||
type DiffRow = {
|
||||
id: string;
|
||||
metric_id: string;
|
||||
sheet_id?: string;
|
||||
verdict: string;
|
||||
color: string;
|
||||
old_kind: string;
|
||||
|
|
@ -129,11 +134,21 @@ type ExpectSheetItem = {
|
|||
};
|
||||
type ExpectSheet = {
|
||||
metric_id: string;
|
||||
sheet_id?: string;
|
||||
title?: string;
|
||||
key_fields: string[];
|
||||
iface_fields: string[];
|
||||
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";
|
||||
|
||||
const COLOR: Record<string, string> = {
|
||||
|
|
@ -237,7 +252,11 @@ export function BizMigrationPage() {
|
|||
const sheetCards = board?.run?.summary?.sheet_cards || [];
|
||||
const visibleDiffs = useMemo(() => {
|
||||
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);
|
||||
return rows;
|
||||
}, [diffs, onlyExpect, boardMetricId]);
|
||||
|
|
@ -245,10 +264,12 @@ export function BizMigrationPage() {
|
|||
const boardMetricOptions = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
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) {
|
||||
if (d.metric_id) ids.add(d.metric_id);
|
||||
const id = sheetIdentity(d);
|
||||
if (id) ids.add(id);
|
||||
}
|
||||
return [...ids];
|
||||
}, [sheetCards, diffs]);
|
||||
|
|
@ -288,7 +309,8 @@ export function BizMigrationPage() {
|
|||
}, [batch]);
|
||||
|
||||
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 kw = portFilter.trim().toLowerCase();
|
||||
if (!kw) return items;
|
||||
|
|
@ -378,8 +400,8 @@ export function BizMigrationPage() {
|
|||
const sheets = (res.sheets || []) as ExpectSheet[];
|
||||
setExpectSheets(sheets);
|
||||
setExpectMetricId((prev) => {
|
||||
if (prev && sheets.some((s) => s.metric_id === prev)) return prev;
|
||||
return sheets[0]?.metric_id || "";
|
||||
if (prev && sheets.some((s) => sheetIdentity(s) === prev)) return prev;
|
||||
return sheets[0] ? sheetIdentity(sheets[0]) : "";
|
||||
});
|
||||
} catch {
|
||||
setExpectSheets([]);
|
||||
|
|
@ -712,11 +734,14 @@ export function BizMigrationPage() {
|
|||
async function onCreateBatch() {
|
||||
if (!projectId) return;
|
||||
// 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[] = [];
|
||||
for (const sheet of expectSheets) {
|
||||
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 (!items.length && !ports.length) {
|
||||
|
|
@ -1416,20 +1441,30 @@ export function BizMigrationPage() {
|
|||
<div className="ct-editor__nav-list" role="tablist">
|
||||
{(expectSheets.length
|
||||
? 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) => {
|
||||
const sid = sheetIdentity(s);
|
||||
const active =
|
||||
(expectMetricId || expectSheets[0]?.metric_id) === s.metric_id;
|
||||
(expectMetricId || sheetIdentity(expectSheets[0])) === sid;
|
||||
return (
|
||||
<button
|
||||
key={s.metric_id}
|
||||
key={sid}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={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>
|
||||
</button>
|
||||
);
|
||||
|
|
@ -1605,7 +1640,7 @@ export function BizMigrationPage() {
|
|||
{t("bizMigration.newBaselineMissingBanner", {
|
||||
metrics: sheetCards
|
||||
.filter((c) => c.new_baseline_missing)
|
||||
.map((c) => c.metric_id)
|
||||
.map((c) => sheetLabel(c))
|
||||
.join(", "),
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -1703,22 +1738,23 @@ export function BizMigrationPage() {
|
|||
<span className="bs-cmp-nav__name">{t("bizMigration.allMetrics")}</span>
|
||||
</button>
|
||||
{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);
|
||||
return (
|
||||
<button
|
||||
key={c.metric_id}
|
||||
key={sid}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={`bs-cmp-nav__item${active ? " is-active" : ""}${
|
||||
hot ? " has-diff" : " is-clean"
|
||||
}`}
|
||||
onClick={() => setBoardMetricId(c.metric_id)}
|
||||
onClick={() => setBoardMetricId(sid)}
|
||||
>
|
||||
<span className="bs-cmp-nav__dot" />
|
||||
<span className="bs-cmp-nav__name">
|
||||
{c.metric_id}
|
||||
{sheetLabel(c)}
|
||||
{c.new_baseline_missing ? " · baseline" : ""}
|
||||
</span>
|
||||
<span className="bs-cmp-nav__stats">
|
||||
|
|
@ -1754,7 +1790,7 @@ export function BizMigrationPage() {
|
|||
<tbody>
|
||||
{visibleDiffs.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td>{d.metric_id || "—"}</td>
|
||||
<td>{sheetLabel(d)}</td>
|
||||
<td>
|
||||
<code>{d.key_str || "—"}</code>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import {
|
|||
type MetricField = { name: string; display_name?: string };
|
||||
type MetricSchema = { metric_id: string; fields: MetricField[] };
|
||||
type CompareSheet = {
|
||||
sheet_id?: string;
|
||||
title?: string;
|
||||
metric_id: string;
|
||||
key_fields?: string[];
|
||||
iface_fields?: string[];
|
||||
|
|
@ -48,6 +50,7 @@ type DualPat = {
|
|||
};
|
||||
type SheetOverride = {
|
||||
metric_id: string;
|
||||
sheet_id?: string;
|
||||
status_fields?: string[];
|
||||
down_values?: string[];
|
||||
up_values?: string[];
|
||||
|
|
@ -237,9 +240,10 @@ function defaultAnomalyPresenceOnly(): DualPat[] {
|
|||
];
|
||||
}
|
||||
|
||||
function emptyOverride(metricId: string): SheetOverride {
|
||||
function emptyOverride(metricId: string, sheetId = ""): SheetOverride {
|
||||
return {
|
||||
metric_id: metricId,
|
||||
sheet_id: sheetId || metricId,
|
||||
status_fields: [],
|
||||
down_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 sid = sheetId || mid;
|
||||
const withId = (ov: SheetOverride): SheetOverride => ({
|
||||
...ov,
|
||||
metric_id: mid,
|
||||
sheet_id: sid,
|
||||
});
|
||||
if (mid === "interface_brief") {
|
||||
const upConds: RuleCond[] = [
|
||||
{ type: "value", field: "admin", op: "in", value: ["up"] },
|
||||
{ type: "value", field: "phy", op: "in", value: ["up"] },
|
||||
];
|
||||
return {
|
||||
return withId({
|
||||
metric_id: mid,
|
||||
status_fields: ["admin", "phy", "prot"],
|
||||
down_values: ["down"],
|
||||
|
|
@ -279,11 +297,11 @@ export function presetForMetric(metricId: string): SheetOverride {
|
|||
],
|
||||
anomaly: defaultAnomalyForState("admin", ["down"]),
|
||||
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" }];
|
||||
return {
|
||||
return withId({
|
||||
metric_id: mid,
|
||||
status_fields: ["state"],
|
||||
down_values: ["idle", "active", "connect", "down"],
|
||||
|
|
@ -310,10 +328,10 @@ export function presetForMetric(metricId: string): SheetOverride {
|
|||
],
|
||||
anomaly: defaultAnomalyForState("state", ["idle", "active", "connect", "down"]),
|
||||
skip_dual: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (mid === "arp" || mid === "nd6_cache" || mid === "lldp_neighbor") {
|
||||
return {
|
||||
return withId({
|
||||
metric_id: mid,
|
||||
status_fields: [],
|
||||
down_values: [],
|
||||
|
|
@ -321,13 +339,13 @@ export function presetForMetric(metricId: string): SheetOverride {
|
|||
success: [successFromPresence(["removed"], ["added"])],
|
||||
anomaly: defaultAnomalyPresenceOnly(),
|
||||
skip_dual: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (mid.includes("isis") || mid.includes("ospf") || mid.includes("adjacency")) {
|
||||
const upConds: RuleCond[] = [
|
||||
{ type: "value", field: "state", op: "in", value: ["up", "full", "2way"] },
|
||||
];
|
||||
return {
|
||||
return withId({
|
||||
metric_id: mid,
|
||||
status_fields: ["state"],
|
||||
down_values: ["down", "init", "idle"],
|
||||
|
|
@ -354,10 +372,10 @@ export function presetForMetric(metricId: string): SheetOverride {
|
|||
],
|
||||
anomaly: defaultAnomalyForState("state", ["down", "init", "idle"]),
|
||||
skip_dual: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (mid.includes("route") || mid.includes("vrf")) {
|
||||
return {
|
||||
return withId({
|
||||
metric_id: mid,
|
||||
status_fields: [],
|
||||
down_values: [],
|
||||
|
|
@ -365,18 +383,23 @@ export function presetForMetric(metricId: string): SheetOverride {
|
|||
success: [successFromPresence(["removed"], ["added"])],
|
||||
anomaly: defaultAnomalyPresenceOnly(),
|
||||
skip_dual: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
return emptyOverride(mid);
|
||||
return emptyOverride(mid, sid);
|
||||
}
|
||||
|
||||
function skipPreset(metricId: string): SheetOverride {
|
||||
return { ...emptyOverride(metricId), skip_dual: true, success: [], anomaly: [] };
|
||||
function skipPreset(metricId: string, sheetId = ""): SheetOverride {
|
||||
return { ...emptyOverride(metricId, sheetId), skip_dual: true, success: [], anomaly: [] };
|
||||
}
|
||||
|
||||
function overrideFor(overrides: SheetOverride[], metricId: string): SheetOverride {
|
||||
const found = overrides.find((o) => o.metric_id === metricId);
|
||||
const merged = found ? { ...emptyOverride(metricId), ...found, metric_id: metricId } : emptyOverride(metricId);
|
||||
function overrideFor(overrides: SheetOverride[], metricId: string, sheetId = ""): SheetOverride {
|
||||
const sid = sheetId || 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 {
|
||||
...merged,
|
||||
success: (merged.success || []).map((p) => normalizePat(p)),
|
||||
|
|
@ -788,16 +811,20 @@ export function BizMonitorTemplatesPage() {
|
|||
const sheets = useMemo(() => selectedCompare?.metrics || [], [selectedCompare]);
|
||||
const activeSheet = sheets[activeSheetIdx] || sheets[0] || null;
|
||||
const activeMetricId = activeSheet?.metric_id || "";
|
||||
const activeSheetId = activeSheet ? sheetIdentity(activeSheet) : "";
|
||||
const activeOverride = useMemo(
|
||||
() => (activeMetricId ? overrideFor(overrides, activeMetricId) : emptyOverride("")),
|
||||
[overrides, activeMetricId],
|
||||
() =>
|
||||
activeMetricId
|
||||
? overrideFor(overrides, activeMetricId, activeSheetId)
|
||||
: emptyOverride(""),
|
||||
[overrides, activeMetricId, activeSheetId],
|
||||
);
|
||||
const activeFields = useMemo(() => {
|
||||
const schema = metricSchemas.find((m) => m.metric_id === activeMetricId);
|
||||
return schema?.fields || [];
|
||||
}, [metricSchemas, activeMetricId]);
|
||||
|
||||
const sheetKey = useMemo(() => sheets.map((s) => s.metric_id).join("|"), [sheets]);
|
||||
const sheetKey = useMemo(() => sheets.map((s) => sheetIdentity(s)).join("|"), [sheets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sheets.length) {
|
||||
|
|
@ -810,16 +837,21 @@ export function BizMonitorTemplatesPage() {
|
|||
useEffect(() => {
|
||||
if (!sheetKey) return;
|
||||
setOverrides((prev) => {
|
||||
const byId = new Map(prev.map((o) => [o.metric_id, o]));
|
||||
return sheets.map((s) => byId.get(s.metric_id) || emptyOverride(s.metric_id));
|
||||
const byId = new Map(prev.map((o) => [o.sheet_id || o.metric_id, o]));
|
||||
return sheets.map(
|
||||
(s) =>
|
||||
byId.get(sheetIdentity(s)) ||
|
||||
emptyOverride(s.metric_id, sheetIdentity(s)),
|
||||
);
|
||||
});
|
||||
}, [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) => {
|
||||
const others = prev.filter((o) => o.metric_id !== metricId);
|
||||
const base = overrideFor(prev, metricId);
|
||||
return [...others, { ...base, ...patch, metric_id: metricId }];
|
||||
const others = prev.filter((o) => (o.sheet_id || o.metric_id) !== sid);
|
||||
const base = overrideFor(prev, metricId, sid);
|
||||
return [...others, { ...base, ...patch, metric_id: metricId, sheet_id: sid }];
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -858,15 +890,18 @@ export function BizMonitorTemplatesPage() {
|
|||
const closeEdit = () => setEditOpen(false);
|
||||
|
||||
const applyPreset = (kind: "auto" | "skip") => {
|
||||
if (!activeMetricId) return;
|
||||
if (!activeMetricId || !activeSheetId) return;
|
||||
syncOverride(
|
||||
activeMetricId,
|
||||
kind === "skip" ? skipPreset(activeMetricId) : presetForMetric(activeMetricId),
|
||||
kind === "skip"
|
||||
? skipPreset(activeMetricId, activeSheetId)
|
||||
: presetForMetric(activeMetricId, activeSheetId),
|
||||
activeSheetId,
|
||||
);
|
||||
};
|
||||
|
||||
const applyAllPresets = () => {
|
||||
setOverrides(sheets.map((s) => presetForMetric(s.metric_id)));
|
||||
setOverrides(sheets.map((s) => presetForMetric(s.metric_id, sheetIdentity(s))));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
|
|
@ -1080,10 +1115,11 @@ export function BizMonitorTemplatesPage() {
|
|||
</div>
|
||||
<div className="mt-editor__nav-list" role="tablist">
|
||||
{sheets.map((s, i) => {
|
||||
const ov = overrideFor(overrides, s.metric_id);
|
||||
const sid = sheetIdentity(s);
|
||||
const ov = overrideFor(overrides, s.metric_id, sid);
|
||||
return (
|
||||
<button
|
||||
key={s.metric_id}
|
||||
key={sid}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeSheetIdx === i}
|
||||
|
|
@ -1093,7 +1129,7 @@ export function BizMonitorTemplatesPage() {
|
|||
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">
|
||||
{ov.skip_dual
|
||||
? t("bizMonitorTpl.tagSkip")
|
||||
|
|
@ -1116,9 +1152,9 @@ export function BizMonitorTemplatesPage() {
|
|||
<div className="mt-editor__pane">
|
||||
<div className="mt-editor__pane-head">
|
||||
<div>
|
||||
<div className="mt-editor__metric">{activeMetricId}</div>
|
||||
<div className="mt-editor__metric">{sheetLabel(activeSheet)}</div>
|
||||
<div className="mt-editor__meta muted">
|
||||
<span>Key {(activeSheet.key_fields || []).join(" · ") || "—"}</span>
|
||||
<span>{activeMetricId}</span>
|
||||
<span>
|
||||
{t("bizMonitorTpl.compare")}{" "}
|
||||
{(activeSheet.compare_fields || []).length
|
||||
|
|
@ -1142,7 +1178,9 @@ export function BizMonitorTemplatesPage() {
|
|||
<input
|
||||
type="checkbox"
|
||||
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>
|
||||
</label>
|
||||
|
|
@ -1177,7 +1215,9 @@ export function BizMonitorTemplatesPage() {
|
|||
patterns={activeOverride.success || []}
|
||||
fieldChoices={fieldChoices}
|
||||
t={t}
|
||||
onChange={(success) => syncOverride(activeMetricId, { success })}
|
||||
onChange={(success) =>
|
||||
syncOverride(activeMetricId, { success }, activeSheetId)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DualPatternList
|
||||
|
|
@ -1185,7 +1225,9 @@ export function BizMonitorTemplatesPage() {
|
|||
patterns={activeOverride.anomaly || []}
|
||||
fieldChoices={fieldChoices}
|
||||
t={t}
|
||||
onChange={(anomaly) => syncOverride(activeMetricId, { anomaly })}
|
||||
onChange={(anomaly) =>
|
||||
syncOverride(activeMetricId, { anomaly }, activeSheetId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -1217,26 +1259,29 @@ export function BizMonitorTemplatesPage() {
|
|||
{t("bizMonitorTpl.collectHint")}
|
||||
</p>
|
||||
<div className="mt-chip-row">
|
||||
{sheets.map((s) => {
|
||||
const checked = collectIds.length === 0 || collectIds.includes(s.metric_id);
|
||||
{Array.from(new Set(sheets.map((s) => s.metric_id).filter(Boolean))).map((mid) => {
|
||||
const checked = collectIds.length === 0 || collectIds.includes(mid);
|
||||
const explicit = collectIds.length > 0;
|
||||
const allMids = Array.from(
|
||||
new Set(sheets.map((s) => s.metric_id).filter(Boolean)),
|
||||
);
|
||||
return (
|
||||
<Chip
|
||||
key={s.metric_id}
|
||||
key={mid}
|
||||
active={checked}
|
||||
label={s.metric_id}
|
||||
label={mid}
|
||||
onClick={() => {
|
||||
if (!explicit) {
|
||||
setCollectIds(sheets.map((x) => x.metric_id).filter((id) => id !== s.metric_id));
|
||||
setCollectIds(allMids.filter((id) => id !== mid));
|
||||
return;
|
||||
}
|
||||
setCollectIds((prev) => {
|
||||
if (prev.includes(s.metric_id)) {
|
||||
const next = prev.filter((x) => x !== s.metric_id);
|
||||
return next.length === sheets.length ? [] : next;
|
||||
if (prev.includes(mid)) {
|
||||
const next = prev.filter((x) => x !== mid);
|
||||
return next.length === allMids.length ? [] : next;
|
||||
}
|
||||
const next = [...prev, s.metric_id];
|
||||
return next.length === sheets.length ? [] : next;
|
||||
const next = [...prev, mid];
|
||||
return next.length === allMids.length ? [] : next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue