Persist cutover evidence cards with device-raw A/B keys for board and AI.

Evaluate keeps match keys for normalize/map pairing while diffs, reds, and the board show collected ports plus show-command provenance; add monitor-context and command-raw read APIs with biz-monitor:read scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-19 22:48:37 +08:00
parent 285c19e5a3
commit c1b1c0f75d
17 changed files with 1141 additions and 57 deletions

View file

@ -140,6 +140,8 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。拓扑 Fabric / 画布工具在 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**。oclaw 中名称带前缀:`mcp__netx__<toolName>`;DSH:`netx__<toolName>`。 参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。拓扑 Fabric / 画布工具在 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**。oclaw 中名称带前缀:`mcp__netx__<toolName>`;DSH:`netx__<toolName>`。
**业务监控 / 割接分析**:不走 netx-mcp。DSH **netxops** 宿主工具组 `bizMonitor`(`netx__getBizMonitorContext` 等)直连 netx REST(`/v1/biz-migration/*`、`/v1/biz-state/*`,需 token scope `biz-monitor:read`)。
--- ---
## 6. 更新 MCP ## 6. 更新 MCP

View file

@ -16,6 +16,7 @@ SCOPE_WEBCRT = "webcrt:session"
SCOPE_SQL = "sql:query" SCOPE_SQL = "sql:query"
SCOPE_ADMIN_USERS = "admin:users" SCOPE_ADMIN_USERS = "admin:users"
SCOPE_OPS_WRITE = "ops:write" SCOPE_OPS_WRITE = "ops:write"
SCOPE_BIZ_MONITOR_READ = "biz-monitor:read"
ALL_SCOPES: frozenset[str] = frozenset( ALL_SCOPES: frozenset[str] = frozenset(
{ {
@ -27,20 +28,22 @@ ALL_SCOPES: frozenset[str] = frozenset(
SCOPE_SQL, SCOPE_SQL,
SCOPE_ADMIN_USERS, SCOPE_ADMIN_USERS,
SCOPE_OPS_WRITE, SCOPE_OPS_WRITE,
SCOPE_BIZ_MONITOR_READ,
} }
) )
ROLE_DEFAULT_SCOPES: dict[str, frozenset[str]] = { ROLE_DEFAULT_SCOPES: dict[str, frozenset[str]] = {
"admin": ALL_SCOPES, "admin": ALL_SCOPES,
# Read-only operator by default (alarms + inventory). # Read-only operator by default (alarms + inventory + biz monitor read).
"user": frozenset({SCOPE_ALARMS_READ, SCOPE_NE_READ}), "user": frozenset({SCOPE_ALARMS_READ, SCOPE_NE_READ, SCOPE_BIZ_MONITOR_READ}),
} }
# Default MCP bootstrap token: diagnostics CLI allowed; no interactive shell / SQL / writes. # Default MCP bootstrap token: diagnostics CLI + biz monitor read; no SQL / writes.
MCP_DEFAULT_SCOPES: tuple[str, ...] = ( MCP_DEFAULT_SCOPES: tuple[str, ...] = (
SCOPE_ALARMS_READ, SCOPE_ALARMS_READ,
SCOPE_NE_READ, SCOPE_NE_READ,
SCOPE_NE_EXEC, SCOPE_NE_EXEC,
SCOPE_BIZ_MONITOR_READ,
) )
@ -178,5 +181,10 @@ def required_scope_for_request(method: str, path: str) -> str | None:
if p.startswith("/v1/integrations"): if p.startswith("/v1/integrations"):
return SCOPE_ALARMS_READ return SCOPE_ALARMS_READ
if p.startswith("/v1/biz-migration") or p.startswith("/v1/biz-state"):
if m in ("POST", "PUT", "PATCH", "DELETE"):
return SCOPE_OPS_WRITE
return SCOPE_BIZ_MONITOR_READ
# Auth self-service, api-tokens, audit: any authenticated user # Auth self-service, api-tokens, audit: any authenticated user
return None return None

View file

@ -938,6 +938,89 @@ def override_for_metric(
return override_for_sheet(sheet_overrides, sheet_id=mid, metric_id=mid) return override_for_sheet(sheet_overrides, sheet_id=mid, metric_id=mid)
def strip_netx(row: dict[str, Any] | None) -> dict[str, Any]:
"""Drop collector provenance key before compare / board row bodies."""
if not row:
return {}
return {k: v for k, v in row.items() if k != "_netx"}
def row_match_key(
row: dict[str, Any],
*,
key_fields: list[str],
iface_fields: list[str],
rules: list[dict[str, str]] | None,
) -> str:
from ..biz_state.iface_normalize import apply_iface_normalize
data = strip_netx(row)
if iface_fields and rules:
data = apply_iface_normalize(dict(data), iface_fields=iface_fields, rules=rules)
return "|".join(str(data.get(k) or "").strip() for k in key_fields)
def index_raw_by_match_key(
raw_rows: list[dict[str, Any]],
*,
key_fields: list[str],
iface_fields: list[str],
rules: list[dict[str, str]] | None,
) -> dict[str, dict[str, Any]]:
"""First raw row per internal match key (normalized iface, raw elsewhere)."""
idx: dict[str, dict[str, Any]] = {}
for raw in raw_rows:
ks = row_match_key(raw, key_fields=key_fields, iface_fields=iface_fields, rules=rules)
if ks and ks not in idx:
idx[ks] = raw
return idx
def display_key_from_raw(raw: dict[str, Any] | None, key_fields: list[str]) -> str:
"""Board / AI facing identity: as collected on the device (no normalize / map)."""
if not raw or not key_fields:
return ""
data = strip_netx(raw)
return "|".join(str(data.get(k) or "").strip() for k in key_fields)
def iface_lineage(
raw: dict[str, Any] | None,
*,
iface_fields: list[str],
rules: list[dict[str, str]] | None,
port_map: dict[str, str] | None,
apply_map: bool,
) -> list[dict[str, Any]]:
"""Per iface field: raw → normalized → (optional) mapped."""
from ..biz_state.iface_normalize import normalize_iface_name, resolve_mapped_iface
if not raw or not iface_fields:
return []
data = strip_netx(raw)
pmap = port_map or {}
out: list[dict[str, Any]] = []
for f in iface_fields:
raw_v = str(data.get(f) or "").strip()
norm_v = normalize_iface_name(raw_v, rules) if rules else raw_v
mapped = False
map_to = ""
if apply_map and pmap and norm_v:
map_to = resolve_mapped_iface(norm_v, pmap)
mapped = bool(map_to) and map_to != norm_v
out.append(
{
"field": f,
"raw": raw_v,
"normalized": norm_v,
"mapped": mapped,
"map_from": norm_v if apply_map and pmap else "",
"map_to": map_to if apply_map and pmap else "",
}
)
return out
def evaluate_metric_dual( def evaluate_metric_dual(
*, *,
metric_id: str, metric_id: str,
@ -958,7 +1041,12 @@ def evaluate_metric_dual(
sheet_id: str = "", sheet_id: str = "",
iface_normalize_rules: list[dict[str, str]] | None = None, iface_normalize_rules: list[dict[str, str]] | None = None,
) -> 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.
Matching uses normalized (+ port-mapped) keys internally.
Board / AI fields ``old_key`` / ``new_key`` and ``old`` / ``new`` stay **raw**
device values (A/B), never the post-map BB form.
"""
from ..biz_state.iface_normalize import ( from ..biz_state.iface_normalize import (
apply_iface_normalize_rows, apply_iface_normalize_rows,
normalize_iface_rules, normalize_iface_rules,
@ -986,20 +1074,48 @@ def evaluate_metric_dual(
success_patterns = list(ov.get("success") or []) if isinstance(ov.get("success"), list) else [] success_patterns = list(ov.get("success") or []) if isinstance(ov.get("success"), list) else []
anomaly_patterns = list(ov.get("anomaly") or []) if isinstance(ov.get("anomaly"), list) else [] anomaly_patterns = list(ov.get("anomaly") or []) if isinstance(ov.get("anomaly"), list) else []
# Keep collector originals for display / evidence; compare on normalized copies.
raw_old_base = [dict(r) for r in (old_baseline_rows or [])]
raw_old_cur = [dict(r) for r in (old_current_rows or [])]
raw_new_cur = [dict(r) for r in (new_current_rows or [])]
raw_new_base = (
[dict(r) for r in new_baseline_rows] if new_baseline_rows is not None else None
)
norm_rules = normalize_iface_rules(iface_normalize_rules) norm_rules = normalize_iface_rules(iface_normalize_rules)
old_baseline_rows = apply_iface_normalize_rows( old_baseline_rows = apply_iface_normalize_rows(
old_baseline_rows, iface_fields=iface_fields, rules=norm_rules [strip_netx(r) for r in raw_old_base], iface_fields=iface_fields, rules=norm_rules
) )
old_current_rows = apply_iface_normalize_rows( old_current_rows = apply_iface_normalize_rows(
old_current_rows, iface_fields=iface_fields, rules=norm_rules [strip_netx(r) for r in raw_old_cur], iface_fields=iface_fields, rules=norm_rules
) )
new_current_rows = apply_iface_normalize_rows( new_current_rows = apply_iface_normalize_rows(
new_current_rows, iface_fields=iface_fields, rules=norm_rules [strip_netx(r) for r in raw_new_cur], iface_fields=iface_fields, rules=norm_rules
) )
if new_baseline_rows is not None: if raw_new_base is not None:
new_baseline_rows = apply_iface_normalize_rows( new_baseline_rows = apply_iface_normalize_rows(
new_baseline_rows, iface_fields=iface_fields, rules=norm_rules [strip_netx(r) for r in raw_new_base],
iface_fields=iface_fields,
rules=norm_rules,
) )
else:
new_baseline_rows = None
old_raw_cur_idx = index_raw_by_match_key(
raw_old_cur, key_fields=key_fields, iface_fields=iface_fields, rules=norm_rules
)
old_raw_base_idx = index_raw_by_match_key(
raw_old_base, key_fields=key_fields, iface_fields=iface_fields, rules=norm_rules
)
new_raw_cur_idx = index_raw_by_match_key(
raw_new_cur, key_fields=key_fields, iface_fields=iface_fields, rules=norm_rules
)
new_raw_base_idx = index_raw_by_match_key(
raw_new_base or [],
key_fields=key_fields,
iface_fields=iface_fields,
rules=norm_rules,
)
old_cmp = compare_rows( old_cmp = compare_rows(
before_rows=old_baseline_rows, before_rows=old_baseline_rows,
@ -1173,22 +1289,83 @@ def evaluate_metric_dual(
if in_exp: if in_exp:
anomaly_in_expect += 1 anomaly_in_expect += 1
# Prefer live raw rows; fall back to baseline raw. Never use port-mapped
# synthetic "before" as the new-side display row.
raw_old = old_raw_cur_idx.get(old_ks) or old_raw_base_idx.get(old_ks)
raw_new = new_raw_cur_idx.get(new_ks) or new_raw_cur_idx.get(old_ks)
if not raw_new:
raw_new = new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks)
display_old = display_key_from_raw(raw_old, key_fields) or old_ks
display_new = display_key_from_raw(raw_new, key_fields) or (
new_ks if raw_new is not None else ""
)
# If new side missing entirely, still show map target as hint only in match_*
if not display_new and new_ks and new_ks != old_ks:
display_new = ""
old_disp = strip_netx(raw_old) if raw_old else {}
new_disp = strip_netx(raw_new) if raw_new else {}
old_base_raw = strip_netx(old_raw_base_idx.get(old_ks)) if old_raw_base_idx.get(old_ks) else {}
new_base_raw = (
strip_netx(new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks))
if (new_raw_base_idx.get(new_ks) or new_raw_base_idx.get(old_ks))
else {}
)
evidence = {
"old": {
"netx": dict((raw_old or {}).get("_netx") or {}),
"iface": iface_lineage(
raw_old,
iface_fields=iface_fields,
rules=norm_rules,
port_map=port_map,
apply_map=True,
),
},
"new": {
"netx": dict((raw_new or {}).get("_netx") or {}),
"iface": iface_lineage(
raw_new,
iface_fields=iface_fields,
rules=norm_rules,
port_map=None,
apply_map=False,
),
},
"port_map": {
"applied": bool(port_map and iface_fields and old_ks != new_ks),
"match_before": old_ks,
"match_after": new_ks,
"display_before": display_old,
"display_after": display_new,
},
}
rows_out.append( rows_out.append(
{ {
"metric_id": metric_id, "metric_id": metric_id,
"key": (od or nd or {}).get("key") or [old_ks], "key": (od or nd or {}).get("key") or [old_ks],
"key_str": old_ks, # Display identity (A/B as collected) — board & MCP primary
"new_key_str": new_ks, "old_key": display_old,
"new_key": display_new,
# Internal match keys (normalized / remapped)
"match_old_key": old_ks,
"match_new_key": new_ks,
# Aliases: key_str = display old; new_key_str = display new
"key_str": display_old,
"new_key_str": display_new,
"verdict": verdict, "verdict": verdict,
"color": color, "color": color,
"rule_hit": rule_hit, "rule_hit": rule_hit,
"in_expect": in_exp, "in_expect": in_exp,
"old_kind": old_kind, "old_kind": old_kind,
"new_kind": new_kind, "new_kind": new_kind,
"old": old_cur, "old": old_disp,
"new": new_cur, "new": new_disp,
"old_baseline": dict((od or {}).get("before") or {}), "old_baseline": old_base_raw,
"new_baseline": dict((nd or {}).get("before") or {}), "new_baseline": new_base_raw,
"old_status": status_label(old_cur, ov) "old_status": status_label(old_cur, ov)
if old_cur if old_cur
else ("gone" if old_kind == "removed" else "—"), else ("gone" if old_kind == "removed" else "—"),
@ -1209,6 +1386,7 @@ def evaluate_metric_dual(
) )
if new_kind if new_kind
else ("", "gray"), else ("", "gray"),
"evidence": evidence,
} }
) )

View file

@ -322,9 +322,17 @@ def _project_collect_override(proj: BizMigrationProject) -> list[str]:
def _task_brief(db: Session, task_id: str) -> dict[str, Any]: def _task_brief(db: Session, task_id: str) -> dict[str, Any]:
t = db.get(BizStateTask, task_id) if task_id else None t = db.get(BizStateTask, task_id) if task_id else None
if not t: if not t:
return {"id": task_id or "", "ne_name": "", "ne_ip": "", "vendor": "", "purpose": ""} return {
"id": task_id or "",
"ne_id": "",
"ne_name": "",
"ne_ip": "",
"vendor": "",
"purpose": "",
}
return { return {
"id": t.id, "id": t.id,
"ne_id": t.ne_id or "",
"ne_name": t.ne_name, "ne_name": t.ne_name,
"ne_ip": t.ne_ip, "ne_ip": t.ne_ip,
"vendor": t.vendor, "vendor": t.vendor,
@ -838,6 +846,7 @@ def run_evaluate(
sheet_cards: list[dict[str, Any]] = [] sheet_cards: list[dict[str, Any]] = []
all_rows: list[dict[str, Any]] = [] all_rows: list[dict[str, Any]] = []
metric_batches: dict[str, dict[str, str]] = {}
seq = 0 seq = 0
verdict_counts: dict[str, int] = {} verdict_counts: dict[str, int] = {}
@ -936,6 +945,16 @@ def run_evaluate(
out_of_expect=out_of_expect, out_of_expect=out_of_expect,
iface_normalize_rules=iface_norm, iface_normalize_rules=iface_norm,
) )
old_tid = _hf_task_id_for_metric(proj, "old", mid) or current_task_id(proj, "old")
new_tid = _hf_task_id_for_metric(proj, "new", mid) or current_task_id(proj, "new")
old_task_brief = _task_brief(db, old_tid)
new_task_brief = _task_brief(db, new_tid)
metric_batches[mid] = {
"old_batch_id": old_cur_mid,
"new_batch_id": new_cur_mid,
"old_task_id": old_tid,
"new_task_id": new_tid,
}
sheet_cards.append( sheet_cards.append(
{ {
"metric_id": mid, "metric_id": mid,
@ -950,9 +969,19 @@ def run_evaluate(
"new_baseline_mode": one.get("new_baseline_mode") or "provided", "new_baseline_mode": one.get("new_baseline_mode") or "provided",
"new_baseline_missing": bool(one.get("new_baseline_missing")), "new_baseline_missing": bool(one.get("new_baseline_missing")),
"collect_skipped": False, "collect_skipped": False,
"old_batch_id": old_cur_mid,
"new_batch_id": new_cur_mid,
} }
) )
for r in one["rows"]: for r in one["rows"]:
_enrich_row_evidence(
db,
r,
old_batch_id=old_cur_mid,
new_batch_id=new_cur_mid,
old_task=old_task_brief,
new_task=new_task_brief,
)
r["seq"] = seq r["seq"] = seq
r["sheet_id"] = sid r["sheet_id"] = sid
seq += 1 seq += 1
@ -977,6 +1006,7 @@ def run_evaluate(
"monitor_template_id": mt.id, "monitor_template_id": mt.id,
"compare_template_id": mt.compare_template_id or "", "compare_template_id": mt.compare_template_id or "",
"acceptance": acceptance, "acceptance": acceptance,
"metric_batches": metric_batches,
"sheet_cards": sheet_cards, "sheet_cards": sheet_cards,
"progress": { "progress": {
"ok": sum(c["progress_ok"] for c in active_cards), "ok": sum(c["progress_ok"] for c in active_cards),
@ -1011,10 +1041,16 @@ def run_evaluate(
db.flush() db.flush()
for r in all_rows: for r in all_rows:
key_list = r.get("key") or [] key_list = r.get("key") or []
ev = r.get("evidence") if isinstance(r.get("evidence"), dict) else {}
pm = ev.get("port_map") if isinstance(ev.get("port_map"), dict) else {}
search = " ".join( search = " ".join(
[ [
str(r.get("key_str") or ""), str(r.get("old_key") or r.get("key_str") or ""),
str(r.get("new_key_str") or ""), str(r.get("new_key") or r.get("new_key_str") or ""),
str(r.get("match_old_key") or ""),
str(r.get("match_new_key") or ""),
str(pm.get("match_before") or ""),
str(pm.get("match_after") or ""),
str(r.get("verdict") or ""), str(r.get("verdict") or ""),
str(r.get("old_status") or ""), str(r.get("old_status") or ""),
str(r.get("new_status") or ""), str(r.get("new_status") or ""),
@ -1031,12 +1067,17 @@ def run_evaluate(
color=str(r.get("color") or ""), color=str(r.get("color") or ""),
key_json={ key_json={
"key": key_list, "key": key_list,
"key_str": r.get("key_str"), "old_key": r.get("old_key") or r.get("key_str"),
"new_key_str": r.get("new_key_str"), "new_key": r.get("new_key") or r.get("new_key_str"),
"key_str": r.get("old_key") or r.get("key_str"),
"new_key_str": r.get("new_key") or r.get("new_key_str"),
"match_old_key": r.get("match_old_key") or "",
"match_new_key": r.get("match_new_key") or "",
"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 "", "sheet_id": r.get("sheet_id") or "",
"evidence": r.get("evidence") 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 ""),
@ -1078,8 +1119,69 @@ def run_to_dict(db: Session, run: BizMigrationRun, *, include_diffs: bool = Fals
return out return out
def _command_brief(db: Session, command_id: str) -> dict[str, Any]:
from ..models import BizStateBatchCommand
cid = str(command_id or "").strip()
if not cid:
return {}
c = db.get(BizStateBatchCommand, cid)
if not c:
return {"command_id": cid}
return {
"command_id": c.id,
"raw_command": c.raw_command or "",
"parse_status": c.parse_status or "",
"row_count": int(c.row_count or 0),
"profile_id": c.profile_id or "",
"parser_id": c.parser_id or "",
"metric_id": c.metric_id or "",
"message": (c.message or "")[:300],
}
def _enrich_row_evidence(
db: Session,
row: dict[str, Any],
*,
old_batch_id: str,
new_batch_id: str,
old_task: dict[str, Any] | None,
new_task: dict[str, Any] | None,
) -> None:
"""Attach device / collect / show-command onto evaluate evidence (in-place)."""
ev = dict(row.get("evidence") or {})
for side, brief, batch_id in (
("old", old_task or {}, old_batch_id),
("new", new_task or {}, new_batch_id),
):
side_ev = dict(ev.get(side) or {})
netx = dict(side_ev.get("netx") or {})
cmd = _command_brief(db, str(netx.get("batch_command_id") or ""))
side_ev["device"] = {
"ne_id": str(brief.get("ne_id") or netx.get("ne_id") or ""),
"ne_name": str(brief.get("ne_name") or ""),
"ne_ip": str(brief.get("ne_ip") or ""),
"side": side,
}
# Prefer the metric row's own batch (baseline vs current); fall back to
# the side's evaluate batch id used for this sheet.
side_ev["collect"] = {
"task_id": str(netx.get("task_id") or brief.get("id") or ""),
"batch_id": str(netx.get("batch_id") or batch_id or ""),
"side_batch_id": str(batch_id or ""),
"collected_at": netx.get("collected_at"),
"parse_status": str(cmd.get("parse_status") or ""),
}
side_ev["command"] = cmd
ev[side] = side_ev
row["evidence"] = ev
def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]: def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]:
kj = d.key_json if isinstance(d.key_json, dict) else {} kj = d.key_json if isinstance(d.key_json, dict) else {}
old_key = str(kj.get("old_key") or kj.get("key_str") or "")
new_key = str(kj.get("new_key") or kj.get("new_key_str") or "")
return { return {
"id": d.id, "id": d.id,
"metric_id": d.metric_id, "metric_id": d.metric_id,
@ -1088,11 +1190,16 @@ def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]:
"verdict": d.verdict, "verdict": d.verdict,
"color": d.color, "color": d.color,
"key": kj, "key": kj,
"key_str": kj.get("key_str") or "", "old_key": old_key,
"new_key_str": kj.get("new_key_str") or "", "new_key": new_key,
"key_str": old_key,
"new_key_str": new_key,
"match_old_key": str(kj.get("match_old_key") or ""),
"match_new_key": str(kj.get("match_new_key") or ""),
"old_status": kj.get("old_status") or "", "old_status": kj.get("old_status") or "",
"new_status": kj.get("new_status") or "", "new_status": kj.get("new_status") or "",
"rule_hit": kj.get("rule_hit") or "", "rule_hit": kj.get("rule_hit") or "",
"evidence": dict(kj.get("evidence") or {}),
"old_kind": d.old_kind, "old_kind": d.old_kind,
"new_kind": d.new_kind, "new_kind": d.new_kind,
"old": d.old_json, "old": d.old_json,
@ -1690,19 +1797,27 @@ def collect_project_now(db: Session, project_id: str) -> dict[str, Any]:
def _red_ticket_to_dict(t: BizMigrationRedTicket) -> dict[str, Any]: def _red_ticket_to_dict(t: BizMigrationRedTicket) -> dict[str, Any]:
detail = dict(t.detail_json or {})
old_key = str(detail.get("old_key") or t.key_str or "")
new_key = str(detail.get("new_key") or t.new_key_str or "")
return { return {
"id": t.id, "id": t.id,
"project_id": t.project_id, "project_id": t.project_id,
"batch_id": t.batch_id, "batch_id": t.batch_id,
"run_id": t.run_id, "run_id": t.run_id,
"metric_id": t.metric_id, "metric_id": t.metric_id,
"key_str": t.key_str, "old_key": old_key,
"new_key_str": t.new_key_str, "new_key": new_key,
"key_str": old_key,
"new_key_str": new_key,
"match_old_key": str(detail.get("match_old_key") or ""),
"match_new_key": str(detail.get("match_new_key") or ""),
"verdict": t.verdict, "verdict": t.verdict,
"color": t.color, "color": t.color,
"old_status": t.old_status, "old_status": t.old_status,
"new_status": t.new_status, "new_status": t.new_status,
"detail": dict(t.detail_json or {}), "detail": detail,
"evidence": dict(detail.get("evidence") or {}),
"status": t.status, "status": t.status,
"carried_to_batch_id": t.carried_to_batch_id, "carried_to_batch_id": t.carried_to_batch_id,
"note": t.note, "note": t.note,
@ -1793,6 +1908,11 @@ def _persist_red_tickets_from_run(
"in_expect": d.in_expect, "in_expect": d.in_expect,
"old": d.old_json, "old": d.old_json,
"new": d.new_json, "new": d.new_json,
"old_key": kj.get("old_key") or kj.get("key_str") or "",
"new_key": kj.get("new_key") or kj.get("new_key_str") or "",
"match_old_key": kj.get("match_old_key") or "",
"match_new_key": kj.get("match_new_key") or "",
"evidence": dict(kj.get("evidence") or {}),
}, },
status="open", status="open",
) )
@ -2004,3 +2124,110 @@ def board(db: Session, batch_id: str, run_id: str = "") -> dict[str, Any]:
"batch": batch_to_dict(mb), "batch": batch_to_dict(mb),
"run": run_to_dict(db, run) if run else None, "run": run_to_dict(db, run) if run else None,
} }
def get_monitor_context(
db: Session,
*,
project_id: str = "",
task_id: str = "",
) -> dict[str, Any]:
"""Fat read for AI / ops: project definition + templates + mapping + tasks.
Prefer ``project_id`` (cutover). ``task_id`` alone returns that biz_state task
and any cutover project that references it.
"""
from ..biz_state import compare_service as cmp_svc
from ..biz_state import service as biz_svc
pid = str(project_id or "").strip()
tid = str(task_id or "").strip()
if not pid and not tid:
raise HTTPException(status_code=400, detail="project_id_or_task_id_required")
proj_row: BizMigrationProject | None = None
if pid:
proj_row = db.get(BizMigrationProject, pid)
if not proj_row:
raise HTTPException(status_code=404, detail="project_not_found")
elif tid:
proj_row = (
db.query(BizMigrationProject)
.filter(
(BizMigrationProject.old_task_id == tid)
| (BizMigrationProject.new_task_id == tid)
| (BizMigrationProject.old_hf_task_id == tid)
| (BizMigrationProject.new_hf_task_id == tid)
)
.order_by(BizMigrationProject.updated_at.desc())
.first()
)
out: dict[str, Any] = {
"project": None,
"monitor_template": None,
"compare_template": None,
"port_mapping": None,
"tasks": {},
"task": None,
}
if tid:
try:
out["task"] = biz_svc.get_task(db, tid)
except HTTPException:
if not proj_row:
raise
if not proj_row:
return out
migrate_project_hf_slots(db, proj_row, commit=False)
out["project"] = project_to_dict(db, proj_row)
mt_id = str(getattr(proj_row, "monitor_template_id", None) or "").strip()
if mt_id:
try:
out["monitor_template"] = mon_tpl.get_monitor_template(db, mt_id)
except HTTPException:
out["monitor_template"] = None
ct_id = ""
if isinstance(out.get("monitor_template"), dict):
ct_id = str(out["monitor_template"].get("compare_template_id") or "").strip()
if ct_id:
ct = db.get(BizCompareTemplate, ct_id)
if ct:
out["compare_template"] = cmp_svc._template_out(ct) # noqa: SLF001
map_id = str(proj_row.mapping_id or "").strip()
if map_id:
m = db.get(BizPortMapping, map_id)
if m:
out["port_mapping"] = cmp_svc._mapping_out(db, m) # noqa: SLF001
task_ids = {
"old_portrait": proj_row.old_task_id or "",
"new_portrait": proj_row.new_task_id or "",
"old_hf": getattr(proj_row, "old_hf_task_id", None) or "",
"new_hf": getattr(proj_row, "new_hf_task_id", None) or "",
}
for side in ("old", "new"):
for i, b in enumerate(_hf_bindings(proj_row, side)):
bid = str(b.get("task_id") or "").strip()
if bid:
task_ids[f"{side}_hf_{i}"] = bid
tasks: dict[str, Any] = {}
for label, task_ref in task_ids.items():
if not task_ref or task_ref in {t.get("id") for t in tasks.values() if isinstance(t, dict)}:
# still key by label even if duplicate id
pass
if not task_ref:
continue
try:
tasks[label] = biz_svc.get_task(db, task_ref)
except HTTPException:
tasks[label] = {"id": task_ref, "error": "task_not_found"}
out["tasks"] = tasks
return out

View file

@ -125,6 +125,16 @@ def api_get_project(project_id: str, db: Session = Depends(get_db)):
return svc.get_project(db, project_id) return svc.get_project(db, project_id)
@router.get("/monitor-context")
def api_monitor_context(
project_id: str = "",
task_id: str = "",
db: Session = Depends(get_db),
):
"""Fat definition bundle for AI / ops (templates, mapping, tasks)."""
return svc.get_monitor_context(db, project_id=project_id, task_id=task_id)
@router.patch("/projects/{project_id}") @router.patch("/projects/{project_id}")
def api_patch_project(project_id: str, body: ProjectPatchIn, db: Session = Depends(get_db)): def api_patch_project(project_id: str, body: ProjectPatchIn, db: Session = Depends(get_db)):
return svc.patch_project(db, project_id, body.model_dump(exclude_unset=True)) return svc.patch_project(db, project_id, body.model_dump(exclude_unset=True))

View file

@ -1191,6 +1191,14 @@ def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dic
"remote_if": n.remote_if, "remote_if": n.remote_if,
"remote_ip": n.remote_ip, "remote_ip": n.remote_ip,
"protocol": n.protocol, "protocol": n.protocol,
"_netx": {
"batch_id": batch_id,
"batch_command_id": n.batch_command_id or "",
"task_id": n.task_id or "",
"ne_id": n.ne_id or "",
"collected_at": n.collected_at.isoformat() + "Z" if n.collected_at else None,
"row_id": n.id,
},
} }
for n in rows for n in rows
] ]
@ -1203,7 +1211,19 @@ def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dic
.all() .all()
) )
return [ return [
{"vrf": r.vrf, "source": r.source, "networks": r.networks} {
"vrf": r.vrf,
"source": r.source,
"networks": r.networks,
"_netx": {
"batch_id": batch_id,
"batch_command_id": r.batch_command_id or "",
"task_id": r.task_id or "",
"ne_id": r.ne_id or "",
"collected_at": r.collected_at.isoformat() + "Z" if r.collected_at else None,
"row_id": r.id,
},
}
for r in rows for r in rows
] ]
# Generic tabular metrics (ISIS / interface / ARP / ND6 / BGP …) # Generic tabular metrics (ISIS / interface / ARP / ND6 / BGP …)
@ -1221,7 +1241,21 @@ def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dic
if rows: if rows:
# Raw rows only — filtering belongs to the compare sheet template # Raw rows only — filtering belongs to the compare sheet template
# (``row_filters``), not metric-specific branches here. # (``row_filters``), not metric-specific branches here.
return [dict(r.data_json or {}) for r in rows] # ``_netx`` is collector provenance (stripped before field compare).
return [
{
**dict(r.data_json or {}),
"_netx": {
"batch_id": batch_id,
"batch_command_id": r.batch_command_id or "",
"task_id": r.task_id or "",
"ne_id": r.ne_id or "",
"collected_at": r.collected_at.isoformat() + "Z" if r.collected_at else None,
"row_id": r.id,
},
}
for r in rows
]
# Known metric with zero rows is OK; unknown metric still errors # Known metric with zero rows is OK; unknown metric still errors
if metric_id in metric_field_map(): if metric_id in metric_field_map():
return [] return []

View file

@ -627,6 +627,41 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
} }
def get_batch_command(db: Session, batch_id: str, command_id: str) -> dict[str, Any]:
"""Full CLI raw text for one collect command (AI / deep dive)."""
from ..models import BizStateBatchCommand
b = db.get(BizStateBatch, batch_id)
if not b:
raise HTTPException(status_code=404, detail="batch_not_found")
c = db.get(BizStateBatchCommand, command_id)
if not c or c.batch_id != batch_id:
raise HTTPException(status_code=404, detail="command_not_found")
task = db.get(BizStateTask, b.task_id) if b.task_id else None
return {
"id": c.id,
"batch_id": batch_id,
"task_id": b.task_id,
"device": {
"ne_id": task.ne_id if task else "",
"ne_name": task.ne_name if task else "",
"ne_ip": task.ne_ip if task else "",
},
"profile_id": c.profile_id,
"parser_id": c.parser_id,
"metric_id": c.metric_id,
"raw_command": c.raw_command,
"params": c.params_json or {},
"parse_status": c.parse_status,
"row_count": c.row_count,
"message": c.message,
"raw_text": c.raw_text or "",
"collected_at": c.created_at.isoformat() + "Z" if c.created_at else None,
"batch_started_at": b.started_at.isoformat() + "Z" if b.started_at else None,
"batch_ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,
}
def export_batch_zip(db: Session, batch_id: str) -> bytes: def export_batch_zip(db: Session, batch_id: str) -> bytes:
detail = get_batch(db, batch_id) detail = get_batch(db, batch_id)
buf = io.BytesIO() buf = io.BytesIO()

View file

@ -237,6 +237,13 @@ def api_get_batch(batch_id: str, db: Session = Depends(get_db)) -> dict[str, Any
return svc.get_batch(db, batch_id) return svc.get_batch(db, batch_id)
@router.get("/batches/{batch_id}/commands/{command_id}")
def api_get_batch_command(
batch_id: str, command_id: str, db: Session = Depends(get_db)
) -> dict[str, Any]:
return svc.get_batch_command(db, batch_id, command_id)
@router.get("/batches/{batch_id}/export") @router.get("/batches/{batch_id}/export")
def api_export_batch(batch_id: str, db: Session = Depends(get_db)) -> StreamingResponse: def api_export_batch(batch_id: str, db: Session = Depends(get_db)) -> StreamingResponse:
data = svc.export_batch_zip(db, batch_id) data = svc.export_batch_zip(db, batch_id)

View file

@ -21,6 +21,7 @@ def test_http_mcp_tool_list_has_expected_tools() -> None:
assert "execManagedNe" in names assert "execManagedNe" in names
assert "listCliTargets" in names assert "listCliTargets" in names
assert "findTopologyPaths" in names assert "findTopologyPaths" in names
assert "getBizMonitorContext" not in names
assert "queryUmeAlarms" not in names assert "queryUmeAlarms" not in names
assert "queryTopologyEdges" not in names assert "queryTopologyEdges" not in names
exec_tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "execManagedNe") exec_tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "execManagedNe")

View file

@ -6,15 +6,16 @@
|-------|--------------|----------------------------------------| |-------|--------------|----------------------------------------|
| `ops` | `ops/netx-ops/` | NMS 告警/库存/SQL + managed CLI + `findTopologyPaths` | | `ops` | `ops/netx-ops/` | NMS 告警/库存/SQL + managed CLI + `findTopologyPaths` |
| `topology` | `topology/netx-topology/` | 画布 / Fabric / dual_unit / 布图 | | `topology` | `topology/netx-topology/` | 画布 / Fabric / dual_unit / 布图 |
| `biz-state` | `biz-state/netx-biz-state/` | 状态监控:TextFSM / parser / Profile / 多辅 EnrichJoin(Cursor Agent;无 MCP) | | `biz-monitor` | `biz-monitor/netx-biz-monitor/` | 割接/业务监控只读分析(**netxops 宿主工具**直连 REST;非 netx-mcp) |
| `biz-state` | `biz-state/netx-biz-state/` | 状态监控:TextFSM / parser / Profile(Cursor Agent;无 DSH 工具) |
## DSH 怎么用 ## DSH 怎么用
1. 运行时优先:`NETX_SKILLS_ROOT` → 旁路 `../netx/skills` → 包内 `presets/netxops/skills` 1. 运行时优先:`NETX_SKILLS_ROOT` → 旁路 `../netx/skills` → 包内 `presets/netxops/skills`
2. 发 npm 前:`powershell -File netxops/scripts/sync-skills-from-netx.ps1` 2. 发 npm 前:`powershell -File netxops/scripts/sync-skills-from-netx.ps1`
3. Settings → 能力组开关:开哪组就注册哪组的 **tools + 对应 skill** 3. Settings → 能力组开关:开哪组就注册哪组的 **tools + 对应 skill**(含 **bizMonitor**)
4. 其它预设可强制挂:`dsh-netxops/tools-ops|topology`(旧别名 `tools-nms` / `tools-common` → ops) 4. 其它预设可强制挂:`dsh-netxops/tools-ops|topology|biz-monitor`(旧别名 `tools-nms` / `tools-common` → ops)
MCP / Cursor:skill 根直接指本目录。MCP 包内不镜像 skills。 MCP / Cursor:skill 根直接指本目录。MCP 包内不镜像 skills。业务监控分析工具只在 **netxops**,不要往 netx-mcp 加。
**Cursor Agent**:加 biz_state 监控命令时启用或点名 **netx-biz-state**(`skills/biz-state/netx-biz-state/`)。 **Cursor Agent**:加 biz_state 监控命令时启用或点名 **netx-biz-state**(`skills/biz-state/netx-biz-state/`)。

View file

@ -0,0 +1,61 @@
---
name: netx-biz-monitor
description: >-
Overnight cutover / biz_state monitor analysis via netxops host tools
(netx__getBizMonitor*). Given a project_id or task_id, read definitions,
board, reds/diffs with evidence (device-raw A/B, show commands), and judge
tool false-positive vs real business fault. Does not create templates/tasks.
---
# netx-biz-monitor(割接 / 业务监控只读分析)
人配好监控与任务;AI 当晚用 **project_id**(或 task_id)做归因。
工具在 **netxops**(`netx__*`),直连 netx REST,**不是** netx-mcp。
## 工具(能力组 `bizMonitor`)
| 工具 | 用途 |
|------|------|
| `netx__getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 |
| `netx__getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 |
| `netx__listBizMonitorReds` | 红单 + **evidence** |
| `netx__getBizMonitorDiffs` | run 明细(可滤 color=red) |
| `netx__getBizCollectBatch` | 某次采集:commands / metrics |
| `netx__getBizCollectCommandRaw` | 单命令全量 show 输出 |
交叉验证真障时再用 ops:`netx__execManagedNe` / 拓扑路径。
## 关键约定(必守)
- **`old_key` / `new_key`**:设备采集原文 **A/B**(大屏与结论用这个)
- **`match_*` / evidence.iface.normalized|map_***:内部配对,**不要**把映射后的 BB 说成两侧端口
- **evidence.*.command.raw_command**:实际 show 命令
- **evidence.*.device / collect**:哪台设备、何时采的
## 推荐顺序
1. `getBizMonitorContext(project_id)` — 弄清盯什么
2. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped
3. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)`
4. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤?
5. 需要原文 → `getBizCollectCommandRaw`
6. 像真障 → `execManagedNe` 现场核对
## Verdict 速查(skill 解释,工具不硬编码)
| verdict | 含义(窗口内) |
|---------|----------------|
| migrated | expect 项已迁到新侧 |
| anomaly | 异常(多为红) |
| unfinished | expect 未完成(验收常红) |
| migrating / expected / ok | 过程态 / 正常 |
| lost / not_involved | 丢失或无关 |
## 误报优先查
1. `parse_status` 非 ok / 行数为 0
2. board:`current_missing` / `collect_skipped`
3. 端口映射漏配、归一化前后不一致导致假 anomaly
4. 再谈真实业务问题
配置整改与关红单由人做;本 skill **只读分析**。

View file

@ -1160,5 +1160,56 @@ class PortMapScopeTests(unittest.TestCase):
self.assertTrue(any(r["key_str"] == "1.1.1.1" and r["in_expect"] for r in out["rows"])) self.assertTrue(any(r["key_str"] == "1.1.1.1" and r["in_expect"] for r in out["rows"]))
class DisplayRawABTests(unittest.TestCase):
"""Board/AI must show device-raw A/B, not post-map BB."""
def test_normalize_and_map_keeps_display_ab(self):
# Old device shows GE-…; template normalizes GE→gei; map gei-old → xgei-new.
# New device collected xgei-new. Internal match is gei-old↔xgei-new;
# display must stay GE-old / xgei-new (never xgei-new / xgei-new).
rules = [{"from": "GE", "to": "gei"}]
out = evaluate_metric_dual(
metric_id="interface_brief",
key_fields=["interface"],
iface_fields=["interface"],
compare_fields=["admin", "phy", "prot"],
old_baseline_rows=[
{"interface": "GE-old", "admin": "up", "phy": "up", "prot": "up"}
],
old_current_rows=[],
new_baseline_rows=None,
new_current_rows=[
{"interface": "xgei-new", "admin": "up", "phy": "up", "prot": "up"}
],
port_map={"gei-old": "xgei-new"},
expect=parse_expect_set({"ports": ["gei-old"]}),
window_active=True,
sheet_override=PORT_OVERRIDE,
iface_normalize_rules=rules,
)
migrated = [r for r in out["rows"] if r["verdict"] == "migrated"]
self.assertTrue(migrated, out["rows"])
row = migrated[0]
self.assertEqual(row["old_key"], "GE-old")
self.assertEqual(row["new_key"], "xgei-new")
self.assertEqual(row["key_str"], "GE-old")
self.assertEqual(row["new_key_str"], "xgei-new")
self.assertEqual(row["match_old_key"], "gei-old")
self.assertEqual(row["match_new_key"], "xgei-new")
self.assertEqual(row["old"].get("interface"), "GE-old")
self.assertEqual(row["new"].get("interface"), "xgei-new")
pm = row["evidence"]["port_map"]
self.assertTrue(pm["applied"])
self.assertEqual(pm["display_before"], "GE-old")
self.assertEqual(pm["display_after"], "xgei-new")
self.assertEqual(pm["match_before"], "gei-old")
self.assertEqual(pm["match_after"], "xgei-new")
old_iface = row["evidence"]["old"]["iface"][0]
self.assertEqual(old_iface["raw"], "GE-old")
self.assertEqual(old_iface["normalized"], "gei-old")
self.assertTrue(old_iface["mapped"])
self.assertEqual(old_iface["map_to"], "xgei-new")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -0,0 +1,393 @@
"""Integration: evaluate persists evidence cards (raw A/B + command) on diffs/reds."""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from unittest import mock
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from netx_api.biz_migration import service as mig
from netx_api.biz_state import service as biz_svc
from netx_api.db import Base
from netx_api.models import (
BizCompareTemplate,
BizMigrationDiff,
BizMigrationRedTicket,
BizMonitorTemplate,
BizPortMapping,
BizPortMappingRow,
BizStateBatch,
BizStateBatchCommand,
BizStateMetricRow,
BizStateTask,
)
def _utcnow() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _stub_catalog(*_a, **_k):
return {
"source_profile_id": "zte.interface_brief",
"kind": "catalog",
"enabled": True,
"title": "IF",
}
def _stub_ne_meta(*_a, **_k):
return {
"ne_name": "ne",
"ne_ip": "10.0.0.1",
"vendor": "zte",
"device_type": "router",
}
PORT_SHEET = {
"sheet_id": "interface_brief",
"title": "ports",
"metric_id": "interface_brief",
"key_fields": ["interface"],
"iface_fields": ["interface"],
"compare_fields": ["admin", "phy", "prot"],
"row_filters": [],
"field_rules": [],
}
PORT_OVERRIDES = [
{
"metric_id": "interface_brief",
"sheet_id": "interface_brief",
"status_fields": ["admin", "phy", "prot"],
"down_values": ["down"],
"up_values": ["up"],
"success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}],
"anomaly": [],
}
]
NORM_RULES = [{"from": "GE", "to": "gei"}]
class EvidencePersistTests(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
TestingSession = sessionmaker(
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
)
Base.metadata.create_all(bind=engine)
self.db = TestingSession()
self.ct = BizCompareTemplate(
id="ct1",
name="port-cmp",
metric_id="interface_brief",
key_fields=["interface"],
iface_fields=["interface"],
compare_fields=["admin", "phy", "prot"],
metrics_json=[PORT_SHEET],
iface_normalize_json=NORM_RULES,
)
self.db.add(self.ct)
self.mt = BizMonitorTemplate(
id="mt1",
name="port-mon",
compare_template_id="ct1",
collect_metric_ids_json=["interface_brief"],
defaults_json={"out_of_expect": "strict"},
sheet_overrides_json=PORT_OVERRIDES,
)
self.db.add(self.mt)
self.mapping = BizPortMapping(id="map1", name="ports", note="")
self.db.add(self.mapping)
self.db.flush()
self.db.add(
BizPortMappingRow(
id="mr1", mapping_id="map1", before_if="gei-old", after_if="xgei-new"
)
)
self.old_p = self._mk_task("old_p", ne_id="ne-old", ne_name="OLD-RTR")
self.new_p = self._mk_task("new_p", ne_id="ne-new", ne_name="NEW-RTR")
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def _mk_task(self, tid: str, *, ne_id: str, ne_name: str) -> BizStateTask:
t = BizStateTask(
id=tid,
source="managed",
ne_id=ne_id,
ne_name=ne_name,
ne_ip="10.0.0.1",
vendor="zte",
device_type="router",
purpose="",
status="running",
interval_sec=3600,
retention_days=30,
)
self.db.add(t)
return t
def _mk_batch(self, bid: str, task_id: str) -> BizStateBatch:
now = _utcnow()
b = BizStateBatch(
id=bid,
task_id=task_id,
status="success",
started_at=now,
ended_at=now,
row_count=1,
command_count=1,
)
self.db.add(b)
self.db.commit()
return b
def _seed_if_row(
self,
*,
batch_id: str,
task_id: str,
ne_id: str,
command_id: str,
raw_command: str,
interface: str,
admin: str = "up",
phy: str = "up",
prot: str = "up",
) -> None:
now = _utcnow()
self.db.add(
BizStateBatchCommand(
id=command_id,
batch_id=batch_id,
profile_id="zte.interface_brief",
parser_id="zte.interface_brief",
metric_id="interface_brief",
raw_command=raw_command,
parse_status="ok",
row_count=1,
raw_text=f"Interface {interface} {admin} {phy} {prot}\n",
created_at=now,
)
)
self.db.add(
BizStateMetricRow(
id=f"row-{command_id}",
batch_id=batch_id,
batch_command_id=command_id,
task_id=task_id,
ne_id=ne_id,
metric_id="interface_brief",
seq=0,
data_json={
"interface": interface,
"admin": admin,
"phy": phy,
"prot": prot,
},
collected_at=now,
)
)
self.db.commit()
def _create_project(self) -> dict:
body = {
"name": "evidence-night",
"old_task_id": self.old_p.id,
"new_task_id": self.new_p.id,
"monitor_template_id": self.mt.id,
"mapping_id": self.mapping.id,
"collect_now": False,
}
with mock.patch("netx_api.biz_state.collect_runner.dispatch_collect"), mock.patch.object(
mig, "_catalog_item_for_metric", side_effect=_stub_catalog
), mock.patch.object(biz_svc, "_ne_meta", side_effect=_stub_ne_meta):
return mig.create_project(self.db, body)
def test_evaluate_persists_display_ab_and_evidence_with_command(self) -> None:
proj = self._create_project()
old_hf = proj["old_hf_task_id"]
new_hf = proj["new_hf_task_id"]
self._mk_batch("bl_old", self.old_p.id)
self._mk_batch("bl_new", self.new_p.id)
self._mk_batch("cur_old", old_hf)
self._mk_batch("cur_new", new_hf)
# Baseline old: raw GE-old (normalize → gei-old)
self._seed_if_row(
batch_id="bl_old",
task_id=self.old_p.id,
ne_id="ne-old",
command_id="cmd-bl-old",
raw_command="show interface brief",
interface="GE-old",
)
# New baseline empty → port_mapped mode uses mapped old
# Old current: gone (no rows) — migration off old
# New current: xgei-new up
self._seed_if_row(
batch_id="cur_new",
task_id=new_hf,
ne_id="ne-new",
command_id="cmd-cur-new",
raw_command="show interface brief",
interface="xgei-new",
)
mig.patch_project(
self.db,
proj["id"],
{"old_baseline_batch_id": "bl_old", "new_baseline_batch_id": "bl_new"},
)
batch = mig.create_batch(
self.db,
proj["id"],
{"batch_label": "wave1", "expect_set": {"ports": ["gei-old"]}},
)
# Activate window so dual verdict uses migrating/migrated path
mig.patch_batch(self.db, batch["id"], {"status": "active"})
out = mig.run_evaluate(self.db, batch_id=batch["id"])
run_id = out["id"]
self.assertTrue(run_id)
diffs = (
self.db.query(BizMigrationDiff)
.filter(BizMigrationDiff.run_id == run_id)
.order_by(BizMigrationDiff.seq.asc())
.all()
)
self.assertTrue(diffs, "expected persisted diffs")
d = diffs[0]
kj = d.key_json if isinstance(d.key_json, dict) else {}
self.assertEqual(kj.get("old_key"), "GE-old")
self.assertEqual(kj.get("new_key"), "xgei-new")
self.assertEqual(kj.get("key_str"), "GE-old")
self.assertEqual(kj.get("new_key_str"), "xgei-new")
self.assertEqual(kj.get("match_old_key"), "gei-old")
self.assertEqual(kj.get("match_new_key"), "xgei-new")
# Display rows stay raw A/B, not mapped BB
self.assertEqual((d.old_json or {}).get("interface"), "GE-old")
self.assertEqual((d.new_json or {}).get("interface"), "xgei-new")
ev = kj.get("evidence") or {}
pm = ev.get("port_map") or {}
self.assertTrue(pm.get("applied"))
self.assertEqual(pm.get("display_before"), "GE-old")
self.assertEqual(pm.get("display_after"), "xgei-new")
self.assertEqual(pm.get("match_before"), "gei-old")
self.assertEqual(pm.get("match_after"), "xgei-new")
old_iface = (ev.get("old") or {}).get("iface") or []
self.assertTrue(old_iface)
self.assertEqual(old_iface[0].get("raw"), "GE-old")
self.assertEqual(old_iface[0].get("normalized"), "gei-old")
self.assertTrue(old_iface[0].get("mapped"))
self.assertEqual(old_iface[0].get("map_to"), "xgei-new")
# Enrichment: device + show command on new side (current row)
new_ev = ev.get("new") or {}
self.assertEqual((new_ev.get("command") or {}).get("raw_command"), "show interface brief")
self.assertEqual((new_ev.get("command") or {}).get("parse_status"), "ok")
self.assertEqual((new_ev.get("device") or {}).get("ne_name"), "NEW-RTR")
self.assertEqual((new_ev.get("collect") or {}).get("batch_id"), "cur_new")
old_ev = ev.get("old") or {}
self.assertEqual((old_ev.get("command") or {}).get("raw_command"), "show interface brief")
self.assertEqual((old_ev.get("collect") or {}).get("batch_id"), "bl_old")
# API serialize path
api = mig.diff_to_dict(d)
self.assertEqual(api["old_key"], "GE-old")
self.assertEqual(api["new_key"], "xgei-new")
self.assertIn("evidence", api)
self.assertEqual(
api["evidence"]["new"]["command"]["raw_command"], "show interface brief"
)
listed = mig.list_run_diffs(self.db, run_id, color="", limit=50)
self.assertGreaterEqual(listed["total"], 1)
item = listed["items"][0]
self.assertEqual(item["old_key"], "GE-old")
self.assertEqual(item["new_key"], "xgei-new")
self.assertEqual(
item["evidence"]["old"]["iface"][0]["raw"], "GE-old"
)
def test_finish_batch_red_tickets_carry_evidence(self) -> None:
"""Acceptance red path: anomaly persists evidence onto red tickets."""
proj = self._create_project()
old_hf = proj["old_hf_task_id"]
new_hf = proj["new_hf_task_id"]
self._mk_batch("bl_old", self.old_p.id)
self._mk_batch("bl_new", self.new_p.id)
self._mk_batch("cur_old", old_hf)
self._mk_batch("cur_new", new_hf)
# Expect port still up on old (not migrated) + missing/down on new → red unfinished/anomaly
self._seed_if_row(
batch_id="bl_old",
task_id=self.old_p.id,
ne_id="ne-old",
command_id="cmd-bl-old2",
raw_command="show interface brief",
interface="GE-old",
)
self._seed_if_row(
batch_id="cur_old",
task_id=old_hf,
ne_id="ne-old",
command_id="cmd-cur-old2",
raw_command="show interface brief",
interface="GE-old",
admin="up",
phy="up",
prot="up",
)
# new side: no matching port → unfinished on acceptance
mig.patch_project(
self.db,
proj["id"],
{"old_baseline_batch_id": "bl_old", "new_baseline_batch_id": "bl_new"},
)
batch = mig.create_batch(
self.db,
proj["id"],
{"batch_label": "wave-red", "expect_set": {"ports": ["gei-old"]}},
)
mig.patch_batch(self.db, batch["id"], {"status": "active"})
finished = mig.finish_batch(self.db, batch["id"], mark_done=False)
self.assertIn("red_tickets", finished)
tickets = (
self.db.query(BizMigrationRedTicket)
.filter(BizMigrationRedTicket.project_id == proj["id"])
.all()
)
self.assertTrue(tickets, f"expected red tickets, finish={finished}")
t = tickets[0]
detail = t.detail_json if isinstance(t.detail_json, dict) else {}
self.assertIn("evidence", detail)
self.assertEqual(detail.get("old_key") or t.key_str, "GE-old")
# API
listed = mig.list_red_tickets(self.db, proj["id"])
self.assertGreaterEqual(listed["open_count"], 1)
item = listed["items"][0]
self.assertEqual(item.get("old_key") or item.get("key_str"), "GE-old")
self.assertIn("evidence", item)
if __name__ == "__main__":
unittest.main()

View file

@ -40,6 +40,7 @@ class ScopeUnitTests(unittest.TestCase):
self.assertNotIn(SCOPE_WEBCRT, MCP_DEFAULT_SCOPES) self.assertNotIn(SCOPE_WEBCRT, MCP_DEFAULT_SCOPES)
self.assertNotIn(SCOPE_SQL, MCP_DEFAULT_SCOPES) self.assertNotIn(SCOPE_SQL, MCP_DEFAULT_SCOPES)
self.assertIn("ne:exec", MCP_DEFAULT_SCOPES) self.assertIn("ne:exec", MCP_DEFAULT_SCOPES)
self.assertIn("biz-monitor:read", MCP_DEFAULT_SCOPES)
def test_token_intersection(self) -> None: def test_token_intersection(self) -> None:
user = {"alarms:read", "ne:read", "ne:exec", "sql:query"} user = {"alarms:read", "ne:read", "ne:exec", "sql:query"}
@ -54,7 +55,18 @@ class ScopeUnitTests(unittest.TestCase):
self.assertEqual(required_scope_for_request("POST", "/v1/managed-ne"), "ne:write") self.assertEqual(required_scope_for_request("POST", "/v1/managed-ne"), "ne:write")
self.assertEqual(required_scope_for_request("POST", "/v1/topology/fabric/paths"), "ne:read") self.assertEqual(required_scope_for_request("POST", "/v1/topology/fabric/paths"), "ne:read")
self.assertEqual(required_scope_for_request("POST", "/v1/topology/fabric/edges"), "ne:write") self.assertEqual(required_scope_for_request("POST", "/v1/topology/fabric/edges"), "ne:write")
self.assertEqual(
required_scope_for_request("GET", "/v1/biz-migration/monitor-context"),
"biz-monitor:read",
)
self.assertEqual(
required_scope_for_request("GET", "/v1/biz-state/batches/abc"),
"biz-monitor:read",
)
self.assertEqual(
required_scope_for_request("POST", "/v1/biz-migration/projects"),
"ops:write",
)
class SqlGuardTests(unittest.TestCase): class SqlGuardTests(unittest.TestCase):
def test_allows_cte(self) -> None: def test_allows_cte(self) -> None:

View file

@ -609,6 +609,7 @@ const en = {
runHistory: "Runs", runHistory: "Runs",
runLatest: "Latest", runLatest: "Latest",
colRuleHit: "Rule hit", colRuleHit: "Rule hit",
colCommand: "Collect command",
mappingBound: "Mapping saved and bound to this project", mappingBound: "Mapping saved and bound to this project",
allMetrics: "All metrics", allMetrics: "All metrics",
colMetric: "Metric", colMetric: "Metric",

View file

@ -602,6 +602,7 @@ const zh = {
runHistory: "判定历史", runHistory: "判定历史",
runLatest: "最新一次", runLatest: "最新一次",
colRuleHit: "命中规则", colRuleHit: "命中规则",
colCommand: "采集命令",
mappingBound: "映射已保存并绑定到本任务", mappingBound: "映射已保存并绑定到本任务",
allMetrics: "全部监控项", allMetrics: "全部监控项",
colMetric: "监控项", colMetric: "监控项",

View file

@ -217,15 +217,60 @@ type MigBatch = {
verdict_counts?: Record<string, number>; verdict_counts?: Record<string, number>;
}; };
}; };
type DiffRow = {
id: string;
metric_id: string;
sheet_id?: string;
verdict: string;
color: string;
old_kind: string;
new_kind: string;
in_expect: boolean;
old_key?: string;
new_key?: string;
key_str?: string;
new_key_str?: string;
match_old_key?: string;
match_new_key?: string;
old_status?: string;
new_status?: string;
rule_hit?: string;
evidence?: {
old?: {
device?: { ne_name?: string; ne_ip?: string };
command?: { raw_command?: string; parse_status?: string };
collect?: { collected_at?: string; batch_id?: string };
iface?: Array<{ raw?: string; normalized?: string; mapped?: boolean; map_to?: string }>;
};
new?: {
device?: { ne_name?: string; ne_ip?: string };
command?: { raw_command?: string; parse_status?: string };
collect?: { collected_at?: string; batch_id?: string };
iface?: Array<{ raw?: string; normalized?: string }>;
};
port_map?: {
applied?: boolean;
match_before?: string;
match_after?: string;
display_before?: string;
display_after?: string;
};
};
old?: Record<string, unknown>;
new?: Record<string, unknown>;
};
type RedTicket = { type RedTicket = {
id: string; id: string;
batch_id: string; batch_id: string;
old_key?: string;
new_key?: string;
key_str: string; key_str: string;
new_key_str?: string; new_key_str?: string;
verdict: string; verdict: string;
old_status?: string; old_status?: string;
new_status?: string; new_status?: string;
status: string; status: string;
evidence?: DiffRow["evidence"];
}; };
type SheetCard = { type SheetCard = {
metric_id: string; metric_id: string;
@ -239,21 +284,6 @@ type SheetCard = {
collect_skipped?: boolean; collect_skipped?: boolean;
current_missing?: boolean; current_missing?: boolean;
}; };
type DiffRow = {
id: string;
metric_id: string;
sheet_id?: string;
verdict: string;
color: string;
old_kind: string;
new_kind: string;
in_expect: boolean;
key_str?: string;
new_key_str?: string;
old_status?: string;
new_status?: string;
rule_hit?: string;
};
type EvalRun = { type EvalRun = {
id: string; id: string;
created_at?: string | null; created_at?: string | null;
@ -2635,9 +2665,11 @@ export function BizMigrationPage() {
{redTickets.map((r) => ( {redTickets.map((r) => (
<tr key={r.id}> <tr key={r.id}>
<td> <td>
<code>{r.key_str}</code> <code>{r.old_key || r.key_str}</code>
</td>
<td>
<code>{r.new_key || r.new_key_str || "—"}</code>
</td> </td>
<td>{r.new_key_str || "—"}</td>
<td>{r.old_status || "—"}</td> <td>{r.old_status || "—"}</td>
<td>{r.new_status || "—"}</td> <td>{r.new_status || "—"}</td>
<td style={{ color: COLOR.red, fontWeight: 600 }}>{verdictLabel(r.verdict)}</td> <td style={{ color: COLOR.red, fontWeight: 600 }}>{verdictLabel(r.verdict)}</td>
@ -2744,24 +2776,53 @@ export function BizMigrationPage() {
<thead> <thead>
<tr> <tr>
<th>{t("bizMigration.colMetric")}</th> <th>{t("bizMigration.colMetric")}</th>
<th>{t("bizMigration.colKey")}</th> <th>{t("bizMigration.colOldPort")}</th>
<th>{t("bizMigration.colMapped")}</th> <th>{t("bizMigration.colNewPort")}</th>
<th>{t("bizMigration.colOld")}</th> <th>{t("bizMigration.colOld")}</th>
<th>{t("bizMigration.colNew")}</th> <th>{t("bizMigration.colNew")}</th>
<th>{t("bizMigration.colCommand")}</th>
<th>{t("bizMigration.colVerdict")}</th> <th>{t("bizMigration.colVerdict")}</th>
<th>{t("bizMigration.colRuleHit")}</th> <th>{t("bizMigration.colRuleHit")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visibleDiffs.map((d) => ( {visibleDiffs.map((d) => {
<tr key={d.id}> const oldKey = d.old_key || d.key_str || "—";
const newKey = d.new_key || d.new_key_str || "—";
const matchHint =
d.match_old_key &&
d.match_new_key &&
(d.match_old_key !== oldKey || d.match_new_key !== newKey)
? `match ${d.match_old_key} → ${d.match_new_key}`
: "";
const oldCmd = d.evidence?.old?.command?.raw_command || "";
const newCmd = d.evidence?.new?.command?.raw_command || "";
const cmdLabel = oldCmd || newCmd || "—";
const cmdTitle = [
d.evidence?.old?.device?.ne_name &&
`old: ${d.evidence.old.device.ne_name}`,
oldCmd && `old cmd: ${oldCmd}`,
d.evidence?.new?.device?.ne_name &&
`new: ${d.evidence.new.device.ne_name}`,
newCmd && `new cmd: ${newCmd}`,
matchHint,
]
.filter(Boolean)
.join("\n");
return (
<tr key={d.id} title={cmdTitle || undefined}>
<td>{sheetLabel(d)}</td> <td>{sheetLabel(d)}</td>
<td> <td>
<code>{d.key_str || "—"}</code> <code>{oldKey}</code>
</td>
<td>
<code>{newKey || "—"}</code>
</td> </td>
<td>{d.new_key_str || "—"}</td>
<td>{d.old_status || d.old_kind || "—"}</td> <td>{d.old_status || d.old_kind || "—"}</td>
<td>{d.new_status || d.new_kind || "—"}</td> <td>{d.new_status || d.new_kind || "—"}</td>
<td className="muted">
<code style={{ fontSize: 11 }}>{cmdLabel}</code>
</td>
<td <td
style={{ style={{
color: COLOR[d.color] || COLOR.gray, color: COLOR[d.color] || COLOR.gray,
@ -2774,10 +2835,11 @@ export function BizMigrationPage() {
<code style={{ fontSize: 11 }}>{d.rule_hit || "—"}</code> <code style={{ fontSize: 11 }}>{d.rule_hit || "—"}</code>
</td> </td>
</tr> </tr>
))} );
})}
{!visibleDiffs.length ? ( {!visibleDiffs.length ? (
<tr> <tr>
<td colSpan={7}> <td colSpan={8}>
<div className="pt-list-empty">{t("bizMigration.emptyDiffs")}</div> <div className="pt-list-empty">{t("bizMigration.emptyDiffs")}</div>
</td> </td>
</tr> </tr>