mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 17:50:44 +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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue