diff --git a/netx_api/biz_state/collect_runner.py b/netx_api/biz_state/collect_runner.py index 32b0d13..ac8f96e 100644 --- a/netx_api/biz_state/collect_runner.py +++ b/netx_api/biz_state/collect_runner.py @@ -77,6 +77,25 @@ def _bindings_for_item(db, item_id: str) -> list[dict[str, str]]: ] +def _resolve_collect_profile(profile_id: str): + """Resolve task-item profile; remap disabled if_intf → config_interface.""" + pid = str(profile_id or "").strip() + if not pid: + return None + profile = get_profile(pid) + if profile is None: + return None + if profile.enabled: + return profile + # Legacy IF VRF check merged into Config Interface Intent — VRF is a subset. + if profile.metric_id == "if_intf" or pid.endswith(".if_intf"): + vk = str(profile.vendor_key or "zte").strip() or "zte" + remapped = get_profile(f"{vk}.config_interface") or get_profile("zte.config_interface") + if remapped and remapped.enabled: + return remapped + return None + + def _persist_lldp_rows( db, *, @@ -199,8 +218,12 @@ def _finish_task(task_id: str, *, error: str = "") -> None: db.close() -def dispatch_collect(task_id: str) -> None: - """Claim and run one collect round.""" +def dispatch_collect(task_id: str, *, manual: bool = False) -> None: + """Claim and run one collect round. + + Scheduler calls with ``manual=False`` (only when task status is ``running``). + Collect-now calls with ``manual=True`` (any status, as long as not already collecting). + """ db = SessionLocal() batch_id = "" try: @@ -209,8 +232,14 @@ def dispatch_collect(task_id: str) -> None: return if task.collect_running: return - if str(task.status or "") not in ("running", "draft", "paused"): - return + st = str(task.status or "").strip() + if manual: + # Idle manual trigger: allow scheduled / paused / draft / stopped + if st in ("", "deleted"): + return + else: + if st != "running": + return items = ( db.query(BizStateTaskItem) @@ -338,19 +367,25 @@ def _run_collect_session( # Build work list before opening session work: list[tuple[str, dict[str, str], str, str, str]] = [] # concrete, params, profile_id, item_id, mode + # Dedupe same CLI → same metric (e.g. legacy if_intf + config_interface). + seen_work: set[tuple[str, str]] = set() for item in items: if item.kind == "custom_raw": cmd = normalize_command(item.command_override) if cmd: + key = (cmd, "__custom__") + if key in seen_work: + continue + seen_work.add(key) work.append((cmd, {}, "", item.id, "custom")) continue - profile = get_profile(item.source_profile_id) + profile = _resolve_collect_profile(item.source_profile_id) if profile is None: _append_event( db, task_id=task_id, - message=f"unknown profile {item.source_profile_id}", - level="error", + message=f"skip profile {item.source_profile_id} (missing or disabled)", + level="info", ) continue binds = _bindings_for_item(db, item.id) @@ -364,7 +399,16 @@ def _run_collect_session( _append_event(db, task_id=task_id, message=str(exc), level="error") continue for concrete, params in pairs: - work.append((concrete, params, profile.profile_id, item.id, "normal")) + cmd = normalize_command(concrete) + # Prefer match_command metric so remapped if_intf shares key with config_interface + hit = match_command(vendor_key=vendor_key, command=cmd) + mid = str((hit.profile.metric_id if hit else profile.metric_id) or "").strip() + pid = str((hit.profile.profile_id if hit else profile.profile_id) or "").strip() + key = (cmd, mid or pid) + if key in seen_work: + continue + seen_work.add(key) + work.append((cmd, params, pid or profile.profile_id, item.id, "normal")) if not work: batch.status = "failed" @@ -424,9 +468,15 @@ def _run_collect_session( params_json=dict(params or {}), created_at=_utcnow(), ) + cache_hit_primary = False try: - raw = send_show_command(conn, concrete, read_timeout=per_cmd) - cmd_row.raw_text = str(raw or "") + cached = session.get_cached(concrete) + if cached is not None and str(cached.raw or "").strip(): + cmd_row.raw_text = str(cached.raw or "") + cache_hit_primary = True + else: + raw = send_show_command(conn, concrete, read_timeout=per_cmd) + cmd_row.raw_text = str(raw or "") except Exception as exc: any_fail = True cmd_row.parse_status = "failed" @@ -466,12 +516,16 @@ def _run_collect_session( sdb.commit() continue - session.remember( - concrete, - raw=cmd_row.raw_text or "", - ok=True, - cmd_row_id=cmd_row.id, - ) + if cache_hit_primary: + # Keep existing cache entry; primary will re-parse from shared raw. + pass + else: + session.remember( + concrete, + raw=cmd_row.raw_text or "", + ok=True, + cmd_row_id=cmd_row.id, + ) resolved_aux = [] aux_results: dict[str, Any] = {} @@ -684,5 +738,6 @@ def trigger_collect_now(task_id: str) -> dict[str, Any]: raise HTTPException(status_code=409, detail="collect already running") finally: db.close() - dispatch_collect(task_id) + # Manual: allow even when schedule is on (status=running) or paused/stopped. + dispatch_collect(task_id, manual=True) return {"ok": True, "task_id": task_id} diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index d6cdfc4..91199e1 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -26,7 +26,7 @@ from ..models import ( ManagedNE, ) from ..timeutil import utcnow_naive -from .command_match import preview_task_item +from .command_match import normalize_command, preview_task_item from .profiles import ( all_profiles, get_profile, @@ -583,18 +583,25 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]: cmd_payload: list[dict[str, Any]] = [] sheets_order: list[str] = [] sheet_cmds: dict[str, list[dict[str, Any]]] = {} + sheet_titles: dict[str, str] = {} - def _push_sheet(mid: str, cmd_info: dict[str, Any] | None = None) -> None: + def _push_sheet(mid: str, cmd_info: dict[str, Any] | None = None, title: str = "") -> None: id_ = str(mid or "").strip() if not id_ or id_ in ("vrf_list", "commands"): return if id_ not in sheets_order: sheets_order.append(id_) sheet_cmds.setdefault(id_, []) + if title: + sheet_titles[id_] = title if cmd_info is not None: + # Prefer primary collect rows over aux / aux_cached for the same CLI sheet_cmds[id_].append(cmd_info) + primary_cmds_by_cli: dict[str, dict[str, Any]] = {} for c in cmds: + status = str(c.parse_status or "").strip().lower() + is_aux = status.startswith("aux") info = { "id": c.id, "profile_id": c.profile_id, @@ -606,10 +613,28 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]: "row_count": c.row_count, "message": c.message, "has_raw": bool(str(c.raw_text or "").strip()), + "is_aux": is_aux, } + cmd_n = normalize_command(str(c.raw_command or "")) + if not is_aux and cmd_n: + primary_cmds_by_cli.setdefault(cmd_n, info) + # Commands sheet: hide aux when the same CLI already has a primary row + if is_aux and cmd_n and cmd_n in primary_cmds_by_cli: + continue + if is_aux and cmd_n: + # aux may appear before primary in list — defer; second pass below + continue cmd_payload.append(info) mid = str(c.metric_id or "").strip() - if mid and mid not in ("", "vrf_list"): + title = "" + pid = str(c.profile_id or "").strip() + if pid: + prof = get_profile(pid) + if prof: + title = str(prof.title or "") + if not mid: + mid = str(prof.metric_id or "").strip() + if mid and mid not in ("", "vrf_list") and not is_aux: _push_sheet( mid, { @@ -621,17 +646,49 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]: "has_raw": info["has_raw"], "profile_id": c.profile_id, }, + title=title, ) - # Aux command rows may have empty metric_id — still attach by profile if needed later + + # Include aux-only CLIs that had no primary counterpart + for c in cmds: + status = str(c.parse_status or "").strip().lower() + if not status.startswith("aux"): + continue + cmd_n = normalize_command(str(c.raw_command or "")) + if cmd_n and cmd_n in primary_cmds_by_cli: + continue + cmd_payload.append( + { + "id": c.id, + "profile_id": c.profile_id, + "parser_id": c.parser_id, + "metric_id": c.metric_id, + "raw_command": c.raw_command, + "params": c.params_json or {}, + "parse_status": c.parse_status, + "row_count": c.row_count, + "message": c.message, + "has_raw": bool(str(c.raw_text or "").strip()), + "is_aux": True, + } + ) for mid in metric_counts: if mid not in sheets_order: sheets_order.append(mid) sheet_cmds.setdefault(mid, []) + if mid not in sheet_titles: + # Best-effort title from any profile with this metric_id + for p in all_profiles(): + if p.metric_id == mid and p.enabled: + sheet_titles[mid] = str(p.title or mid) + break + sheet_titles.setdefault(mid, mid) sheets = [ { "metric_id": mid, + "title": sheet_titles.get(mid) or mid, "row_count": int(metric_counts.get(mid) or 0), "commands": list(sheet_cmds.get(mid) or []), } diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index fc9c61c..c9f983e 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -193,10 +193,11 @@ def api_collect_now( raise HTTPException(status_code=404, detail="task not found") if bool(task.collect_running): return {"ok": True, "started": False, "reason": "already_collecting", "task_id": task_id} - # Allow one-shot from draft/paused + # Allow one-shot even when schedule is enabled (idle only). task.last_collect_ended_at = None db.commit() - background_tasks.add_task(dispatch_collect, task_id) + tid = task_id + background_tasks.add_task(lambda: dispatch_collect(tid, manual=True)) return {"ok": True, "started": True, "task_id": task_id} diff --git a/tests/test_batch_workbook_api.py b/tests/test_batch_workbook_api.py index cdb3727..e62ce6e 100644 --- a/tests/test_batch_workbook_api.py +++ b/tests/test_batch_workbook_api.py @@ -68,6 +68,7 @@ class BatchWorkbookApiTests(unittest.TestCase): self.assertEqual(out["sheets"][0]["metric_id"], "arp") self.assertEqual(out["sheets"][0]["row_count"], 2) self.assertEqual(out["sheets"][0]["commands"][0]["raw_command"], "show arp | one-line") + self.assertTrue(out["sheets"][0].get("title")) def test_list_metric_rows_rejects_commands_sheet(self) -> None: db = MagicMock() diff --git a/tests/test_collect_dedupe.py b/tests/test_collect_dedupe.py new file mode 100644 index 0000000..ee12c23 --- /dev/null +++ b/tests/test_collect_dedupe.py @@ -0,0 +1,76 @@ +"""Collect work-list dedupe / if_intf remap / manual collect.""" + +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +from netx_api.biz_state.collect_runner import ( + _resolve_collect_profile, + dispatch_collect, + trigger_collect_now, +) +from netx_api.biz_state.profiles import get_profile, reload_profiles + + +class CollectProfileResolveTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + reload_profiles() + + def test_if_intf_remaps_to_config_interface(self) -> None: + p = _resolve_collect_profile("zte.if_intf") + self.assertIsNotNone(p) + assert p is not None + self.assertEqual(p.profile_id, "zte.config_interface") + self.assertEqual(p.metric_id, "config_interface") + + def test_enabled_profile_passthrough(self) -> None: + p = _resolve_collect_profile("zte.arp") + self.assertIsNotNone(p) + assert p is not None + self.assertEqual(p.profile_id, "zte.arp") + + def test_if_intf_profile_disabled(self) -> None: + raw = get_profile("zte.if_intf") + self.assertIsNotNone(raw) + assert raw is not None + self.assertFalse(raw.enabled) + + +class ManualCollectTests(unittest.TestCase): + def test_trigger_collect_now_passes_manual(self) -> None: + task = MagicMock() + task.collect_running = False + db = MagicMock() + db.get.return_value = task + with ( + patch("netx_api.biz_state.collect_runner.SessionLocal", return_value=db), + patch("netx_api.biz_state.collect_runner.dispatch_collect") as dc, + ): + out = trigger_collect_now("t1") + self.assertTrue(out["ok"]) + dc.assert_called_once_with("t1", manual=True) + + def test_dispatch_manual_allows_paused(self) -> None: + task = MagicMock() + task.collect_running = False + task.status = "paused" + task.id = "t1" + task.source = "managed" + task.ne_id = "n1" + task.ne_name = "NE" + task.vendor = "zte" + db = MagicMock() + db.get.return_value = task + # items query → empty so it returns early after setting error + q = MagicMock() + q.filter.return_value.order_by.return_value.all.return_value = [] + db.query.return_value = q + with patch("netx_api.biz_state.collect_runner.SessionLocal", return_value=db): + dispatch_collect("t1", manual=True) + self.assertEqual(task.last_error, "no enabled task items") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/src/index.css b/web/src/index.css index 23eab86..7e76daa 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -10051,6 +10051,25 @@ html.login-page--paused .login-page__flare { flex: 1 1 auto; min-height: 240px; max-height: min(48vh, 520px); + overflow: auto; + border-collapse: separate; +} + +.bs-workbook-modal .bs-sheet-table { + overflow: auto; +} + +.bs-workbook-modal .bs-sheet-table table { + border-collapse: separate; + border-spacing: 0; +} + +.bs-workbook-modal .bs-sheet-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: rgba(15, 23, 42, 0.98) !important; + box-shadow: 0 1px 0 rgba(148, 163, 184, 0.22); } .bs-cmp-result-table.bs-sheet-table { diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/pages/network/BizStatePage.tsx index 53ec781..3543383 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/pages/network/BizStatePage.tsx @@ -166,29 +166,13 @@ function columnsFromRows(rows: Record[]): SheetCol[] { return keys.map((k) => ({ key: k, header: k })); } -function metricSheetTitle(metricId: string, t: (k: string) => string): string { - const map: Record = { - lldp_neighbor: t("bizState.sheetLldp"), - isis_adjacency: t("bizState.sheetIsis"), - interface_brief: t("bizState.sheetIfaceBrief"), - interface_detail: t("bizState.sheetIfaceBrief"), - arp: t("bizState.sheetArp"), - nd6_cache: t("bizState.sheetNd6"), - bgp_peer: t("bizState.sheetBgpPeer"), - config_interface: t("bizState.sheetConfigIface"), - config_vrf: t("bizState.sheetConfigVrf"), - config_bgp_peer: t("bizState.sheetConfigBgp"), - }; - return map[metricId] || metricId; -} - function buildSheetTabs(batch: any, t: (k: string) => string): SheetTab[] { const tabs: SheetTab[] = []; const cmds = (batch?.commands || []) as SheetCmd[]; if (cmds.length) { tabs.push({ id: "commands", - title: t("bizState.sheetCommands"), + title: "Commands", rowCount: cmds.length, commands: cmds, }); @@ -196,9 +180,10 @@ function buildSheetTabs(batch: any, t: (k: string) => string): SheetTab[] { for (const s of (batch?.sheets || []) as any[]) { const mid = String(s?.metric_id || "").trim(); if (!mid || mid === "vrf_list") continue; + const title = String(s?.title || "").trim() || mid; tabs.push({ id: mid, - title: metricSheetTitle(mid, t), + title, rowCount: Number(s?.row_count || 0), commands: Array.isArray(s?.commands) ? (s.commands as SheetCmd[]) : [], }); @@ -1451,7 +1436,12 @@ export function BizStatePage() { )} -