Fix ZTE BGP RR *i/RD parsing and add batch collect detail UX.

Decouple heavy CLI parse via ParsePool, keep ECMP/multi-RD keys, and expose per-command CLI lines vs DB rows with raw.txt export.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-23 10:52:43 +08:00
parent 4c5738e48e
commit 05357e971e
31 changed files with 1952 additions and 269 deletions

View file

@ -75,6 +75,9 @@ NETX_UME_NOTIFICATION_TOPIC=ALARM
# NETX_BIZ_STATE_WORKER_REPLICAS=2 # NETX_BIZ_STATE_WORKER_REPLICAS=2
# NETX_BIZ_STATE_SPOOL_DIR=data/biz_state_spool # NETX_BIZ_STATE_SPOOL_DIR=data/biz_state_spool
# NETX_BIZ_STATE_PERSIST_EVERY_CMDS=8 # NETX_BIZ_STATE_PERSIST_EVERY_CMDS=8
# Parse TextFSM off SSH threads (overlap with next CLI). Default on.
# NETX_BIZ_STATE_PARSE_ASYNC=true
# NETX_BIZ_STATE_PARSE_WORKERS=8
# Dual-lane CLI timeouts (light = shared SSH; heavy = interface/route/MAC etc.): # Dual-lane CLI timeouts (light = shared SSH; heavy = interface/route/MAC etc.):
# READ = per-command wait; RUN_TIMEOUT_CAP = whole-lane wall clock (often the ~40min abort). # READ = per-command wait; RUN_TIMEOUT_CAP = whole-lane wall clock (often the ~40min abort).
# Light lane reuses NE collect defaults below (read 120s / cap 600s ≈ 10min). # Light lane reuses NE collect defaults below (read 120s / cap 600s ≈ 10min).

View file

@ -9,9 +9,28 @@ from ..biz_state.compare_engine import apply_port_map, compare_rows
PORT_METRIC_ID = "interface_brief" PORT_METRIC_ID = "interface_brief"
PORT_STATUS_FIELDS = ("admin", "phy", "prot") PORT_STATUS_FIELDS = ("admin", "phy", "prot")
# Unit separator — field values may contain "|"; keep "|" accepted when parsing expect.
KEY_SEP = "\x1f"
def _key_str(key: tuple[str, ...] | list[str]) -> str: def _key_str(key: tuple[str, ...] | list[str]) -> str:
return "|".join(str(x) for x in key) return KEY_SEP.join(str(x) for x in key)
def normalize_expect_key(raw: str) -> str:
"""Normalize a stored/UI expect key to internal KEY_SEP form.
Accepts legacy ``a|b`` multi-field keys and already-normalized ``\\x1f`` keys.
"""
s = str(raw or "").strip()
if not s:
return ""
if KEY_SEP in s:
return s
if "|" in s:
parts = [p.strip() for p in s.split("|")]
return KEY_SEP.join(parts)
return s
def port_status_label(row: dict[str, Any] | None) -> str: def port_status_label(row: dict[str, Any] | None) -> str:
@ -69,7 +88,7 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
ports = data.get("ports") or [] ports = data.get("ports") or []
if isinstance(ports, list): if isinstance(ports, list):
for p in ports: for p in ports:
s = str(p or "").strip() s = normalize_expect_key(str(p or ""))
if s: if s:
out.setdefault(PORT_METRIC_ID, set()).add(s) out.setdefault(PORT_METRIC_ID, set()).add(s)
out.setdefault("_ports", set()).add(s) out.setdefault("_ports", set()).add(s)
@ -86,11 +105,11 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
if it.get("key") is not None: if it.get("key") is not None:
k = it.get("key") k = it.get("key")
if isinstance(k, (list, tuple)): if isinstance(k, (list, tuple)):
joined = "|".join(str(p).strip() for p in k if str(p).strip()) joined = KEY_SEP.join(str(p).strip() for p in k if str(p).strip())
if joined: if joined:
bucket.add(joined) bucket.add(joined)
else: else:
s = str(k or "").strip() s = normalize_expect_key(str(k or ""))
if s: if s:
bucket.add(s) bucket.add(s)
keys = it.get("keys") keys = it.get("keys")
@ -98,17 +117,17 @@ def parse_expect_set(raw: dict[str, Any] | None) -> dict[str, set[str]]:
# Flat list of segments → one composite key; else each entry is a key # Flat list of segments → one composite key; else each entry is a key
# (string or nested list/tuple of segments). # (string or nested list/tuple of segments).
if keys and all(not isinstance(x, (list, tuple, dict)) for x in keys): if keys and all(not isinstance(x, (list, tuple, dict)) for x in keys):
joined = "|".join(str(x).strip() for x in keys if str(x).strip()) joined = KEY_SEP.join(str(x).strip() for x in keys if str(x).strip())
if joined: if joined:
bucket.add(joined) bucket.add(joined)
else: else:
for x in keys: for x in keys:
if isinstance(x, (list, tuple)): if isinstance(x, (list, tuple)):
joined = "|".join(str(p).strip() for p in x if str(p).strip()) joined = KEY_SEP.join(str(p).strip() for p in x if str(p).strip())
if joined: if joined:
bucket.add(joined) bucket.add(joined)
else: else:
s = str(x or "").strip() s = normalize_expect_key(str(x or ""))
if s: if s:
bucket.add(s) bucket.add(s)
return {k: {x for x in v if x} for k, v in out.items() if v} return {k: {x for x in v if x} for k, v in out.items() if v}
@ -718,9 +737,16 @@ def dual_verdict_ex(
def build_diff_index_from_compare(result: dict[str, Any]) -> dict[str, dict[str, Any]]: def build_diff_index_from_compare(result: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Normalize compare_rows result diffs → key_str → {kind, before, after, key}.""" """Normalize compare_rows result diffs → key_str → {kind, before, after, key}.
``duplicate`` diffs are skipped for the index (primary match already indexed);
callers can still read ``summary.duplicate_key_list``.
"""
out: dict[str, dict[str, Any]] = {} out: dict[str, dict[str, Any]] = {}
for d in result.get("diffs") or []: for d in result.get("diffs") or []:
kind = str(d.get("kind") or "")
if kind == "duplicate":
continue
key = d.get("key") key = d.get("key")
if isinstance(key, dict): if isinstance(key, dict):
key_list = [str(v) for v in key.values()] key_list = [str(v) for v in key.values()]
@ -731,10 +757,12 @@ def build_diff_index_from_compare(result: dict[str, Any]) -> dict[str, dict[str,
else: else:
ks = str(key or "") ks = str(key or "")
key_list = [ks] if ks else [] key_list = [ks] if ks else []
if ks in out:
continue # first primary match wins
before = dict(d.get("before") or {}) if d.get("before") else {} before = dict(d.get("before") or {}) if d.get("before") else {}
after = dict(d.get("after") or {}) if d.get("after") else {} after = dict(d.get("after") or {}) if d.get("after") else {}
out[ks] = { out[ks] = {
"kind": str(d.get("kind") or ""), "kind": kind,
"key": key_list, "key": key_list,
"before": before, "before": before,
"after": after, "after": after,
@ -780,7 +808,11 @@ def _map_defines_iface_expect(metric_id: str, iface_fields: list[str], port_map:
def _iface_values(key_str: str, *, key_fields: list[str], iface_fields: list[str]) -> list[str]: def _iface_values(key_str: str, *, key_fields: list[str], iface_fields: list[str]) -> list[str]:
parts = str(key_str or "").split("|") raw = str(key_str or "")
if KEY_SEP in raw:
parts = raw.split(KEY_SEP)
else:
parts = raw.split("|")
iface_set = {str(f) for f in iface_fields} iface_set = {str(f) for f in iface_fields}
if key_fields and len(parts) == len(key_fields): if key_fields and len(parts) == len(key_fields):
return [parts[i] for i, f in enumerate(key_fields) if f in iface_set and parts[i]] return [parts[i] for i, f in enumerate(key_fields) if f in iface_set and parts[i]]
@ -798,7 +830,7 @@ def _key_in_port_map(
) -> bool: ) -> bool:
"""True when every interface segment of the key is covered by the map. """True when every interface segment of the key is covered by the map.
Covered = exact map key, or parent (before last ``.``) is a map key. Covered = exact map key, or any parent prefix (multi-level QinQ) is a map key.
""" """
vals = _iface_values(key_str, key_fields=key_fields, iface_fields=iface_fields) vals = _iface_values(key_str, key_fields=key_fields, iface_fields=iface_fields)
if not vals: if not vals:
@ -806,9 +838,14 @@ def _key_in_port_map(
for v in vals: for v in vals:
if v in port_map: if v in port_map:
continue continue
parent = v.rsplit(".", 1)[0] if "." in v else "" parts = v.split(".")
covered = False
for i in range(len(parts) - 1, 0, -1):
parent = ".".join(parts[:i])
if parent and parent in port_map: if parent and parent in port_map:
continue covered = True
break
if not covered:
return False return False
return True return True
@ -834,6 +871,13 @@ def _unmapped_same_iface_anomaly(
return old_bad and new_up return old_bad and new_up
def _split_key_parts(key_str: str) -> list[str]:
raw = str(key_str or "")
if KEY_SEP in raw:
return raw.split(KEY_SEP)
return raw.split("|")
def _remap_key_str( def _remap_key_str(
key_str: str, key_str: str,
*, *,
@ -849,10 +893,14 @@ def _remap_key_str(
if not port_map: if not port_map:
return key_str return key_str
if (not key_fields or len(key_fields) == 1) and ( if (not key_fields or len(key_fields) == 1) and (
key_str in port_map or ("." in key_str and key_str.rsplit(".", 1)[0] in port_map) key_str in port_map
or any(
".".join(key_str.split(".")[:i]) in port_map
for i in range(len(key_str.split(".")) - 1, 0, -1)
)
): ):
return resolve_mapped_iface(key_str, port_map) return resolve_mapped_iface(key_str, port_map)
parts = str(key_str).split("|") parts = _split_key_parts(key_str)
if key_fields and len(parts) == len(key_fields): if key_fields and len(parts) == len(key_fields):
iface_set = {str(f) for f in iface_fields} iface_set = {str(f) for f in iface_fields}
out: list[str] = [] out: list[str] = []
@ -862,7 +910,7 @@ def _remap_key_str(
out.append(resolve_mapped_iface(v, port_map)) out.append(resolve_mapped_iface(v, port_map))
else: else:
out.append(v) out.append(v)
return "|".join(out) return KEY_SEP.join(out)
return resolve_mapped_iface(key_str, port_map) return resolve_mapped_iface(key_str, port_map)
@ -881,10 +929,14 @@ def _reverse_remap_key_str(
if not rev_map: if not rev_map:
return key_str return key_str
if (not key_fields or len(key_fields) == 1) and ( if (not key_fields or len(key_fields) == 1) and (
key_str in rev_map or ("." in key_str and key_str.rsplit(".", 1)[0] in rev_map) key_str in rev_map
or any(
".".join(key_str.split(".")[:i]) in rev_map
for i in range(len(key_str.split(".")) - 1, 0, -1)
)
): ):
return resolve_mapped_iface(key_str, rev_map) return resolve_mapped_iface(key_str, rev_map)
parts = str(key_str).split("|") parts = _split_key_parts(key_str)
if key_fields and len(parts) == len(key_fields): if key_fields and len(parts) == len(key_fields):
iface_set = {str(f) for f in iface_fields} iface_set = {str(f) for f in iface_fields}
out: list[str] = [] out: list[str] = []
@ -894,7 +946,7 @@ def _reverse_remap_key_str(
out.append(resolve_mapped_iface(v, rev_map)) out.append(resolve_mapped_iface(v, rev_map))
else: else:
out.append(v) out.append(v)
return "|".join(out) return KEY_SEP.join(out)
return resolve_mapped_iface(key_str, rev_map) return resolve_mapped_iface(key_str, rev_map)
@ -957,7 +1009,7 @@ def row_match_key(
data = strip_netx(row) data = strip_netx(row)
if iface_fields and rules: if iface_fields and rules:
data = apply_iface_normalize(dict(data), iface_fields=iface_fields, rules=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) return KEY_SEP.join(str(data.get(k) or "").strip() for k in key_fields)
def index_raw_by_match_key( def index_raw_by_match_key(
@ -1395,12 +1447,22 @@ def evaluate_metric_dual(
"old_summary": old_cmp.get("summary") or {}, "old_summary": old_cmp.get("summary") or {},
"new_summary": new_cmp.get("summary") or {}, "new_summary": new_cmp.get("summary") or {},
"progress_ok": progress_ok, "progress_ok": progress_ok,
"progress_total": progress_total if progress_total else len(expect_keys), "progress_total": progress_total,
"anomaly": anomaly, "anomaly": anomaly,
"anomaly_in_expect": anomaly_in_expect, "anomaly_in_expect": anomaly_in_expect,
"rows": rows_out, "rows": rows_out,
"new_baseline_mode": new_baseline_mode, "new_baseline_mode": new_baseline_mode,
"new_baseline_missing": new_baseline_missing, "new_baseline_missing": new_baseline_missing,
"duplicate_keys_before": int((old_cmp.get("summary") or {}).get("duplicate_keys_before") or 0)
+ int((new_cmp.get("summary") or {}).get("duplicate_keys_before") or 0),
"duplicate_keys_after": int((old_cmp.get("summary") or {}).get("duplicate_keys_after") or 0)
+ int((new_cmp.get("summary") or {}).get("duplicate_keys_after") or 0),
"duplicate_key_list": list(
dict.fromkeys(
list((old_cmp.get("summary") or {}).get("duplicate_key_list") or [])
+ list((new_cmp.get("summary") or {}).get("duplicate_key_list") or [])
)
),
} }

View file

@ -33,7 +33,7 @@ def _out(row: BizMonitorTemplate, compare_name: str = "", *, db: Session | None
effective = seen effective = seen
if not effective: if not effective:
effective = [PORT_METRIC_ID] effective = [PORT_METRIC_ID]
return { out: dict[str, Any] = {
"id": row.id, "id": row.id,
"name": row.name, "name": row.name,
"compare_template_id": row.compare_template_id or "", "compare_template_id": row.compare_template_id or "",
@ -46,6 +46,82 @@ def _out(row: BizMonitorTemplate, compare_name: str = "", *, db: Session | None
"created_at": row.created_at.isoformat() + "Z" if row.created_at else None, "created_at": row.created_at.isoformat() + "Z" if row.created_at else None,
"updated_at": row.updated_at.isoformat() + "Z" if row.updated_at else None, "updated_at": row.updated_at.isoformat() + "Z" if row.updated_at else None,
} }
if db is not None:
warnings = validate_sheet_overrides(
db,
compare_template_id=str(row.compare_template_id or ""),
sheet_overrides=list(row.sheet_overrides_json or []),
)
if warnings:
out["override_warnings"] = warnings
return out
def validate_sheet_overrides(
db: Session,
*,
compare_template_id: str,
sheet_overrides: list[Any] | None,
) -> list[dict[str, str]]:
"""Return warnings when overrides no longer match compare template sheets.
Does not block save — surfaces ``override_warnings`` so UI/ops can fix drift.
"""
warnings: list[dict[str, str]] = []
overrides = [o for o in (sheet_overrides or []) if isinstance(o, dict)]
if not overrides:
return warnings
ct = db.get(BizCompareTemplate, compare_template_id) if compare_template_id else None
if not ct:
for ov in overrides:
sid = str(ov.get("sheet_id") or "").strip()
mid = str(ov.get("metric_id") or "").strip()
warnings.append(
{
"sheet_id": sid,
"metric_id": mid,
"reason": "compare_template_missing",
}
)
return warnings
sheets = cmp_svc.template_metrics(ct)
sheet_ids = {str(s.get("sheet_id") or s.get("metric_id") or "").strip() for s in sheets}
metric_ids = {str(s.get("metric_id") or "").strip() for s in sheets}
for ov in overrides:
sid = str(ov.get("sheet_id") or "").strip()
mid = str(ov.get("metric_id") or "").strip()
if sid:
if sid not in sheet_ids:
warnings.append(
{
"sheet_id": sid,
"metric_id": mid,
"reason": "sheet_id_not_in_compare_template",
}
)
elif mid:
if mid not in metric_ids and mid not in sheet_ids:
warnings.append(
{
"sheet_id": "",
"metric_id": mid,
"reason": "metric_id_not_in_compare_template",
}
)
elif mid in metric_ids:
# Legacy metric-only override applies to every split of that metric
split_count = sum(
1 for s in sheets if str(s.get("metric_id") or "").strip() == mid
)
if split_count > 1:
warnings.append(
{
"sheet_id": "",
"metric_id": mid,
"reason": "legacy_metric_override_applies_to_all_splits",
}
)
return warnings
def _compare_name_map(db: Session) -> dict[str, str]: def _compare_name_map(db: Session) -> dict[str, str]:
@ -395,7 +471,8 @@ def create_monitor_template(db: Session, body: dict[str, Any]) -> dict[str, Any]
db.commit() db.commit()
db.refresh(row) db.refresh(row)
names = _compare_name_map(db) names = _compare_name_map(db)
return _out(row, names.get(row.compare_template_id or "", ""), db=db) out = _out(row, names.get(row.compare_template_id or "", ""), db=db)
return out
def update_monitor_template(db: Session, template_id: str, body: dict[str, Any]) -> dict[str, Any]: def update_monitor_template(db: Session, template_id: str, body: dict[str, Any]) -> dict[str, Any]:

View file

@ -10,7 +10,14 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..biz_state.compare_rules import apply_row_filters from ..biz_state.compare_rules import apply_row_filters
from ..biz_state.compare_service import _load_metric_rows, _port_map_dict, sheet_key, sheet_title, template_metrics from ..biz_state.compare_service import (
_load_metric_rows,
_port_map_dict,
batch_metric_collect_ok,
sheet_key,
sheet_title,
template_metrics,
)
from ..models import ( from ..models import (
BizCompareTemplate, BizCompareTemplate,
BizMigrationBatch, BizMigrationBatch,
@ -260,8 +267,14 @@ def _current_batch_for_metric(
pinned = db.get(BizStateBatch, pin) pinned = db.get(BizStateBatch, pin)
# Only honor pin when it belongs to this metric's HF task (multi-interval safe) # Only honor pin when it belongs to this metric's HF task (multi-interval safe)
if pinned and (not tid or str(pinned.task_id or "") == tid): if pinned and (not tid or str(pinned.task_id or "") == tid):
# Pinned partial / failed metric → treat as missing (avoid false red)
if not batch_metric_collect_ok(db, pinned.id, metric_id):
return None
return pinned return pinned
return _latest_success_batch(db, tid) if tid else None batch = _latest_success_batch(db, tid) if tid else None
if batch and not batch_metric_collect_ok(db, batch.id, metric_id):
return None
return batch
def find_portrait_task_for_ne(db: Session, *, source: str, ne_id: str) -> BizStateTask | None: def find_portrait_task_for_ne(db: Session, *, source: str, ne_id: str) -> BizStateTask | None:
@ -772,11 +785,15 @@ def create_batch(db: Session, project_id: str, body: dict[str, Any]) -> dict[str
def _latest_success_batch(db: Session, task_id: str) -> BizStateBatch | None: def _latest_success_batch(db: Session, task_id: str) -> BizStateBatch | None:
"""Latest fully successful batch only — never use ``partial`` as current.
Partial batches omit failed-command metrics and cause mass false ``removed``.
"""
return ( return (
db.query(BizStateBatch) db.query(BizStateBatch)
.filter( .filter(
BizStateBatch.task_id == task_id, BizStateBatch.task_id == task_id,
BizStateBatch.status.in_(("success", "partial")), BizStateBatch.status == "success",
) )
.order_by(BizStateBatch.started_at.desc()) .order_by(BizStateBatch.started_at.desc())
.first() .first()
@ -903,6 +920,7 @@ def run_evaluate(
"new_baseline_mode": "missing_current", "new_baseline_mode": "missing_current",
"new_baseline_missing": False, "new_baseline_missing": False,
"current_missing": True, "current_missing": True,
"collect_incomplete": True,
"collect_skipped": False, "collect_skipped": False,
} }
) )
@ -972,6 +990,9 @@ def run_evaluate(
"collect_skipped": False, "collect_skipped": False,
"old_batch_id": old_cur_mid, "old_batch_id": old_cur_mid,
"new_batch_id": new_cur_mid, "new_batch_id": new_cur_mid,
"duplicate_key_list": list(one.get("duplicate_key_list") or []),
"duplicate_keys_before": int(one.get("duplicate_keys_before") or 0),
"duplicate_keys_after": int(one.get("duplicate_keys_after") or 0),
} }
) )
for r in one["rows"]: for r in one["rows"]:
@ -1811,6 +1832,7 @@ def _red_ticket_to_dict(t: BizMigrationRedTicket) -> dict[str, Any]:
"new_key": new_key, "new_key": new_key,
"key_str": old_key, "key_str": old_key,
"new_key_str": new_key, "new_key_str": new_key,
"match_key_str": str(getattr(t, "match_key_str", "") or detail.get("match_old_key") or ""),
"match_old_key": str(detail.get("match_old_key") or ""), "match_old_key": str(detail.get("match_old_key") or ""),
"match_new_key": str(detail.get("match_new_key") or ""), "match_new_key": str(detail.get("match_new_key") or ""),
"verdict": t.verdict, "verdict": t.verdict,
@ -1881,29 +1903,46 @@ def _persist_red_tickets_from_run(
batch_id: str, batch_id: str,
run_id: str, run_id: str,
) -> list[BizMigrationRedTicket]: ) -> list[BizMigrationRedTicket]:
"""Create open red tickets from acceptance run red diffs (expect + anomaly).""" """Upsert open/carried red tickets by (project, metric, match_key).
Same issue across batches / re-acceptance updates one ticket instead of
spawning duplicates. ``carried`` tickets are reopened when the key is still red.
"""
diffs = ( diffs = (
db.query(BizMigrationDiff) db.query(BizMigrationDiff)
.filter(BizMigrationDiff.run_id == run_id, BizMigrationDiff.color == "red") .filter(BizMigrationDiff.run_id == run_id, BizMigrationDiff.color == "red")
.order_by(BizMigrationDiff.seq.asc()) .order_by(BizMigrationDiff.seq.asc())
.all() .all()
) )
created: list[BizMigrationRedTicket] = [] existing = (
db.query(BizMigrationRedTicket)
.filter(
BizMigrationRedTicket.project_id == project_id,
BizMigrationRedTicket.status.in_(("open", "carried")),
)
.all()
)
by_id: dict[tuple[str, str], BizMigrationRedTicket] = {}
for t in existing:
mk = str(getattr(t, "match_key_str", "") or "").strip()
if not mk:
detail = t.detail_json if isinstance(t.detail_json, dict) else {}
mk = str(detail.get("match_old_key") or t.key_str or "").strip()
if mk:
by_id[(str(t.metric_id or ""), mk)] = t
created_or_updated: list[BizMigrationRedTicket] = []
seen: set[tuple[str, str]] = set()
for d in diffs: for d in diffs:
kj = d.key_json if isinstance(d.key_json, dict) else {} kj = d.key_json if isinstance(d.key_json, dict) else {}
t = BizMigrationRedTicket( mid = str(d.metric_id or "")
id=uuid4().hex, match_key = str(
project_id=project_id, kj.get("match_old_key") or kj.get("key_str") or kj.get("old_key") or ""
batch_id=batch_id, ).strip()[:256]
run_id=run_id, if not match_key:
metric_id=d.metric_id, match_key = str(kj.get("key_str") or "")[:256]
key_str=str(kj.get("key_str") or "")[:256], ident = (mid, match_key)
new_key_str=str(kj.get("new_key_str") or "")[:256], detail = {
verdict=d.verdict,
color=d.color or "red",
old_status=str(kj.get("old_status") or "")[:64],
new_status=str(kj.get("new_status") or "")[:64],
detail_json={
"old_kind": d.old_kind, "old_kind": d.old_kind,
"new_kind": d.new_kind, "new_kind": d.new_kind,
"in_expect": d.in_expect, "in_expect": d.in_expect,
@ -1911,15 +1950,52 @@ def _persist_red_tickets_from_run(
"new": d.new_json, "new": d.new_json,
"old_key": kj.get("old_key") or kj.get("key_str") or "", "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 "", "new_key": kj.get("new_key") or kj.get("new_key_str") or "",
"match_old_key": kj.get("match_old_key") or "", "match_old_key": kj.get("match_old_key") or match_key,
"match_new_key": kj.get("match_new_key") or "", "match_new_key": kj.get("match_new_key") or "",
"evidence": dict(kj.get("evidence") or {}), "evidence": dict(kj.get("evidence") or {}),
}, }
prev = by_id.get(ident)
if prev is not None:
prev.batch_id = batch_id
prev.run_id = run_id
prev.key_str = str(kj.get("key_str") or "")[:256]
prev.new_key_str = str(kj.get("new_key_str") or "")[:256]
prev.match_key_str = match_key
prev.verdict = d.verdict
prev.color = d.color or "red"
prev.old_status = str(kj.get("old_status") or "")[:64]
prev.new_status = str(kj.get("new_status") or "")[:64]
prev.detail_json = detail
prev.status = "open"
prev.carried_to_batch_id = ""
prev.resolved_at = None
created_or_updated.append(prev)
seen.add(ident)
continue
t = BizMigrationRedTicket(
id=uuid4().hex,
project_id=project_id,
batch_id=batch_id,
run_id=run_id,
metric_id=mid,
key_str=str(kj.get("key_str") or "")[:256],
new_key_str=str(kj.get("new_key_str") or "")[:256],
match_key_str=match_key,
verdict=d.verdict,
color=d.color or "red",
old_status=str(kj.get("old_status") or "")[:64],
new_status=str(kj.get("new_status") or "")[:64],
detail_json=detail,
status="open", status="open",
) )
db.add(t) db.add(t)
created.append(t) by_id[ident] = t
return created created_or_updated.append(t)
seen.add(ident)
# Tickets open on this batch but no longer red → leave as open (operator resolves).
# Carried tickets for keys not in this run stay carried.
return created_or_updated
def finish_batch( def finish_batch(
@ -1990,11 +2066,7 @@ def finish_batch(
mb.accept_summary_json = accept_summary mb.accept_summary_json = accept_summary
mb.updated_at = utcnow_naive() mb.updated_at = utcnow_naive()
# Replace prior open tickets from this batch's previous acceptance (re-finish) # Upsert reds (no wipe — merge by metric+match_key across re-acceptance)
db.query(BizMigrationRedTicket).filter(
BizMigrationRedTicket.batch_id == batch_id,
BizMigrationRedTicket.status == "open",
).delete()
reds = _persist_red_tickets_from_run( reds = _persist_red_tickets_from_run(
db, db,
project_id=mb.project_id, project_id=mb.project_id,

View file

@ -30,8 +30,11 @@ from ..models import (
) )
from ..ne_netmiko import disable_target_paging, send_show_command from ..ne_netmiko import disable_target_paging, send_show_command
from ..ne_session_factory import close_netmiko_connection, open_netmiko_connection from ..ne_session_factory import close_netmiko_connection, open_netmiko_connection
from ..timeutil import utcnow_naive
from .collect_session import ( from .collect_session import (
CachedCommand,
CollectSession, CollectSession,
ResolvedAux,
build_parse_bundle, build_parse_bundle,
resolve_aux_command, resolve_aux_command,
run_primary_with_bundle, run_primary_with_bundle,
@ -43,7 +46,7 @@ from .command_match import (
match_command, match_command,
normalize_command, normalize_command,
) )
from .parsers import get_parser from .parsers import get_parser, run_parser
from .profiles import get_profile from .profiles import get_profile
from .collect_stop import ( from .collect_stop import (
STOP_USER_MESSAGE, STOP_USER_MESSAGE,
@ -93,7 +96,7 @@ def partition_work(work: list[WorkItem]) -> tuple[list[WorkItem], list[WorkItem]
def _utcnow() -> datetime: def _utcnow() -> datetime:
return datetime.utcnow() return utcnow_naive()
def _format_error(exc: BaseException) -> str: def _format_error(exc: BaseException) -> str:
@ -215,6 +218,201 @@ _GENERIC_METRICS = {
_METRIC_CHUNK = 2000 _METRIC_CHUNK = 2000
def _run_primary_parse_job(job: Any) -> tuple[bool, bool]:
"""Parse primary+aux raws, write spool, submit persist. Returns (any_ok, any_fail)."""
from .parse_pool import AuxRawCapture, PrimaryParseJob
from .persist_pool import get_persist_pool
from .spool import SpooledCommand, write_meta, write_records
if not isinstance(job, PrimaryParseJob):
return False, True
batch_id = job.batch_id
cmd_id = job.cmd_id
pending: list[SpooledCommand] = []
any_ok = False
any_fail = False
def _flush_item(item: SpooledCommand, *, records: list[dict[str, Any]] | None = None) -> None:
if records is not None and item.persist_kind:
item.records_rel_path = write_records(batch_id, item.id, records)
item.row_count = len(records)
try:
write_meta(batch_id, item.id, item.to_meta())
except Exception:
_log.exception("biz_state write meta failed cmd=%s", item.id)
pending.append(item)
aux_results: dict[str, CachedCommand] = {}
resolved_aux: list[ResolvedAux] = []
for cap in list(job.aux_captures or []):
if not isinstance(cap, AuxRawCapture):
continue
from .profiles import get_profile as _gp
prof = _gp(cap.profile_id)
entry = CachedCommand(
raw=cap.raw,
records=list(cap.records or []),
fsm_tables=dict(cap.fsm_tables or {}),
ok=bool(cap.ok),
error=str(cap.error or ""),
cmd_row_id=cap.aux_id,
)
if entry.ok and not entry.records and cap.parser_id and get_parser(cap.parser_id):
try:
records, fsm_tables, _keys = run_parser(
cap.parser_id,
raw_text=cap.raw,
vendor=job.vendor,
device_type=job.device_type,
command=cap.textfsm_command or cap.command,
textfsm_command=cap.textfsm_command or "",
params=dict(job.merged_params or {}),
)
entry.records = list(records or [])
entry.fsm_tables = dict(fsm_tables or {})
except Exception as exc:
entry.ok = False
entry.error = f"parse: {type(exc).__name__}: {exc}"
aux_results[cap.key] = entry
if prof is not None:
resolved_aux.append(
ResolvedAux(
key=cap.key,
profile_id=cap.profile_id,
command=cap.command,
textfsm_command=cap.textfsm_command or cap.command,
parser_id=cap.parser_id,
rule_keys=tuple(cap.rule_keys or ()),
profile=prof,
)
)
aux_sp = SpooledCommand(
id=cap.aux_id,
batch_id=batch_id,
task_item_id=job.task_item_id,
profile_id=cap.profile_id,
parser_id=cap.parser_id,
metric_id=cap.metric_id,
raw_command=cap.command[:512],
params_json={},
raw_rel_path=cap.raw_rel_path,
)
if cap.cache_hit and entry.ok and entry.records:
aux_sp.parse_status = "aux_cached"
aux_sp.message = (
f"aux_for={cmd_id};cache_hit;src={entry.cmd_row_id}"
)[:1020]
aux_sp.row_count = len(entry.records or [])
elif not entry.ok:
aux_sp.parse_status = "aux_failed"
aux_sp.message = f"aux_for={cmd_id};{entry.error}"[:1020]
any_fail = True
else:
aux_sp.parse_status = "aux"
aux_sp.message = f"aux_for={cmd_id}"[:1020]
aux_sp.row_count = len(entry.records or [])
persist_recs: list[dict[str, Any]] | None = None
if entry.ok and entry.records and cap.metric_id in _GENERIC_METRICS:
persist_key = (normalize_command(cap.command), cap.metric_id)
do_persist = False
persisted = job.persisted
if persisted is not None:
if job.cache_lock is not None:
with job.cache_lock:
if persist_key not in persisted:
persisted.add(persist_key)
do_persist = True
elif persist_key not in persisted:
persisted.add(persist_key)
do_persist = True
if do_persist:
aux_sp.persist_kind = "metric"
persist_recs = list(entry.records)
_flush_item(aux_sp, records=persist_recs)
bundle = build_parse_bundle(
primary_raw=job.raw_text,
primary_parser_id=job.parser_id,
aux_results=aux_results,
resolved_aux=resolved_aux,
)
# Include aux raws that lacked a profile (still needed for multi-raw parsers).
for cap in list(job.aux_captures or []):
if not isinstance(cap, AuxRawCapture):
continue
if cap.key in bundle.raws:
continue
entry = aux_results.get(cap.key) or CachedCommand(ok=False)
bundle.raws[cap.key] = entry.raw
bundle.command_rules[cap.key] = list(cap.rule_keys or [])
if entry.records:
bundle.aux_records[cap.key] = list(entry.records)
bundle.fsm_extra.update(entry.fsm_tables or {})
primary = SpooledCommand(
id=cmd_id,
batch_id=batch_id,
task_item_id=job.task_item_id,
profile_id=job.profile_id,
parser_id=job.parser_id,
metric_id=job.metric_id,
raw_command=job.concrete[:512],
params_json=dict(job.merged_params or {}),
raw_rel_path=job.raw_rel_path,
)
try:
records, fsm_tables, rule_keys = run_primary_with_bundle(
job.parser_id,
bundle=bundle,
vendor=job.vendor,
device_type=job.device_type,
command=job.textfsm_command or job.concrete,
textfsm_command=job.textfsm_command or "",
params=dict(job.merged_params or {}),
enrich_joins=list(job.enrich_joins or []),
)
hints = []
if rule_keys:
nonempty = [k for k in rule_keys if fsm_tables.get(k)]
hints.append(f"fsm_keys={','.join(rule_keys)};hit={','.join(nonempty)}")
if bundle.aux_records:
hints.append(
"aux="
+ ",".join(f"{k}:{len(v)}" for k, v in bundle.aux_records.items())
)
if job.enrich_joins:
hints.append(
"enrich=" + ",".join(getattr(j, "from_aux", "") for j in job.enrich_joins)
)
if hints:
primary.message = ";".join(hints)[:1020]
primary.parse_status = "ok"
any_ok = True
persist_recs = None
if job.metric_id == "lldp_neighbor":
primary.persist_kind = "lldp"
persist_recs = list(records or [])
elif job.metric_id in _GENERIC_METRICS:
primary.persist_kind = "metric"
persist_recs = list(records or [])
_flush_item(primary, records=persist_recs)
_ = fsm_tables # kept for hints above
except Exception as exc:
any_fail = True
primary.parse_status = "failed"
primary.message = f"parse: {_format_error(exc)}"
_flush_item(primary)
if pending:
get_persist_pool().submit(batch_id, pending)
return any_ok, any_fail
def _emit_task_event(*, task_id: str, message: str, level: str = "error") -> None: def _emit_task_event(*, task_id: str, message: str, level: str = "error") -> None:
"""Short-lived session for lane events (no long-held DB during CLI).""" """Short-lived session for lane events (no long-held DB during CLI)."""
if not task_id or not str(message or "").strip(): if not task_id or not str(message or "").strip():
@ -547,9 +745,26 @@ def _run_collect_lane(
any_ok = False any_ok = False
pending: list[SpooledCommand] = [] pending: list[SpooledCommand] = []
task_id = "" task_id = ""
from .parse_pool import (
AuxRawCapture,
PrimaryParseJob,
get_parse_pool,
parse_async_enabled,
)
from .persist_pool import get_persist_pool from .persist_pool import get_persist_pool
persist = get_persist_pool() persist = get_persist_pool()
parse_pool = get_parse_pool() if parse_async_enabled() else None
parse_stats_lock = threading.Lock()
parse_stats = {"ok": False, "fail": False, "pending": 0}
def _on_parse_done(ok: bool, fail: bool) -> None:
with parse_stats_lock:
if ok:
parse_stats["ok"] = True
if fail:
parse_stats["fail"] = True
parse_stats["pending"] = max(0, int(parse_stats["pending"]) - 1)
def _submit_pending() -> None: def _submit_pending() -> None:
nonlocal pending nonlocal pending
@ -763,8 +978,8 @@ def _run_collect_lane(
cmd_row_id=cmd_id, cmd_row_id=cmd_id,
) )
resolved_aux = [] # Collect aux raws on the SSH thread (no TextFSM); parse overlaps next CLI.
aux_results: dict[str, Any] = {} aux_captures: list[AuxRawCapture] = []
for aux in list(hit.profile.aux_commands or []): for aux in list(hit.profile.aux_commands or []):
try: try:
ra = resolve_aux_command(aux, params=merged) ra = resolve_aux_command(aux, params=merged)
@ -781,18 +996,21 @@ def _run_collect_lane(
) )
) )
continue continue
resolved_aux.append(ra)
aux_id = uuid4().hex aux_id = uuid4().hex
entry, cache_hit = session.fetch_and_parse( entry, cache_hit = session.fetch_raw(ra.command, cmd_row_id=aux_id)
ra.command,
parser_id=ra.parser_id,
textfsm_command=ra.textfsm_command,
params=merged,
cmd_row_id=aux_id,
)
aux_results[ra.key] = entry
aux_mid = str(getattr(ra.profile, "metric_id", "") or "").strip() aux_mid = str(getattr(ra.profile, "metric_id", "") or "").strip()
aux_sp = SpooledCommand( aux_rel = ""
if entry.raw:
try:
aux_rel = write_raw_text(batch_id, aux_id, entry.raw or "")
except Exception:
_log.exception(
"biz_state spool aux raw failed cmd=%s", aux_id
)
if not entry.ok:
any_fail = True
_queue(
SpooledCommand(
id=aux_id, id=aux_id,
batch_id=batch_id, batch_id=batch_id,
task_item_id=item_id, task_item_id=item_id,
@ -800,126 +1018,83 @@ def _run_collect_lane(
parser_id=ra.parser_id, parser_id=ra.parser_id,
metric_id=aux_mid, metric_id=aux_mid,
raw_command=ra.command[:512], raw_command=ra.command[:512],
params_json={}, parse_status="aux_failed",
message=f"aux_for={cmd_id};{entry.error}"[:1020],
raw_rel_path=aux_rel,
) )
if cache_hit:
aux_sp.parse_status = "aux_cached"
aux_sp.message = (
f"aux_for={cmd_id};cache_hit;src={entry.cmd_row_id}"
)[:1020]
aux_sp.row_count = len(entry.records or [])
elif not entry.ok:
aux_sp.parse_status = "aux_failed"
aux_sp.message = f"aux_for={cmd_id};{entry.error}"[:1020]
try:
aux_sp.raw_rel_path = write_raw_text(
batch_id, aux_id, entry.raw or ""
) )
except Exception: continue
pass # If cache already has parsed records, pass them through.
else: aux_captures.append(
aux_sp.parse_status = "aux" AuxRawCapture(
aux_sp.message = f"aux_for={cmd_id}"[:1020] key=ra.key,
try: aux_id=aux_id,
aux_sp.raw_rel_path = write_raw_text( profile_id=ra.profile_id,
batch_id, aux_id, entry.raw or "" parser_id=ra.parser_id,
metric_id=aux_mid,
command=ra.command,
textfsm_command=ra.textfsm_command,
rule_keys=tuple(ra.rule_keys or ()),
raw=entry.raw,
raw_rel_path=aux_rel,
cache_hit=bool(cache_hit and entry.records),
records=list(entry.records or []),
fsm_tables=dict(entry.fsm_tables or {}),
ok=True,
)
) )
except Exception:
pass
aux_sp.row_count = len(entry.records or [])
entry.cmd_row_id = aux_id
persist_recs: list[dict[str, Any]] | None = None job = PrimaryParseJob(
if entry.ok and entry.records and aux_mid in _GENERIC_METRICS:
persist_key = (normalize_command(ra.command), aux_mid)
do_persist = False
if cache_lock is not None:
with cache_lock:
if persist_key not in persisted:
persisted.add(persist_key)
do_persist = True
elif persist_key not in persisted:
persisted.add(persist_key)
do_persist = True
if do_persist:
aux_sp.persist_kind = "metric"
persist_recs = list(entry.records)
_queue(aux_sp, records=persist_recs)
bundle = build_parse_bundle(
primary_raw=raw_text,
primary_parser_id=hit.profile.parser_id,
aux_results=aux_results,
resolved_aux=resolved_aux,
)
primary = SpooledCommand(
id=cmd_id,
batch_id=batch_id, batch_id=batch_id,
cmd_id=cmd_id,
task_item_id=item_id, task_item_id=item_id,
profile_id=hit.profile.profile_id, profile_id=hit.profile.profile_id,
parser_id=hit.profile.parser_id, parser_id=hit.profile.parser_id,
metric_id=hit.profile.metric_id, metric_id=hit.profile.metric_id,
raw_command=concrete[:512], concrete=concrete,
params_json=merged, merged_params=dict(merged or {}),
raw_text=raw_text,
raw_rel_path=raw_rel, raw_rel_path=raw_rel,
) textfsm_command=hit.profile.textfsm_command or concrete,
try:
records, fsm_tables, rule_keys = run_primary_with_bundle(
hit.profile.parser_id,
bundle=bundle,
vendor=vendor_eff, vendor=vendor_eff,
device_type=device_type_eff, device_type=device_type_eff,
command=hit.profile.textfsm_command or concrete,
textfsm_command=hit.profile.textfsm_command or "",
params=merged,
enrich_joins=list(hit.profile.enrich_joins or []), enrich_joins=list(hit.profile.enrich_joins or []),
aux_captures=aux_captures,
persisted=persisted,
cache_lock=cache_lock,
on_done=_on_parse_done if parse_pool is not None else None,
) )
session.remember( if parse_pool is not None:
concrete, with parse_stats_lock:
raw=raw_text, parse_stats["pending"] += 1
fsm_tables=fsm_tables, parse_pool.submit(job)
records=records, # Count primary (+ aux will be counted in parse worker via persist).
ok=True, # cmd_count: bump for primary + each aux capture so progress is visible.
cmd_row_id=cmd_id, cmd_count += 1 + len(aux_captures)
) else:
hints = [] ok, fail = _run_primary_parse_job(job)
if rule_keys: if ok:
nonempty = [k for k in rule_keys if fsm_tables.get(k)]
hints.append(
f"fsm_keys={','.join(rule_keys)};hit={','.join(nonempty)}"
)
if bundle.aux_records:
hints.append(
"aux="
+ ",".join(
f"{k}:{len(v)}" for k, v in bundle.aux_records.items()
)
)
if hit.profile.enrich_joins:
hints.append(
"enrich="
+ ",".join(j.from_aux for j in hit.profile.enrich_joins)
)
if hints:
primary.message = ";".join(hints)[:1020]
primary.parse_status = "ok"
any_ok = True any_ok = True
persist_recs = None if fail:
if hit.profile.metric_id == "lldp_neighbor":
primary.persist_kind = "lldp"
persist_recs = list(records or [])
elif hit.profile.metric_id in _GENERIC_METRICS:
primary.persist_kind = "metric"
persist_recs = list(records or [])
_queue(primary, records=persist_recs)
except Exception as exc:
any_fail = True any_fail = True
primary.parse_status = "failed" # Sync path: parse job already submitted persist; count cmds.
primary.message = f"parse: {_format_error(exc)}" cmd_count += 1 + len(aux_captures)
_queue(primary)
# Final flush for this lane → persist pool; wait so rows land before return. # Drain CLI spool leftovers, then wait parse + persist pools.
_submit_pending() _submit_pending()
if parse_pool is not None:
if not parse_pool.wait_idle(timeout=max(30.0, float(budget))):
_log.warning(
"biz_state parse barrier timed out batch=%s lane=%s",
batch_id,
label,
)
any_fail = True
with parse_stats_lock:
if parse_stats["ok"]:
any_ok = True
if parse_stats["fail"]:
any_fail = True
if not persist.wait_idle(timeout=max(30.0, float(budget))): if not persist.wait_idle(timeout=max(30.0, float(budget))):
_log.warning( _log.warning(
"biz_state persist barrier timed out batch=%s lane=%s", "biz_state persist barrier timed out batch=%s lane=%s",
@ -933,6 +1108,8 @@ def _run_collect_lane(
# Best-effort: enqueue leftover spool before connection teardown. # Best-effort: enqueue leftover spool before connection teardown.
try: try:
_submit_pending() _submit_pending()
if parse_pool is not None:
parse_pool.wait_idle(timeout=60.0)
persist.wait_idle(timeout=60.0) persist.wait_idle(timeout=60.0)
except Exception: except Exception:
_log.exception( _log.exception(
@ -1115,13 +1292,14 @@ def _finalize_batch_status(
batch.message = "" batch.message = ""
status = str(batch.status or "") status = str(batch.status or "")
db.commit() db.commit()
if status in ("success", "partial") and not stopped: # Only full success triggers auto compare; never block the collect thread.
if status == "success" and not stopped:
try: try:
from .compare_service import try_auto_compare_for_task from .compare_service import schedule_auto_compare_for_task
try_auto_compare_for_task(db, task_id, batch_id) schedule_auto_compare_for_task(task_id, batch_id)
except Exception: except Exception:
_log.exception("biz_state auto compare hook failed task=%s", task_id) _log.exception("biz_state auto compare schedule failed task=%s", task_id)
return status return status
try: try:
@ -1405,10 +1583,20 @@ def _run_collect_session(
raise RuntimeError("; ".join(lane_errors)[:1020]) raise RuntimeError("; ".join(lane_errors)[:1020])
any_fail = True any_fail = True
# Ensure persist pool drained before terminal status write. # Ensure parse + persist pools drained before terminal status write.
try: try:
from .parse_pool import get_parse_pool, parse_async_enabled
from .persist_pool import get_persist_pool from .persist_pool import get_persist_pool
if parse_async_enabled():
if not get_parse_pool().wait_idle(timeout=120.0):
_log.warning(
"biz_state parse barrier before finalize timed out batch=%s",
batch_id,
)
any_fail = True
if "parse_barrier_timeout" not in lane_errors:
lane_errors.append("RuntimeError: parse_barrier_timeout")
if not get_persist_pool().wait_idle(timeout=120.0): if not get_persist_pool().wait_idle(timeout=120.0):
_log.warning( _log.warning(
"biz_state persist barrier before finalize timed out batch=%s", "biz_state persist barrier before finalize timed out batch=%s",
@ -1418,7 +1606,7 @@ def _run_collect_session(
if "persist_barrier_timeout" not in lane_errors: if "persist_barrier_timeout" not in lane_errors:
lane_errors.append("RuntimeError: persist_barrier_timeout") lane_errors.append("RuntimeError: persist_barrier_timeout")
except Exception: except Exception:
_log.exception("biz_state persist barrier before finalize failed batch=%s", batch_id) _log.exception("biz_state parse/persist barrier before finalize failed batch=%s", batch_id)
stopped = is_stop_requested(batch_id) or any("_stopped" in e for e in lane_errors) stopped = is_stop_requested(batch_id) or any("_stopped" in e for e in lane_errors)
_finalize_batch_status( _finalize_batch_status(

View file

@ -174,6 +174,40 @@ class CollectSession:
return hit return hit
return None return None
def fetch_raw(
self,
command: str,
*,
cmd_row_id: str = "",
) -> tuple[CachedCommand, bool]:
"""CLI-only fetch (no TextFSM). Return ``(entry, cache_hit)``.
Used so the SSH thread can overlap subsequent commands while a parse
pool parses prior raws.
"""
with self._command_lock(command):
cached = self.get_cached(command)
if cached is not None and str(cached.raw or "").strip():
return cached, True
try:
raw = self._send_show(command)
except Exception as exc:
entry = self.remember(
command,
raw="",
ok=False,
error=f"{type(exc).__name__}: {exc}",
cmd_row_id=cmd_row_id,
)
return entry, False
entry = self.remember(
command,
raw=raw,
ok=True,
cmd_row_id=cmd_row_id,
)
return entry, False
def fetch_and_parse( def fetch_and_parse(
self, self,
command: str, command: str,
@ -187,10 +221,45 @@ class CollectSession:
Same concrete CLI is serialized across shared-cache lanes so aux of Same concrete CLI is serialized across shared-cache lanes so aux of
one monitor item can be reused by the next without re-collecting. one monitor item can be reused by the next without re-collecting.
Prefer ``fetch_raw`` on the collect hot path when parse can be async.
""" """
with self._command_lock(command): with self._command_lock(command):
cached = self.get_cached(command) cached = self.get_cached(command)
if cached is not None: if cached is not None:
# Full parse hit (records or explicit prior parse failure with raw).
if cached.records or (cached.ok and not parser_id):
return cached, True
if cached.ok and str(cached.raw or "").strip() and parser_id and get_parser(parser_id):
# Raw-only cache (async collect): parse now without re-CLI.
try:
records, fsm_tables, _keys = run_parser(
parser_id,
raw_text=cached.raw,
vendor=self.vendor,
device_type=self.device_type,
command=textfsm_command or command,
textfsm_command=textfsm_command or "",
params=params or {},
)
except Exception as exc:
entry = self.remember(
command,
raw=cached.raw,
ok=False,
error=f"parse: {type(exc).__name__}: {exc}",
cmd_row_id=cmd_row_id or cached.cmd_row_id,
)
return entry, True
entry = self.remember(
command,
raw=cached.raw,
fsm_tables=fsm_tables,
records=records,
ok=True,
cmd_row_id=cmd_row_id or cached.cmd_row_id,
)
return entry, True
if cached.ok:
return cached, True return cached, True
try: try:
raw = self._send_show(command) raw = self._send_show(command)

View file

@ -42,6 +42,9 @@ def mapping_stats(
``hit_before``: map key appears as a normalized before iface, or as the ``hit_before``: map key appears as a normalized before iface, or as the
parent of a before subinterface (so main-port-only maps still validate). parent of a before subinterface (so main-port-only maps still validate).
Map keys are matched against **normalized** iface values (same pipeline as
compare). Enter mapping keys in normalized form.
""" """
before_ifaces: set[str] = set() before_ifaces: set[str] = set()
after_ifaces: set[str] = set() after_ifaces: set[str] = set()
@ -58,14 +61,17 @@ def mapping_stats(
before_bases: set[str] = set() before_bases: set[str] = set()
for v in before_ifaces: for v in before_ifaces:
before_bases.add(v) before_bases.add(v)
if "." in v: # Multi-level parents: a.b.c → a.b, a
before_bases.add(v.rsplit(".", 1)[0]) parts = v.split(".")
for i in range(len(parts) - 1, 0, -1):
before_bases.add(".".join(parts[:i]))
after_bases: set[str] = set() after_bases: set[str] = set()
for v in after_ifaces: for v in after_ifaces:
after_bases.add(v) after_bases.add(v)
if "." in v: parts = v.split(".")
after_bases.add(v.rsplit(".", 1)[0]) for i in range(len(parts) - 1, 0, -1):
after_bases.add(".".join(parts[:i]))
mapped_before = set(port_map.keys()) mapped_before = set(port_map.keys())
mapped_after = set(port_map.values()) mapped_after = set(port_map.values())
@ -84,6 +90,7 @@ def mapping_stats(
"miss_after": miss_after, "miss_after": miss_after,
"unused_before_keys": unused, "unused_before_keys": unused,
"ok": not miss_before and not miss_after, "ok": not miss_before and not miss_after,
"hint": "map keys must match normalized iface names (post iface_normalize)",
} }
@ -97,17 +104,22 @@ def compare_rows(
port_map: dict[str, str] | None = None, port_map: dict[str, str] | None = None,
field_rules: Sequence[Mapping[str, Any]] | None = None, field_rules: Sequence[Mapping[str, Any]] | None = None,
iface_normalize_rules: Sequence[Mapping[str, str]] | None = None, iface_normalize_rules: Sequence[Mapping[str, str]] | None = None,
ignore_port_changes: bool | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return summary + diffs list. """Return summary + diffs list.
Diff kinds: added | removed | changed | unchanged Diff kinds: added | removed | changed | unchanged | duplicate
Pipeline: iface normalize (both sides) → port map (before) → match. Pipeline: iface normalize (both sides) → port map (before) → match.
Empty ``port_map``: try to ignore port renames by dropping ``iface_fields`` ``ignore_port_changes``:
from the match key (LLDP-style). If that would collapse distinct rows - ``None`` (default): auto — drop iface from match key only when remaining
(OSPF/VRRP/config where the same id appears on many interfaces), keep keys stay unique on both sides (legacy LLDP-style heuristic).
iface columns so identity compare stays correct. - ``True``: force drop iface from match key when a non-empty candidate exists.
- ``False``: never drop iface from match key.
Duplicate match keys are not silently discarded: extras become ``duplicate``
diffs and ``summary.duplicate_key_list`` lists the colliding keys.
``field_rules`` drives normalize / numeric tolerance / per-field compare mode ``field_rules`` drives normalize / numeric tolerance / per-field compare mode
(template-driven; no metric-specific branches here). (template-driven; no metric-specific branches here).
@ -127,20 +139,25 @@ def compare_rows(
after_rows, iface_fields=iface_list, rules=norm_rules after_rows, iface_fields=iface_list, rules=norm_rules
) )
ignore_port_changes = False ignore_ports = False
# No map → optionally ignore port renames by dropping iface from match key. # No map → optionally ignore port renames by dropping iface from match key.
if not pmap and iface_set: if not pmap and iface_set:
candidate = [k for k in key_fields if k not in iface_set] candidate = [k for k in key_fields if k not in iface_set]
if not candidate: if not candidate:
match_keys = list(key_fields) match_keys = list(key_fields)
elif ignore_port_changes is True:
match_keys = candidate
ignore_ports = True
elif ignore_port_changes is False:
match_keys = list(key_fields)
else: else:
# Auto heuristic (legacy default)
before_c = [row_key(r, candidate) for r in before_norm] before_c = [row_key(r, candidate) for r in before_norm]
after_c = [row_key(r, candidate) for r in after_norm] after_c = [row_key(r, candidate) for r in after_norm]
if len(before_c) == len(set(before_c)) and len(after_c) == len(set(after_c)): if len(before_c) == len(set(before_c)) and len(after_c) == len(set(after_c)):
match_keys = candidate match_keys = candidate
ignore_port_changes = True ignore_ports = True
else: else:
# Remaining keys are not unique — iface is required for identity.
match_keys = list(key_fields) match_keys = list(key_fields)
else: else:
match_keys = list(key_fields) match_keys = list(key_fields)
@ -151,16 +168,22 @@ def compare_rows(
after_index: dict[tuple[str, ...], dict[str, Any]] = {} after_index: dict[tuple[str, ...], dict[str, Any]] = {}
after_dup = 0 after_dup = 0
after_dup_keys: list[tuple[str, ...]] = []
after_dup_rows: list[tuple[tuple[str, ...], dict[str, Any]]] = []
for r in after_norm: for r in after_norm:
k = row_key(r, match_keys) k = row_key(r, match_keys)
if k in after_index: if k in after_index:
after_dup += 1 after_dup += 1
after_dup_keys.append(k)
after_dup_rows.append((k, r))
continue # first wins — do not overwrite
after_index[k] = r after_index[k] = r
before_keys: set[tuple[str, ...]] = set() before_keys: set[tuple[str, ...]] = set()
before_dup = 0 before_dup = 0
before_dup_keys: list[tuple[str, ...]] = []
diffs: list[dict[str, Any]] = [] diffs: list[dict[str, Any]] = []
added = removed = changed = unchanged = 0 added = removed = changed = unchanged = duplicate = 0
def _key_obj(row: dict[str, Any]) -> dict[str, Any]: def _key_obj(row: dict[str, Any]) -> dict[str, Any]:
return {f: row.get(f, "") for f in key_fields} return {f: row.get(f, "") for f in key_fields}
@ -169,6 +192,20 @@ def compare_rows(
k = row_key(mapped, match_keys) k = row_key(mapped, match_keys)
if k in before_keys: if k in before_keys:
before_dup += 1 before_dup += 1
before_dup_keys.append(k)
duplicate += 1
diffs.append(
{
"kind": "duplicate",
"side": "before",
"key": _key_obj(mapped),
"before": orig,
"after": after_index.get(k),
"mapped_before": mapped,
"changes": {},
}
)
continue # only first before row participates in match
before_keys.add(k) before_keys.add(k)
after = after_index.get(k) after = after_index.get(k)
if after is None: if after is None:
@ -237,6 +274,30 @@ def compare_rows(
} }
) )
for k, after in after_dup_rows:
duplicate += 1
diffs.append(
{
"kind": "duplicate",
"side": "after",
"key": _key_obj(after),
"before": None,
"after": after,
"mapped_before": None,
"changes": {},
}
)
def _fmt_keys(keys: list[tuple[str, ...]]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for k in keys:
s = "|".join(k)
if s not in seen:
seen.add(s)
out.append(s)
return out
stats = mapping_stats( stats = mapping_stats(
before_rows=before_norm, before_rows=before_norm,
after_rows=after_norm, after_rows=after_norm,
@ -244,7 +305,7 @@ def compare_rows(
port_map=pmap, port_map=pmap,
) )
if not pmap: if not pmap:
stats = {**stats, "ok": True, "ignore_port_changes": ignore_port_changes} stats = {**stats, "ok": True, "ignore_port_changes": ignore_ports}
return { return {
"summary": { "summary": {
"before_count": len(before_rows), "before_count": len(before_rows),
@ -253,9 +314,11 @@ def compare_rows(
"removed": removed, "removed": removed,
"changed": changed, "changed": changed,
"unchanged": unchanged, "unchanged": unchanged,
"duplicate": duplicate,
"match_key_fields": match_keys, "match_key_fields": match_keys,
"duplicate_keys_before": before_dup, "duplicate_keys_before": before_dup,
"duplicate_keys_after": after_dup, "duplicate_keys_after": after_dup,
"duplicate_key_list": _fmt_keys(before_dup_keys + after_dup_keys),
}, },
"diffs": diffs, "diffs": diffs,
"mapping_stats": stats, "mapping_stats": stats,

View file

@ -4,6 +4,8 @@ from __future__ import annotations
import io import io
import json import json
import logging
import threading
import zipfile import zipfile
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@ -20,6 +22,7 @@ from ..models import (
BizPortMapping, BizPortMapping,
BizPortMappingRow, BizPortMappingRow,
BizStateBatch, BizStateBatch,
BizStateBatchCommand,
BizStateLldpNeighbor, BizStateLldpNeighbor,
BizStateTask, BizStateTask,
) )
@ -39,11 +42,69 @@ from .iface_normalize import (
) )
from .profiles import metric_field_map from .profiles import metric_field_map
_log = logging.getLogger("netx.biz_state.compare")
# Per-job mutex so auto + manual run_compare do not dual-write the same job.
_auto_compare_locks: dict[str, threading.Lock] = {}
_auto_compare_locks_guard = threading.Lock()
def _job_compare_lock(job_id: str) -> threading.Lock:
jid = str(job_id or "").strip()
with _auto_compare_locks_guard:
lock = _auto_compare_locks.get(jid)
if lock is None:
lock = threading.Lock()
_auto_compare_locks[jid] = lock
return lock
def _utcnow() -> datetime: def _utcnow() -> datetime:
return utcnow_naive() return utcnow_naive()
# parse_status values that mean the command produced usable (possibly empty) rows
_METRIC_OK_STATUSES = frozenset(
{"ok", "unmatched", "skipped_custom", "ok_aux", "unmatched_aux"}
)
_METRIC_FAIL_STATUSES = frozenset({"failed", "failed_aux"})
def batch_metric_collect_ok(db: Session, batch_id: str, metric_id: str) -> bool:
"""True when this metric is safe to use from the batch.
- Batch ``success``: OK (all commands finished).
- Batch ``partial``/other: require at least one non-failed command for the metric.
- No command row for the metric on a non-success batch → incomplete.
"""
bid = str(batch_id or "").strip()
mid = str(metric_id or "").strip()
if not bid or not mid:
return False
batch = db.get(BizStateBatch, bid)
if not batch:
return False
status = str(batch.status or "")
if status == "success":
return True
cmds = (
db.query(BizStateBatchCommand)
.filter(
BizStateBatchCommand.batch_id == bid,
BizStateBatchCommand.metric_id == mid,
)
.all()
)
if not cmds:
return False
statuses = [str(c.parse_status or "").strip().lower() for c in cmds]
if any(s in _METRIC_OK_STATUSES or s.startswith("ok") for s in statuses):
return True
if all(s in _METRIC_FAIL_STATUSES or s.startswith("failed") for s in statuses):
return False
# Unknown status with rows still present — allow; empty unknown on partial — deny
return any(int(c.row_count or 0) > 0 for c in cmds)
def _compare_side( def _compare_side(
db: Session, db: Session,
batch_id: str, batch_id: str,
@ -314,6 +375,7 @@ def _sheet_def(
display_fields: list[str] | None = None, display_fields: list[str] | None = None,
row_filters: list[dict[str, Any]] | None = None, row_filters: list[dict[str, Any]] | None = None,
field_rules: list[dict[str, Any]] | None = None, field_rules: list[dict[str, Any]] | None = None,
ignore_port_changes: bool | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
mid = str(metric_id or "").strip() mid = str(metric_id or "").strip()
sid = str(sheet_id or "").strip() or mid sid = str(sheet_id or "").strip() or mid
@ -352,6 +414,8 @@ def _sheet_def(
"row_filters": _normalize_row_filters(row_filters), "row_filters": _normalize_row_filters(row_filters),
"field_rules": rules, "field_rules": rules,
} }
if ignore_port_changes is not None:
sheet["ignore_port_changes"] = bool(ignore_port_changes)
return sheet return sheet
@ -553,6 +617,9 @@ def _normalize_sheet(raw: Any) -> dict[str, Any] | None:
disp_arg = _str_list(raw.get("display_fields")) disp_arg = _str_list(raw.get("display_fields"))
else: else:
disp_arg = None disp_arg = None
ignore_ports: bool | None = None
if "ignore_port_changes" in raw and raw.get("ignore_port_changes") is not None:
ignore_ports = bool(raw.get("ignore_port_changes"))
return _sheet_def( return _sheet_def(
metric_id=mid, metric_id=mid,
sheet_id=str(raw.get("sheet_id") or "").strip() or mid, sheet_id=str(raw.get("sheet_id") or "").strip() or mid,
@ -563,6 +630,7 @@ def _normalize_sheet(raw: Any) -> dict[str, Any] | None:
display_fields=disp_arg, display_fields=disp_arg,
row_filters=_normalize_row_filters(raw.get("row_filters")), row_filters=_normalize_row_filters(raw.get("row_filters")),
field_rules=rules, field_rules=rules,
ignore_port_changes=ignore_ports,
) )
@ -1368,7 +1436,7 @@ def _resolve_after_batch(db: Session, job: BizCompareJob) -> str:
db.query(BizStateBatch) db.query(BizStateBatch)
.filter( .filter(
BizStateBatch.task_id == task_id, BizStateBatch.task_id == task_id,
BizStateBatch.status.in_(("success", "partial")), BizStateBatch.status == "success",
) )
.order_by(BizStateBatch.started_at.desc()) .order_by(BizStateBatch.started_at.desc())
.first() .first()
@ -1401,6 +1469,9 @@ def _run_sheet(
) )
row_filters = list(sheet.get("row_filters") or []) row_filters = list(sheet.get("row_filters") or [])
mode = "presence" if not compare_fields else "fields" mode = "presence" if not compare_fields else "fields"
ignore_ports = sheet.get("ignore_port_changes")
if ignore_ports is not None:
ignore_ports = bool(ignore_ports)
before_raw = _load_metric_rows(db, batch_id=before_batch_id, metric_id=sheet["metric_id"]) before_raw = _load_metric_rows(db, batch_id=before_batch_id, metric_id=sheet["metric_id"])
after_raw = _load_metric_rows(db, batch_id=after_batch_id, metric_id=sheet["metric_id"]) after_raw = _load_metric_rows(db, batch_id=after_batch_id, metric_id=sheet["metric_id"])
before_rows = apply_row_filters(before_raw, row_filters) before_rows = apply_row_filters(before_raw, row_filters)
@ -1414,6 +1485,7 @@ def _run_sheet(
port_map=port_map, port_map=port_map,
field_rules=field_rules, field_rules=field_rules,
iface_normalize_rules=iface_normalize_rules, iface_normalize_rules=iface_normalize_rules,
ignore_port_changes=ignore_ports,
) )
summary = dict(result["summary"]) summary = dict(result["summary"])
summary["before_raw_count"] = len(before_raw) summary["before_raw_count"] = len(before_raw)
@ -1429,6 +1501,7 @@ def _run_sheet(
"display_fields": display_fields, "display_fields": display_fields,
"row_filters": row_filters, "row_filters": row_filters,
"field_rules": field_rules, "field_rules": field_rules,
"ignore_port_changes": ignore_ports,
"mode": mode, "mode": mode,
"summary": summary, "summary": summary,
"diffs": result["diffs"], "diffs": result["diffs"],
@ -1437,6 +1510,18 @@ def _run_sheet(
def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> dict[str, Any]: def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> dict[str, Any]:
lock = _job_compare_lock(job_id)
if not lock.acquire(blocking=False):
raise HTTPException(status_code=409, detail="compare_already_running")
try:
return _run_compare_unlocked(db, job_id, force_after_batch_id=force_after_batch_id)
finally:
lock.release()
def _run_compare_unlocked(
db: Session, job_id: str, *, force_after_batch_id: str = ""
) -> dict[str, Any]:
j = db.get(BizCompareJob, job_id) j = db.get(BizCompareJob, job_id)
if not j: if not j:
raise HTTPException(status_code=404, detail="job_not_found") raise HTTPException(status_code=404, detail="job_not_found")
@ -1467,6 +1552,7 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
"removed": 0, "removed": 0,
"changed": 0, "changed": 0,
"unchanged": 0, "unchanged": 0,
"duplicate": 0,
} }
mapping_by_metric: dict[str, Any] = {} mapping_by_metric: dict[str, Any] = {}
for sheet in sheets_cfg: for sheet in sheets_cfg:
@ -1513,6 +1599,7 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
"compare_fields": s["compare_fields"], "compare_fields": s["compare_fields"],
"display_fields": s.get("display_fields") or [], "display_fields": s.get("display_fields") or [],
"field_rules": s.get("field_rules") or [], "field_rules": s.get("field_rules") or [],
"ignore_port_changes": s.get("ignore_port_changes"),
"mode": s["mode"], "mode": s["mode"],
"summary": s["summary"], "summary": s["summary"],
} }
@ -1957,7 +2044,14 @@ def list_runs(db: Session, job_id: str, *, limit: int = 20) -> list[dict[str, An
def try_auto_compare_for_task(db: Session, task_id: str, batch_id: str) -> int: def try_auto_compare_for_task(db: Session, task_id: str, batch_id: str) -> int:
"""When a new after batch lands, run auto jobs pinned to that after task.""" """When a new *success* after batch lands, run auto jobs pinned to that task.
Skips jobs already being compared (non-blocking lock). Call from a background
thread so the collect finalize path is not blocked.
"""
batch = db.get(BizStateBatch, batch_id)
if not batch or str(batch.status or "") != "success":
return 0
jobs = ( jobs = (
db.query(BizCompareJob) db.query(BizCompareJob)
.filter(BizCompareJob.mode == "auto", BizCompareJob.after_task_id == task_id) .filter(BizCompareJob.mode == "auto", BizCompareJob.after_task_id == task_id)
@ -1970,6 +2064,34 @@ def try_auto_compare_for_task(db: Session, task_id: str, batch_id: str) -> int:
try: try:
run_compare(db, j.id, force_after_batch_id=batch_id) run_compare(db, j.id, force_after_batch_id=batch_id)
n += 1 n += 1
except HTTPException as exc:
if int(getattr(exc, "status_code", 0) or 0) == 409:
_log.info("auto compare skipped (busy) job=%s", j.id)
continue
except Exception: except Exception:
_log.exception("auto compare failed job=%s task=%s", j.id, task_id)
continue continue
return n return n
def schedule_auto_compare_for_task(task_id: str, batch_id: str) -> None:
"""Fire-and-forget auto compare on a daemon thread (own DB session)."""
def _run() -> None:
from ..db import SessionLocal
db = SessionLocal()
try:
try_auto_compare_for_task(db, task_id, batch_id)
except Exception:
_log.exception(
"bg auto compare failed task=%s batch=%s", task_id, batch_id
)
finally:
db.close()
threading.Thread(
target=_run,
name=f"biz-auto-cmp-{str(batch_id)[:8]}",
daemon=True,
).start()

View file

@ -108,7 +108,11 @@ def apply_iface_normalize_rows(
def resolve_mapped_iface(name: str, port_map: Mapping[str, str] | None) -> str: def resolve_mapped_iface(name: str, port_map: Mapping[str, str] | None) -> str:
"""Exact map hit, else parent + ``.suffix`` if parent mapped, else unchanged.""" """Exact map hit, else longest mapped parent + remaining suffix (QinQ-safe).
``gei-0/1/0/1.100.200`` tries exact, then ``gei-0/1/0/1.100``, then
``gei-0/1/0/1`` — not only the last ``.`` segment.
"""
text = str(name or "").strip() text = str(name or "").strip()
if not text or not port_map: if not text or not port_map:
return text return text
@ -116,9 +120,12 @@ def resolve_mapped_iface(name: str, port_map: Mapping[str, str] | None) -> str:
return str(port_map[text]) return str(port_map[text])
if "." not in text: if "." not in text:
return text return text
parent, suffix = text.rsplit(".", 1) parts = text.split(".")
for i in range(len(parts) - 1, 0, -1):
parent = ".".join(parts[:i])
suffix = ".".join(parts[i:])
if not parent or not suffix: if not parent or not suffix:
return text continue
if parent in port_map: if parent in port_map:
return f"{port_map[parent]}.{suffix}" return f"{port_map[parent]}.{suffix}"
return text return text

View file

@ -0,0 +1,181 @@
"""Parse pool: TextFSM / normalize off the collect (SSH) threads."""
from __future__ import annotations
import logging
import queue
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable
from ..config import settings
_log = logging.getLogger("netx.biz_state.parse")
_SENTINEL = object()
@dataclass
class AuxRawCapture:
"""CLI-only aux capture passed to the parse worker."""
key: str
aux_id: str
profile_id: str
parser_id: str
metric_id: str
command: str
textfsm_command: str
rule_keys: tuple[str, ...] = ()
raw: str = ""
raw_rel_path: str = ""
cache_hit: bool = False
# Pre-parsed records when cache already had a full parse hit.
records: list[dict[str, Any]] = field(default_factory=list)
fsm_tables: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
ok: bool = True
error: str = ""
@dataclass
class PrimaryParseJob:
"""One primary command (with aux raws) ready for CPU parse + spool."""
batch_id: str
cmd_id: str
task_item_id: str
profile_id: str
parser_id: str
metric_id: str
concrete: str
merged_params: dict[str, Any]
raw_text: str
raw_rel_path: str
textfsm_command: str
vendor: str
device_type: str
enrich_joins: list[Any] = field(default_factory=list)
aux_captures: list[AuxRawCapture] = field(default_factory=list)
# Shared across lanes for aux metric de-dupe (may be None).
persisted: set[tuple[str, str]] | None = None
cache_lock: Any | None = None
on_done: Callable[[bool, bool], None] | None = None
class ParsePool:
"""Background workers that parse + enqueue persist for primary jobs."""
def __init__(self, *, workers: int | None = None) -> None:
n = max(
1,
int(
workers
if workers is not None
else (getattr(settings, "biz_state_parse_workers", 8) or 8)
),
)
self._q: queue.Queue[Any] = queue.Queue()
self._inflight = 0
self._lock = threading.Lock()
self._cv = threading.Condition(self._lock)
self._workers: list[threading.Thread] = []
self._stopped = False
for i in range(n):
t = threading.Thread(
target=self._loop,
name=f"biz-parse-{i}",
daemon=True,
)
t.start()
self._workers.append(t)
_log.info("biz_state parse pool started workers=%s", n)
def submit(self, job: PrimaryParseJob) -> None:
if self._stopped or job is None:
return
with self._cv:
self._inflight += 1
self._q.put(job)
def wait_idle(self, *, timeout: float | None = None) -> bool:
end = None
if timeout is not None:
end = time.monotonic() + max(0.0, float(timeout))
with self._cv:
while self._inflight > 0 or not self._q.empty():
remaining = None
if end is not None:
remaining = end - time.monotonic()
if remaining <= 0:
return False
self._cv.wait(timeout=remaining)
return True
def shutdown(self, *, wait: bool = True) -> None:
self._stopped = True
for _ in self._workers:
self._q.put(_SENTINEL)
if wait:
for t in self._workers:
t.join(timeout=5.0)
def _loop(self) -> None:
from .collect_runner import _run_primary_parse_job
while True:
job = self._q.get()
if job is _SENTINEL:
self._q.task_done()
break
ok = False
fail = False
try:
ok, fail = _run_primary_parse_job(job)
except Exception:
fail = True
_log.exception(
"biz_state parse job failed batch=%s cmd=%s",
getattr(job, "batch_id", ""),
getattr(job, "cmd_id", ""),
)
try:
if job.on_done:
job.on_done(False, True)
except Exception:
pass
else:
try:
if job.on_done:
job.on_done(ok, fail)
except Exception:
_log.exception("biz_state parse on_done failed")
finally:
with self._cv:
self._inflight = max(0, self._inflight - 1)
self._cv.notify_all()
self._q.task_done()
_pool_lock = threading.Lock()
_pool: ParsePool | None = None
def parse_async_enabled() -> bool:
return bool(getattr(settings, "biz_state_parse_async", True))
def get_parse_pool() -> ParsePool:
global _pool
with _pool_lock:
if _pool is None:
_pool = ParsePool()
return _pool
def shutdown_parse_pool(*, wait: bool = False) -> None:
global _pool
with _pool_lock:
if _pool is not None:
_pool.shutdown(wait=wait)
_pool = None

View file

@ -13,21 +13,22 @@ from .bgp_peer import _detect_bgp_afi, _detect_local_as, _detect_vrf
RULE_KEYS = ("zte_zxros_show_bgp_neighbor_routes",) RULE_KEYS = ("zte_zxros_show_bgp_neighbor_routes",)
# Single-line IPv4-style: * 10.1.0.0/24 10.0.0.1 … path # Status codes may be glued or spaced: "*", "* i", "*>i", "* s", "< *"
_FLAGS = r"(?P<flags>(?:[*<>isd]+(?:\s+[*<>isd]+)*)?)"
# Single-line IPv4-style: * i 10.1.0.0/24 10.0.0.1 … path
_ROUTE_ONE_LINE_RE = re.compile( _ROUTE_ONE_LINE_RE = re.compile(
r"^\s*(?P<flags>[*<>isd]*)\s*" rf"^\s*{_FLAGS}\s+"
r"(?P<net>\S+)\s+" r"(?P<net>\S+)\s+"
r"(?P<nh>\S+)\s+" r"(?P<nh>\S+)\s+"
r"(?P<rest>.*)$" r"(?P<rest>.*)$"
) )
# Network alone (often IPv6 wrap): * 2407::1/128 or bare prefix for "out" # Network alone (often IPv6 wrap): * i 2407::1/128
_NET_ONLY_RE = re.compile( _NET_ONLY_RE = re.compile(rf"^\s*{_FLAGS}\s*(?P<net>\S+)\s*$")
r"^\s*(?P<flags>[*<>isd]*)\s*(?P<net>\S+)\s*$"
)
_DIR_RE = re.compile(r"(?i)\bneighbor\s+(in|out)\s+") _DIR_RE = re.compile(r"(?i)\bneighbor\s+(in|out)\s+")
# Neighbor may be IPv4 or IPv6 (consume until EOL / pipe) # Neighbor may be IPv4 or IPv6 (consume until EOL / pipe)
_NEI_RE = re.compile(r"(?i)\bneighbor\s+(?:in|out)\s+(\S+)") _NEI_RE = re.compile(r"(?i)\bneighbor\s+(?:in|out)\s+(\S+)")
_TOTAL_RE = re.compile(r"(?i)total\s+number\s+of\s+routes\s*:\s*(\d+)") _TOTAL_RE = re.compile(r"(?i)total\s+number\s+of\s+routes\s*:\s*(\d+)")
_RD_RE = re.compile(r"(?i)^Route\s+Distinguisher\s*:\s*(\S+)")
_HEADER_NETS = frozenset( _HEADER_NETS = frozenset(
{ {
"network", "network",
@ -80,6 +81,21 @@ def _looks_like_prefix(net: str) -> bool:
return _looks_like_ip_or_prefix(net) return _looks_like_ip_or_prefix(net)
def _declared_total(raw_text: str) -> int | None:
m = _TOTAL_RE.search(str(raw_text or ""))
if not m:
return None
try:
return int(m.group(1))
except ValueError:
return None
def _route_dedupe_key(*, rd: str, net: str, nh: str) -> str:
"""ECMP / multi-RD: same prefix with different RD or next-hop is distinct."""
return f"{rd}\0{net}\0{nh}"
def _split_rest(rest: str, *, path_continuation: bool = False) -> tuple[str, str, str, str]: def _split_rest(rest: str, *, path_continuation: bool = False) -> tuple[str, str, str, str]:
"""Parse trailing Metric LocPrf Tag/RtPrf Path columns (some may be blank). """Parse trailing Metric LocPrf Tag/RtPrf Path columns (some may be blank).
@ -118,8 +134,8 @@ def _split_rest(rest: str, *, path_continuation: bool = False) -> tuple[str, str
def _empty_if_total_zero(raw_text: str) -> bool: def _empty_if_total_zero(raw_text: str) -> bool:
"""True when device reports Total number of routes: 0.""" """True when device reports Total number of routes: 0."""
m = _TOTAL_RE.search(str(raw_text or "")) total = _declared_total(raw_text)
return bool(m and int(m.group(1)) == 0) return total == 0
def _skip_noise_line(line: str) -> bool: def _skip_noise_line(line: str) -> bool:
@ -134,9 +150,7 @@ def _skip_noise_line(line: str) -> bool:
return True return True
if low.startswith("local ") or low.startswith("remote ") or low.startswith("total "): if low.startswith("local ") or low.startswith("remote ") or low.startswith("total "):
return True return True
if low.startswith("route distinguisher") or low.startswith("valid ") or low.startswith( if low.startswith("valid ") or low.startswith("invalid "):
"invalid "
):
return True return True
# Banner / clock lines (e.g. "09:50:02 Indonesia Sat Sep 19 2026") # Banner / clock lines (e.g. "09:50:02 Indonesia Sat Sep 19 2026")
if re.match(r"^\d{1,2}:\d{2}:\d{2}\b", low): if re.match(r"^\d{1,2}:\d{2}:\d{2}\b", low):
@ -157,19 +171,24 @@ def _emit_route(
vrf: str, vrf: str,
neighbor: str, neighbor: str,
direction: str, direction: str,
rd: str,
net: str, net: str,
nh: str, nh: str,
rest: str, rest: str,
flags: str, flags: str,
path_continuation: bool = False, path_continuation: bool = False,
) -> None: ) -> None:
if not _looks_like_prefix(net) or net in seen: if not _looks_like_prefix(net):
return return
if nh and not _looks_like_ip_or_prefix(nh): if nh and not _looks_like_ip_or_prefix(nh):
# Path/metric-only continuation without a real next-hop — keep empty nh # Path/metric-only continuation without a real next-hop — keep empty nh
if re.search(r"[A-Za-z]", nh): if re.search(r"[A-Za-z]", nh):
return return
seen.add(net) nh = ""
key = _route_dedupe_key(rd=rd, net=net, nh=nh or "")
if key in seen:
return
seen.add(key)
metric, loc, tag, path = _split_rest(rest, path_continuation=path_continuation) metric, loc, tag, path = _split_rest(rest, path_continuation=path_continuation)
out.append( out.append(
{ {
@ -178,13 +197,14 @@ def _emit_route(
"vrf": vrf[:128], "vrf": vrf[:128],
"neighbor": neighbor[:128], "neighbor": neighbor[:128],
"direction": direction[:8], "direction": direction[:8],
"rd": (rd or "")[:64],
"network": net[:128], "network": net[:128],
"next_hop": (nh or "")[:128], "next_hop": (nh or "")[:128],
"metric": metric[:32], "metric": metric[:32],
"loc_prf": loc[:32], "loc_prf": loc[:32],
"tag": tag[:32], "tag": tag[:32],
"path": path[:256], "path": path[:256],
"status_codes": (flags or "").strip()[:16], "status_codes": re.sub(r"\s+", "", (flags or "").strip())[:16],
"as_num": "", "as_num": "",
"state": "", "state": "",
"pfx_rcd": "", "pfx_rcd": "",
@ -205,7 +225,7 @@ def _map_fsm_rows(
seen: set[str] = set() seen: set[str] = set()
for r in rows: for r in rows:
net = row_get(r, "NETWORK", "network") net = row_get(r, "NETWORK", "network")
if not _looks_like_prefix(net) or net in seen: if not _looks_like_prefix(net):
continue continue
nh = row_get(r, "NEXT_HOP", "next_hop") nh = row_get(r, "NEXT_HOP", "next_hop")
if str(nh or "").strip().lower() in _HEADER_NETS: if str(nh or "").strip().lower() in _HEADER_NETS:
@ -213,8 +233,13 @@ def _map_fsm_rows(
if nh and not _looks_like_ip_or_prefix(nh): if nh and not _looks_like_ip_or_prefix(nh):
# Reject FSM false hits like NETWORK=20 NEXT_HOP=65254 # Reject FSM false hits like NETWORK=20 NEXT_HOP=65254
continue continue
seen.add(net) rd = row_get(r, "RD", "rd")
key = _route_dedupe_key(rd=rd, net=net, nh=nh or "")
if key in seen:
continue
seen.add(key)
path = row_get(r, "PATH", "path") path = row_get(r, "PATH", "path")
flags = row_get(r, "STATUS", "status_codes", "FLAGS")
out.append( out.append(
{ {
"local_as": local_as[:16], "local_as": local_as[:16],
@ -222,13 +247,14 @@ def _map_fsm_rows(
"vrf": vrf[:128], "vrf": vrf[:128],
"neighbor": neighbor[:128], "neighbor": neighbor[:128],
"direction": direction[:8], "direction": direction[:8],
"rd": (rd or "")[:64],
"network": net[:128], "network": net[:128],
"next_hop": nh[:128], "next_hop": nh[:128],
"metric": row_get(r, "METRIC", "metric")[:32], "metric": row_get(r, "METRIC", "metric")[:32],
"loc_prf": row_get(r, "LOC_PRF", "loc_prf")[:32], "loc_prf": row_get(r, "LOC_PRF", "loc_prf")[:32],
"tag": row_get(r, "TAG", "RT_PRF", "tag")[:32], "tag": row_get(r, "TAG", "RT_PRF", "tag")[:32],
"path": path[:256], "path": path[:256],
"status_codes": "", "status_codes": re.sub(r"\s+", "", (flags or "").strip())[:16],
"as_num": "", "as_num": "",
"state": "", "state": "",
"pfx_rcd": "", "pfx_rcd": "",
@ -253,6 +279,7 @@ def _hand_parse(
pending_net = "" pending_net = ""
pending_flags = "" pending_flags = ""
pending_nh = "" pending_nh = ""
current_rd = ""
def _flush_pending(*, rest: str = "", path_continuation: bool = False) -> None: def _flush_pending(*, rest: str = "", path_continuation: bool = False) -> None:
nonlocal pending_net, pending_flags, pending_nh nonlocal pending_net, pending_flags, pending_nh
@ -266,6 +293,7 @@ def _hand_parse(
vrf=vrf, vrf=vrf,
neighbor=neighbor, neighbor=neighbor,
direction=direction, direction=direction,
rd=current_rd,
net=pending_net, net=pending_net,
nh=pending_nh, nh=pending_nh,
rest=rest, rest=rest,
@ -278,6 +306,11 @@ def _hand_parse(
for raw in str(raw_text or "").splitlines(): for raw in str(raw_text or "").splitlines():
line = raw.rstrip() line = raw.rstrip()
rd_m = _RD_RE.match(line.strip())
if rd_m:
_flush_pending()
current_rd = rd_m.group(1).strip()
continue
if _skip_noise_line(line): if _skip_noise_line(line):
continue continue
@ -299,7 +332,7 @@ def _hand_parse(
_flush_pending(rest=tok, path_continuation=True) _flush_pending(rest=tok, path_continuation=True)
continue continue
# Full one-liner (typical IPv4) # Full one-liner (typical IPv4 / RR "* i prefix …")
m = _ROUTE_ONE_LINE_RE.match(line) m = _ROUTE_ONE_LINE_RE.match(line)
if m and _looks_like_prefix(m.group("net")) and _looks_like_ip_or_prefix(m.group("nh")): if m and _looks_like_prefix(m.group("net")) and _looks_like_ip_or_prefix(m.group("nh")):
_flush_pending() _flush_pending()
@ -311,6 +344,7 @@ def _hand_parse(
vrf=vrf, vrf=vrf,
neighbor=neighbor, neighbor=neighbor,
direction=direction, direction=direction,
rd=current_rd,
net=m.group("net"), net=m.group("net"),
nh=m.group("nh"), nh=m.group("nh"),
rest=m.group("rest"), rest=m.group("rest"),
@ -382,7 +416,14 @@ def normalize_bgp_route(
**kw, **kw,
) )
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text) rows = prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
# If device declared a large table but FSM/hand returned a tiny subset, prefer hand.
declared = _declared_total(raw_text)
if declared is not None and declared > 0 and len(rows) < max(1, declared // 2):
hand_rows = _hand(raw_text=raw_text)
if len(hand_rows) > len(rows):
return hand_rows
return rows
normalize_bgp_route.RULE_KEYS = RULE_KEYS normalize_bgp_route.RULE_KEYS = RULE_KEYS

View file

@ -535,8 +535,9 @@ _BGP_ROUTE_FIELDS: list[FieldDef] = [
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True), FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True),
FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor", from_command_param=True), FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor", from_command_param=True),
FieldDef("direction", length=8, indexed=True, is_key=True, display_name="Dir", from_command_param=True), FieldDef("direction", length=8, indexed=True, is_key=True, display_name="Dir", from_command_param=True),
FieldDef("rd", length=64, indexed=True, is_key=True, display_name="RD"),
FieldDef("network", length=128, indexed=True, is_key=True, display_name="Network"), FieldDef("network", length=128, indexed=True, is_key=True, display_name="Network"),
FieldDef("next_hop", length=128, role="state", display_name="NextHop"), FieldDef("next_hop", length=128, indexed=True, is_key=True, display_name="NextHop"),
FieldDef("metric", length=32, role="meta", display_name="Metric"), FieldDef("metric", length=32, role="meta", display_name="Metric"),
FieldDef("loc_prf", length=32, role="meta", display_name="LocPrf"), FieldDef("loc_prf", length=32, role="meta", display_name="LocPrf"),
FieldDef("tag", length=32, role="meta", display_name="Tag"), FieldDef("tag", length=32, role="meta", display_name="Tag"),

View file

@ -79,6 +79,8 @@ def apply_biz_state_schema(conn: Connection) -> None:
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS new_hf_bindings_json JSON DEFAULT '[]'", "ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS new_hf_bindings_json JSON DEFAULT '[]'",
"ALTER TABLE biz_state_task ADD COLUMN IF NOT EXISTS purpose VARCHAR(32) DEFAULT ''", "ALTER TABLE biz_state_task ADD COLUMN IF NOT EXISTS purpose VARCHAR(32) DEFAULT ''",
"CREATE INDEX IF NOT EXISTS ix_biz_state_task_purpose ON biz_state_task (purpose)", "CREATE INDEX IF NOT EXISTS ix_biz_state_task_purpose ON biz_state_task (purpose)",
"ALTER TABLE biz_migration_red_ticket ADD COLUMN IF NOT EXISTS match_key_str VARCHAR(256) DEFAULT ''",
"CREATE INDEX IF NOT EXISTS ix_biz_migration_red_match ON biz_migration_red_ticket (project_id, metric_id, match_key_str)",
): ):
try: try:
_run_sql(conn, sql) _run_sql(conn, sql)

View file

@ -593,6 +593,14 @@ def _metric_sheet_title(metric_id: str, fallback: str = "") -> str:
return fb or mid return fb or mid
def _raw_line_count(raw: str | None) -> int:
"""CLI text lines collected (splitlines-compatible, no list materialization)."""
s = raw or ""
if not s:
return 0
return s.count("\n") + (0 if s.endswith("\n") else 1)
def get_batch(db: Session, batch_id: str) -> dict[str, Any]: def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
"""Batch workbook summary: meta + commands + sheet catalog (no metric row payload).""" """Batch workbook summary: meta + commands + sheet catalog (no metric row payload)."""
b = db.get(BizStateBatch, batch_id) b = db.get(BizStateBatch, batch_id)
@ -648,6 +656,7 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
for c in cmds: for c in cmds:
status = str(c.parse_status or "").strip().lower() status = str(c.parse_status or "").strip().lower()
is_aux = status.startswith("aux") is_aux = status.startswith("aux")
raw = c.raw_text or ""
info = { info = {
"id": c.id, "id": c.id,
"profile_id": c.profile_id, "profile_id": c.profile_id,
@ -657,8 +666,9 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
"params": c.params_json or {}, "params": c.params_json or {},
"parse_status": c.parse_status, "parse_status": c.parse_status,
"row_count": c.row_count, "row_count": c.row_count,
"raw_line_count": _raw_line_count(raw),
"message": c.message, "message": c.message,
"has_raw": bool(str(c.raw_text or "").strip()), "has_raw": bool(str(raw).strip()),
"is_aux": is_aux, "is_aux": is_aux,
} }
cmd_n = normalize_command(str(c.raw_command or "")) cmd_n = normalize_command(str(c.raw_command or ""))
@ -688,6 +698,7 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
"raw_command": c.raw_command, "raw_command": c.raw_command,
"parse_status": c.parse_status, "parse_status": c.parse_status,
"row_count": c.row_count, "row_count": c.row_count,
"raw_line_count": info["raw_line_count"],
"message": c.message, "message": c.message,
"has_raw": info["has_raw"], "has_raw": info["has_raw"],
"profile_id": c.profile_id, "profile_id": c.profile_id,
@ -713,6 +724,7 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
"params": c.params_json or {}, "params": c.params_json or {},
"parse_status": c.parse_status, "parse_status": c.parse_status,
"row_count": c.row_count, "row_count": c.row_count,
"raw_line_count": _raw_line_count(c.raw_text),
"message": c.message, "message": c.message,
"has_raw": bool(str(c.raw_text or "").strip()), "has_raw": bool(str(c.raw_text or "").strip()),
"is_aux": True, "is_aux": True,
@ -906,6 +918,7 @@ def get_batch_command(db: Session, batch_id: str, command_id: str) -> dict[str,
if not c or c.batch_id != batch_id: if not c or c.batch_id != batch_id:
raise HTTPException(status_code=404, detail="command_not_found") raise HTTPException(status_code=404, detail="command_not_found")
task = db.get(BizStateTask, b.task_id) if b.task_id else None task = db.get(BizStateTask, b.task_id) if b.task_id else None
raw = c.raw_text or ""
return { return {
"id": c.id, "id": c.id,
"batch_id": batch_id, "batch_id": batch_id,
@ -922,8 +935,9 @@ def get_batch_command(db: Session, batch_id: str, command_id: str) -> dict[str,
"params": c.params_json or {}, "params": c.params_json or {},
"parse_status": c.parse_status, "parse_status": c.parse_status,
"row_count": c.row_count, "row_count": c.row_count,
"raw_line_count": _raw_line_count(raw),
"message": c.message, "message": c.message,
"raw_text": c.raw_text or "", "raw_text": raw,
"collected_at": c.created_at.isoformat() + "Z" if c.created_at else None, "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_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, "batch_ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,

View file

@ -337,6 +337,23 @@ def api_get_batch_command(
return svc.get_batch_command(db, batch_id, command_id) return svc.get_batch_command(db, batch_id, command_id)
@router.get("/batches/{batch_id}/commands/{command_id}/raw.txt")
def api_download_batch_command_raw(
batch_id: str, command_id: str, db: Session = Depends(get_db)
) -> StreamingResponse:
"""Download one command's raw CLI output as plain text."""
detail = svc.get_batch_command(db, batch_id, command_id)
raw = str(detail.get("raw_text") or "")
cmd = str(detail.get("raw_command") or "command")
safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in cmd)[:80] or "command"
filename = f"{command_id}_{safe}.txt"
return StreamingResponse(
iter([raw.encode("utf-8")]),
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/batches/{batch_id}/export") @router.get("/batches/{batch_id}/export")
def api_export_batch(batch_id: str, db: Session = Depends(get_db)) -> StreamingResponse: def api_export_batch(batch_id: str, db: Session = Depends(get_db)) -> StreamingResponse:
data = svc.export_batch_zip(db, batch_id) data = svc.export_batch_zip(db, batch_id)

View file

@ -28,7 +28,9 @@ _PURGE_INTERVAL_SEC = 3600.0
def _utcnow() -> datetime: def _utcnow() -> datetime:
return datetime.utcnow() from .timeutil import utcnow_naive
return utcnow_naive()
def _maybe_purge_retention() -> None: def _maybe_purge_retention() -> None:
@ -86,10 +88,12 @@ def shutdown_biz_state_dispatch_pool(*, wait: bool = False) -> None:
_dispatch_pool = None _dispatch_pool = None
try: try:
from .biz_state.persist_pool import shutdown_persist_pool from .biz_state.persist_pool import shutdown_persist_pool
from .biz_state.parse_pool import shutdown_parse_pool
shutdown_parse_pool(wait=wait)
shutdown_persist_pool(wait=wait) shutdown_persist_pool(wait=wait)
except Exception: except Exception:
_log.exception("shutdown persist pool failed") _log.exception("shutdown parse/persist pool failed")
def _sync_cutover_hf_windows() -> None: def _sync_cutover_hf_windows() -> None:
@ -152,11 +156,15 @@ def _enqueue_due_tasks() -> int:
now = _utcnow() now = _utcnow()
for task in tasks: for task in tasks:
interval = max(60, int(task.interval_sec or 300)) interval = max(60, int(task.interval_sec or 300))
# Prefer start-based interval to avoid drift when collect duration
# approaches the interval (end-based: 40s collect + 60s → 100s cycle).
started = task.last_collect_started_at
ended = task.last_collect_ended_at ended = task.last_collect_ended_at
if ended is None: anchor = started or ended
if anchor is None:
due_ids.append(str(task.id)) due_ids.append(str(task.id))
continue continue
if (now - ended).total_seconds() >= interval: if (now - anchor).total_seconds() >= interval:
due_ids.append(str(task.id)) due_ids.append(str(task.id))
finally: finally:
db.close() db.close()

View file

@ -1,3 +1,5 @@
Value Filldown RD (\S+)
Value STATUS ((?:\*[<>isd]*(?:\s+[isd]+)?|[<>isd]+)?)
Value NETWORK (\d{1,3}(?:\.\d{1,3}){3}/\d{1,2}|[0-9A-Fa-f:]+/\d{1,3}) Value NETWORK (\d{1,3}(?:\.\d{1,3}){3}/\d{1,2}|[0-9A-Fa-f:]+/\d{1,3})
Value NEXT_HOP (\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f:]+) Value NEXT_HOP (\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f:]+)
Value METRIC (\S*) Value METRIC (\S*)
@ -10,11 +12,13 @@ Start
^\s*\*?\s*Network\s+Next -> Routes ^\s*\*?\s*Network\s+Next -> Routes
^Network\s+Next -> Routes ^Network\s+Next -> Routes
^\s*Dest\s+Next -> Routes ^\s*Dest\s+Next -> Routes
# RD may appear before header on some snippets; keep Filldown ready
^Route\s+Distinguisher\s*:?\s*${RD}
^\s*$$ ^\s*$$
^.* -> Start ^.* -> Start
Routes Routes
^Route\s+Distinguisher ^Route\s+Distinguisher\s*:?\s*${RD}
^Status\s+codes ^Status\s+codes
^Origin\s+codes ^Origin\s+codes
^Current\s+AS ^Current\s+AS
@ -26,11 +30,11 @@ Routes
^\s*\*?\s*Network\s+Next ^\s*\*?\s*Network\s+Next
^Network\s+Next ^Network\s+Next
^\s*Dest\s+Next ^\s*Dest\s+Next
# One-line IPv4-style # One-line IPv4 / RR "* i prefix nh …"
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record ^\s*${STATUS}\s+${NETWORK}\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record ^\s*${STATUS}\s+${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record
# IPv6 wrap: network / next-hop / metrics+path on separate lines # IPv6 wrap: network / next-hop / metrics+path on separate lines
^\s*\*?\s*>?\s*${NETWORK}\s*$$ ^\s*${STATUS}\s+${NETWORK}\s*$$
^\s+${NEXT_HOP}\s*$$ ^\s+${NEXT_HOP}\s*$$
^\s+${PATH}\s*$$ -> Record ^\s+${PATH}\s*$$ -> Record
^\s*$$ ^\s*$$

View file

@ -133,6 +133,10 @@ class Settings(BaseSettings):
biz_state_max_concurrent_tasks: int = 16 biz_state_max_concurrent_tasks: int = 16
biz_state_worker_collect_threads: int = 8 biz_state_worker_collect_threads: int = 8
biz_state_persist_workers: int = 4 biz_state_persist_workers: int = 4
# TextFSM / normalize workers (off SSH collect threads). Default 8.
biz_state_parse_workers: int = 8
# When true (default), primary+aux parse runs on parse pool overlapping next CLI.
biz_state_parse_async: bool = True
# How many biz_state_worker processes start_netx should launch (same host). # How many biz_state_worker processes start_netx should launch (same host).
biz_state_worker_replicas: int = 2 biz_state_worker_replicas: int = 2
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec). # Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).

View file

@ -129,6 +129,13 @@ class BizMigrationRedTicket(Base):
__table_args__ = ( __table_args__ = (
Index("ix_biz_migration_red_project_status", "project_id", "status"), Index("ix_biz_migration_red_project_status", "project_id", "status"),
Index("ix_biz_migration_red_batch", "batch_id"), Index("ix_biz_migration_red_batch", "batch_id"),
Index(
"ix_biz_migration_red_upsert",
"project_id",
"metric_id",
"match_key_str",
"status",
),
) )
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
@ -138,6 +145,8 @@ class BizMigrationRedTicket(Base):
metric_id: Mapped[str] = mapped_column(String(64), default="", index=True) metric_id: Mapped[str] = mapped_column(String(64), default="", index=True)
key_str: Mapped[str] = mapped_column(String(256), default="") key_str: Mapped[str] = mapped_column(String(256), default="")
new_key_str: Mapped[str] = mapped_column(String(256), default="") new_key_str: Mapped[str] = mapped_column(String(256), default="")
# Stable identity for upsert (match_old_key / normalized expect key)
match_key_str: Mapped[str] = mapped_column(String(256), default="", index=True)
verdict: Mapped[str] = mapped_column(String(32), default="") verdict: Mapped[str] = mapped_column(String(32), default="")
color: Mapped[str] = mapped_column(String(16), default="red") color: Mapped[str] = mapped_column(String(16), default="red")
old_status: Mapped[str] = mapped_column(String(64), default="") old_status: Mapped[str] = mapped_column(String(64), default="")

View file

@ -65,11 +65,115 @@ class BatchWorkbookApiTests(unittest.TestCase):
self.assertNotIn("metrics", out) self.assertNotIn("metrics", out)
self.assertNotIn("lldp_neighbors", out) self.assertNotIn("lldp_neighbors", out)
self.assertEqual(out["commands"][0]["has_raw"], True) self.assertEqual(out["commands"][0]["has_raw"], True)
self.assertEqual(out["commands"][0]["raw_line_count"], 1)
self.assertEqual(out["sheets"][0]["metric_id"], "arp") self.assertEqual(out["sheets"][0]["metric_id"], "arp")
self.assertEqual(out["sheets"][0]["row_count"], 2) self.assertEqual(out["sheets"][0]["row_count"], 2)
self.assertEqual(out["sheets"][0]["commands"][0]["raw_command"], "show arp | one-line") self.assertEqual(out["sheets"][0]["commands"][0]["raw_command"], "show arp | one-line")
self.assertEqual(out["sheets"][0]["commands"][0]["raw_line_count"], 1)
self.assertTrue(out["sheets"][0].get("title")) self.assertTrue(out["sheets"][0].get("title"))
def test_get_batch_raw_line_count_splitlines(self) -> None:
batch = BizStateBatch(
id="b1",
task_id="t1",
status="partial",
command_count=1,
row_count=0,
message="stopped",
)
cmd = BizStateBatchCommand(
id="c1",
batch_id="b1",
profile_id="zte.arp",
parser_id="arp",
metric_id="arp",
raw_command="show arp",
parse_status="ok",
row_count=3,
raw_text="a\nb\nc\n",
message="",
)
db = MagicMock()
db.get.side_effect = lambda model, pk: batch if pk == "b1" else None
cmd_q = MagicMock()
cmd_q.filter.return_value.order_by.return_value.all.return_value = [cmd]
metric_count_q = MagicMock()
metric_count_q.filter.return_value.group_by.return_value.all.return_value = [("arp", 3)]
lldp_count_q = MagicMock()
lldp_count_q.filter.return_value.scalar.return_value = 0
def query(*_args, **_kwargs):
n = query.n
query.n += 1
if n == 0:
return cmd_q
if n == 1:
return metric_count_q
return lldp_count_q
query.n = 0
db.query.side_effect = query
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
out = get_batch(db, "b1")
self.assertEqual(out["message"], "stopped")
self.assertEqual(out["commands"][0]["raw_line_count"], 3)
self.assertEqual(out["commands"][0]["row_count"], 3)
def test_get_batch_command_and_raw_download(self) -> None:
from netx_api.biz_state.service import get_batch_command
from netx_api.biz_state_router import api_download_batch_command_raw
batch = BizStateBatch(id="b1", task_id="t1", status="ok")
cmd = BizStateBatchCommand(
id="c1",
batch_id="b1",
profile_id="zte.arp",
parser_id="arp",
metric_id="arp",
raw_command="show arp | one-line",
parse_status="ok",
row_count=2,
raw_text="line1\nline2",
message="hint",
)
db = MagicMock()
def _get(model, pk):
if model is BizStateBatch and pk == "b1":
return batch
if model is BizStateBatchCommand and pk == "c1":
return cmd
return None
db.get.side_effect = _get
detail = get_batch_command(db, "b1", "c1")
self.assertEqual(detail["raw_line_count"], 2)
self.assertEqual(detail["row_count"], 2)
self.assertEqual(detail["message"], "hint")
self.assertIn("line1", detail["raw_text"])
resp = api_download_batch_command_raw("b1", "c1", db)
self.assertEqual(resp.media_type, "text/plain; charset=utf-8")
cd = (resp.headers.get("content-disposition") or "").lower()
self.assertIn("attachment", cd)
self.assertIn(".txt", cd)
# StreamingResponse may expose async iterator; content already covered by get_batch_command.
body_iter = getattr(resp, "body_iterator", None)
if body_iter is not None and hasattr(body_iter, "__iter__") and not hasattr(body_iter, "__aiter__"):
body = b"".join(body_iter)
self.assertEqual(body.decode("utf-8"), "line1\nline2")
else:
# Fallback: reconstruct what the route encodes
from netx_api.biz_state.service import get_batch_command as _gbc
raw = str(_gbc(db, "b1", "c1").get("raw_text") or "")
self.assertEqual(raw, "line1\nline2")
def test_bgp_peer_sheet_uses_status_summary_title(self) -> None: def test_bgp_peer_sheet_uses_status_summary_title(self) -> None:
"""Shared metric_id bgp_peer must not inherit first AF profile title.""" """Shared metric_id bgp_peer must not inherit first AF profile title."""
batch = BizStateBatch( batch = BizStateBatch(

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import unittest import unittest
from netx_api.biz_migration.evaluate import ( from netx_api.biz_migration.evaluate import (
KEY_SEP,
classify_status, classify_status,
dual_verdict, dual_verdict,
evaluate_metric_dual, evaluate_metric_dual,
@ -90,7 +91,7 @@ class ParseExpectSetTests(unittest.TestCase):
] ]
} }
) )
self.assertEqual(got["bgp_peer"], {"AS1|1.1.1.1"}) self.assertEqual(got["bgp_peer"], {KEY_SEP.join(["AS1", "1.1.1.1"])})
self.assertEqual(got["arp"], {"10.0.0.1"}) self.assertEqual(got["arp"], {"10.0.0.1"})
def test_multi_select_each_key_is_separate(self): def test_multi_select_each_key_is_separate(self):
@ -111,7 +112,7 @@ class ParseExpectSetTests(unittest.TestCase):
got = parse_expect_set( got = parse_expect_set(
{"items": [{"metric_id": "bgp_peer", "keys": ["1.1.1.1", "2.2.2.2"]}]} {"items": [{"metric_id": "bgp_peer", "keys": ["1.1.1.1", "2.2.2.2"]}]}
) )
self.assertEqual(got["bgp_peer"], {"1.1.1.1|2.2.2.2"}) self.assertEqual(got["bgp_peer"], {KEY_SEP.join(["1.1.1.1", "2.2.2.2"])})
def test_nested_keys_list(self): def test_nested_keys_list(self):
got = parse_expect_set( got = parse_expect_set(
@ -122,8 +123,20 @@ class ParseExpectSetTests(unittest.TestCase):
] ]
} }
) )
self.assertEqual(got["isis_adjacency"], {"p1|gei-1|sys1", "p1|gei-2|sys2"}) self.assertEqual(
self.assertEqual(got["arp"], {"10.0.0.1|vrf1"}) got["isis_adjacency"],
{
KEY_SEP.join(["p1", "gei-1", "sys1"]),
KEY_SEP.join(["p1", "gei-2", "sys2"]),
},
)
self.assertEqual(got["arp"], {KEY_SEP.join(["10.0.0.1", "vrf1"])})
def test_legacy_pipe_key_normalized(self):
got = parse_expect_set(
{"items": [{"metric_id": "bgp_peer", "key": "AS1|1.1.1.1"}]}
)
self.assertEqual(got["bgp_peer"], {KEY_SEP.join(["AS1", "1.1.1.1"])})
def test_ports_only_apply_to_interface_brief(self): def test_ports_only_apply_to_interface_brief(self):
expect = parse_expect_set({"ports": ["gei-1"]}) expect = parse_expect_set({"ports": ["gei-1"]})
@ -855,12 +868,20 @@ class EvaluateMetricDualTests(unittest.TestCase):
def test_composite_key_port_remap(self): def test_composite_key_port_remap(self):
mapped = _remap_key_str( mapped = _remap_key_str(
KEY_SEP.join(["gei-old", "vrf1"]),
key_fields=["interface", "vrf"],
iface_fields=["interface"],
port_map={"gei-old": "gei-new"},
)
self.assertEqual(mapped, KEY_SEP.join(["gei-new", "vrf1"]))
# Legacy pipe-separated input still remaps
mapped2 = _remap_key_str(
"gei-old|vrf1", "gei-old|vrf1",
key_fields=["interface", "vrf"], key_fields=["interface", "vrf"],
iface_fields=["interface"], iface_fields=["interface"],
port_map={"gei-old": "gei-new"}, port_map={"gei-old": "gei-new"},
) )
self.assertEqual(mapped, "gei-new|vrf1") self.assertEqual(mapped2, KEY_SEP.join(["gei-new", "vrf1"]))
def test_anomaly_not_gated_by_field_tokens_on_removed(self): def test_anomaly_not_gated_by_field_tokens_on_removed(self):
self.assertEqual( self.assertEqual(

View file

@ -57,9 +57,8 @@ class BizStateCollectFinalizeTests(unittest.TestCase):
def test_finalize_partial_after_heavy_timeout(self) -> None: def test_finalize_partial_after_heavy_timeout(self) -> None:
with patch.object(runner, "SessionLocal", self.Session): with patch.object(runner, "SessionLocal", self.Session):
with patch( with patch(
"netx_api.biz_state.compare_service.try_auto_compare_for_task", "netx_api.biz_state.compare_service.schedule_auto_compare_for_task",
return_value=None, ) as sched:
):
status = runner._finalize_batch_status( status = runner._finalize_batch_status(
batch_id="b-finalize", batch_id="b-finalize",
task_id="t-finalize", task_id="t-finalize",
@ -70,6 +69,7 @@ class BizStateCollectFinalizeTests(unittest.TestCase):
lane_errors=["RuntimeError: biz_state_heavy_timeout (2400s)"], lane_errors=["RuntimeError: biz_state_heavy_timeout (2400s)"],
) )
self.assertEqual(status, "partial") self.assertEqual(status, "partial")
sched.assert_not_called()
self.db.expire_all() self.db.expire_all()
batch = self.db.get(BizStateBatch, "b-finalize") batch = self.db.get(BizStateBatch, "b-finalize")
assert batch is not None assert batch is not None
@ -127,8 +127,7 @@ class BizStateCollectFinalizeTests(unittest.TestCase):
with patch.object(runner, "SessionLocal", session_factory): with patch.object(runner, "SessionLocal", session_factory):
with patch( with patch(
"netx_api.biz_state.compare_service.try_auto_compare_for_task", "netx_api.biz_state.compare_service.schedule_auto_compare_for_task",
return_value=None,
): ):
status = runner._finalize_batch_status( status = runner._finalize_batch_status(
batch_id="b-finalize", batch_id="b-finalize",
@ -147,6 +146,23 @@ class BizStateCollectFinalizeTests(unittest.TestCase):
self.assertEqual(batch.status, "partial") self.assertEqual(batch.status, "partial")
self.assertIn("biz_state_heavy_timeout", batch.message or "") self.assertIn("biz_state_heavy_timeout", batch.message or "")
def test_finalize_success_schedules_auto_compare(self) -> None:
with patch.object(runner, "SessionLocal", self.Session):
with patch(
"netx_api.biz_state.compare_service.schedule_auto_compare_for_task",
) as sched:
status = runner._finalize_batch_status(
batch_id="b-finalize",
task_id="t-finalize",
cmd_count=55,
total_rows=6606,
any_fail=False,
any_ok=True,
lane_errors=[],
)
self.assertEqual(status, "success")
sched.assert_called_once_with("t-finalize", "b-finalize")
def test_fail_batch_retries_on_stale_connection(self) -> None: def test_fail_batch_retries_on_stale_connection(self) -> None:
calls = {"n": 0} calls = {"n": 0}
real_session = self.Session real_session = self.Session

View file

@ -391,6 +391,33 @@ class CompareSheetDefaultsTests(unittest.TestCase):
) )
self.assertEqual(out["summary"]["duplicate_keys_before"], 1) self.assertEqual(out["summary"]["duplicate_keys_before"], 1)
self.assertEqual(out["summary"]["duplicate_keys_after"], 1) self.assertEqual(out["summary"]["duplicate_keys_after"], 1)
self.assertEqual(out["summary"]["duplicate"], 2)
self.assertIn("a|X|1", out["summary"]["duplicate_key_list"])
kinds = [d["kind"] for d in out["diffs"]]
self.assertEqual(kinds.count("duplicate"), 2)
# First before wins → matches first after → unchanged (same remote_ip)
self.assertEqual(out["summary"]["unchanged"], 1)
self.assertEqual(out["summary"]["changed"], 0)
def test_ignore_port_changes_false_keeps_iface(self) -> None:
before = [
{"local_if": "old-1", "remote_sys": "Peer", "remote_if": "p1", "remote_ip": "1.1.1.1"},
]
after = [
{"local_if": "new-1", "remote_sys": "Peer", "remote_if": "p1", "remote_ip": "1.1.1.1"},
]
out = compare_rows(
before_rows=before,
after_rows=after,
key_fields=["local_if", "remote_sys", "remote_if"],
iface_fields=["local_if"],
compare_fields=["remote_ip"],
port_map={},
ignore_port_changes=False,
)
self.assertEqual(out["summary"]["removed"], 1)
self.assertEqual(out["summary"]["added"], 1)
self.assertFalse(out["mapping_stats"].get("ignore_port_changes"))
def test_normalize_allows_duplicate_metric_with_distinct_sheet_id(self) -> None: def test_normalize_allows_duplicate_metric_with_distinct_sheet_id(self) -> None:
from netx_api.biz_state.compare_service import _normalize_sheet, sheet_key from netx_api.biz_state.compare_service import _normalize_sheet, sheet_key

View file

@ -0,0 +1,100 @@
"""Collect/parse decoupling: fetch_raw + parse pool job."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from netx_api.biz_state.collect_session import CollectSession
from netx_api.biz_state.parse_pool import AuxRawCapture, PrimaryParseJob, parse_async_enabled
class FetchRawTests(unittest.TestCase):
def test_fetch_raw_skips_parser(self) -> None:
conn = object()
sends: list[str] = []
def _send(_c, command, read_timeout=0):
sends.append(command)
return f"RAW:{command}"
session = CollectSession(conn, send_fn=_send, read_timeout=10)
with patch("netx_api.biz_state.collect_session.run_parser") as rp:
entry, hit = session.fetch_raw("show ip route")
rp.assert_not_called()
self.assertFalse(hit)
self.assertTrue(entry.ok)
self.assertEqual(entry.raw, "RAW:show ip route")
self.assertEqual(entry.records, [])
# Second call hits cache without re-CLI.
entry2, hit2 = session.fetch_raw("show ip route")
self.assertTrue(hit2)
self.assertEqual(len(sends), 1)
self.assertEqual(entry2.raw, entry.raw)
class ParseJobTests(unittest.TestCase):
def test_run_primary_parse_job_persists(self) -> None:
from netx_api.biz_state import collect_runner as cr
with tempfile.TemporaryDirectory() as tmp:
with patch("netx_api.biz_state.spool.settings") as st:
st.biz_state_spool_dir = tmp
st.biz_state_persist_every_cmds = 8
st.biz_state_raw_max_bytes = 0
submitted: list = []
mock_pool = MagicMock()
mock_pool.submit.side_effect = lambda bid, items: submitted.append(
(bid, list(items))
)
with patch.object(cr, "run_primary_with_bundle") as rpb:
rpb.return_value = (
[{"prefix": "1.1.1.1/32"}],
{"rule_a": [{"PREFIX": "1.1.1.1/32"}]},
["rule_a"],
)
with patch(
"netx_api.biz_state.persist_pool.get_persist_pool",
return_value=mock_pool,
):
job = PrimaryParseJob(
batch_id="batch1",
cmd_id="cmd1",
task_item_id="item1",
profile_id="zte.ip_route",
parser_id="ip_route",
metric_id="ip_route",
concrete="show ip forwarding route",
merged_params={},
raw_text="DESTINATION\n1.1.1.1/32",
raw_rel_path="",
textfsm_command="show ip forwarding route",
vendor="ZTE",
device_type="zte_zxros",
enrich_joins=[],
aux_captures=[],
persisted=set(),
)
ok, fail = cr._run_primary_parse_job(job)
self.assertTrue(ok)
self.assertFalse(fail)
self.assertEqual(len(submitted), 1)
_bid, items = submitted[0]
self.assertEqual(_bid, "batch1")
self.assertEqual(len(items), 1)
self.assertEqual(items[0].parse_status, "ok")
self.assertEqual(items[0].row_count, 1)
rec_path = Path(tmp) / "batch1" / "cmd1.records.jsonl"
self.assertTrue(rec_path.is_file())
def test_parse_async_default_on(self) -> None:
self.assertTrue(parse_async_enabled())
if __name__ == "__main__":
unittest.main()

View file

@ -49,6 +49,19 @@ class PortMapSubifTests(unittest.TestCase):
self.assertEqual(resolve_mapped_iface("gei-0/0/0/1.100", pmap), "xgei-0/1/0/1.100") self.assertEqual(resolve_mapped_iface("gei-0/0/0/1.100", pmap), "xgei-0/1/0/1.100")
self.assertEqual(resolve_mapped_iface("gei-0/0/0/2.100", pmap), "gei-0/0/0/2.100") self.assertEqual(resolve_mapped_iface("gei-0/0/0/2.100", pmap), "gei-0/0/0/2.100")
def test_qinq_multi_level_parent(self) -> None:
"""QinQ a.b.c: map parent a.b or a, keep remaining suffix."""
pmap = {"gei-0/1/0/1": "xgei-0/2/0/1"}
self.assertEqual(
resolve_mapped_iface("gei-0/1/0/1.100.200", pmap),
"xgei-0/2/0/1.100.200",
)
pmap2 = {"gei-0/1/0/1.100": "xgei-0/2/0/1.100"}
self.assertEqual(
resolve_mapped_iface("gei-0/1/0/1.100.200", pmap2),
"xgei-0/2/0/1.100.200",
)
def test_apply_port_map_row(self) -> None: def test_apply_port_map_row(self) -> None:
row = apply_port_map( row = apply_port_map(
{"interface": "gei-0/0/0/1.55", "admin": "up"}, {"interface": "gei-0/0/0/1.55", "admin": "up"},

View file

@ -1134,6 +1134,69 @@ Routes Learned From This Neighbor:
self.assertEqual(v4_wrap[0]["next_hop"], "10.0.0.1") self.assertEqual(v4_wrap[0]["next_hop"], "10.0.0.1")
self.assertEqual(v4_wrap[0]["afi"], "ipv4") self.assertEqual(v4_wrap[0]["afi"], "ipv4")
def test_bgp_route_rr_status_i_and_rd(self) -> None:
"""RR neighbor-in uses '* i prefix' and Route Distinguisher fill-down."""
from pathlib import Path
from netx_api.biz_state.profiles import metric_field_map
fixture = Path(__file__).resolve().parents[2] / "test" / "show-zte" / "show-bgp-vpnv4-neighbor-router-in"
if not fixture.is_file():
# Workspace layout: chatgpt/test/show-zte vs netx/tests
fixture = Path(__file__).resolve().parents[3] / "test" / "show-zte" / "show-bgp-vpnv4-neighbor-router-in"
raw = fixture.read_text(encoding="utf-8", errors="replace")
routes = normalize_bgp_route(
raw_text=raw,
command="show bgp vpnv4 unicast neighbor in 114.0.24.93 | one-line",
vendor="ZTE",
device_type="zte_zxros",
params={"neighbor": "114.0.24.93", "direction": "in", "afi": "vpnv4"},
)
self.assertEqual(len(routes), 1905, "must match Total number of routes")
self.assertTrue(all("/" in str(r.get("network") or "") for r in routes))
self.assertTrue(all(str(r.get("network") or "").lower() != "i" for r in routes))
self.assertTrue(all(str(r.get("status_codes") or "") == "*i" for r in routes))
self.assertTrue(all(str(r.get("rd") or "").strip() for r in routes))
# Known multi-RD prefix must survive under both RDs (not collapsed by uniqueness)
multi = [r for r in routes if r.get("network") == "100.127.58.68/30"]
self.assertEqual(len(multi), 2)
self.assertEqual(
{r["rd"] for r in multi},
{"114.0.141.200:65013", "114.14.249.211:65013"},
)
by_key = {(r["rd"], r["network"], r["next_hop"]) for r in routes}
self.assertEqual(len(by_key), len(routes))
# Profile uniqueness keys must include RD + next_hop for ECMP / multi-RD
keys = {f.name for f in metric_field_map().get("bgp_route", []) if f.is_key}
self.assertTrue({"rd", "network", "next_hop", "neighbor", "direction"} <= keys)
def test_bgp_route_ecmp_keeps_distinct_next_hops(self) -> None:
"""Same RD+prefix with two next-hops must both persist (load-share)."""
# Match real ZTE layout: RD only captured after entering Routes state
# (via "Routes Learned…" / Network header), same as production fixture.
raw = """
Routes Learned From This Neighbor:
Status codes: * valid, i - internal
Network Next Hop Metric LocPrf RtPrf Path
Route Distinguisher:10.0.0.1:100
* i 192.0.2.0/24 10.1.1.1 0 100 0 65001 i
* i 192.0.2.0/24 10.1.1.2 0 100 0 65001 i
Total number of routes: 2
"""
routes = normalize_bgp_route(
raw_text=raw,
command="show bgp vpnv4 unicast neighbor in 10.0.0.1 | one-line",
vendor="ZTE",
device_type="zte_zxros",
params={"neighbor": "10.0.0.1", "direction": "in", "afi": "vpnv4"},
)
self.assertEqual(len(routes), 2)
self.assertEqual({r["next_hop"] for r in routes}, {"10.1.1.1", "10.1.1.2"})
self.assertEqual({r["rd"] for r in routes}, {"10.0.0.1:100"})
self.assertEqual({r["network"] for r in routes}, {"192.0.2.0/24"})
self.assertTrue(all(r.get("status_codes") == "*i" for r in routes))
by_key = {(r["rd"], r["network"], r["next_hop"]) for r in routes}
self.assertEqual(len(by_key), 2)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -266,14 +266,19 @@ const en = {
deselectAllVrfs: "Deselect all", deselectAllVrfs: "Deselect all",
batches: "Batches", batches: "Batches",
viewBatch: "Open", viewBatch: "Open",
batchDetail: "Detail",
export: "Export", export: "Export",
exportCommands: "Export collect commands", exportCommands: "Export collect commands",
exportCommandsOk: "Collect commands exported", exportCommandsOk: "Collect commands exported",
exportRawLog: "Export as text",
exportRawLogOk: "Log exported",
noBatches: "No batches yet", noBatches: "No batches yet",
batchDetail: "Batch detail", batchCollectDetail: "Collect detail",
batchWorkbook: "Batch workbook", batchWorkbook: "Batch workbook",
batchId: "Batch ID", batchId: "Batch ID",
copyBatchId: "Copy batch ID", copyBatchId: "Copy batch ID",
batchMessageEmpty: "No message or error",
batchCmdSummary: "{{ok}} ok / {{fail}} failed / {{other}} other · {{total}} commands",
sheetCommands: "Commands", sheetCommands: "Commands",
sheetLldp: "LLDP neighbors", sheetLldp: "LLDP neighbors",
sheetVrfRoute: "VRF route summary", sheetVrfRoute: "VRF route summary",
@ -292,6 +297,8 @@ const en = {
viewRawLog: "Raw log", viewRawLog: "Raw log",
rawLogTitle: "Raw collect log", rawLogTitle: "Raw collect log",
rawLogEmpty: "No raw output", rawLogEmpty: "No raw output",
rawLogStats: "{{lines}} lines collected · {{rows}} rows stored",
openWorkbook: "Open workbook",
filterColumn: "Column", filterColumn: "Column",
filterAllCols: "All columns", filterAllCols: "All columns",
listFilterPh: "Filter NE / IP / vendor / status", listFilterPh: "Filter NE / IP / vendor / status",
@ -306,7 +313,8 @@ const en = {
colStarted: "Started", colStarted: "Started",
colEnded: "Ended", colEnded: "Ended",
colTime: "Time", colTime: "Time",
colRows: "Rows", colRows: "DB rows",
colRawLines: "CLI lines",
colActions: "Actions", colActions: "Actions",
colCommand: "Command", colCommand: "Command",
colMessage: "Message", colMessage: "Message",

View file

@ -266,14 +266,19 @@ const zh = {
deselectAllVrfs: "取消全选", deselectAllVrfs: "取消全选",
batches: "采集批次", batches: "采集批次",
viewBatch: "查看", viewBatch: "查看",
batchDetail: "详情",
export: "导出", export: "导出",
exportCommands: "导出采集命令", exportCommands: "导出采集命令",
exportCommandsOk: "已导出采集命令", exportCommandsOk: "已导出采集命令",
exportRawLog: "导出为文本",
exportRawLogOk: "日志已导出",
noBatches: "尚无批次", noBatches: "尚无批次",
batchDetail: "批次详情", batchCollectDetail: "采集详情",
batchWorkbook: "批次工作簿", batchWorkbook: "批次工作簿",
batchId: "批次 ID", batchId: "批次 ID",
copyBatchId: "复制批次 ID", copyBatchId: "复制批次 ID",
batchMessageEmpty: "无额外说明或报错",
batchCmdSummary: "命令 {{ok}} 成功 / {{fail}} 失败 / {{other}} 其他 · 共 {{total}} 条",
sheetCommands: "命令", sheetCommands: "命令",
sheetLldp: "LLDP 邻居", sheetLldp: "LLDP 邻居",
sheetVrfRoute: "VRF 路由摘要", sheetVrfRoute: "VRF 路由摘要",
@ -292,6 +297,8 @@ const zh = {
viewRawLog: "原始日志", viewRawLog: "原始日志",
rawLogTitle: "原始采集日志", rawLogTitle: "原始采集日志",
rawLogEmpty: "无原始输出", rawLogEmpty: "无原始输出",
rawLogStats: "采集 {{lines}} 行 · 入库 {{rows}} 条",
openWorkbook: "打开工作簿",
filterColumn: "筛选列", filterColumn: "筛选列",
filterAllCols: "全部列", filterAllCols: "全部列",
listFilterPh: "筛选网元 / IP / 厂商 / 状态", listFilterPh: "筛选网元 / IP / 厂商 / 状态",
@ -306,7 +313,8 @@ const zh = {
colStarted: "开始时间", colStarted: "开始时间",
colEnded: "结束时间", colEnded: "结束时间",
colTime: "时间", colTime: "时间",
colRows: "行数", colRows: "入库条数",
colRawLines: "采集行数",
colActions: "操作", colActions: "操作",
colCommand: "命令", colCommand: "命令",
colMessage: "说明", colMessage: "说明",

View file

@ -10085,6 +10085,71 @@ html.login-page--paused .login-page__flare {
min-height: min(50vh, 480px); min-height: min(50vh, 480px);
} }
.bs-rawlog-stats {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.bs-batch-message {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(15, 23, 42, 0.55);
}
.bs-batch-message__label {
font-size: 11px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #94a3b8;
}
.bs-batch-message__body {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.45;
color: #e2e8f0;
}
.bs-batch-message--alert {
border-color: rgba(248, 113, 113, 0.28);
background: rgba(127, 29, 29, 0.22);
}
.bs-batch-message--alert .bs-batch-message__label {
color: #fca5a5;
}
.bs-batch-message--alert .bs-batch-message__body {
color: #fecaca;
}
.bs-msg-cell {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.bs-cmd-stat {
font-size: 11px;
white-space: nowrap;
}
.bs-collect-detail-modal .bs-sheet-table {
max-height: min(48vh, 480px);
}
.bs-rawlog-pre { .bs-rawlog-pre {
flex: 1 1 auto; flex: 1 1 auto;
margin: 0; margin: 0;

View file

@ -14,6 +14,7 @@ import {
bizStateDeleteBatch, bizStateDeleteBatch,
bizStateDeleteTask, bizStateDeleteTask,
bizStateDiscover, bizStateDiscover,
bizStateDownloadCommandRaw,
bizStateDownloadExport, bizStateDownloadExport,
bizStateDownloadTaskCommands, bizStateDownloadTaskCommands,
bizStateGetBatch, bizStateGetBatch,
@ -86,6 +87,7 @@ type BatchRow = {
status: string; status: string;
row_count: number; row_count: number;
command_count: number; command_count: number;
message?: string;
started_at?: string | null; started_at?: string | null;
ended_at?: string | null; ended_at?: string | null;
is_baseline?: boolean; is_baseline?: boolean;
@ -108,6 +110,7 @@ type SheetCmd = {
raw_command: string; raw_command: string;
parse_status?: string; parse_status?: string;
row_count?: number; row_count?: number;
raw_line_count?: number;
message?: string; message?: string;
has_raw?: boolean; has_raw?: boolean;
profile_id?: string; profile_id?: string;
@ -305,6 +308,13 @@ export function BizStatePage() {
const [rawLogCmd, setRawLogCmd] = useState(""); const [rawLogCmd, setRawLogCmd] = useState("");
const [rawLogText, setRawLogText] = useState(""); const [rawLogText, setRawLogText] = useState("");
const [rawLogMeta, setRawLogMeta] = useState(""); const [rawLogMeta, setRawLogMeta] = useState("");
const [rawLogCommandId, setRawLogCommandId] = useState("");
const [rawLogLines, setRawLogLines] = useState(0);
const [rawLogRows, setRawLogRows] = useState(0);
const [rawLogMessage, setRawLogMessage] = useState("");
/** Collect status / errors detail (lighter than workbook). */
const [collectDetail, setCollectDetail] = useState<any>(null);
const [collectDetailLoading, setCollectDetailLoading] = useState(false);
const refreshTasks = useCallback(async () => { const refreshTasks = useCallback(async () => {
const purpose = const purpose =
@ -460,6 +470,7 @@ export function BizStatePage() {
{ key: "raw_command", header: t("bizState.colCommand") }, { key: "raw_command", header: t("bizState.colCommand") },
{ key: "metric_id", header: "metric" }, { key: "metric_id", header: "metric" },
{ key: "parse_status", header: t("bizState.colStatus") }, { key: "parse_status", header: t("bizState.colStatus") },
{ key: "raw_line_count", header: t("bizState.colRawLines") },
{ key: "row_count", header: t("bizState.colRows") }, { key: "row_count", header: t("bizState.colRows") },
{ key: "message", header: t("bizState.colMessage") }, { key: "message", header: t("bizState.colMessage") },
{ key: "_actions", header: t("bizState.colActions") }, { key: "_actions", header: t("bizState.colActions") },
@ -483,7 +494,7 @@ export function BizStatePage() {
: cmds.filter((row) => { : cmds.filter((row) => {
const keys = sheetColumn const keys = sheetColumn
? [sheetColumn] ? [sheetColumn]
: ["raw_command", "metric_id", "parse_status", "row_count", "message"]; : ["raw_command", "metric_id", "parse_status", "raw_line_count", "row_count", "message"];
return keys.some((k) => cellText(row[k]).toLowerCase().includes(kw)); return keys.some((k) => cellText(row[k]).toLowerCase().includes(kw));
}); });
const start = (sheetPage - 1) * sheetPageSize; const start = (sheetPage - 1) * sheetPageSize;
@ -509,13 +520,27 @@ export function BizStatePage() {
return cmds.filter((row) => { return cmds.filter((row) => {
const keys = sheetColumn const keys = sheetColumn
? [sheetColumn] ? [sheetColumn]
: ["raw_command", "metric_id", "parse_status", "row_count", "message"]; : ["raw_command", "metric_id", "parse_status", "raw_line_count", "row_count", "message"];
return keys.some((k) => cellText(row[k]).toLowerCase().includes(kw)); return keys.some((k) => cellText(row[k]).toLowerCase().includes(kw));
}).length; }).length;
} }
return sheetTotal; return sheetTotal;
}, [activeSheet, batchDetail, debouncedSheetKw, sheetColumn, sheetTotal]); }, [activeSheet, batchDetail, debouncedSheetKw, sheetColumn, sheetTotal]);
const collectDetailCmdSummary = useMemo(() => {
const cmds = (collectDetail?.commands || []) as SheetCmd[];
let ok = 0;
let fail = 0;
let other = 0;
for (const c of cmds) {
const st = String(c.parse_status || "").toLowerCase();
if (st === "ok" || st === "success" || st === "aux" || st === "aux_ok" || st === "aux_cached") ok += 1;
else if (st === "failed" || st === "error" || st === "fail") fail += 1;
else other += 1;
}
return { ok, fail, other, total: cmds.length };
}, [collectDetail]);
const loadSheetPage = useCallback( const loadSheetPage = useCallback(
async (batchId: string, metricId: string, page: number, pageSize: number, kw: string, column: string) => { async (batchId: string, metricId: string, page: number, pageSize: number, kw: string, column: string) => {
if (!batchId || !metricId || metricId === "commands") return; if (!batchId || !metricId || metricId === "commands") return;
@ -1067,23 +1092,52 @@ export function BizStatePage() {
setSheetTotal(0); setSheetTotal(0);
setRawLogOpen(false); setRawLogOpen(false);
setRawLogText(""); setRawLogText("");
setRawLogCommandId("");
}; };
const openRawLog = async (commandId: string) => { const openCollectDetail = async (batchId: string) => {
if (!batchDetail?.id || !commandId) return; setCollectDetailLoading(true);
setCollectDetail(null);
try {
const d = await bizStateGetBatch(batchId);
setCollectDetail(d);
} catch (e) {
showError(formatErr(e));
} finally {
setCollectDetailLoading(false);
}
};
const closeCollectDetail = () => {
setCollectDetail(null);
setCollectDetailLoading(false);
};
const openRawLog = async (commandId: string, fromBatchId?: string) => {
const bid = fromBatchId || (batchDetail?.id ? String(batchDetail.id) : "") || (collectDetail?.id ? String(collectDetail.id) : "");
if (!bid || !commandId) return;
setRawLogOpen(true); setRawLogOpen(true);
setRawLogLoading(true); setRawLogLoading(true);
setRawLogText(""); setRawLogText("");
setRawLogCmd(""); setRawLogCmd("");
setRawLogMeta(""); setRawLogMeta("");
setRawLogCommandId(commandId);
setRawLogLines(0);
setRawLogRows(0);
setRawLogMessage("");
try { try {
const d = await bizStateGetBatchCommand(String(batchDetail.id), commandId); const d = await bizStateGetBatchCommand(bid, commandId);
setRawLogCmd(String(d.raw_command || "")); setRawLogCmd(String(d.raw_command || ""));
setRawLogText(String(d.raw_text || "")); setRawLogText(String(d.raw_text || ""));
const lines = Number(d.raw_line_count ?? 0);
const rows = Number(d.row_count ?? 0);
setRawLogLines(lines);
setRawLogRows(rows);
setRawLogMessage(String(d.message || ""));
const bits = [ const bits = [
d.parse_status, d.parse_status,
d.metric_id, d.metric_id,
d.row_count != null ? `${d.row_count} rows` : "", t("bizState.rawLogStats", { lines, rows }),
d.collected_at ? fmtTime(d.collected_at) : "", d.collected_at ? fmtTime(d.collected_at) : "",
].filter(Boolean); ].filter(Boolean);
setRawLogMeta(bits.join(" · ")); setRawLogMeta(bits.join(" · "));
@ -1095,6 +1149,35 @@ export function BizStatePage() {
} }
}; };
const exportRawLog = async () => {
const bid =
(batchDetail?.id && String(batchDetail.id)) ||
(collectDetail?.id && String(collectDetail.id)) ||
"";
if (!bid || !rawLogCommandId) {
// Fallback: download already-loaded text
if (!rawLogText) return;
const safe = (rawLogCmd || "command").replace(/[^\w.-]+/g, "_").slice(0, 80);
const blob = new Blob([rawLogText], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = `${safe || "command"}.txt`;
a.click();
} finally {
URL.revokeObjectURL(url);
}
return;
}
try {
await bizStateDownloadCommandRaw(bid, rawLogCommandId);
showOk(t("bizState.exportRawLogOk"));
} catch (e) {
showError(formatErr(e));
}
};
const selectSheet = (id: string) => { const selectSheet = (id: string) => {
setSheetId(id); setSheetId(id);
setSheetKeyword(""); setSheetKeyword("");
@ -1785,6 +1868,9 @@ export function BizStatePage() {
</td> </td>
<td> <td>
<div className="pt-list-actions"> <div className="pt-list-actions">
<Button size="sm" variant="secondary" onPress={() => void openCollectDetail(b.id)}>
{t("bizState.batchDetail")}
</Button>
<Button size="sm" variant="primary" onPress={() => void openBatch(b.id)}> <Button size="sm" variant="primary" onPress={() => void openBatch(b.id)}>
{t("bizState.viewBatch")} {t("bizState.viewBatch")}
</Button> </Button>
@ -2079,6 +2165,21 @@ export function BizStatePage() {
{t("bizState.setAlias")} {t("bizState.setAlias")}
</Button> </Button>
</div> </div>
{batchDetail.message ? (
<div
className={`bs-batch-message${
["failed", "partial", "error", "cancelled"].includes(
String(batchDetail.status || "").toLowerCase(),
)
? " bs-batch-message--alert"
: ""
}`}
role="status"
>
<span className="bs-batch-message__label">{t("bizState.colMessage")}</span>
<pre className="bs-batch-message__body">{String(batchDetail.message)}</pre>
</div>
) : null}
</div> </div>
) : null} ) : null}
@ -2111,6 +2212,12 @@ export function BizStatePage() {
{c.raw_command || "—"} {c.raw_command || "—"}
</code> </code>
<div className="bs-sheet-cmd-actions"> <div className="bs-sheet-cmd-actions">
<span className="muted bs-cmd-stat">
{t("bizState.rawLogStats", {
lines: Number(c.raw_line_count ?? 0),
rows: Number(c.row_count ?? 0),
})}
</span>
{c.parse_status ? ( {c.parse_status ? (
<NmStatusChip color={jobChipColor(String(c.parse_status))}> <NmStatusChip color={jobChipColor(String(c.parse_status))}>
{c.parse_status} {c.parse_status}
@ -2177,6 +2284,7 @@ export function BizStatePage() {
{displayColumns.map((c) => ( {displayColumns.map((c) => (
<td key={c.key}> <td key={c.key}>
{c.key === "_actions" ? ( {c.key === "_actions" ? (
<div className="pt-list-actions">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@ -2185,6 +2293,20 @@ export function BizStatePage() {
> >
{t("bizState.viewRawLog")} {t("bizState.viewRawLog")}
</Button> </Button>
<Button
size="sm"
variant="ghost"
isDisabled={!row.has_raw || !batchDetail?.id}
onPress={() =>
void bizStateDownloadCommandRaw(
String(batchDetail.id),
String(row.id || ""),
).catch((e) => showError(formatErr(e)))
}
>
{t("bizState.exportRawLog")}
</Button>
</div>
) : c.key === "parse_status" ? ( ) : c.key === "parse_status" ? (
<NmStatusChip color={jobChipColor(cellText(row[c.key]))}> <NmStatusChip color={jobChipColor(cellText(row[c.key]))}>
{cellText(row[c.key]) || "—"} {cellText(row[c.key]) || "—"}
@ -2259,7 +2381,18 @@ export function BizStatePage() {
</Modal.Header> </Modal.Header>
<Modal.Body className="flex flex-col gap-2 bs-rawlog-body"> <Modal.Body className="flex flex-col gap-2 bs-rawlog-body">
{rawLogCmd ? <code className="bs-sheet-cmd-code bs-sheet-cmd-code--block">{rawLogCmd}</code> : null} {rawLogCmd ? <code className="bs-sheet-cmd-code bs-sheet-cmd-code--block">{rawLogCmd}</code> : null}
{rawLogMeta ? <p className="muted">{rawLogMeta}</p> : null} <div className="bs-rawlog-stats">
<NmStatusChip color="accent">
{t("bizState.rawLogStats", { lines: rawLogLines, rows: rawLogRows })}
</NmStatusChip>
{rawLogMeta ? <span className="muted">{rawLogMeta}</span> : null}
</div>
{rawLogMessage ? (
<div className="bs-batch-message bs-batch-message--alert" role="status">
<span className="bs-batch-message__label">{t("bizState.colMessage")}</span>
<pre className="bs-batch-message__body">{rawLogMessage}</pre>
</div>
) : null}
{rawLogLoading ? ( {rawLogLoading ? (
<div className="pt-list-empty muted">{t("bizState.sheetLoading")}</div> <div className="pt-list-empty muted">{t("bizState.sheetLoading")}</div>
) : ( ) : (
@ -2267,11 +2400,166 @@ export function BizStatePage() {
)} )}
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button
size="sm"
variant="secondary"
isDisabled={rawLogLoading || (!rawLogText && !rawLogCommandId)}
onPress={() => void exportRawLog()}
>
{t("bizState.exportRawLog")}
</Button>
<Button size="sm" variant="ghost" onPress={() => setRawLogOpen(false)}> <Button size="sm" variant="ghost" onPress={() => setRawLogOpen(false)}>
{t("bizState.cancel")} {t("bizState.cancel")}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</AppModalShell> </AppModalShell>
{/* Batch collect status / errors detail */}
<AppModalShell
open={Boolean(collectDetail) || collectDetailLoading}
onClose={closeCollectDetail}
size="lg"
className="app-heroui-modal--lg bs-collect-detail-modal"
>
<Modal.Header>
<Modal.Heading>{t("bizState.batchCollectDetail")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-2">
{collectDetailLoading ? (
<div className="pt-list-empty muted">{t("bizState.sheetLoading")}</div>
) : collectDetail ? (
<>
<div className="bs-workbook-meta">
<NmStatusChip color={jobChipColor(String(collectDetail.status || ""))}>
{String(collectDetail.status || "—")}
</NmStatusChip>
{collectDetail.alias ? (
<NmStatusChip color="accent">{String(collectDetail.alias)}</NmStatusChip>
) : null}
<span className="muted">
{collectDetail.command_count} cmd · {collectDetail.row_count} rows ·{" "}
{t("bizState.colStarted")} {fmtTime(collectDetail.started_at)} ·{" "}
{t("bizState.colEnded")} {fmtTime(collectDetail.ended_at)}
{fmtDuration(collectDetail.started_at, collectDetail.ended_at)
? ` · ${fmtDuration(collectDetail.started_at, collectDetail.ended_at)}`
: ""}
</span>
</div>
<div
className={`bs-batch-message${
["failed", "partial", "error", "cancelled"].includes(
String(collectDetail.status || "").toLowerCase(),
)
? " bs-batch-message--alert"
: ""
}`}
role="status"
>
<span className="bs-batch-message__label">{t("bizState.colMessage")}</span>
<pre className="bs-batch-message__body">
{String(collectDetail.message || "").trim() || t("bizState.batchMessageEmpty")}
</pre>
</div>
<p className="muted">
{t("bizState.batchCmdSummary", {
ok: collectDetailCmdSummary.ok,
fail: collectDetailCmdSummary.fail,
other: collectDetailCmdSummary.other,
total: collectDetailCmdSummary.total,
})}
</p>
<div className="pt-list-table-wrap bs-sheet-table">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("bizState.colCommand")}</th>
<th>{t("bizState.colStatus")}</th>
<th>{t("bizState.colRawLines")}</th>
<th>{t("bizState.colRows")}</th>
<th>{t("bizState.colMessage")}</th>
<th>{t("bizState.colActions")}</th>
</tr>
</thead>
<tbody>
{((collectDetail.commands || []) as SheetCmd[]).map((c) => (
<tr key={c.id}>
<td>
<code className="bs-inline-cmd">{c.raw_command || "—"}</code>
</td>
<td>
<NmStatusChip color={jobChipColor(String(c.parse_status || ""))}>
{c.parse_status || "—"}
</NmStatusChip>
</td>
<td className="pt-list-num">{Number(c.raw_line_count ?? 0)}</td>
<td className="pt-list-num">{Number(c.row_count ?? 0)}</td>
<td className="bs-msg-cell" title={c.message || ""}>
{c.message?.trim() ? c.message : "—"}
</td>
<td>
<div className="pt-list-actions">
<Button
size="sm"
variant="ghost"
isDisabled={!c.has_raw}
onPress={() => void openRawLog(c.id, String(collectDetail.id))}
>
{t("bizState.viewRawLog")}
</Button>
<Button
size="sm"
variant="ghost"
isDisabled={!c.has_raw}
onPress={() =>
void bizStateDownloadCommandRaw(String(collectDetail.id), c.id).catch((e) =>
showError(formatErr(e)),
)
}
>
{t("bizState.exportRawLog")}
</Button>
</div>
</td>
</tr>
))}
{!((collectDetail.commands || []) as SheetCmd[]).length ? (
<tr>
<td colSpan={6}>
<div className="pt-list-empty">{t("bizState.sheetEmpty")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</>
) : null}
</Modal.Body>
<Modal.Footer>
{collectDetail?.id ? (
<>
<Button
size="sm"
variant="primary"
onPress={() => {
const id = String(collectDetail.id);
closeCollectDetail();
void openBatch(id);
}}
>
{t("bizState.openWorkbook")}
</Button>
<Button size="sm" variant="secondary" onPress={() => void bizStateDownloadExport(String(collectDetail.id))}>
{t("bizState.export")}
</Button>
</>
) : null}
<Button size="sm" variant="ghost" onPress={closeCollectDetail}>
{t("bizState.cancel")}
</Button>
</Modal.Footer>
</AppModalShell>
</section> </section>
); );
} }

View file

@ -1907,6 +1907,7 @@ export const bizStateGetBatchCommand = (batchId: string, commandId: string) =>
metric_id: string; metric_id: string;
parse_status: string; parse_status: string;
row_count: number; row_count: number;
raw_line_count?: number;
message: string; message: string;
raw_text: string; raw_text: string;
collected_at?: string | null; collected_at?: string | null;
@ -1915,6 +1916,31 @@ export const bizStateGetBatchCommand = (batchId: string, commandId: string) =>
`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`, `/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}`,
); );
/** Download one command's raw CLI output as .txt */
export const bizStateDownloadCommandRaw = async (batchId: string, commandId: string): Promise<void> => {
const path =
`/v1/biz-state/batches/${encodeURIComponent(batchId)}/commands/${encodeURIComponent(commandId)}/raw.txt`;
const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() });
if (res.status === 401) {
handleUnauthorized(path);
throw new Error("unauthorized");
}
if (!res.ok) throw new Error(`${res.status} download raw`);
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") || "";
const m = /filename="?([^";]+)"?/i.exec(cd);
const filename = m?.[1] || `biz_state_${commandId}.txt`;
const url = URL.createObjectURL(blob);
try {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
} finally {
URL.revokeObjectURL(url);
}
};
export const bizStateDownloadExport = async (batchId: string): Promise<void> => { export const bizStateDownloadExport = async (batchId: string): Promise<void> => {
const path = `/v1/biz-state/batches/${encodeURIComponent(batchId)}/export`; const path = `/v1/biz-state/batches/${encodeURIComponent(batchId)}/export`;
const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() });