diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index 6de4918..6b6d88e 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -1029,18 +1029,43 @@ def _resolve_export_profile(profile_id: str): return None +def _append_aux_commands( + section: dict[str, Any], + profile: Any, + *, + params: dict[str, str], + add_cmd, +) -> None: + """Append resolved aux CLIs onto a plan section (dedupe via add_cmd).""" + for aux in list(getattr(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, + } + ) + + def plan_task_collect_commands( db: Session, task_id: str, *, enabled_only: bool = True, - include_aux: bool = False, + include_aux: bool = True, ) -> dict[str, Any]: """Plan concrete collect CLIs for a task (no device login). - By default only each monitoring item's primary command is listed. - Aux CLIs (parser enrich helpers such as FIB/config under BGP summary) - are omitted unless include_aux=True — they are not separate UI items. + Includes each item's primary command plus aux enrich CLIs by default + (same as live collect). Pass include_aux=False for primary-only lists. """ task = db.get(BizStateTask, task_id) if not task: @@ -1131,6 +1156,8 @@ def plan_task_collect_commands( "profile_id": profile.profile_id, } ) + if include_aux: + _append_aux_commands(section, profile, params={}, add_cmd=_add_cmd) sections.append(section) continue @@ -1148,6 +1175,8 @@ def plan_task_collect_commands( "profile_id": profile.profile_id, } ) + if include_aux: + _append_aux_commands(section, profile, params={}, add_cmd=_add_cmd) sections.append(section) continue @@ -1168,24 +1197,13 @@ def plan_task_collect_commands( "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, - } - ) + if include_aux: + _append_aux_commands( + section, + hit.profile if hit else profile, + params=dict(params or {}), + add_cmd=_add_cmd, + ) # No concrete primary cmds → still show template as comment if not any(c.get("role") == "primary" for c in section["commands"]): @@ -1224,7 +1242,7 @@ def export_task_commands_text( task_id: str, *, enabled_only: bool = True, - include_aux: bool = False, + include_aux: bool = True, ) -> str: """Plain-text export of planned collect commands (one CLI per line + section headers).""" plan = plan_task_collect_commands( diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 75c81b4..7c96164 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -177,7 +177,7 @@ def api_get_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: def api_plan_task_commands( task_id: str, enabled_only: bool = Query(True), - include_aux: bool = Query(False), + 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).""" @@ -190,7 +190,7 @@ def api_plan_task_commands( def api_export_task_commands( task_id: str, enabled_only: bool = Query(True), - include_aux: bool = Query(False), + include_aux: bool = Query(True), db: Session = Depends(get_db), ) -> StreamingResponse: """Download planned collect commands as a .txt file.""" diff --git a/tests/test_biz_state_export_commands.py b/tests/test_biz_state_export_commands.py index 9abff51..5bcd94f 100644 --- a/tests/test_biz_state_export_commands.py +++ b/tests/test_biz_state_export_commands.py @@ -82,24 +82,23 @@ class BizStateExportCommandsTests(unittest.TestCase): cmds = plan["commands"] self.assertTrue(any("lldp" in c.lower() for c in cmds)) self.assertTrue(any("CUST_A" in c and "summary" in c for c in cmds)) - # Default export is primary-only (no FIB/config aux under BGP summary) + # Default export includes discover-backed aux (config_vrf), not FIB + self.assertTrue(any("running-config vrf" in c for c in cmds)) self.assertFalse(any("forwarding route" in c for c in cmds)) - self.assertFalse(any("running-config vrf" in c for c in cmds)) # Disabled item excluded by default self.assertFalse(any("isis" in c.lower() for c in cmds)) - with_aux = plan_task_collect_commands( - self.db, self.task.id, include_aux=True + primary_only = plan_task_collect_commands( + self.db, self.task.id, include_aux=False ) - # BGP VRF summary aux is only config_vrf (FIB is a separate monitor item) - self.assertTrue(any("running-config vrf" in c for c in with_aux["commands"])) - self.assertFalse(any("forwarding route" in c for c in with_aux["commands"])) + self.assertFalse(any("running-config vrf" in c for c in primary_only["commands"])) 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.assertNotIn("# aux:", text) + self.assertIn("# aux:", text) + self.assertIn("running-config vrf", text) self.assertIn("# ---- flat unique commands ----", text) def test_unbound_required_placeholder_noted(self) -> None: @@ -119,16 +118,23 @@ class BizStateExportCommandsTests(unittest.TestCase): sec = plan["items"][0] notes = " ".join(sec.get("notes") or []) self.assertIn("requires parameter bindings", notes) - self.assertEqual(plan["command_count"], 0) + # Primary is template-only; aux (config_vrf) still counts as executable + self.assertEqual(plan["command_count"], 1) + self.assertTrue(any("running-config vrf" in c for c in plan["commands"])) tmpl_cmds = [c for c in sec.get("commands") or [] if c.get("role") == "template"] self.assertEqual(len(tmpl_cmds), 1) self.assertIn("", tmpl_cmds[0]["command"]) + aux_cmds = [c for c in sec.get("commands") or [] if c.get("role") == "aux"] + self.assertEqual(len(aux_cmds), 1) text = export_task_commands_text(self.db, self.task.id) self.assertIn("# show bgp vpnv4 unicast vrf summary", text) + self.assertIn("# aux:", text) + self.assertIn("running-config vrf", text) # Template must not appear in the flat executable list flat = text.split("# ---- flat unique commands ----", 1)[-1] self.assertNotIn("", flat) + self.assertIn("running-config vrf", flat) if __name__ == "__main__": diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 6dcebfa..615be08 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1931,7 +1931,8 @@ export const bizStateDownloadTaskCommands = async ( opts?: { includeAux?: boolean; enabledOnly?: boolean }, ): Promise => { const q = new URLSearchParams(); - if (opts?.includeAux === false) q.set("include_aux", "false"); + // Default includes aux (matches live collect); pass includeAux:false to omit. + q.set("include_aux", opts?.includeAux === false ? "false" : "true"); if (opts?.enabledOnly === false) q.set("enabled_only", "false"); const qs = q.toString(); const path =