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):
|
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"],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: "仅条目",
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue