From 089c88d35e634fdc435da63e13a1faef063c0755 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 22 Sep 2026 11:39:44 +0800 Subject: [PATCH] Show aux commands in biz-state UI; keep only config_vrf under BGP VRF summary. Add an Aux column for each monitor item. Drop ip_route/ipv6_route as summary enrich aux so FIB stays a standalone collect profile. Export defaults to primary commands only. Co-authored-by: Cursor --- netx_api/biz_state/profiles.py | 25 +++++++------ netx_api/biz_state/service.py | 9 ++--- netx_api/biz_state_router.py | 4 +-- tests/test_biz_state_export_commands.py | 15 ++++++-- tests/test_zte_extended_parsers.py | 10 +++--- web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/index.css | 47 ++++++++++++++++++++++--- web/src/pages/network/BizStatePage.tsx | 33 ++++++++++++++++- 9 files changed, 116 insertions(+), 29 deletions(-) diff --git a/netx_api/biz_state/profiles.py b/netx_api/biz_state/profiles.py index 08580d0..3482788 100644 --- a/netx_api/biz_state/profiles.py +++ b/netx_api/biz_state/profiles.py @@ -807,7 +807,7 @@ def _zte_status_profiles() -> list[ParseProfile]: textfsm_command="show bgp vpnv4 unicast summary", description=( "Per-VRF BGP VPNv4 peer summary. Bind one or more VRFs; " - "aux: IPv4 FIB + config_vrf." + "aux: config_vrf (RD/AF enrich). IPv4 FIB is a separate monitor item." ), placeholders=[_VRF_PLACEHOLDER_IPV4], fields=list(_BGP_PEER_FIELDS), @@ -817,7 +817,6 @@ def _zte_status_profiles() -> list[ParseProfile]: kind="collect", aux_commands=[ AuxCommand(key="config_vrf", profile_id="zte.config_vrf"), - AuxCommand(key="ip_route", profile_id="zte.ip_route_vrf"), ], enrich_joins=[ EnrichJoin( @@ -839,7 +838,7 @@ def _zte_status_profiles() -> list[ParseProfile]: textfsm_command="show bgp vpnv6 unicast summary", description=( "Per-VRF BGP VPNv6 peer summary. Bind one or more VRFs; " - "aux: IPv6 FIB + config_vrf." + "aux: config_vrf (RD/AF enrich). IPv6 FIB is a separate monitor item." ), placeholders=[_VRF_PLACEHOLDER_IPV6], fields=list(_BGP_PEER_FIELDS), @@ -849,7 +848,6 @@ def _zte_status_profiles() -> list[ParseProfile]: kind="collect", aux_commands=[ AuxCommand(key="config_vrf", profile_id="zte.config_vrf"), - AuxCommand(key="ipv6_route", profile_id="zte.ipv6_route_vrf"), ], enrich_joins=[ EnrichJoin( @@ -1548,6 +1546,17 @@ def metric_field_map() -> dict[str, list[FieldDef]]: def profile_to_public_dict(p: ParseProfile, *, overrides: dict[str, Any] | None = None) -> dict[str, Any]: ov = overrides or {} + aux_out: list[dict[str, Any]] = [] + for a in p.aux_commands or []: + ap = get_profile(a.profile_id) + aux_out.append( + { + "key": a.key, + "profile_id": a.profile_id, + "title": (ap.title if ap else "") or a.profile_id, + "command_template": (ap.command_template if ap else "") or "", + } + ) return { "profile_id": p.profile_id, "vendor_key": p.vendor_key, @@ -1591,13 +1600,7 @@ def profile_to_public_dict(p: ParseProfile, *, overrides: dict[str, Any] | None "kind": p.kind, "match": p.match, "textfsm_command": p.textfsm_command or p.command_template, - "aux_commands": [ - { - "key": a.key, - "profile_id": a.profile_id, - } - for a in (p.aux_commands or []) - ], + "aux_commands": aux_out, "enrich_joins": [ { "from_aux": j.from_aux, diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index 6367591..6de4918 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -1034,12 +1034,13 @@ def plan_task_collect_commands( task_id: str, *, enabled_only: bool = True, - include_aux: bool = True, + include_aux: bool = False, ) -> 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. + 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. """ task = db.get(BizStateTask, task_id) if not task: @@ -1223,7 +1224,7 @@ def export_task_commands_text( task_id: str, *, enabled_only: bool = True, - include_aux: bool = True, + include_aux: bool = False, ) -> 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 7c96164..75c81b4 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(True), + include_aux: bool = Query(False), 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(True), + include_aux: bool = Query(False), 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 2e9d475..9abff51 100644 --- a/tests/test_biz_state_export_commands.py +++ b/tests/test_biz_state_export_commands.py @@ -76,19 +76,30 @@ class BizStateExportCommandsTests(unittest.TestCase): ) self.db.commit() - plan = plan_task_collect_commands(self.db, self.task.id, include_aux=True) + plan = plan_task_collect_commands(self.db, self.task.id) 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)) + 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) + 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 + ) + # 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"])) + 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("# ---- flat unique commands ----", text) def test_unbound_required_placeholder_noted(self) -> None: diff --git a/tests/test_zte_extended_parsers.py b/tests/test_zte_extended_parsers.py index c30385c..16284bf 100644 --- a/tests/test_zte_extended_parsers.py +++ b/tests/test_zte_extended_parsers.py @@ -578,20 +578,20 @@ class ZteExtendedParserTests(unittest.TestCase): v4 = get_profile("zte.bgp_vpnv4_vrf_summary") assert v4 is not None - self.assertTrue(any(a.key == "ip_route" for a in v4.aux_commands)) + self.assertEqual([a.key for a in v4.aux_commands], ["config_vrf"]) with self.assertRaises(ValueError): expand_from_bindings(profile=v4, bindings=[]) bound = expand_from_bindings(profile=v4, bindings=[{"vrf": "CUST_A"}]) self.assertEqual(bound[0][0], "show bgp vpnv4 unicast vrf CUST_A summary | one-line") - ra_ip = resolve_aux_command( - next(a for a in v4.aux_commands if a.key == "ip_route"), + ra_cfg = resolve_aux_command( + next(a for a in v4.aux_commands if a.key == "config_vrf"), params={"vrf": "CUST_A"}, ) - self.assertEqual(ra_ip.command, "show ip forwarding route vrf CUST_A | one-line") + self.assertEqual(ra_cfg.command, "show running-config vrf | one-line") v6 = get_profile("zte.bgp_vpnv6_vrf_summary") assert v6 is not None - self.assertTrue(any(a.key == "ipv6_route" for a in v6.aux_commands)) + self.assertEqual([a.key for a in v6.aux_commands], ["config_vrf"]) records = [ {"vrf_name": "CUST_A", "address_families": "ipv4,ipv6", "rd": "100:1"}, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index be2876f..bbc31aa 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -237,6 +237,7 @@ const en = { profiles: "Monitor items", enable: "On", command: "Command template", + colAux: "Aux commands", params: "Bindings", discoverVrf: "Discover VRFs", discoverBind: "Discover / Bind", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b7d4fbd..c3c5544 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -237,6 +237,7 @@ const zh = { profiles: "监控项", enable: "启用", command: "命令模板", + colAux: "辅命令", params: "参数关联", discoverVrf: "发现 VRF", discoverBind: "发现 / 关联", diff --git a/web/src/index.css b/web/src/index.css index 6661feb..3b63d36 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -11331,7 +11331,7 @@ html.login-page--paused .login-page__flare { .bs-profiles-table { width: 100%; - min-width: 960px; + min-width: 1180px; table-layout: fixed; border-collapse: separate; border-spacing: 0; @@ -11352,21 +11352,26 @@ html.login-page--paused .login-page__flare { .bs-profiles-table th:nth-child(2), .bs-profiles-table td:nth-child(2) { - width: 220px; + width: 200px; } .bs-profiles-table th:nth-child(3), .bs-profiles-table td:nth-child(3) { - width: 240px; + width: 200px; } .bs-profiles-table th:nth-child(4), .bs-profiles-table td:nth-child(4) { - width: 108px; + width: 100px; } .bs-profiles-table th:nth-child(5), .bs-profiles-table td:nth-child(5) { + width: 280px; +} + +.bs-profiles-table th:nth-child(6), +.bs-profiles-table td:nth-child(6) { width: auto; } @@ -11379,6 +11384,40 @@ html.login-page--paused .login-page__flare { max-width: 100%; } +.bs-profiles-table .bs-aux-cell { + vertical-align: top; + font-size: 12px; +} + +.bs-profiles-table .bs-aux-list { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.bs-profiles-table .bs-aux-list li { + min-width: 0; +} + +.bs-profiles-table .bs-aux-key { + font-weight: 600; + color: #cbd5e1; +} + +.bs-profiles-table .bs-aux-cmd { + display: block; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11px; + color: #94a3b8; + max-width: 100%; +} + .bs-profiles-table .bs-params-cell { vertical-align: top; } diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/pages/network/BizStatePage.tsx index 3d63091..f3be2bb 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/pages/network/BizStatePage.tsx @@ -68,6 +68,12 @@ type Profile = { metric_id: string; kind?: string; placeholders?: Placeholder[]; + aux_commands?: Array<{ + key: string; + profile_id: string; + title?: string; + command_template?: string; + }>; }; type BatchRow = { @@ -1429,6 +1435,7 @@ export function BizStatePage() { {t("bizState.params")} {t("bizState.colActions")} {t("bizState.command")} + {t("bizState.colAux")} @@ -1443,6 +1450,7 @@ export function BizStatePage() { }[]; const needsBind = (prof.placeholders || []).length > 0; const phNames = (prof.placeholders || []).map((p) => p.name); + const auxList = prof.aux_commands || []; let bindLines: string[] = []; if (needsBind && binds.length) { if (phNames.length >= 2) { @@ -1533,7 +1541,30 @@ export function BizStatePage() { )} - {prof.command_template} + + {prof.command_template} + + + + {auxList.length ? ( + + ) : ( + — + )} );