From c1b1c0f75d03bb7e98421efec3ae32ad79c96c26 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 19 Sep 2026 22:48:37 +0800 Subject: [PATCH] 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 --- docs/MCP.md | 2 + netx_api/auth_scopes.py | 14 +- netx_api/biz_migration/evaluate.py | 202 +++++++++- netx_api/biz_migration/service.py | 247 +++++++++++- netx_api/biz_migration_router.py | 10 + netx_api/biz_state/compare_service.py | 38 +- netx_api/biz_state/service.py | 35 ++ netx_api/biz_state_router.py | 7 + packages/netx-mcp/tests/test_mcp_http.py | 1 + skills/README.md | 9 +- skills/biz-monitor/netx-biz-monitor/SKILL.md | 61 +++ tests/test_biz_migration_evaluate.py | 51 +++ tests/test_biz_migration_evidence.py | 393 +++++++++++++++++++ tests/test_rbac_scopes.py | 14 +- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/pages/network/BizMigrationPage.tsx | 112 ++++-- 17 files changed, 1141 insertions(+), 57 deletions(-) create mode 100644 skills/biz-monitor/netx-biz-monitor/SKILL.md create mode 100644 tests/test_biz_migration_evidence.py diff --git a/docs/MCP.md b/docs/MCP.md index 8e3d18c..29899d3 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -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__`;DSH:`netx__`。 +**业务监控 / 割接分析**:不走 netx-mcp。DSH **netxops** 宿主工具组 `bizMonitor`(`netx__getBizMonitorContext` 等)直连 netx REST(`/v1/biz-migration/*`、`/v1/biz-state/*`,需 token scope `biz-monitor:read`)。 + --- ## 6. 更新 MCP diff --git a/netx_api/auth_scopes.py b/netx_api/auth_scopes.py index 7c3dc27..c88373f 100644 --- a/netx_api/auth_scopes.py +++ b/netx_api/auth_scopes.py @@ -16,6 +16,7 @@ SCOPE_WEBCRT = "webcrt:session" SCOPE_SQL = "sql:query" SCOPE_ADMIN_USERS = "admin:users" SCOPE_OPS_WRITE = "ops:write" +SCOPE_BIZ_MONITOR_READ = "biz-monitor:read" ALL_SCOPES: frozenset[str] = frozenset( { @@ -27,20 +28,22 @@ ALL_SCOPES: frozenset[str] = frozenset( SCOPE_SQL, SCOPE_ADMIN_USERS, SCOPE_OPS_WRITE, + SCOPE_BIZ_MONITOR_READ, } ) ROLE_DEFAULT_SCOPES: dict[str, frozenset[str]] = { "admin": ALL_SCOPES, - # Read-only operator by default (alarms + inventory). - "user": frozenset({SCOPE_ALARMS_READ, SCOPE_NE_READ}), + # Read-only operator by default (alarms + inventory + biz monitor 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, ...] = ( SCOPE_ALARMS_READ, SCOPE_NE_READ, 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"): 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 return None diff --git a/netx_api/biz_migration/evaluate.py b/netx_api/biz_migration/evaluate.py index 6423324..ec5847a 100644 --- a/netx_api/biz_migration/evaluate.py +++ b/netx_api/biz_migration/evaluate.py @@ -938,6 +938,89 @@ def override_for_metric( 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( *, metric_id: str, @@ -958,7 +1041,12 @@ def evaluate_metric_dual( sheet_id: str = "", iface_normalize_rules: list[dict[str, str]] | None = None, ) -> 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 ( apply_iface_normalize_rows, 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 [] 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) 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, 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, 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, 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( before_rows=old_baseline_rows, @@ -1173,22 +1289,83 @@ def evaluate_metric_dual( if in_exp: 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( { "metric_id": metric_id, "key": (od or nd or {}).get("key") or [old_ks], - "key_str": old_ks, - "new_key_str": new_ks, + # Display identity (A/B as collected) — board & MCP primary + "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, "color": color, "rule_hit": rule_hit, "in_expect": in_exp, "old_kind": old_kind, "new_kind": new_kind, - "old": old_cur, - "new": new_cur, - "old_baseline": dict((od or {}).get("before") or {}), - "new_baseline": dict((nd or {}).get("before") or {}), + "old": old_disp, + "new": new_disp, + "old_baseline": old_base_raw, + "new_baseline": new_base_raw, "old_status": status_label(old_cur, ov) if old_cur else ("gone" if old_kind == "removed" else "—"), @@ -1209,6 +1386,7 @@ def evaluate_metric_dual( ) if new_kind else ("", "gray"), + "evidence": evidence, } ) diff --git a/netx_api/biz_migration/service.py b/netx_api/biz_migration/service.py index 2acce52..5eacd31 100644 --- a/netx_api/biz_migration/service.py +++ b/netx_api/biz_migration/service.py @@ -322,9 +322,17 @@ def _project_collect_override(proj: BizMigrationProject) -> list[str]: def _task_brief(db: Session, task_id: str) -> dict[str, Any]: t = db.get(BizStateTask, task_id) if task_id else None 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 { "id": t.id, + "ne_id": t.ne_id or "", "ne_name": t.ne_name, "ne_ip": t.ne_ip, "vendor": t.vendor, @@ -838,6 +846,7 @@ def run_evaluate( sheet_cards: list[dict[str, Any]] = [] all_rows: list[dict[str, Any]] = [] + metric_batches: dict[str, dict[str, str]] = {} seq = 0 verdict_counts: dict[str, int] = {} @@ -936,6 +945,16 @@ def run_evaluate( out_of_expect=out_of_expect, 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( { "metric_id": mid, @@ -950,9 +969,19 @@ def run_evaluate( "new_baseline_mode": one.get("new_baseline_mode") or "provided", "new_baseline_missing": bool(one.get("new_baseline_missing")), "collect_skipped": False, + "old_batch_id": old_cur_mid, + "new_batch_id": new_cur_mid, } ) 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["sheet_id"] = sid seq += 1 @@ -977,6 +1006,7 @@ def run_evaluate( "monitor_template_id": mt.id, "compare_template_id": mt.compare_template_id or "", "acceptance": acceptance, + "metric_batches": metric_batches, "sheet_cards": sheet_cards, "progress": { "ok": sum(c["progress_ok"] for c in active_cards), @@ -1011,10 +1041,16 @@ def run_evaluate( db.flush() for r in all_rows: 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( [ - str(r.get("key_str") or ""), - str(r.get("new_key_str") or ""), + str(r.get("old_key") or r.get("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("old_status") or ""), str(r.get("new_status") or ""), @@ -1031,12 +1067,17 @@ def run_evaluate( color=str(r.get("color") or ""), key_json={ "key": key_list, - "key_str": r.get("key_str"), - "new_key_str": r.get("new_key_str"), + "old_key": r.get("old_key") or r.get("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"), "new_status": r.get("new_status"), "rule_hit": r.get("rule_hit") or "", "sheet_id": r.get("sheet_id") or "", + "evidence": r.get("evidence") or {}, }, old_kind=str(r.get("old_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 +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]: 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 { "id": d.id, "metric_id": d.metric_id, @@ -1088,11 +1190,16 @@ def diff_to_dict(d: BizMigrationDiff) -> dict[str, Any]: "verdict": d.verdict, "color": d.color, "key": kj, - "key_str": kj.get("key_str") or "", - "new_key_str": kj.get("new_key_str") or "", + "old_key": old_key, + "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 "", "new_status": kj.get("new_status") or "", "rule_hit": kj.get("rule_hit") or "", + "evidence": dict(kj.get("evidence") or {}), "old_kind": d.old_kind, "new_kind": d.new_kind, "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]: + 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 { "id": t.id, "project_id": t.project_id, "batch_id": t.batch_id, "run_id": t.run_id, "metric_id": t.metric_id, - "key_str": t.key_str, - "new_key_str": t.new_key_str, + "old_key": old_key, + "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, "color": t.color, "old_status": t.old_status, "new_status": t.new_status, - "detail": dict(t.detail_json or {}), + "detail": detail, + "evidence": dict(detail.get("evidence") or {}), "status": t.status, "carried_to_batch_id": t.carried_to_batch_id, "note": t.note, @@ -1793,6 +1908,11 @@ def _persist_red_tickets_from_run( "in_expect": d.in_expect, "old": d.old_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", ) @@ -2004,3 +2124,110 @@ def board(db: Session, batch_id: str, run_id: str = "") -> dict[str, Any]: "batch": batch_to_dict(mb), "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 diff --git a/netx_api/biz_migration_router.py b/netx_api/biz_migration_router.py index 0a0c1df..29b4377 100644 --- a/netx_api/biz_migration_router.py +++ b/netx_api/biz_migration_router.py @@ -125,6 +125,16 @@ def api_get_project(project_id: str, db: Session = Depends(get_db)): 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}") 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)) diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index 0f70591..726cc1a 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -1191,6 +1191,14 @@ def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dic "remote_if": n.remote_if, "remote_ip": n.remote_ip, "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 ] @@ -1203,7 +1211,19 @@ def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dic .all() ) 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 ] # 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: # Raw rows only — filtering belongs to the compare sheet template # (``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 if metric_id in metric_field_map(): return [] diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index 8725826..06e269b 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -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: detail = get_batch(db, batch_id) buf = io.BytesIO() diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 6c60430..bedaa24 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -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) +@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") def api_export_batch(batch_id: str, db: Session = Depends(get_db)) -> StreamingResponse: data = svc.export_batch_zip(db, batch_id) diff --git a/packages/netx-mcp/tests/test_mcp_http.py b/packages/netx-mcp/tests/test_mcp_http.py index a6693ce..11686c1 100644 --- a/packages/netx-mcp/tests/test_mcp_http.py +++ b/packages/netx-mcp/tests/test_mcp_http.py @@ -21,6 +21,7 @@ def test_http_mcp_tool_list_has_expected_tools() -> None: assert "execManagedNe" in names assert "listCliTargets" in names assert "findTopologyPaths" in names + assert "getBizMonitorContext" not in names assert "queryUmeAlarms" not in names assert "queryTopologyEdges" not in names exec_tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "execManagedNe") diff --git a/skills/README.md b/skills/README.md index b830495..df5570b 100644 --- a/skills/README.md +++ b/skills/README.md @@ -6,15 +6,16 @@ |-------|--------------|----------------------------------------| | `ops` | `ops/netx-ops/` | NMS 告警/库存/SQL + managed CLI + `findTopologyPaths` | | `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 怎么用 1. 运行时优先:`NETX_SKILLS_ROOT` → 旁路 `../netx/skills` → 包内 `presets/netxops/skills` 2. 发 npm 前:`powershell -File netxops/scripts/sync-skills-from-netx.ps1` -3. Settings → 能力组开关:开哪组就注册哪组的 **tools + 对应 skill** -4. 其它预设可强制挂:`dsh-netxops/tools-ops|topology`(旧别名 `tools-nms` / `tools-common` → ops) +3. Settings → 能力组开关:开哪组就注册哪组的 **tools + 对应 skill**(含 **bizMonitor**) +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/`)。 diff --git a/skills/biz-monitor/netx-biz-monitor/SKILL.md b/skills/biz-monitor/netx-biz-monitor/SKILL.md new file mode 100644 index 0000000..76dd1fd --- /dev/null +++ b/skills/biz-monitor/netx-biz-monitor/SKILL.md @@ -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 **只读分析**。 diff --git a/tests/test_biz_migration_evaluate.py b/tests/test_biz_migration_evaluate.py index 36826e5..d9e9364 100644 --- a/tests/test_biz_migration_evaluate.py +++ b/tests/test_biz_migration_evaluate.py @@ -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"])) +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__": unittest.main() diff --git a/tests/test_biz_migration_evidence.py b/tests/test_biz_migration_evidence.py new file mode 100644 index 0000000..e22a259 --- /dev/null +++ b/tests/test_biz_migration_evidence.py @@ -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() diff --git a/tests/test_rbac_scopes.py b/tests/test_rbac_scopes.py index f89cc8c..da9e751 100644 --- a/tests/test_rbac_scopes.py +++ b/tests/test_rbac_scopes.py @@ -40,6 +40,7 @@ class ScopeUnitTests(unittest.TestCase): self.assertNotIn(SCOPE_WEBCRT, MCP_DEFAULT_SCOPES) self.assertNotIn(SCOPE_SQL, MCP_DEFAULT_SCOPES) self.assertIn("ne:exec", MCP_DEFAULT_SCOPES) + self.assertIn("biz-monitor:read", MCP_DEFAULT_SCOPES) def test_token_intersection(self) -> None: 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/topology/fabric/paths"), "ne:read") 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): def test_allows_cte(self) -> None: diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 5384b14..616c83f 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -609,6 +609,7 @@ const en = { runHistory: "Runs", runLatest: "Latest", colRuleHit: "Rule hit", + colCommand: "Collect command", mappingBound: "Mapping saved and bound to this project", allMetrics: "All metrics", colMetric: "Metric", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 5a281b1..a6e3532 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -602,6 +602,7 @@ const zh = { runHistory: "判定历史", runLatest: "最新一次", colRuleHit: "命中规则", + colCommand: "采集命令", mappingBound: "映射已保存并绑定到本任务", allMetrics: "全部监控项", colMetric: "监控项", diff --git a/web/src/pages/network/BizMigrationPage.tsx b/web/src/pages/network/BizMigrationPage.tsx index 8530b50..c0c9e4e 100644 --- a/web/src/pages/network/BizMigrationPage.tsx +++ b/web/src/pages/network/BizMigrationPage.tsx @@ -217,15 +217,60 @@ type MigBatch = { verdict_counts?: Record; }; }; +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; + new?: Record; +}; type RedTicket = { id: string; batch_id: string; + old_key?: string; + new_key?: string; key_str: string; new_key_str?: string; verdict: string; old_status?: string; new_status?: string; status: string; + evidence?: DiffRow["evidence"]; }; type SheetCard = { metric_id: string; @@ -239,21 +284,6 @@ type SheetCard = { collect_skipped?: 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 = { id: string; created_at?: string | null; @@ -2635,9 +2665,11 @@ export function BizMigrationPage() { {redTickets.map((r) => ( - {r.key_str} + {r.old_key || r.key_str} + + + {r.new_key || r.new_key_str || "—"} - {r.new_key_str || "—"} {r.old_status || "—"} {r.new_status || "—"} {verdictLabel(r.verdict)} @@ -2744,24 +2776,53 @@ export function BizMigrationPage() { {t("bizMigration.colMetric")} - {t("bizMigration.colKey")} - {t("bizMigration.colMapped")} + {t("bizMigration.colOldPort")} + {t("bizMigration.colNewPort")} {t("bizMigration.colOld")} {t("bizMigration.colNew")} + {t("bizMigration.colCommand")} {t("bizMigration.colVerdict")} {t("bizMigration.colRuleHit")} - {visibleDiffs.map((d) => ( - + {visibleDiffs.map((d) => { + 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 ( + {sheetLabel(d)} - {d.key_str || "—"} + {oldKey} + + + {newKey || "—"} - {d.new_key_str || "—"} {d.old_status || d.old_kind || "—"} {d.new_status || d.new_kind || "—"} + + {cmdLabel} + {d.rule_hit || "—"} - ))} + ); + })} {!visibleDiffs.length ? ( - +
{t("bizMigration.emptyDiffs")}