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 = () =>