From 32deca1687dd500c5da9cca4205dbfebd5949bff Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 22 Sep 2026 11:18:58 +0800 Subject: [PATCH] Add one-click export of biz-state collect commands. Plan enabled monitoring items into concrete CLIs (with aux) and download as txt from the task detail modal. Co-authored-by: Cursor --- netx_api/biz_state/service.py | 234 +++++++++++++++++++++++- netx_api/biz_state_router.py | 34 ++++ tests/test_biz_state_export_commands.py | 115 ++++++++++++ web/src/i18n/en.ts | 2 + web/src/i18n/zh.ts | 2 + web/src/pages/network/BizStatePage.tsx | 22 +++ web/src/services/api.ts | 38 ++++ 7 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 tests/test_biz_state_export_commands.py diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index 68a2de6..2ef8a90 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -26,7 +26,14 @@ from ..models import ( ManagedNE, ) from ..timeutil import utcnow_naive -from .command_match import normalize_command, preview_task_item +from .command_match import ( + EXPAND_ALL_COMMAND, + expand_from_bindings, + match_command, + normalize_command, + preview_task_item, +) +from .collect_session import resolve_aux_command from .profiles import ( all_profiles, get_profile, @@ -1002,3 +1009,228 @@ def preview_items(db: Session, *, vendor: str, device_type: str, items: list[dic ) ) return out + + +def _resolve_export_profile(profile_id: str): + """Resolve collect 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 + 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 plan_task_collect_commands( + db: Session, + task_id: str, + *, + enabled_only: bool = True, + include_aux: bool = True, +) -> dict[str, Any]: + """Plan concrete collect CLIs for a task (no device login). + + expand_all items (unbound optional discover) are listed with a note; aux + commands are resolved from primary params when include_aux is True. + """ + task = db.get(BizStateTask, task_id) + if not task: + raise HTTPException(status_code=404, detail="task_not_found") + vendor_key = resolve_vendor_key(task.vendor or "", task.device_type or "") + q = db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == task_id) + if enabled_only: + q = q.filter(BizStateTaskItem.enabled.is_(True)) + items = q.order_by(BizStateTaskItem.sort_order.asc()).all() + + sections: list[dict[str, Any]] = [] + flat: list[str] = [] + seen: set[str] = set() + + def _add_cmd(cmd: str) -> bool: + c = normalize_command(cmd) + if not c or c in seen: + return False + seen.add(c) + flat.append(c) + return True + + for item in items: + title = str(item.title or "").strip() + kind = str(item.kind or "catalog") + section: dict[str, Any] = { + "item_id": item.id, + "kind": kind, + "source_profile_id": str(item.source_profile_id or ""), + "title": title, + "enabled": bool(item.enabled), + "commands": [], + "notes": [], + } + + if kind == "custom_raw": + cmd = normalize_command(item.command_override) + if cmd and _add_cmd(cmd): + section["commands"].append( + {"command": cmd, "role": "primary", "params": {}, "profile_id": ""} + ) + elif not cmd: + section["notes"].append("empty custom command") + sections.append(section) + continue + + profile = _resolve_export_profile(item.source_profile_id) + if profile is None: + section["notes"].append( + f"skip profile {item.source_profile_id} (missing or disabled)" + ) + sections.append(section) + continue + + section["title"] = title or str(profile.title or profile.profile_id) + section["source_profile_id"] = profile.profile_id + binds = ( + db.query(BizStateTaskItemBinding) + .filter(BizStateTaskItemBinding.item_id == item.id) + .all() + ) + binding_dicts = [ + { + "placeholder": str(b.placeholder or "").strip(), + "value": str(b.value or "").strip(), + } + for b in binds + if str(b.placeholder or "").strip() and str(b.value or "").strip() + ] + + try: + pairs = expand_from_bindings( + profile=profile, + bindings=binding_dicts, + command_override=item.command_override, + ) + except ValueError as exc: + section["notes"].append(str(exc)) + sections.append(section) + continue + + if pairs and pairs[0][0] == EXPAND_ALL_COMMAND: + tmpl = normalize_command(profile.command_template) + section["notes"].append( + "expand_all: no bindings; collect will expand discover values" + ) + section["commands"].append( + { + "command": tmpl, + "role": "template", + "params": {"__expand_all__": "1"}, + "profile_id": profile.profile_id, + } + ) + sections.append(section) + continue + + for concrete, params in pairs: + cmd = normalize_command(concrete) + if not cmd: + continue + hit = match_command(vendor_key=vendor_key, command=cmd) + pid = str( + (hit.profile.profile_id if hit else profile.profile_id) or "" + ).strip() + if _add_cmd(cmd): + section["commands"].append( + { + "command": cmd, + "role": "primary", + "params": dict(params or {}), + "profile_id": pid, + } + ) + if not include_aux: + continue + for aux in list(getattr(hit.profile if hit else profile, "aux_commands", None) or []): + try: + ra = resolve_aux_command(aux, params=dict(params or {})) + except ValueError as exc: + section["notes"].append(f"aux {getattr(aux, 'key', '')}: {exc}") + continue + if _add_cmd(ra.command): + section["commands"].append( + { + "command": ra.command, + "role": "aux", + "aux_key": ra.key, + "params": dict(params or {}), + "profile_id": ra.profile_id, + } + ) + + sections.append(section) + + return { + "task_id": task.id, + "ne_name": task.ne_name or "", + "ne_ip": task.ne_ip or "", + "vendor": task.vendor or "", + "device_type": task.device_type or "", + "command_count": len(flat), + "commands": flat, + "items": sections, + } + + +def export_task_commands_text( + db: Session, + task_id: str, + *, + enabled_only: bool = True, + include_aux: bool = True, +) -> str: + """Plain-text export of planned collect commands (one CLI per line + section headers).""" + plan = plan_task_collect_commands( + db, task_id, enabled_only=enabled_only, include_aux=include_aux + ) + lines = [ + "# biz-state collect commands", + f"# task_id={plan['task_id']}", + f"# ne={plan['ne_name'] or '-'} ({plan['ne_ip'] or '-'})", + f"# vendor={plan['vendor'] or '-'} device_type={plan['device_type'] or '-'}", + f"# command_count={plan['command_count']}", + f"# include_aux={'1' if include_aux else '0'}", + f"# enabled_only={'1' if enabled_only else '0'}", + "", + ] + for sec in plan["items"]: + title = str(sec.get("title") or sec.get("source_profile_id") or "item").strip() + pid = str(sec.get("source_profile_id") or "").strip() + header = f"## {title}" + if pid and pid not in title: + header = f"## {title} · {pid}" + lines.append(header) + for note in sec.get("notes") or []: + lines.append(f"# note: {note}") + cmds = list(sec.get("commands") or []) + if not cmds and not (sec.get("notes") or []): + lines.append("# (no commands)") + for c in cmds: + role = str(c.get("role") or "primary") + if role == "aux": + lines.append(f"# aux:{c.get('aux_key') or ''}") + elif role == "template": + lines.append("# template (expand_all at collect):") + lines.append(str(c.get("command") or "")) + lines.append("") + # Flat unique list at end for easy copy into scripts + lines.append("# ---- flat unique commands ----") + for cmd in plan["commands"]: + lines.append(str(cmd)) + lines.append("") + return "\n".join(lines) diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 6ce09cf..7c96164 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -173,6 +173,40 @@ def api_get_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return svc.get_task(db, task_id) +@router.get("/tasks/{task_id}/commands") +def api_plan_task_commands( + task_id: str, + enabled_only: bool = Query(True), + include_aux: bool = Query(True), + db: Session = Depends(get_db), +) -> dict[str, Any]: + """List planned collect CLIs for a task (bindings expanded; no device login).""" + return svc.plan_task_collect_commands( + db, task_id, enabled_only=enabled_only, include_aux=include_aux + ) + + +@router.get("/tasks/{task_id}/commands/export") +def api_export_task_commands( + task_id: str, + enabled_only: bool = Query(True), + include_aux: bool = Query(True), + db: Session = Depends(get_db), +) -> StreamingResponse: + """Download planned collect commands as a .txt file.""" + text = svc.export_task_commands_text( + db, task_id, enabled_only=enabled_only, include_aux=include_aux + ) + safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in task_id)[:40] + return StreamingResponse( + iter([text.encode("utf-8")]), + media_type="text/plain; charset=utf-8", + headers={ + "Content-Disposition": f'attachment; filename="biz_state_commands_{safe}.txt"' + }, + ) + + @router.patch("/tasks/{task_id}") def api_patch_task( task_id: str, body: TaskPatchIn, db: Session = Depends(get_db) diff --git a/tests/test_biz_state_export_commands.py b/tests/test_biz_state_export_commands.py new file mode 100644 index 0000000..aaa71a7 --- /dev/null +++ b/tests/test_biz_state_export_commands.py @@ -0,0 +1,115 @@ +"""Plan/export collect commands for a biz-state task (no device).""" + +from __future__ import annotations + +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from netx_api.biz_state.service import export_task_commands_text, plan_task_collect_commands +from netx_api.db import Base +from netx_api.models import BizStateTask, BizStateTaskItem, BizStateTaskItemBinding + + +class BizStateExportCommandsTests(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + TestingSession = sessionmaker( + bind=engine, autoflush=False, autocommit=False, expire_on_commit=False + ) + Base.metadata.create_all(bind=engine) + self.db = TestingSession() + self.task = BizStateTask( + id="t_export", + source="managed", + ne_id="ne1", + ne_name="PE1", + ne_ip="10.0.0.1", + vendor="zte", + device_type="zte_zxros", + status="running", + interval_sec=300, + ) + self.db.add(self.task) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def test_plan_and_export_lldp_and_bound_vrf(self) -> None: + lldp = BizStateTaskItem( + id="i_lldp", + task_id=self.task.id, + source_profile_id="zte.lldp_neighbors", + kind="catalog", + enabled=True, + title="LLDP", + sort_order=1, + ) + vrf = BizStateTaskItem( + id="i_vrf", + task_id=self.task.id, + source_profile_id="zte.bgp_vpnv4_vrf_summary", + kind="catalog", + enabled=True, + title="BGP VRF", + sort_order=2, + ) + disabled = BizStateTaskItem( + id="i_off", + task_id=self.task.id, + source_profile_id="zte.isis_neighbors", + kind="catalog", + enabled=False, + title="ISIS off", + sort_order=3, + ) + self.db.add_all([lldp, vrf, disabled]) + self.db.add( + BizStateTaskItemBinding( + id="b1", + item_id=vrf.id, + placeholder="vrf", + value="CUST_A", + ) + ) + self.db.commit() + + plan = plan_task_collect_commands(self.db, self.task.id, include_aux=True) + self.assertEqual(plan["ne_name"], "PE1") + self.assertGreaterEqual(plan["command_count"], 2) + cmds = plan["commands"] + self.assertTrue(any("lldp" in c.lower() for c in cmds)) + self.assertTrue(any("CUST_A" in c for c in cmds)) + # Disabled item excluded by default + self.assertFalse(any("isis" in c.lower() for c in cmds)) + + text = export_task_commands_text(self.db, self.task.id) + self.assertIn("task_id=t_export", text) + self.assertIn("show lldp neighbor brief", text) + self.assertIn("CUST_A", text) + self.assertIn("# ---- flat unique commands ----", text) + + def test_unbound_required_placeholder_noted(self) -> None: + item = BizStateTaskItem( + id="i_exp", + task_id=self.task.id, + source_profile_id="zte.bgp_vpnv4_vrf_summary", + kind="catalog", + enabled=True, + title="BGP unbound", + sort_order=1, + ) + self.db.add(item) + self.db.commit() + + plan = plan_task_collect_commands(self.db, self.task.id) + sec = plan["items"][0] + notes = " ".join(sec.get("notes") or []) + self.assertIn("requires parameter bindings", notes) + self.assertEqual(plan["command_count"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 5701025..be2876f 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -258,6 +258,8 @@ const en = { batches: "Batches", viewBatch: "Open", export: "Export", + exportCommands: "Export collect commands", + exportCommandsOk: "Collect commands exported", noBatches: "No batches yet", batchDetail: "Batch detail", batchWorkbook: "Batch workbook", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 3cbef60..b7d4fbd 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -258,6 +258,8 @@ const zh = { batches: "采集批次", viewBatch: "查看", export: "导出", + exportCommands: "导出采集命令", + exportCommandsOk: "已导出采集命令", noBatches: "尚无批次", batchDetail: "批次详情", batchWorkbook: "批次工作簿", diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/pages/network/BizStatePage.tsx index e414542..3d63091 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/pages/network/BizStatePage.tsx @@ -14,6 +14,7 @@ import { bizStateDeleteTask, bizStateDiscover, bizStateDownloadExport, + bizStateDownloadTaskCommands, bizStateGetBatch, bizStateGetBatchCommand, bizStateGetTask, @@ -747,6 +748,19 @@ export function BizStatePage() { await collectNowForTask(taskId, true); }; + const exportTaskCommands = async () => { + if (!taskId) return; + setBusy(true); + try { + await bizStateDownloadTaskCommands(taskId); + showOk(t("bizState.exportCommandsOk")); + } catch (e) { + showError(formatErr(e)); + } finally { + setBusy(false); + } + }; + const removeTask = async (id: string) => { if (!window.confirm(t("bizState.confirmDelete"))) return; setBusy(true); @@ -1666,6 +1680,14 @@ export function BizStatePage() { > {t("bizState.collectNow")} + diff --git a/web/src/services/api.ts b/web/src/services/api.ts index f1da439..6dcebfa 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1926,6 +1926,44 @@ export const bizStateDownloadExport = async (batchId: string): Promise => } }; +export const bizStateDownloadTaskCommands = async ( + taskId: string, + opts?: { includeAux?: boolean; enabledOnly?: boolean }, +): Promise => { + const q = new URLSearchParams(); + if (opts?.includeAux === false) q.set("include_aux", "false"); + if (opts?.enabledOnly === false) q.set("enabled_only", "false"); + const qs = q.toString(); + const path = + `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/commands/export` + + (qs ? `?${qs}` : ""); + const res = await fetch(path, { method: "GET", credentials: fetchCreds, headers: authHeaders() }); + if (res.status === 401) { + handleUnauthorized(path); + throw new Error("unauthorized"); + } + if (!res.ok) { + let detail = `${res.status} export commands`; + try { + const j = (await res.json()) as { detail?: unknown }; + if (j?.detail != null) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); + } catch { + /* ignore */ + } + throw new Error(detail); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + try { + const a = document.createElement("a"); + a.href = url; + a.download = `biz_state_commands_${taskId}.txt`; + a.click(); + } finally { + URL.revokeObjectURL(url); + } +}; + /* ---- biz_state compare (Phase2) ---- */ export const bizCompareListTemplates = () =>