mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 09:00:51 +08:00
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:
parent
285c19e5a3
commit
c1b1c0f75d
17 changed files with 1141 additions and 57 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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/`)。
|
||||||
|
|
|
||||||
61
skills/biz-monitor/netx-biz-monitor/SKILL.md
Normal file
61
skills/biz-monitor/netx-biz-monitor/SKILL.md
Normal 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 **只读分析**。
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
393
tests/test_biz_migration_evidence.py
Normal file
393
tests/test_biz_migration_evidence.py
Normal 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()
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -602,6 +602,7 @@ const zh = {
|
||||||
runHistory: "判定历史",
|
runHistory: "判定历史",
|
||||||
runLatest: "最新一次",
|
runLatest: "最新一次",
|
||||||
colRuleHit: "命中规则",
|
colRuleHit: "命中规则",
|
||||||
|
colCommand: "采集命令",
|
||||||
mappingBound: "映射已保存并绑定到本任务",
|
mappingBound: "映射已保存并绑定到本任务",
|
||||||
allMetrics: "全部监控项",
|
allMetrics: "全部监控项",
|
||||||
colMetric: "监控项",
|
colMetric: "监控项",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue