From 393fd0e0b4b85443c22c09fbe168baf834e67774 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 2 Aug 2026 23:06:40 +0800 Subject: [PATCH] Switch CLI parsing to TextFSM and polish topology/workbench UX. Use custom cli_templates plus community ntc-templates for LLDP and port traffic across ZTE/Huawei/Cisco/H3C/Juniper/Nokia, drop regex parsers, and surface Admin/Phy/Prot on port discover. Also batch-add topology NEs and tighten workbench branding. Co-authored-by: Cursor --- netx_api/cli_templates/README.md | 57 ++ netx_api/cli_templates/cisco/.gitkeep | 0 .../cisco/cisco_ios_show_interfaces.textfsm | 19 + netx_api/cli_templates/h3c/.gitkeep | 0 netx_api/cli_templates/huawei/.gitkeep | 0 .../huawei_vrp_display_interface.textfsm | 20 + ...huawei_vrp_display_interface_brief.textfsm | 14 + .../huawei_vrp_display_lldp_neighbor.textfsm | 23 + netx_api/cli_templates/index | 25 + netx_api/cli_templates/juniper/.gitkeep | 0 .../zte/zte_zxros_show_interface.textfsm | 30 + .../zte_zxros_show_interface_brief.textfsm | 21 + ...zte_zxros_show_lldp_neighbor_brief.textfsm | 15 + netx_api/config_sync_commands.py | 2 + netx_api/device_types.py | 1 + netx_api/ntc_parse.py | 176 +++++ netx_api/port_traffic_commands.py | 35 +- netx_api/port_traffic_devices.py | 7 +- netx_api/port_traffic_parsers.py | 690 +++++++----------- netx_api/port_traffic_runner.py | 7 +- netx_api/topology_lldp.py | 499 ++++--------- requirements.txt | 4 + tests/test_ntc_parse.py | 146 ++++ tests/test_port_traffic_parsers.py | 27 +- tests/test_topology.py | 27 +- web/WEB.md | 2 +- web/index.html | 2 +- web/public/favicon.svg | 17 +- web/src/components/HeaderMenu.tsx | 73 +- web/src/components/WorkbenchCardIcon.tsx | 52 +- web/src/config/modules.ts | 20 + web/src/i18n/en.ts | 22 +- web/src/i18n/zh.ts | 22 +- web/src/index.css | 259 +++++-- web/src/layout/AppLayout.tsx | 23 +- web/src/pages/ForceChangePasswordPage.tsx | 2 +- web/src/pages/LoginPage.tsx | 2 +- web/src/pages/TopologyPage.tsx | 337 ++++++--- web/src/pages/WorkbenchPage.tsx | 68 +- web/src/pages/network/PortTrafficPage.tsx | 98 ++- web/src/pages/network/PortTrafficWallPage.tsx | 2 +- 41 files changed, 1771 insertions(+), 1075 deletions(-) create mode 100644 netx_api/cli_templates/README.md create mode 100644 netx_api/cli_templates/cisco/.gitkeep create mode 100644 netx_api/cli_templates/cisco/cisco_ios_show_interfaces.textfsm create mode 100644 netx_api/cli_templates/h3c/.gitkeep create mode 100644 netx_api/cli_templates/huawei/.gitkeep create mode 100644 netx_api/cli_templates/huawei/huawei_vrp_display_interface.textfsm create mode 100644 netx_api/cli_templates/huawei/huawei_vrp_display_interface_brief.textfsm create mode 100644 netx_api/cli_templates/huawei/huawei_vrp_display_lldp_neighbor.textfsm create mode 100644 netx_api/cli_templates/index create mode 100644 netx_api/cli_templates/juniper/.gitkeep create mode 100644 netx_api/cli_templates/zte/zte_zxros_show_interface.textfsm create mode 100644 netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm create mode 100644 netx_api/cli_templates/zte/zte_zxros_show_lldp_neighbor_brief.textfsm create mode 100644 netx_api/ntc_parse.py create mode 100644 tests/test_ntc_parse.py diff --git a/netx_api/cli_templates/README.md b/netx_api/cli_templates/README.md new file mode 100644 index 0000000..a34f9dc --- /dev/null +++ b/netx_api/cli_templates/README.md @@ -0,0 +1,57 @@ +# NetX CLI TextFSM templates + +Custom TextFSM templates that complement (and can **override**) community `ntc-templates`. +Organize by **vendor folder**; prefer adding templates here for every new CLI parse path. + +## Layout + +```text +cli_templates/ + index # global index (paths relative to this dir) + README.md + zte/ # ZTE ZXROS / ROSNG, ... + cisco/ # overrides / extras beyond community cisco_* + huawei/ + h3c/ + juniper/ +``` + +## Lookup order + +1. **This tree** (`index` → `vendor/*.textfsm`) +2. Community `ntc-templates` package + +No regex CLI parsers. If both miss, the call returns empty — fix or add a template. + +## Vendor coverage (blind fill from community + NetX) + +| NetX vendor_key | ntc platform | LLDP command | Port brief | Port detail | Notes | +|-----------------|--------------|--------------|------------|-------------|-------| +| `zte` | `zte_zxros` | `show lldp neighbor brief` | `show interface brief` | `show interface {if}` | NetX custom (community has iface only) | +| `huawei` | `huawei_vrp` | `display lldp neighbor` | `display interface brief` | `display interface {if}` | NetX custom (community prompt-fragile) | +| `cisco` | `cisco_ios` / `nxos` / `xr` | `show lldp neighbors detail` | `show ip interface brief` | `show interfaces {if}` | Community + NetX iface override | +| `h3c` | `hp_comware` | `display lldp neighbor-information list` | `display interface brief` | `display interface {if}` | Community | +| `juniper` | `juniper_junos` | `show lldp neighbors` | `show interfaces` | `show interfaces {if}` | Community (rates often sparse) | +| `nokia` | `alcatel_sros` | *(stub / no SROS LLDP tpl)* | `show port` | `show port {if}` | Community port status; AOS LLDP via `alcatel_aos` | +| `mikrotik` | `mikrotik_routeros` | — | `/interface print brief` | `/interface print detail …` | Community; no LLDP path | +| `ericsson` | `ericsson_ipos` | stub | — | — | No community LLDP/iface templates | + +`alcatel_aos` device_type uses community `show lldp remote-system` (mapped to platform `alcatel_aos`). + +## Adding a template + +1. Put the file under the vendor folder, e.g. `zte/zte_zxros_show_xxx.textfsm` +2. Name: `{platform}_{command_slug}.textfsm` (same convention as ntc-templates) +3. Register in root `index` with a **relative path**: + `zte/zte_zxros_show_xxx.textfsm, .*, zte_zxros, sh[[ow]] ...` +4. Align Value names with community templates when possible +5. Add unit tests under `tests/` + +When community already works, no file is required. +When community is wrong/incomplete for our lab, add an override under the vendor folder +with the same Platform+Command so NetX wins. + +## Concurrency + +Template files are read-only and safe across processes. Parsing goes through +`netx_api.ntc_parse` (fresh `CliTable` + process lock; never share instances across threads). diff --git a/netx_api/cli_templates/cisco/.gitkeep b/netx_api/cli_templates/cisco/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/netx_api/cli_templates/cisco/cisco_ios_show_interfaces.textfsm b/netx_api/cli_templates/cisco/cisco_ios_show_interfaces.textfsm new file mode 100644 index 0000000..2477819 --- /dev/null +++ b/netx_api/cli_templates/cisco/cisco_ios_show_interfaces.textfsm @@ -0,0 +1,19 @@ +Value Required INTERFACE (\S+) +Value LINK_STATUS (.+?) +Value PROTOCOL_STATUS (.+?) +Value DESCRIPTION (.+?) +Value BANDWIDTH (\d+\s+\w+) +Value RATE_PERIOD (\d+) +Value RATE_PERIOD_UNIT (second|minute|hour) +Value INPUT_RATE (\d+) +Value OUTPUT_RATE (\d+) + +Start + ^${INTERFACE}\s+is\s+${LINK_STATUS},\s*line\s+protocol\s+is\s+${PROTOCOL_STATUS}\s*$$ + ^\s+Description:\s+${DESCRIPTION}\s*$$ + ^\s+MTU\s+\d+\s+bytes,\s+BW\s+${BANDWIDTH}(?:/s(?:ec)?)?, + ^\s+${RATE_PERIOD}\s+${RATE_PERIOD_UNIT}s?\s+input\s+rate\s+${INPUT_RATE}\s+bits/sec + ^\s+\d+\s+(?:second|minute|hour)s?\s+output\s+rate\s+${OUTPUT_RATE}\s+bits/sec -> Record + ^\s*$$ + ^\S+#\s*$$ -> End + ^.* -> Start diff --git a/netx_api/cli_templates/h3c/.gitkeep b/netx_api/cli_templates/h3c/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/netx_api/cli_templates/huawei/.gitkeep b/netx_api/cli_templates/huawei/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/netx_api/cli_templates/huawei/huawei_vrp_display_interface.textfsm b/netx_api/cli_templates/huawei/huawei_vrp_display_interface.textfsm new file mode 100644 index 0000000..084d534 --- /dev/null +++ b/netx_api/cli_templates/huawei/huawei_vrp_display_interface.textfsm @@ -0,0 +1,20 @@ +Value Filldown INTERFACE (\S+) +Value Filldown ADMIN_OPER (UP|DOWN|Administratively\s+DOWN) +Value Filldown DESCRIPTION (.*) +Value Filldown RATE_PERIOD (\d+) +Value Filldown INPUT_BPS ([\d.]+) +Value Filldown OUTPUT_BPS ([\d.]+) +Value Filldown IN_UTIL ([\d.]+) +Value OUT_UTIL ([\d.]+) + +Start + ^${INTERFACE}\s+current\s+state\s*:\s*${ADMIN_OPER} + ^Description:\s*${DESCRIPTION}\s*$$ + ^\s*Last\s+${RATE_PERIOD}\s+seconds\s+input\s+rate:\s*${INPUT_BPS}\s*bits/sec + ^\s*Last\s+\d+\s+seconds\s+output\s+rate:\s*${OUTPUT_BPS}\s*bits/sec + ^\s*Last\s+\d+\s+seconds\s+input\s+utility\s+rate:\s*${IN_UTIL}\s*% + ^\s*Last\s+\d+\s+seconds\s+output\s+utility\s+rate:\s*${OUT_UTIL}\s*% -> Record + ^\s*$$ + ^\s*<.*>\s*$$ + ^.*#\s*$$ + ^.* -> Start diff --git a/netx_api/cli_templates/huawei/huawei_vrp_display_interface_brief.textfsm b/netx_api/cli_templates/huawei/huawei_vrp_display_interface_brief.textfsm new file mode 100644 index 0000000..291f4a0 --- /dev/null +++ b/netx_api/cli_templates/huawei/huawei_vrp_display_interface_brief.textfsm @@ -0,0 +1,14 @@ +Value INTERFACE (\S+) +Value PHY (down|[\*\^]down|up|up\(\w+\)) +Value PROTOCOL (down|[\*\^]down|up|up\(\w+\)) +Value INUTI (\d*\.?\d*%|\-\-) +Value OUTUTI (\d*\.?\d*%|\-\-) +Value INERRORS (\d+) +Value OUTERRORS (\d+) + +Start + ^\s*${INTERFACE}\s+${PHY}\s+${PROTOCOL}\s+${INUTI}\s+${OUTUTI}\s+${INERRORS}\s+${OUTERRORS}\s*$$ -> Record + ^\s*$$ + ^\s*<.*>\s*$$ + ^.*#\s*$$ + ^.* -> Start diff --git a/netx_api/cli_templates/huawei/huawei_vrp_display_lldp_neighbor.textfsm b/netx_api/cli_templates/huawei/huawei_vrp_display_lldp_neighbor.textfsm new file mode 100644 index 0000000..dc135e2 --- /dev/null +++ b/netx_api/cli_templates/huawei/huawei_vrp_display_lldp_neighbor.textfsm @@ -0,0 +1,23 @@ +Value Filldown LOCAL_INTERFACE (\S+) +Value CHASSIS_ID (\S+) +Value NEIGHBOR_PORT_ID (\S+) +Value NEIGHBOR_NAME (\S+) +Value MGMT_ADDRESS (\S+) + +Start + ^${LOCAL_INTERFACE}\s+has\s+\d+\s+neighbor -> Neighbor + ^\s*$$ + ^.* -> Start + +Neighbor + ^Chassis\s+ID\s*:\s*${CHASSIS_ID} + ^Port\s+ID\s*:\s*${NEIGHBOR_PORT_ID} + ^System\s+name\s*:\s*${NEIGHBOR_NAME} + ^Management\s+address\s*:\s*${MGMT_ADDRESS} -> Record + ^Local\s+Interface\s*:\s*${LOCAL_INTERFACE} + ^Neighbor\s+index\s*: + ^${LOCAL_INTERFACE}\s+has\s+\d+\s+neighbor -> Neighbor + ^\s*$$ + ^\s*<.*>\s*$$ + ^.*#\s*$$ + ^.* -> Neighbor diff --git a/netx_api/cli_templates/index b/netx_api/cli_templates/index new file mode 100644 index 0000000..1006415 --- /dev/null +++ b/netx_api/cli_templates/index @@ -0,0 +1,25 @@ +# NetX custom TextFSM index (checked before community ntc-templates). +# Templates live under vendor folders; paths are relative to this directory. +Template, Hostname, Platform, Command + +# --- zte --- +zte/zte_zxros_show_lldp_neighbor_brief.textfsm, .*, zte_zxros, sh[[ow]] lld[[p]] nei[[ghbor]] br[[ief]] +zte/zte_zxros_show_interface_brief.textfsm, .*, zte_zxros, sh[[ow]] int[[erface]] br[[ief]] +zte/zte_zxros_show_interface.textfsm, .*, zte_zxros, sh[[ow]] int[[erface]] .+ + +# --- cisco --- +cisco/cisco_ios_show_interfaces.textfsm, .*, cisco_ios, sh[[ow]] int[[erfaces]] .+ + +# --- huawei --- +huawei/huawei_vrp_display_lldp_neighbor.textfsm, .*, huawei_vrp, dis[[play]] lldp nei[[ghbor]] +huawei/huawei_vrp_display_interface_brief.textfsm, .*, huawei_vrp, dis[[play]] inter[[face]] br[[ief]] +huawei/huawei_vrp_display_interface.textfsm, .*, huawei_vrp, dis[[play]] inter[[face]]\s*((?!brief|counters|description).)*$ + +# --- h3c (community hp_comware covers list/brief/detail; override here if lab differs) --- +# h3c/hp_comware_display_lldp_neighbor-information_list.textfsm, .*, hp_comware, ... + +# --- juniper (community juniper_junos covers lldp + show interfaces) --- +# juniper/juniper_junos_....textfsm, .*, juniper_junos, ... + +# --- nokia / alcatel (community alcatel_sros show port; aos lldp in community) --- +# (add overrides when SROS LLDP / rates are captured from lab) diff --git a/netx_api/cli_templates/juniper/.gitkeep b/netx_api/cli_templates/juniper/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/netx_api/cli_templates/zte/zte_zxros_show_interface.textfsm b/netx_api/cli_templates/zte/zte_zxros_show_interface.textfsm new file mode 100644 index 0000000..bdd5b87 --- /dev/null +++ b/netx_api/cli_templates/zte/zte_zxros_show_interface.textfsm @@ -0,0 +1,30 @@ +Value Filldown IFNAME (\S+) +Value Filldown ADMIN_OPER (up|down) +Value Filldown DESCRIPTION (.+?) +Value Filldown BW_RAW (\d+(?:\.\d+)?\s*[kKmMgGtT]?\s*(?:G?bit|bit)/s(?:ec)?) +Value Filldown RATE_PERIOD (\d+) +Value Filldown INPUT_BPS ([\d.]+) +Value Filldown OUTPUT_BPS ([\d.]+) +Value IN_UTIL ([\d.]+) +Value OUT_UTIL ([\d.]+) + +Start + ^${IFNAME}\s+is\s+${ADMIN_OPER}\b + ^\s*Description:\s*${DESCRIPTION}\s*$$ + ^\s*BW\s+${BW_RAW} + ^\s*Rate\s+period\s*:\s*${RATE_PERIOD}\s*s -> Rates + ^\s*$$ + ^.* -> Start + +Rates + ^\s*Input\s*:\s*${INPUT_BPS}\s*bit/s + ^\s*Output\s*:\s*${OUTPUT_BPS}\s*bit/s + ^\s*Peak\s+rate: -> AfterPeak + ^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record + ^\s*$$ + ^.* -> Rates + +AfterPeak + ^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record + ^\s*$$ + ^.* -> AfterPeak diff --git a/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm b/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm new file mode 100644 index 0000000..c3f1b71 --- /dev/null +++ b/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm @@ -0,0 +1,21 @@ +Value INTERFACE (\S+) +Value ATTRIBUTE (\S+) +Value MODE (\S+) +Value BW (\S+) +Value ADMIN (up|down) +Value PHY (up|down) +Value PROT (up|down) +Value DESCRIPTION (.*) + +Start + ^Interface\s+Attribute\s+Mode -> Ports + ^\s*$$ + ^.* -> Start + +Ports + ^${INTERFACE}\s+${ATTRIBUTE}\s+${MODE}\s+${BW}\s+${ADMIN}\s+${PHY}\s+${PROT}(?:\s+${DESCRIPTION})?\s*$$ -> Record + ^${INTERFACE}\s+${ATTRIBUTE}\s+${MODE}\s+${ADMIN}\s+${PHY}\s+${PROT}(?:\s+${DESCRIPTION})?\s*$$ -> Record + ^\s*$$ + ^\S+\s*#\s*$$ -> End + ^.*#\s*$$ -> End + ^.* -> Ports diff --git a/netx_api/cli_templates/zte/zte_zxros_show_lldp_neighbor_brief.textfsm b/netx_api/cli_templates/zte/zte_zxros_show_lldp_neighbor_brief.textfsm new file mode 100644 index 0000000..cc72c42 --- /dev/null +++ b/netx_api/cli_templates/zte/zte_zxros_show_lldp_neighbor_brief.textfsm @@ -0,0 +1,15 @@ +Value LOCAL_INTERFACE (\S+) +Value CHASSIS_ID (\S+) +Value NEIGHBOR_PORT_ID (\S+) +Value NEIGHBOR_NAME (\S+.*) + +Start + ^Local\s+Interface\s+Chassis\s+ID -> Neighbors + ^\s*$$ + ^.* -> Start + +Neighbors + ^${LOCAL_INTERFACE}\s+${CHASSIS_ID}\s+${NEIGHBOR_PORT_ID}\s+${NEIGHBOR_NAME}\s*$$ -> Record + ^\s*$$ + ^Local\s+Interface -> Neighbors + ^.* -> Start diff --git a/netx_api/config_sync_commands.py b/netx_api/config_sync_commands.py index 8e9cfa4..740d8b0 100644 --- a/netx_api/config_sync_commands.py +++ b/netx_api/config_sync_commands.py @@ -28,6 +28,8 @@ def normalize_vendor_key(vendor: str, device_type: str = "") -> str: return "h3c" if "zte" in blob or "zxros" in blob: return "zte" + if "mikrotik" in blob or "routeros" in blob or "switchos" in blob: + return "mikrotik" if "cisco" in blob or "ios" in blob or "nx-os" in blob or "xr" in blob: return "cisco" return "other" diff --git a/netx_api/device_types.py b/netx_api/device_types.py index d6d87b4..ddade15 100644 --- a/netx_api/device_types.py +++ b/netx_api/device_types.py @@ -39,6 +39,7 @@ SUPPORTED_VENDORS: tuple[str, ...] = ( "ZTE", "Huawei", "Cisco", + "H3C", "Juniper", "Nokia", "Other", diff --git a/netx_api/ntc_parse.py b/netx_api/ntc_parse.py new file mode 100644 index 0000000..b1001cd --- /dev/null +++ b/netx_api/ntc_parse.py @@ -0,0 +1,176 @@ +"""Thread-safe CLI parse: NetX cli_templates -> community ntc-templates. + +CliTable/ParseCmd is not safe to share across threads. Each call builds a fresh +CliTable under a process-wide lock. Template files on disk are read-only and +safe for multi-process workers. +""" + +from __future__ import annotations + +import logging +import threading +from functools import lru_cache +from pathlib import Path +from typing import Any, Callable, TypeVar + +_log = logging.getLogger("netx.ntc_parse") + +_PARSE_LOCK = threading.Lock() + +CUSTOM_TEMPLATE_DIR = Path(__file__).resolve().parent / "cli_templates" + +T = TypeVar("T") + + +@lru_cache(maxsize=1) +def _community_template_dir() -> str: + import ntc_templates + + return str(Path(ntc_templates.__file__).resolve().parent / "templates") + + +def resolve_cli_platform(*, vendor: str = "", device_type: str = "", vendor_key: str = "") -> str: + """Map NetX inventory fields to an ntc / NetX TextFSM platform id.""" + dt = str(device_type or "").strip().lower() + key = str(vendor_key or "").strip().lower() + vend = str(vendor or "").strip().lower() + + if dt.startswith("cisco_nxos") or dt == "cisco_nxos": + return "cisco_nxos" + if dt.startswith("cisco_xr") or dt == "cisco_xr": + return "cisco_xr" + if dt.startswith("cisco_"): + return "cisco_ios" + if dt.startswith("huawei"): + return "huawei_vrp" + if dt.startswith("hp_comware") or dt.startswith("h3c_"): + return "hp_comware" + if dt.startswith("juniper"): + return "juniper_junos" + if dt.startswith("zte_"): + return "zte_zxros" + if dt.startswith("alcatel_aos"): + return "alcatel_aos" + if dt.startswith("alcatel_sros") or dt.startswith("nokia_sros"): + return "alcatel_sros" + if dt.startswith("nokia_"): + # SRL etc.: best-effort SROS templates until dedicated ones exist. + return "alcatel_sros" + if dt.startswith("alcatel"): + return "alcatel_sros" + if dt.startswith("ericsson_"): + return "ericsson_ipos" + if dt.startswith("mikrotik"): + return "mikrotik_routeros" + + blob = key or vend + if "nxos" in blob: + return "cisco_nxos" + if "cisco" in blob: + return "cisco_ios" + if "huawei" in blob: + return "huawei_vrp" + if "h3c" in blob or "comware" in blob: + return "hp_comware" + if "juniper" in blob or "junos" in blob: + return "juniper_junos" + if "zte" in blob: + return "zte_zxros" + if "aos" in blob and "alcatel" in blob: + return "alcatel_aos" + if "nokia" in blob or "alcatel" in blob or "sros" in blob: + return "alcatel_sros" + if "ericsson" in blob: + return "ericsson_ipos" + if "mikrotik" in blob or "routeros" in blob: + return "mikrotik_routeros" + return "" + + +def row_get(row: dict[str, Any], *names: str) -> str: + if not row: + return "" + lower_map = {str(k).lower(): v for k, v in row.items()} + for name in names: + val = lower_map.get(name.lower()) + if val is None: + continue + text = str(val).strip() + if text: + return text + return "" + + +def parse_cli( + *, + platform: str, + command: str, + text: str, +) -> list[dict[str, Any]]: + """Parse CLI text: custom templates first, then community ntc-templates.""" + plat = str(platform or "").strip() + cmd = str(command or "").strip() + raw = str(text or "") + if not plat or not cmd or not raw.strip(): + return [] + + with _PARSE_LOCK: + rows = _parse_dir(CUSTOM_TEMPLATE_DIR, plat, cmd, raw) + if rows: + return rows + try: + community = Path(_community_template_dir()) + except Exception: + _log.debug("community ntc template dir unavailable", exc_info=True) + return [] + return _parse_dir(community, plat, cmd, raw) + + +def _parse_dir(template_dir: Path, platform: str, command: str, text: str) -> list[dict[str, Any]]: + index_path = template_dir / "index" + if not index_path.is_file(): + return [] + try: + from ntc_templates.parse import ParsingException, parse_output + except Exception: + _log.debug("ntc_templates unavailable", exc_info=True) + return [] + + try: + # Fresh CliTable inside parse_output each call; we still hold _PARSE_LOCK. + rows = parse_output( + platform=platform, + command=command, + data=text, + template_dir=str(template_dir), + try_fallback=False, + ) + except ParsingException: + return [] + except Exception: + _log.debug( + "cli parse failed platform=%s command=%s dir=%s", + platform, + command, + template_dir, + exc_info=True, + ) + return [] + + if not isinstance(rows, list): + return [] + return [r for r in rows if isinstance(r, dict)] + + +def parse_cli_mapped( + *, + platform: str, + command: str, + text: str, + map_rows: Callable[[list[dict[str, Any]]], list[T]], +) -> list[T]: + """Parse CLI and map rows; empty list if no TextFSM match.""" + rows = parse_cli(platform=platform, command=command, text=text) + if not rows: + return [] + return map_rows(rows) diff --git a/netx_api/port_traffic_commands.py b/netx_api/port_traffic_commands.py index b747367..f83dc0f 100644 --- a/netx_api/port_traffic_commands.py +++ b/netx_api/port_traffic_commands.py @@ -1,4 +1,4 @@ -"""Vendor → port traffic CLI command matrix (ZTE / Huawei / Cisco).""" +"""Vendor → port traffic CLI command matrix (TextFSM-backed vendors).""" from __future__ import annotations @@ -12,27 +12,60 @@ class PortTrafficCommands: brief: str detail_template: str # format with ifname= vendor_key: str = "other" + notes: str = "" def commands_for_vendor(vendor: str, device_type: str = "") -> PortTrafficCommands | None: + """Return brief/detail commands when a TextFSM path exists (custom or community).""" key = normalize_vendor_key(vendor, device_type) if key == "zte": return PortTrafficCommands( brief="show interface brief", detail_template="show interface {ifname}", vendor_key=key, + notes="NetX custom TextFSM (+ community zte_zxros).", ) if key == "huawei": return PortTrafficCommands( brief="display interface brief", detail_template="display interface {ifname}", vendor_key=key, + notes="NetX custom TextFSM (community prompt-sensitive).", ) if key == "cisco": return PortTrafficCommands( brief="show ip interface brief", detail_template="show interfaces {ifname}", vendor_key=key, + notes="Community ntc (ios/nxos/xr via device_type platform).", + ) + if key == "h3c": + return PortTrafficCommands( + brief="display interface brief", + detail_template="display interface {ifname}", + vendor_key=key, + notes="Community hp_comware TextFSM.", + ) + if key == "juniper": + return PortTrafficCommands( + brief="show interfaces", + detail_template="show interfaces {ifname}", + vendor_key=key, + notes="Community juniper_junos show interfaces (rates may be sparse).", + ) + if key == "nokia": + return PortTrafficCommands( + brief="show port", + detail_template="show port {ifname}", + vendor_key=key, + notes="Community alcatel_sros show port (status; rates usually absent).", + ) + if key == "mikrotik": + return PortTrafficCommands( + brief="/interface print brief", + detail_template="/interface print detail where name={ifname}", + vendor_key=key, + notes="Community mikrotik_routeros interface print*.", ) return None diff --git a/netx_api/port_traffic_devices.py b/netx_api/port_traffic_devices.py index 450a151..d2146d0 100644 --- a/netx_api/port_traffic_devices.py +++ b/netx_api/port_traffic_devices.py @@ -490,7 +490,12 @@ def discover_ports(db: Session, body: DiscoverPortsRequest) -> DiscoverPortsResp ports = [ DiscoverPortItem(**brief_port_to_dict(p)) - for p in parse_interface_brief(raw, cmds.vendor_key) + for p in parse_interface_brief( + raw, + cmds.vendor_key, + command=cmds.brief, + device_type=device_type, + ) ] return DiscoverPortsResponse( source=body.source, diff --git a/netx_api/port_traffic_parsers.py b/netx_api/port_traffic_parsers.py index 6e56546..82b71b8 100644 --- a/netx_api/port_traffic_parsers.py +++ b/netx_api/port_traffic_parsers.py @@ -1,4 +1,7 @@ -"""Parsers for ZTE / Huawei / Cisco interface brief & detail (rate bit/s).""" +"""Interface brief/detail parsers via TextFSM only (``ntc_parse``). + +No regex CLI parsers — add / fix templates under ``cli_templates/{vendor}/``. +""" from __future__ import annotations @@ -6,6 +9,8 @@ import re from dataclasses import dataclass from typing import Any +from .ntc_parse import parse_cli, resolve_cli_platform, row_get + _BW_UNIT = { "k": 1_000, @@ -15,79 +20,10 @@ _BW_UNIT = { } _RE_BW_COMPACT = re.compile(r"^(\d+(?:\.\d+)?)\s*([kKmMgGtT])(?:bit)?s?$", re.I) -# ZTE "BW 1 Gbit/s" and Cisco "BW 1000000 Kbit/sec" _RE_BW_DETAIL = re.compile( r"\bBW\s+(\d+(?:\.\d+)?)\s*([kKmMgGtT])?\s*(?:G?bit|bit)/s(?:ec)?\b", re.I, ) -_RE_RATE_PERIOD = re.compile(r"Rate\s+period\s*:\s*(\d+)\s*s", re.I) -_RE_INPUT_BPS = re.compile(r"^\s*Input\s*:\s*([\d.]+)\s*bit/s", re.I | re.M) -_RE_OUTPUT_BPS = re.compile(r"^\s*Output\s*:\s*([\d.]+)\s*bit/s", re.I | re.M) -_RE_UTIL = re.compile( - r"Intf\s+utilization\s*:\s*input\s*([\d.]+)%\s*output\s*([\d.]+)%", - re.I, -) -_RE_IF_UP = re.compile(r"^(\S+)\s+is\s+(up|down)\b", re.I | re.M) -_RE_DESC = re.compile(r"^\s*Description:\s*(.+?)\s*$", re.I | re.M) -_RE_PROMPT_LINE = re.compile(r"[#>]\s*$") -_RE_UPDOWN = re.compile(r"^(up|down)$", re.I) -_RE_UPDOWN_TOKEN = re.compile(r"^(up|down)\b", re.I) -# ZTE / Huawei / Cisco / common logical iface names. -_RE_IFNAME = re.compile( - r"^(?:" - r"xxvgei|xgei|cgei|gei|fei|qli|smartgroup|bvi|vlan|loopback|mgmt|" - r"null|pos|atm|tunnel|irb|pw|eth|ethernet|port-channel|bundle|" - r"gigabitethernet|xgigabitethernet|fastethernet|tengigabitethernet|" - r"hundredgige|fivegige|fortygige|serial|dialer|cellular|multilink|" - r"10ge|25ge|40ge|100ge|eth-trunk|vlanif|meth|loopback" - r")[\w./:-]*$", - re.I, -) -_RE_CISCO_BRIEF_ROW = re.compile( - r"^(\S+)\s+(\S+)\s+(YES|NO)\s+(\S+)\s+(.+?)\s+(up|down)\s*$", - re.I, -) -_RE_CISCO_IF_STATE = re.compile( - r"^(\S+)\s+is\s+(administratively\s+)?(up|down),\s*line\s+protocol\s+is\s+(up|down)\b", - re.I | re.M, -) -_RE_CISCO_RATE = re.compile( - r"(\d+)\s+(second|minute|hour)s?\s+(input|output)\s+rate\s+([\d.]+)\s*bits/sec", - re.I, -) -_RE_HW_BRIEF_ROW = re.compile( - r"^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)\s*$" -) -_RE_HW_STATE = re.compile( - r"^(\S+)\s+current\s+state\s*:\s*(UP|DOWN|Administratively\s+DOWN)\b", - re.I | re.M, -) -_RE_HW_IN_RATE = re.compile( - r"Last\s+(\d+)\s+seconds\s+input\s+rate\s*:\s*([\d.]+)\s*bits/sec", - re.I, -) -_RE_HW_OUT_RATE = re.compile( - r"Last\s+(\d+)\s+seconds\s+output\s+rate\s*:\s*([\d.]+)\s*bits/sec", - re.I, -) -_RE_HW_IN_UTIL = re.compile( - r"Last\s+(\d+)\s+seconds\s+input\s+utility\s+rate\s*:\s*([\d.]+)\s*%", - re.I, -) -_RE_HW_OUT_UTIL = re.compile( - r"Last\s+(\d+)\s+seconds\s+output\s+utility\s+rate\s*:\s*([\d.]+)\s*%", - re.I, -) - -# Fixed-width columns from ZTE `show interface brief` header. -_COL_IF = (0, 24) -_COL_ATTR = (24, 35) -_COL_MODE = (35, 48) -_COL_BW = (48, 54) -_COL_ADMIN = (54, 60) -_COL_PHY = (60, 66) -_COL_PROT = (66, 72) -_COL_DESC = (72, None) @dataclass(frozen=True) @@ -129,28 +65,24 @@ def resolve_util_pct(vendor_util: float, bps: float, bw_bps: int) -> float: def parse_bw_to_bps(raw: str) -> int: + """Normalize bandwidth field values from TextFSM (``1G``, ``BW 1 Gbit/s``, …).""" text = (raw or "").strip() if not text or text.upper() == "N/A": return 0 m = _RE_BW_COMPACT.match(text.replace(" ", "")) if m: - value = float(m.group(1)) - mult = _BW_UNIT[m.group(2).lower()] - return int(value * mult) + return int(float(m.group(1)) * _BW_UNIT[m.group(2).lower()]) m2 = _RE_BW_DETAIL.search(text) if m2: value = float(m2.group(1)) unit = (m2.group(2) or "g").lower() - # "BW 1 Gbit/s" — unit letter may be in "Gbit" when group2 empty after "1 " if m2.group(2) is None and "gbit" in text.lower(): unit = "g" elif m2.group(2) is None and "mbit" in text.lower(): unit = "m" elif m2.group(2) is None and "kbit" in text.lower(): unit = "k" - mult = _BW_UNIT.get(unit, 1_000_000_000) - return int(value * mult) - # Plain "BW 1000000000" unlikely; try digits only + return int(value * _BW_UNIT.get(unit, 1_000_000_000)) digits = re.sub(r"[^\d.]", "", text) if digits: try: @@ -160,149 +92,6 @@ def parse_bw_to_bps(raw: str) -> int: return 0 -def _slice(line: str, start: int, end: int | None) -> str: - if end is None: - return line[start:].rstrip() if len(line) > start else "" - if len(line) <= start: - return "" - return line[start:end].strip() - - -def _looks_like_brief_port(ifname: str, admin: str, phy: str, prot: str) -> bool: - """Reject prompts / hostname lines that leak after the brief table.""" - name = (ifname or "").strip() - if not name or name.lower() == "interface": - return False - if "#" in name or ">" in name: - return False - if _RE_PROMPT_LINE.search(name): - return False - # Real brief rows always have Admin/Phy/Prot as up|down. - if not ( - _RE_UPDOWN.match(admin or "") - and _RE_UPDOWN.match(phy or "") - and _RE_UPDOWN.match(prot or "") - ): - return False - # Prefer known iface prefixes; still allow smartgroup/bvi style names. - if not _RE_IFNAME.match(name): - return False - return True - - -def parse_zte_interface_brief(text: str) -> list[BriefPort]: - """Parse ZTE `show interface brief` into port rows.""" - lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") - out: list[BriefPort] = [] - started = False - for raw in lines: - line = raw.rstrip() - if not line.strip(): - continue - if line.lstrip().startswith("Interface") and "Admin" in line and "Description" in line: - started = True - continue - if not started: - continue - stripped = line.strip() - # Trailing device prompt after the table (Netmiko often leaves hostname#). - if "#" in stripped or (stripped.endswith(">") and " " not in stripped): - admin_probe = _slice(line, *_COL_ADMIN).lower() - if not _RE_UPDOWN.match(admin_probe): - break - if " is " in line and "ifindex" in line.lower(): - break - if_field = _slice(line, *_COL_IF) - ifname = if_field.split()[0] if if_field else "" - admin = _slice(line, *_COL_ADMIN).lower() - phy = _slice(line, *_COL_PHY).lower() - prot = _slice(line, *_COL_PROT).lower() - if not _looks_like_brief_port(ifname, admin, phy, prot): - continue - bw_raw = _slice(line, *_COL_BW) - out.append( - BriefPort( - ifname=ifname, - attribute=_slice(line, *_COL_ATTR), - mode=_slice(line, *_COL_MODE), - bw_raw=bw_raw, - bw_bps=parse_bw_to_bps(bw_raw), - admin=admin, - phy=phy, - prot=prot, - description=_slice(line, *_COL_DESC).strip(), - ) - ) - return out - - -def parse_zte_interface_detail(text: str) -> DetailRates: - """Parse ZTE `show interface {ifname}` rate / util / BW.""" - blob = text or "" - ifname = "" - admin_oper = "" - m_if = _RE_IF_UP.search(blob) - if m_if: - ifname = m_if.group(1) - admin_oper = m_if.group(2).lower() - desc = "" - m_desc = _RE_DESC.search(blob) - if m_desc: - desc = m_desc.group(1).strip() - - bw_bps = 0 - m_bw = _RE_BW_DETAIL.search(blob) - if m_bw: - bw_bps = parse_bw_to_bps(m_bw.group(0)) - else: - # Fallback line scan - for line in blob.splitlines(): - if re.search(r"\bBW\b", line, re.I): - bw_bps = parse_bw_to_bps(line) - if bw_bps: - break - - rate_period = 0 - m_rp = _RE_RATE_PERIOD.search(blob) - if m_rp: - rate_period = int(m_rp.group(1)) - - # Prefer Rate period block: first Input/Output after "Rate period" - in_bps = 0.0 - out_bps = 0.0 - rp_idx = blob.lower().find("rate period") - rate_blob = blob[rp_idx:] if rp_idx >= 0 else blob - # Stop before Peak rate to avoid peak Input/Output - peak_idx = rate_blob.lower().find("peak rate") - if peak_idx >= 0: - rate_blob = rate_blob[:peak_idx] - m_in = _RE_INPUT_BPS.search(rate_blob) - m_out = _RE_OUTPUT_BPS.search(rate_blob) - if m_in: - in_bps = float(m_in.group(1)) - if m_out: - out_bps = float(m_out.group(1)) - - in_util = 0.0 - out_util = 0.0 - m_util = _RE_UTIL.search(blob) - if m_util: - in_util = float(m_util.group(1)) - out_util = float(m_util.group(2)) - - return DetailRates( - ifname=ifname, - admin_oper=admin_oper, - description=desc, - bw_bps=bw_bps, - rate_period_sec=rate_period, - in_bps=in_bps, - out_bps=out_bps, - in_util_pct=in_util, - out_util_pct=out_util, - ) - - def brief_port_to_dict(row: BriefPort) -> dict[str, Any]: return { "ifname": row.ifname, @@ -331,138 +120,137 @@ def detail_to_dict(row: DetailRates) -> dict[str, Any]: } +def _brief_command_for_vendor(vendor_key: str) -> str: + from .port_traffic_commands import commands_for_vendor + + key = str(vendor_key or "zte").strip().lower() + cmds = commands_for_vendor(key, key) + return cmds.brief if cmds else "show interface brief" + + +def _detail_command_for_vendor(vendor_key: str, ifname: str = "") -> str: + from .port_traffic_commands import commands_for_vendor, detail_command + + key = str(vendor_key or "zte").strip().lower() + name = (ifname or "IFACE").strip() or "IFACE" + cmds = commands_for_vendor(key, key) + if cmds: + return detail_command(cmds, name) + return f"show interface {name}" + + +def _bw_field_to_bps(raw: str) -> int: + """Normalize TextFSM bandwidth fields (e.g. ``1000000 Kbit``, ``1 Gbit/s``).""" + text = (raw or "").strip() + if not text: + return 0 + m = re.search(r"(?i)(\d+(?:\.\d+)?)\s*([kKmMgGtT])\s*bit\b", text) + if m: + return int(float(m.group(1)) * _BW_UNIT[m.group(2).lower()]) + prefixed = text if text.upper().startswith("BW") else f"BW {text}" + got = parse_bw_to_bps(prefixed) + if got: + return got + if text.isdigit(): + try: + return int(text) + except ValueError: + return 0 + return parse_bw_to_bps(text) + + def _norm_updown(token: str) -> str: - """Normalize Huawei tokens like up(s) / *down to up|down|''.""" text = (token or "").strip().lower() + if "administratively" in text: + return "down" if text.startswith("*"): text = text[1:] - m = _RE_UPDOWN_TOKEN.match(text) - return m.group(1).lower() if m else "" + if text.startswith("up"): + return "up" + if text.startswith("down"): + return "down" + return text if text in {"up", "down"} else "" -def parse_huawei_interface_brief(text: str) -> list[BriefPort]: - """Parse Huawei `display interface brief` into port rows (BW ignored / 0).""" - lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") +def _yes_no_updown(token: str) -> str: + text = (token or "").strip().lower() + if text in {"yes", "up", "true", "1"}: + return "up" + if text in {"no", "down", "false", "0", "ghost"}: + return "down" + return _norm_updown(text) + + +def _map_brief_rows(rows: list[dict[str, Any]], *, vendor_key: str) -> list[BriefPort]: + key = str(vendor_key or "zte").strip().lower() out: list[BriefPort] = [] - started = False - for raw in lines: - line = raw.rstrip() - if not line.strip(): + for row in rows: + ifname = row_get(row, "interface", "ifname", "port", "intf", "port_id", "name") + if not ifname or "#" in ifname or ">" in ifname: continue - low = line.lstrip().lower() - if low.startswith("interface") and "phy" in low and ("inuti" in low or "protocol" in low): - started = True - continue - if not started: - continue - stripped = line.strip() - if stripped.startswith("<") and stripped.endswith(">"): - break - if "#" in stripped and " " not in stripped.split("#", 1)[0]: - break - m = _RE_HW_BRIEF_ROW.match(stripped) - if not m: - continue - ifname = m.group(1) - phy = _norm_updown(m.group(2)) - prot = _norm_updown(m.group(3)) - if not ifname or ifname.lower() == "interface": - continue - if "#" in ifname or ">" in ifname: - continue - if not _RE_IFNAME.match(ifname): - continue - if not phy or not prot: + admin = _norm_updown( + row_get(row, "admin", "admin_state", "status", "phy", "link", "link_status") + ) + phy = _norm_updown(row_get(row, "phy", "status", "link_status", "port_state", "link")) + prot = _norm_updown(row_get(row, "prot", "protocol", "proto", "protocol_status")) + if key == "cisco": + admin = _norm_updown(row_get(row, "status", "admin", "link_status")) or admin + phy = admin + prot = _norm_updown(row_get(row, "proto", "protocol", "prot")) or prot + elif key == "huawei": + phy = _norm_updown(row_get(row, "phy", "status")) or phy + prot = _norm_updown(row_get(row, "protocol", "prot", "proto")) or prot + admin = phy or admin + elif key == "h3c": + # Community: LINK / PROTOCOL (route mode) or LINK only (bridge mode). + link = _norm_updown(row_get(row, "link", "admin", "phy")) + admin = link or admin + phy = link or phy + prot = _norm_updown(row_get(row, "protocol", "prot", "proto")) or prot or phy + elif key == "juniper": + admin = _norm_updown(row_get(row, "admin_state", "admin", "status")) or admin + phy = _norm_updown(row_get(row, "link_status", "phy", "status")) or phy + prot = phy or prot or admin + elif key == "nokia": + admin = _norm_updown(row_get(row, "admin_state", "admin")) or admin + link = _yes_no_updown(row_get(row, "link")) + phy = _norm_updown(row_get(row, "port_state", "phy")) or link or phy + prot = phy or prot or admin + elif key == "mikrotik": + # print brief often exposes flags / name; tolerate missing proto. + admin = _norm_updown(row_get(row, "status", "admin", "link")) or admin or "up" + phy = _norm_updown(row_get(row, "link", "status", "phy")) or phy or admin + prot = _norm_updown(row_get(row, "protocol", "prot")) or prot or phy + if admin not in {"up", "down"} and phy in {"up", "down"}: + admin = phy + if phy not in {"up", "down"}: + phy = admin if admin in {"up", "down"} else phy + if prot not in {"up", "down"}: + # Prefer inferring prot from phy when vendor brief has no protocol column. + if phy in {"up", "down"} and key in {"juniper", "nokia", "mikrotik", "h3c"}: + prot = phy + else: + continue + if admin not in {"up", "down"} or phy not in {"up", "down"}: continue + bw_raw = row_get(row, "bw", "bandwidth", "bw_raw", "speed") out.append( BriefPort( ifname=ifname, - attribute="", - mode="", - bw_raw="", - bw_bps=0, - admin=phy, # Huawei brief has no separate Admin; PHY is closest. + attribute=row_get(row, "attribute", "attr", "port_type", "type"), + mode=row_get(row, "mode", "port_mode", "duplex"), + bw_raw=bw_raw, + bw_bps=parse_bw_to_bps(bw_raw), + admin=admin, phy=phy, prot=prot, - description="", + description=row_get(row, "description", "descrip", "desc"), ) ) return out -def parse_huawei_interface_detail(text: str) -> DetailRates: - """Parse Huawei `display interface {if}` Last N seconds rate / utility.""" - blob = text or "" - ifname = "" - admin_oper = "" - m_state = _RE_HW_STATE.search(blob) - if m_state: - ifname = m_state.group(1) - st = m_state.group(2).lower() - admin_oper = "down" if "down" in st else "up" - desc = "" - m_desc = _RE_DESC.search(blob) - if m_desc: - desc = m_desc.group(1).strip() - - rate_period = 0 - in_bps = 0.0 - out_bps = 0.0 - m_in = _RE_HW_IN_RATE.search(blob) - if m_in: - rate_period = int(m_in.group(1)) - in_bps = float(m_in.group(2)) - m_out = _RE_HW_OUT_RATE.search(blob) - if m_out: - if not rate_period: - rate_period = int(m_out.group(1)) - out_bps = float(m_out.group(2)) - - in_util = 0.0 - out_util = 0.0 - m_iu = _RE_HW_IN_UTIL.search(blob) - if m_iu: - if not rate_period: - rate_period = int(m_iu.group(1)) - in_util = float(m_iu.group(2)) - m_ou = _RE_HW_OUT_UTIL.search(blob) - if m_ou: - if not rate_period: - rate_period = int(m_ou.group(1)) - out_util = float(m_ou.group(2)) - - return DetailRates( - ifname=ifname, - admin_oper=admin_oper, - description=desc, - bw_bps=0, # sample has no BW; leave 0 for now - rate_period_sec=rate_period, - in_bps=in_bps, - out_bps=out_bps, - in_util_pct=in_util, - out_util_pct=out_util, - ) - - -def parse_interface_brief(text: str, vendor_key: str = "zte") -> list[BriefPort]: - key = str(vendor_key or "zte").strip().lower() - if key == "huawei": - return parse_huawei_interface_brief(text) - if key == "cisco": - return parse_cisco_interface_brief(text) - return parse_zte_interface_brief(text) - - -def parse_interface_detail(text: str, vendor_key: str = "zte") -> DetailRates: - key = str(vendor_key or "zte").strip().lower() - if key == "huawei": - return parse_huawei_interface_detail(text) - if key == "cisco": - return parse_cisco_interface_detail(text) - return parse_zte_interface_detail(text) - - -def _cisco_period_to_sec(n: int, unit: str) -> int: +def _period_to_sec(n: int, unit: str) -> int: u = (unit or "").lower() if u.startswith("second"): return int(n) @@ -473,109 +261,149 @@ def _cisco_period_to_sec(n: int, unit: str) -> int: return int(n) -def parse_cisco_interface_brief(text: str) -> list[BriefPort]: - """Parse Cisco `show ip interface brief` into port rows.""" - lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n") - out: list[BriefPort] = [] - started = False - for raw in lines: - line = raw.rstrip() - if not line.strip(): - continue - low = line.lstrip().lower() - if low.startswith("interface") and "status" in low and "protocol" in low: - started = True - continue - if not started: - continue - stripped = line.strip() - if stripped.endswith("#") or (stripped.endswith(">") and stripped.startswith("<")): - break - if "#" in stripped and " " not in stripped.split("#", 1)[0]: - break - m = _RE_CISCO_BRIEF_ROW.match(stripped) - if not m: - continue - ifname = m.group(1) - status = (m.group(5) or "").strip().lower() - prot = (m.group(6) or "").strip().lower() - if not _RE_IFNAME.match(ifname): - continue - if prot not in ("up", "down"): - continue - admin_down = "administratively" in status - if "down" in status: - phy = "down" - elif "up" in status: - phy = "up" - else: - continue - admin = "down" if admin_down else phy - out.append( - BriefPort( - ifname=ifname, - attribute="", - mode="", - bw_raw="", - bw_bps=0, - admin=admin, - phy=phy, - prot=prot, - description="", +def _map_detail_rows(rows: list[dict[str, Any]]) -> list[DetailRates]: + best: dict[str, DetailRates] = {} + for row in rows: + ifname = row_get(row, "ifname", "interface", "port") + in_bps_s = row_get(row, "input_bps", "in_bps", "input_rate", "input") + out_bps_s = row_get(row, "output_bps", "out_bps", "output_rate", "output") + try: + in_bps = float(in_bps_s or 0) + except ValueError: + in_bps = 0.0 + try: + out_bps = float(out_bps_s or 0) + except ValueError: + out_bps = 0.0 + bw_raw = row_get(row, "bw_raw", "bandwidth", "bw") + bw_bps = _bw_field_to_bps(bw_raw) + period_s = row_get(row, "rate_period", "period", "rate_period_sec") + period_unit = row_get(row, "rate_period_unit", "period_unit") + try: + rate_period_sec = _period_to_sec(int(float(period_s or 0)), period_unit) + except ValueError: + rate_period_sec = 0 + in_util_s = row_get(row, "in_util", "input_util", "inuti") + out_util_s = row_get(row, "out_util", "output_util", "oututi") + try: + in_util = float((in_util_s or "0").rstrip("%")) + except ValueError: + in_util = 0.0 + try: + out_util = float((out_util_s or "0").rstrip("%")) + except ValueError: + out_util = 0.0 + admin = _norm_updown( + row_get( + row, + "admin_oper", + "status", + "admin", + "link_status", + "line_status", + "admin_state", ) ) - return out + ifname = ifname or row_get(row, "port_id", "name") + if not ifname and in_bps <= 0 and out_bps <= 0 and not bw_bps: + continue + cand = DetailRates( + ifname=ifname, + admin_oper=admin, + description=row_get(row, "description", "descrip", "interface_description"), + bw_bps=bw_bps, + rate_period_sec=rate_period_sec, + in_bps=in_bps, + out_bps=out_bps, + in_util_pct=in_util, + out_util_pct=out_util, + ) + key = ifname or "_" + prev = best.get(key) + if prev is None or (in_util_s or out_util_s or in_bps or out_bps): + best[key] = cand + return list(best.values()) -def parse_cisco_interface_detail(text: str) -> DetailRates: - """Parse Cisco `show interfaces {if}` BW + N minute/second rate (util ignored).""" - blob = text or "" - ifname = "" - admin_oper = "" - m_state = _RE_CISCO_IF_STATE.search(blob) - if m_state: - ifname = m_state.group(1) - admin_oper = "down" if m_state.group(2) or m_state.group(3).lower() == "down" else "up" - if not ifname: - m_up = _RE_IF_UP.search(blob) - if m_up: - ifname = m_up.group(1) - admin_oper = m_up.group(2).lower() +def parse_interface_brief( + text: str, + vendor_key: str = "zte", + *, + command: str = "", + device_type: str = "", +) -> list[BriefPort]: + """Parse brief CLI via custom/community TextFSM. Empty list if no template match.""" + key = str(vendor_key or "zte").strip().lower() + plat = resolve_cli_platform(vendor_key=key, device_type=device_type) + cmd = (command or _brief_command_for_vendor(key)).strip() + if not plat or not cmd: + return [] + rows = parse_cli(platform=plat, command=cmd, text=text) + return _map_brief_rows(rows, vendor_key=key) - desc = "" - m_desc = _RE_DESC.search(blob) - if m_desc: - desc = m_desc.group(1).strip() - bw_bps = 0 - for line in blob.splitlines(): - if "BW" in line.upper() and ("bit" in line.lower()): - bw_bps = parse_bw_to_bps(line) - if bw_bps: - break +def parse_interface_detail( + text: str, + vendor_key: str = "zte", + *, + command: str = "", + device_type: str = "", + ifname: str = "", +) -> DetailRates: + """Parse detail CLI via custom/community TextFSM. Empty DetailRates if no match.""" + key = str(vendor_key or "zte").strip().lower() + plat = resolve_cli_platform(vendor_key=key, device_type=device_type) + cmd = (command or _detail_command_for_vendor(key, ifname=ifname)).strip() + if not plat or not cmd: + return DetailRates() + rows = parse_cli(platform=plat, command=cmd, text=text) + mapped = _map_detail_rows(rows) + return mapped[0] if mapped else DetailRates() - rate_period = 0 - in_bps = 0.0 - out_bps = 0.0 - for m in _RE_CISCO_RATE.finditer(blob): - period = _cisco_period_to_sec(int(m.group(1)), m.group(2)) - direction = m.group(3).lower() - rate = float(m.group(4)) - if not rate_period: - rate_period = period - if direction == "input": - in_bps = rate - else: - out_bps = rate - return DetailRates( - ifname=ifname, - admin_oper=admin_oper, - description=desc, - bw_bps=bw_bps, - rate_period_sec=rate_period, - in_bps=in_bps, - out_bps=out_bps, - in_util_pct=0.0, # sample has no util %; ignore for now - out_util_pct=0.0, - ) +def parse_zte_interface_brief(text: str) -> list[BriefPort]: + return parse_interface_brief(text, "zte", device_type="zte_zxros") + + +def parse_zte_interface_detail(text: str, *, ifname: str = "") -> DetailRates: + return parse_interface_detail(text, "zte", device_type="zte_zxros", ifname=ifname) + + +def parse_huawei_interface_brief(text: str) -> list[BriefPort]: + return parse_interface_brief(text, "huawei", device_type="huawei_vrp") + + +def parse_huawei_interface_detail(text: str, *, ifname: str = "") -> DetailRates: + return parse_interface_detail(text, "huawei", device_type="huawei_vrp", ifname=ifname) + + +def parse_cisco_interface_brief(text: str) -> list[BriefPort]: + return parse_interface_brief(text, "cisco", device_type="cisco_ios") + + +def parse_cisco_interface_detail(text: str, *, ifname: str = "") -> DetailRates: + return parse_interface_detail(text, "cisco", device_type="cisco_ios", ifname=ifname) + + +def parse_h3c_interface_brief(text: str) -> list[BriefPort]: + return parse_interface_brief(text, "h3c", device_type="hp_comware") + + +def parse_h3c_interface_detail(text: str, *, ifname: str = "") -> DetailRates: + return parse_interface_detail(text, "h3c", device_type="hp_comware", ifname=ifname) + + +def parse_juniper_interface_brief(text: str) -> list[BriefPort]: + return parse_interface_brief(text, "juniper", device_type="juniper_junos") + + +def parse_juniper_interface_detail(text: str, *, ifname: str = "") -> DetailRates: + return parse_interface_detail(text, "juniper", device_type="juniper_junos", ifname=ifname) + + +def parse_nokia_interface_brief(text: str) -> list[BriefPort]: + return parse_interface_brief(text, "nokia", device_type="nokia_sros") + + +def parse_nokia_interface_detail(text: str, *, ifname: str = "") -> DetailRates: + return parse_interface_detail(text, "nokia", device_type="nokia_sros", ifname=ifname) diff --git a/netx_api/port_traffic_runner.py b/netx_api/port_traffic_runner.py index adfdd37..4e5416a 100644 --- a/netx_api/port_traffic_runner.py +++ b/netx_api/port_traffic_runner.py @@ -263,7 +263,12 @@ def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> tup try: cmd = detail_command(cmds, ifname) raw = send_show_command(conn, cmd, read_timeout=per_cmd) - parsed = parse_interface_detail(raw, vendor_key) + parsed = parse_interface_detail( + raw, + vendor_key, + command=cmd, + ifname=ifname, + ) _save_sample(tid, parsed) except Exception as exc: local_errors += 1 diff --git a/netx_api/topology_lldp.py b/netx_api/topology_lldp.py index 5a52384..647a057 100644 --- a/netx_api/topology_lldp.py +++ b/netx_api/topology_lldp.py @@ -1,18 +1,18 @@ -"""LLDP/CDP neighbor command templates and output parsers (per vendor). +"""LLDP neighbor command profiles and TextFSM parsers (per vendor). Multi-vendor fabrics default to LLDP. Resolve profile primarily from Netmiko ``device_type`` (managed NE / UME already store it), then fall back to vendor label. -Each vendor has: - - a show/display command - - a dedicated parse_* stub (fill with real lab echoes later) +Parsing is TextFSM-only via ``ntc_parse`` (custom ``cli_templates/`` then community). """ from __future__ import annotations import re -from collections.abc import Callable from dataclasses import dataclass +from typing import Any + +from .ntc_parse import parse_cli, resolve_cli_platform, row_get @dataclass @@ -34,12 +34,6 @@ class VendorLldpProfile: notes: str = "" -ParserFn = Callable[[str], list[NeighborHit]] - -_IPV4_RE = re.compile( - r"(? profile key. Prefer inventory device_type over fuzzy text. # Keep aligned with netx_api.device_types.SUPPORTED_DEVICE_TYPES families. _DEVICE_TYPE_PREFIX_RULES: tuple[tuple[str, str], ...] = ( @@ -68,10 +62,6 @@ _VENDOR_LABEL_TO_KEY: dict[str, str] = { } -# --------------------------------------------------------------------------- -# Vendor registry — command templates (edit / refine with lab echoes) -# --------------------------------------------------------------------------- - VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = { "cisco": VendorLldpProfile( key="cisco", @@ -87,27 +77,27 @@ VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = { "h3c": VendorLldpProfile( key="h3c", lldp_command="display lldp neighbor-information list", - notes="Placeholder Comware command; confirm on lab.", + notes="Community hp_comware TextFSM (list; verbose also available).", ), "zte": VendorLldpProfile( key="zte", lldp_command="show lldp neighbor brief", - notes="device_type zte_*; ZXROS brief table (Local Interface / Port ID / System Name).", + notes="device_type zte_*; NetX custom TextFSM.", ), "juniper": VendorLldpProfile( key="juniper", lldp_command="show lldp neighbors", - notes="device_type juniper*; detail form TBD.", + notes="Community juniper_junos TextFSM.", ), "nokia": VendorLldpProfile( key="nokia", lldp_command="show system lldp neighbor", - notes="device_type nokia_* / alcatel_*; SRL may differ.", + notes="SROS: no community LLDP template yet. alcatel_aos uses show lldp remote-system.", ), "ericsson": VendorLldpProfile( key="ericsson", lldp_command="show lldp neighbors", - notes="device_type ericsson_*; confirm IPOS/SEOS on lab.", + notes="No community ericsson_ipos LLDP TextFSM yet.", ), "generic": VendorLldpProfile( key="generic", @@ -116,6 +106,9 @@ VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = { ), } +# Vendors without a working TextFSM path yet (custom or community). +STUB_PARSER_KEYS = frozenset({"nokia", "ericsson", "generic"}) + def resolve_vendor_key(vendor: str = "", device_type: str = "") -> str: """Map inventory device_type (preferred) or vendor label -> profile key.""" @@ -143,6 +136,10 @@ def get_vendor_profile(vendor: str = "", device_type: str = "") -> VendorLldpPro def lldp_command_for_vendor(vendor: str = "", device_type: str = "") -> str: + dtype = str(device_type or "").strip().lower() + # AOS has a community template; SROS profile command differs. + if dtype.startswith("alcatel_aos"): + return "show lldp remote-system" return get_vendor_profile(vendor, device_type).lldp_command @@ -159,397 +156,154 @@ def pick_neighbor_command( ) -> tuple[str, str]: """Return (lldp_command, \"lldp\"). Physical discovery is LLDP-only (CDP ignored).""" _ = protocol # accepted for call-site compat; always LLDP - profile = get_vendor_profile(vendor, device_type) - return profile.lldp_command, "lldp" - - -# --------------------------------------------------------------------------- -# Parsers — keep working ones; stubs return [] until lab echoes are added -# --------------------------------------------------------------------------- - - -def parse_cisco_lldp(text: str) -> list[NeighborHit]: - """Cisco `show lldp neighbors detail` (preferred); brief table as fallback.""" - hits = _parse_cisco_lldp_detail(text) - if hits: - return hits - return _parse_lldp_brief_table(text) - - -def parse_cisco_cdp(text: str) -> list[NeighborHit]: - """Cisco `show cdp neighbors detail`.""" - return _parse_cdp_detail(text) - - -def parse_huawei_lldp(text: str) -> list[NeighborHit]: - """Huawei `display lldp neighbor`.""" - hits = _parse_huawei_lldp_neighbor(text) - if hits: - return hits - return _parse_lldp_brief_table(text) - - -def parse_h3c_lldp(text: str) -> list[NeighborHit]: - """H3C Comware LLDP — placeholder until lab echo is captured.""" - # TODO: replace with Comware-specific parser using real `display lldp ...` output. - _ = text - return [] - - -def parse_zte_lldp(text: str) -> list[NeighborHit]: - """ZTE ZXROS `show lldp neighbor brief` table.""" - hits = _parse_zte_lldp_brief(text) - if hits: - return hits - return _parse_lldp_brief_table(text) - - -def parse_juniper_lldp(text: str) -> list[NeighborHit]: - """Juniper Junos LLDP — placeholder until lab echo is captured.""" - # TODO: parse `show lldp neighbors` / detail from Junos sample. - _ = text - return [] - - -def parse_nokia_lldp(text: str) -> list[NeighborHit]: - """Nokia SROS/SRL LLDP — placeholder until lab echo is captured.""" - # TODO: parse `show system lldp neighbor` (SROS) / SRL equivalent. - _ = text - return [] - - -def parse_ericsson_lldp(text: str) -> list[NeighborHit]: - """Ericsson IPOS/SEOS LLDP — placeholder until lab echo is captured.""" - # TODO: parse vendor show output from lab. - _ = text - return [] - - -def parse_generic_lldp(text: str) -> list[NeighborHit]: - """Best-effort fallback when vendor is unknown.""" - hits = _parse_cisco_lldp_detail(text) - if hits: - return hits - hits = _parse_huawei_lldp_neighbor(text) - if hits: - return hits - return _parse_lldp_brief_table(text) - - -_VENDOR_PARSERS: dict[str, ParserFn] = { - "cisco": parse_cisco_lldp, - "huawei": parse_huawei_lldp, - "h3c": parse_h3c_lldp, - "zte": parse_zte_lldp, - "juniper": parse_juniper_lldp, - "nokia": parse_nokia_lldp, - "ericsson": parse_ericsson_lldp, - "generic": parse_generic_lldp, -} - -# Parsers that intentionally return [] until lab samples are added. -STUB_PARSER_KEYS = frozenset({"h3c", "juniper", "nokia", "ericsson"}) + return lldp_command_for_vendor(vendor, device_type), "lldp" def parser_meta(*, vendor: str = "", device_type: str = "") -> tuple[str, bool]: """Return (parser_key, is_stub).""" key = resolve_vendor_key(vendor, device_type) + dtype = str(device_type or "").strip().lower() + if dtype.startswith("alcatel_aos"): + return key, False return key, key in STUB_PARSER_KEYS +def _map_lldp_rows(rows: list[dict[str, Any]]) -> list[NeighborHit]: + hits: list[NeighborHit] = [] + for row in rows: + local_port = row_get(row, "local_interface", "local_intf", "local_port") + remote_name = row_get(row, "neighbor_name", "system_name", "neighbor", "device_id") + remote_port = row_get( + row, + "neighbor_interface", + "neighbor_port_id", + "port_id", + "neighbor_port", + "remote_port", + ) + remote_ip = row_get( + row, "mgmt_address", "management_address", "management_ip", "neighbor_ip" + ) + name = remote_name.strip() + if name.lower() in {"", "-", "not advertised"}: + name = "" + # Require a remote identity; skip filldown-only leftovers. + if not name and not remote_port and not remote_ip: + continue + hits.append( + NeighborHit( + remote_name=name, + remote_ip=remote_ip, + local_port=local_port, + remote_port=remote_port, + protocol="lldp", + ) + ) + return hits + + +def _parse_lldp_via_ntc( + text: str, + *, + vendor: str = "", + device_type: str = "", + command: str = "", +) -> list[NeighborHit]: + plat = resolve_cli_platform(vendor=vendor, device_type=device_type) + cmd = (command or "").strip() or lldp_command_for_vendor(vendor, device_type) + if not plat or not cmd: + return [] + rows = parse_cli(platform=plat, command=cmd, text=text) + return _map_lldp_rows(rows) + + def parse_neighbor_output( text: str, *, protocol: str = "lldp", vendor: str = "", device_type: str = "", + command: str = "", ) -> list[NeighborHit]: - """Parse neighbor CLI output using the device_type/vendor-specific parser.""" + """Parse neighbor CLI via TextFSM only (custom then community).""" raw = str(text or "") if not raw.strip(): return [] _ = protocol # CDP discovery removed; always parse as LLDP - key = resolve_vendor_key(vendor, device_type) + return _parse_lldp_via_ntc( + raw, + vendor=vendor, + device_type=device_type, + command=command or lldp_command_for_vendor(vendor, device_type), + ) - parser = _VENDOR_PARSERS.get(key) or parse_generic_lldp - hits = parser(raw) - if hits: - return hits - # Soft fallbacks so an early/wrong tag still yields something useful. - if key != "cisco": - hits = parse_cisco_lldp(raw) - if hits: - return hits - if key != "huawei": - hits = parse_huawei_lldp(raw) +def parse_cisco_lldp(text: str) -> list[NeighborHit]: + """Cisco LLDP: try detail command mapping first, then brief table.""" + for cmd in ("show lldp neighbors detail", "show lldp neighbors"): + hits = _parse_lldp_via_ntc( + text, vendor="cisco", device_type="cisco_ios", command=cmd + ) if hits: return hits return [] -# --------------------------------------------------------------------------- -# Shared low-level helpers -# --------------------------------------------------------------------------- +def parse_cisco_cdp(text: str) -> list[NeighborHit]: + """CDP discovery disabled; kept for API compat.""" + _ = text + return [] -def _parse_cisco_lldp_detail(text: str) -> list[NeighborHit]: - """Cisco IOS `show lldp neighbors detail` blocks starting at Local Intf.""" - raw = str(text or "") - if not re.search(r"(?i)Local\s+Intf\s*:", raw): - return [] - chunks = re.split(r"(?i)(?=Local\s+Intf\s*:)", raw) - hits: list[NeighborHit] = [] - for chunk in chunks: - if not re.search(r"(?i)Local\s+Intf\s*:", chunk): - continue - local_port = _kv(chunk, r"Local\s+Intf\s*:\s*(.+)") - remote_port = _kv(chunk, r"Port\s+id\s*:\s*(.+)") - sys_name = _kv(chunk, r"System\s+Name\s*:\s*(.+)") - # Prefer IPv4 under Management Addresses; skip OID / MAC "Other:" lines. - ip = "" - m = re.search( - r"(?is)Management\s+Addresses?\s*:(.*?)(?:\n\s*\n|Auto Negotiation|Total entries|$)", - chunk, - ) - if m: - mgmt_lines = [] - for ln in (m.group(1) or "").splitlines(): - low = ln.lower() - if "oid" in low or re.search(r"(?i)^\s*other\s*:", ln): - continue - mgmt_lines.append(ln) - ip_m = _IPV4_RE.search("\n".join(mgmt_lines)) - if ip_m: - ip = ip_m.group(0) - if not sys_name and not remote_port and not local_port: - continue - # Skip empty / not-advertised system names - name = (sys_name or "").strip() - if name.lower() in {"", "-", "not advertised"}: - name = "" - hits.append( - NeighborHit( - remote_name=name, - remote_ip=ip, - local_port=(local_port or "").strip(), - remote_port=(remote_port or "").strip(), - protocol="lldp", - ) - ) - return hits - - -def _parse_zte_lldp_brief(text: str) -> list[NeighborHit]: - """ZTE ZXROS `show lldp neighbor brief`. - - Columns: Local Interface | Scope | Chassis ID | Port ID | Holdtime | System Name - Example: - cgei-1/1/0/34 NB 744a.a42d.8970 cgei-1/1/0/36 91 KND-VKAU-EN1-Z20HS - """ - raw = str(text or "") - if not re.search(r"(?i)Local\s+Interface", raw) or not re.search(r"(?i)System\s+Name", raw): - return [] - - # Scope codes seen on ZXROS: NB / NC / NTPMR (and possibly others). - row_re = re.compile( - r"^(?P\S+)\s+" - r"(?P[A-Za-z]{2,8})\s+" - r"(?P\S+)\s+" - r"(?P\S+)\s+" - r"(?P\d+)\s+" - r"(?P\S.*?)\s*$" +def parse_huawei_lldp(text: str) -> list[NeighborHit]: + return _parse_lldp_via_ntc( + text, vendor="huawei", device_type="huawei", command="display lldp neighbor" ) - hits: list[NeighborHit] = [] - for ln in raw.splitlines(): - s = ln.strip() - if not s or set(s) <= {"-", "="}: - continue - low = s.lower() - if "local interface" in low or low.startswith(("total", "scope", "capability", "---")): - continue - if s.endswith("#") or "show lldp" in low: - continue - m = row_re.match(s) - if not m: - continue - scope = m.group("scope").upper() - # Reject rows that clearly aren't neighbor entries (e.g. mis-split header leftovers). - if scope in {"INTERFACE", "CHASSIS", "PORT", "HOLDTIME", "SYSTEM"}: - continue - name = (m.group("name") or "").strip() - local_port = (m.group("local") or "").strip() - remote_port = (m.group("port") or "").strip() - if not name and not remote_port and not local_port: - continue - hits.append( - NeighborHit( - remote_name=name, - local_port=local_port, - remote_port=remote_port, - protocol="lldp", - ) + + +def parse_h3c_lldp(text: str) -> list[NeighborHit]: + for cmd in ( + "display lldp neighbor-information list", + "display lldp neighbor-information verbose", + ): + hits = _parse_lldp_via_ntc( + text, vendor="h3c", device_type="hp_comware", command=cmd ) - return hits + if hits: + return hits + return [] -def _parse_lldp_brief_table(text: str) -> list[NeighborHit]: - """Cisco/ZTE-style brief table: Device ID / Local Intf / ... / Port ID.""" - lines = [ln.rstrip() for ln in str(text or "").splitlines()] - start = -1 - for i, ln in enumerate(lines): - low = ln.lower() - if "device id" in low and ("local" in low or "intf" in low or "port" in low): - start = i + 1 - break - if "system name" in low and "local" in low: - start = i + 1 - break - if start < 0: - return [] - hits: list[NeighborHit] = [] - for ln in lines[start:]: - s = ln.strip() - if not s or set(s) <= {"-", "="}: - continue - if s.lower().startswith(("total", "capability", "---")): - continue - parts = s.split() - if len(parts) < 2: - continue - remote = parts[0] - local_port = parts[1] if len(parts) >= 2 else "" - remote_port = parts[-1] if len(parts) >= 4 else "" - if remote.lower() in {"device", "system", "chassis"}: - continue - hits.append( - NeighborHit( - remote_name=remote, - local_port=local_port, - remote_port=remote_port, - protocol="lldp", - ) - ) - return hits - - -def _parse_huawei_lldp_neighbor(text: str) -> list[NeighborHit]: - """Huawei VRP `display lldp neighbor` — per-interface sections.""" - raw = str(text or "") - # Split on " has N neighbor(s):" - header_re = re.compile( - r"(?im)^(\S+)\s+has\s+(\d+)\s+neighbor\(s\)\s*:\s*$" +def parse_zte_lldp(text: str) -> list[NeighborHit]: + return _parse_lldp_via_ntc( + text, vendor="zte", device_type="zte_zxros", command="show lldp neighbor brief" ) - hits: list[NeighborHit] = [] - matches = list(header_re.finditer(raw)) - if not matches: - # Older compact sample with Local Interface: field - return _parse_huawei_lldp_blocks_legacy(raw) - - for i, m in enumerate(matches): - local_if = m.group(1).strip() - count = int(m.group(2)) - if count <= 0: - continue - start = m.end() - end = matches[i + 1].start() if i + 1 < len(matches) else len(raw) - section = raw[start:end] - # One section may contain multiple neighbors; split on Neighbor index - sub_chunks = re.split(r"(?im)(?=^Neighbor\s+index\s*:)", section) - for chunk in sub_chunks: - if not re.search(r"(?i)Neighbor\s+index\s*:", chunk): - # Sometimes fields appear without explicit index; still try once. - if not re.search(r"(?i)System\s+name\s*:", chunk): - continue - sys_name = _kv(chunk, r"System\s+name\s*:\s*(.+)") - port_id = _kv(chunk, r"Port\s+ID\s*:\s*(.+)") - mgmt = _kv(chunk, r"Management\s+address\s*:\s*(.+)") - ip = "" - if mgmt: - ip_m = _IPV4_RE.search(mgmt) - if ip_m: - ip = ip_m.group(0) - name = (sys_name or "").strip() - # Hostname may be FQDN — keep as-is; matcher strips domain. - if not name and not port_id and not ip: - continue - hits.append( - NeighborHit( - remote_name=name, - remote_ip=ip, - local_port=local_if, - remote_port=(port_id or "").strip(), - protocol="lldp", - ) - ) - return hits -def _parse_huawei_lldp_blocks_legacy(text: str) -> list[NeighborHit]: - """Older/compact Huawei block with Local Interface field.""" - hits: list[NeighborHit] = [] - blocks = re.split(r"\n\s*\n", str(text or "")) - for block in blocks: - if not block.strip(): - continue - sys_name = _kv(block, r"System\s+name\s*[:=]\s*(.+)") - local_if = _kv(block, r"Local\s+(?:Interface|Port)\s*[:=]\s*(.+)") - port_id = _kv(block, r"Port\s+ID\s*[:=]\s*(.+)") - mgmt = _kv(block, r"Management\s+address\s*[:=]\s*(.+)") - if not sys_name and not port_id: - continue - ip = "" - if mgmt: - m = _IPV4_RE.search(mgmt) - if m: - ip = m.group(0) - hits.append( - NeighborHit( - remote_name=(sys_name or "").strip(), - remote_ip=ip, - local_port=(local_if or "").strip(), - remote_port=(port_id or "").strip(), - protocol="lldp", - ) - ) - return hits +def parse_juniper_lldp(text: str) -> list[NeighborHit]: + return _parse_lldp_via_ntc( + text, + vendor="juniper", + device_type="juniper_junos", + command="show lldp neighbors", + ) -def _parse_cdp_detail(text: str) -> list[NeighborHit]: - """Cisco `show cdp neighbors detail`.""" - hits: list[NeighborHit] = [] - chunks = re.split(r"(?i)\n(?=Device ID\s*:)", str(text or "")) - for chunk in chunks: - if not re.search(r"(?i)Device\s+ID\s*:", chunk): - continue - device_id = _kv(chunk, r"Device\s+ID\s*:\s*(.+)") - ip = "" - ip_line = _kv(chunk, r"IP(?:v4)?\s+address\s*:\s*(.+)") - if ip_line: - m = _IPV4_RE.search(ip_line) - if m: - ip = m.group(0) - local_port = _kv(chunk, r"Interface\s*:\s*([^,\n]+)") - remote_port = _kv(chunk, r"Port ID\s*(?:\(outgoing port\))?\s*:\s*(.+)") - if not device_id and not ip: - continue - hits.append( - NeighborHit( - remote_name=(device_id or "").strip(), - remote_ip=ip, - local_port=(local_port or "").strip().rstrip(","), - remote_port=(remote_port or "").strip(), - protocol="cdp", - ) - ) - return hits +def parse_nokia_lldp(text: str, *, device_type: str = "") -> list[NeighborHit]: + dtype = str(device_type or "").strip() or "nokia_sros" + cmd = lldp_command_for_vendor(vendor="nokia", device_type=dtype) + return _parse_lldp_via_ntc(text, vendor="nokia", device_type=dtype, command=cmd) -def _kv(text: str, pattern: str) -> str: - m = re.search(pattern, text, flags=re.IGNORECASE | re.MULTILINE) - if not m: - return "" - return str(m.group(1) or "").strip() +def parse_ericsson_lldp(text: str) -> list[NeighborHit]: + _ = text + return [] + + +def parse_generic_lldp(text: str) -> list[NeighborHit]: + """Unknown vendor: no heuristic regex; require an explicit platform template.""" + _ = text + return [] # Long media names → short canonical form (case-insensitive prefix). @@ -596,5 +350,4 @@ def normalize_ifname(name: str) -> str: rest = rest[1:] return f"{short}{rest}" break - # Already-short forms: gi0/0, te1/0/1, xge0/0/1, 10ge1/0/1 return s diff --git a/requirements.txt b/requirements.txt index 602552b..6ad2d4f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,10 @@ python-multipart>=0.0.9 websocket-client>=1.8.0 cryptography>=42.0.0 netmiko>=4.3.0 +ntc-templates>=6.0.0 +textfsm>=1.1.0 +ntc-templates>=6.0.0 +textfsm>=1.1.0 bcrypt>=4.1.0 PyJWT>=2.8.0 alembic>=1.13.0 diff --git a/tests/test_ntc_parse.py b/tests/test_ntc_parse.py new file mode 100644 index 0000000..40d5cbb --- /dev/null +++ b/tests/test_ntc_parse.py @@ -0,0 +1,146 @@ +"""Tests for NetX/community TextFSM parse adapter.""" + +from __future__ import annotations + +import unittest + +from netx_api.ntc_parse import parse_cli, resolve_cli_platform +from netx_api.port_traffic_parsers import parse_interface_brief, parse_interface_detail +from netx_api import topology_lldp as lldp + + +ZTE_LLDP = """\ +Local Interface Chassis ID Port ID System Name +gei-0/1/0/1 0011.2233.4455 gei-0/1/0/2 R1 +""" + +ZTE_BRIEF = """\ +Interface Attribute Mode BW Admin Phy Prot Description +xgei-1/1/0/1 optical Duplex/full 1G up up up C2930L100-EQ2 +xgei-1/1/0/2 optical Duplex/full 1G up down down +""" + +ZTE_DETAIL = """\ +xgei-1/1/0/1 is up, ifindex: 8194 + Description: C2930L100-EQ2 + BW 1 Gbit/s + Rate period : 30 s + Input : 824 bit/s 1 packet/s + Output : 824 bit/s 1 packet/s + Intf utilization: input 0% output 0% +""" + +CISCO_LLDP_DETAIL = """\ +------------------------------------------------ +Local Intf: Gi0/1 +Chassis id: 707b.5c6e.d130 +Port id: Ethernet1/0/1 +System Name: r1 +Management Addresses: + IP: 192.168.0.1 +""" + + +class PlatformMapTests(unittest.TestCase): + def test_map_common(self) -> None: + self.assertEqual(resolve_cli_platform(device_type="cisco_ios"), "cisco_ios") + self.assertEqual(resolve_cli_platform(device_type="cisco_nxos"), "cisco_nxos") + self.assertEqual(resolve_cli_platform(device_type="huawei"), "huawei_vrp") + self.assertEqual(resolve_cli_platform(vendor_key="zte"), "zte_zxros") + self.assertEqual(resolve_cli_platform(device_type="zte_zxros"), "zte_zxros") + self.assertEqual(resolve_cli_platform(device_type="hp_comware"), "hp_comware") + self.assertEqual(resolve_cli_platform(vendor_key="h3c"), "hp_comware") + self.assertEqual(resolve_cli_platform(device_type="juniper_junos"), "juniper_junos") + self.assertEqual(resolve_cli_platform(device_type="nokia_sros"), "alcatel_sros") + self.assertEqual(resolve_cli_platform(device_type="alcatel_aos"), "alcatel_aos") + self.assertEqual(resolve_cli_platform(device_type="mikrotik_routeros"), "mikrotik_routeros") + + +class CustomTemplateTests(unittest.TestCase): + def test_zte_lldp_custom(self) -> None: + rows = parse_cli( + platform="zte_zxros", + command="show lldp neighbor brief", + text=ZTE_LLDP, + ) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].get("local_interface"), "gei-0/1/0/1") + self.assertEqual(rows[0].get("neighbor_name"), "R1") + + def test_zte_brief_custom(self) -> None: + rows = parse_cli( + platform="zte_zxros", + command="show interface brief", + text=ZTE_BRIEF, + ) + self.assertGreaterEqual(len(rows), 2) + self.assertEqual(rows[0].get("interface"), "xgei-1/1/0/1") + + def test_zte_detail_custom(self) -> None: + rows = parse_cli( + platform="zte_zxros", + command="show interface xgei-1/1/0/1", + text=ZTE_DETAIL, + ) + self.assertGreaterEqual(len(rows), 1) + hit = next((r for r in rows if str(r.get("input_bps") or "")), rows[0]) + self.assertEqual(str(hit.get("input_bps")), "824") + + +class WiredParserTests(unittest.TestCase): + def test_zte_lldp_parser(self) -> None: + hits = lldp.parse_zte_lldp(ZTE_LLDP) + self.assertEqual(len(hits), 1) + self.assertEqual(hits[0].remote_name, "R1") + self.assertEqual(hits[0].local_port, "gei-0/1/0/1") + + def test_cisco_lldp_community(self) -> None: + hits = lldp.parse_cisco_lldp(CISCO_LLDP_DETAIL) + self.assertEqual(len(hits), 1) + self.assertEqual(hits[0].remote_name.lower(), "r1") + self.assertEqual(hits[0].remote_ip, "192.168.0.1") + + def test_zte_brief_wired(self) -> None: + ports = parse_interface_brief(ZTE_BRIEF, "zte") + self.assertGreaterEqual(len(ports), 2) + self.assertEqual(ports[0].ifname, "xgei-1/1/0/1") + self.assertEqual(ports[0].admin, "up") + + def test_zte_detail_wired(self) -> None: + detail = parse_interface_detail( + ZTE_DETAIL, + "zte", + command="show interface xgei-1/1/0/1", + ifname="xgei-1/1/0/1", + ) + self.assertEqual(detail.ifname, "xgei-1/1/0/1") + self.assertEqual(detail.in_bps, 824.0) + self.assertEqual(detail.out_bps, 824.0) + self.assertGreaterEqual(detail.bw_bps, 1_000_000_000) + + def test_unknown_command_returns_empty(self) -> None: + ports = parse_interface_brief(ZTE_BRIEF, "zte", command="show totally unknown") + self.assertEqual(ports, []) + + def test_h3c_juniper_lldp_community(self) -> None: + h3c = """\ +Local Interface Chassis ID Port ID System Name +GE1/0/1 0000-5e00-0101 GE1/0/2 SW-A +""" + hits = lldp.parse_h3c_lldp(h3c) + self.assertEqual(len(hits), 1) + self.assertEqual(hits[0].remote_name, "SW-A") + self.assertEqual(hits[0].local_port, "GE1/0/1") + + junos = """\ +Local Interface Parent Interface Chassis Id Port info System Name +ge-0/0/1.0 ge-0/0/1 00:11:22:33:44:55 ge-0/0/0.0 r1 +""" + jhits = lldp.parse_juniper_lldp(junos) + self.assertEqual(len(jhits), 1) + self.assertEqual(jhits[0].remote_name, "r1") + self.assertEqual(jhits[0].local_port, "ge-0/0/1.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_port_traffic_parsers.py b/tests/test_port_traffic_parsers.py index 066f866..f4d9f82 100644 --- a/tests/test_port_traffic_parsers.py +++ b/tests/test_port_traffic_parsers.py @@ -258,7 +258,6 @@ class CommandsTests(unittest.TestCase): assert cmds is not None self.assertEqual(cmds.brief, "show interface brief") self.assertEqual(detail_command(cmds, "xgei-1/1/0/1"), "show interface xgei-1/1/0/1") - self.assertIsNone(commands_for_vendor("Nokia", "sros")) def test_huawei_matrix(self): cmds = commands_for_vendor("Huawei", "huawei_vrp") @@ -280,6 +279,32 @@ class CommandsTests(unittest.TestCase): "show interfaces GigabitEthernet0/1", ) + def test_h3c_juniper_nokia_mikrotik_matrix(self): + h3c = commands_for_vendor("H3C", "hp_comware") + assert h3c is not None + self.assertEqual(h3c.vendor_key, "h3c") + self.assertEqual(h3c.brief, "display interface brief") + self.assertEqual(detail_command(h3c, "GE1/0/1"), "display interface GE1/0/1") + + junos = commands_for_vendor("Juniper", "juniper_junos") + assert junos is not None + self.assertEqual(junos.vendor_key, "juniper") + self.assertEqual(junos.brief, "show interfaces") + self.assertEqual(detail_command(junos, "ge-0/0/0"), "show interfaces ge-0/0/0") + + nokia = commands_for_vendor("Nokia", "nokia_sros") + assert nokia is not None + self.assertEqual(nokia.vendor_key, "nokia") + self.assertEqual(nokia.brief, "show port") + self.assertEqual(detail_command(nokia, "1/1/1"), "show port 1/1/1") + + mikrotik = commands_for_vendor("MikroTik", "mikrotik_routeros") + assert mikrotik is not None + self.assertEqual(mikrotik.vendor_key, "mikrotik") + self.assertTrue(mikrotik.brief.startswith("/interface")) + + self.assertIsNone(commands_for_vendor("Ericsson", "ericsson_ipos")) + class ResolveUtilTests(unittest.TestCase): def test_keeps_vendor_util(self): diff --git a/tests/test_topology.py b/tests/test_topology.py index a5d928b..4653e85 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -83,7 +83,7 @@ class LldpParserTests(unittest.TestCase): self.assertEqual(hits[0].local_port, "Gi0/1") self.assertEqual(hits[0].remote_port, "Ethernet1/0/1") - def test_cisco_detail_fallback(self) -> None: + def test_cisco_detail(self) -> None: hits = lldp.parse_cisco_lldp(CISCO_LLDP_DETAIL) self.assertEqual(len(hits), 1) self.assertEqual(hits[0].remote_name.lower(), "r1") @@ -93,12 +93,37 @@ class LldpParserTests(unittest.TestCase): hits = lldp.parse_huawei_lldp(HUAWEI_LLDP) self.assertGreaterEqual(len(hits), 1) self.assertEqual(hits[0].remote_name.lower(), "r1") + self.assertEqual(hits[0].local_port, "GigabitEthernet0/0/1") + + def test_zte_brief(self) -> None: + hits = lldp.parse_zte_lldp(ZTE_LLDP_BRIEF) + self.assertEqual(len(hits), 1) + self.assertEqual(hits[0].remote_name, "R1") def test_pick_command_lldp_only(self) -> None: cmd, tag = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios") self.assertEqual(tag, "lldp") self.assertEqual(cmd, "show lldp neighbors detail") + def test_pick_command_community_vendors(self) -> None: + self.assertEqual( + lldp.pick_neighbor_command(vendor="H3C", device_type="hp_comware")[0], + "display lldp neighbor-information list", + ) + self.assertEqual( + lldp.pick_neighbor_command(vendor="Juniper", device_type="juniper_junos")[0], + "show lldp neighbors", + ) + self.assertEqual( + lldp.pick_neighbor_command(vendor="Nokia", device_type="alcatel_aos")[0], + "show lldp remote-system", + ) + key, stub = lldp.parser_meta(vendor="H3C", device_type="hp_comware") + self.assertEqual(key, "h3c") + self.assertFalse(stub) + key, stub = lldp.parser_meta(vendor="Ericsson", device_type="ericsson_ipos") + self.assertTrue(stub) + class DeadlockHelperTests(unittest.TestCase): def test_is_deadlock_error_detects_pg_message(self) -> None: diff --git a/web/WEB.md b/web/WEB.md index dc39c75..3084f75 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -132,7 +132,7 @@ src/ - 拓扑图:`topo_view.kind=physical|custom`(同站点下平级;建站自动建物理拓扑)+ `topo_view_node` - 边界:图 `filter.membership`(max_nodes / expand_hops / frozen);`project-neighbors` / `populate` 不得无界灌全网 - API:`/v1/topology/tree`、`/folders*`、`/fabric/*`、`/views*`(含 `populate`、`kind`) -- 前端:左侧站点→物理/自定义图;右侧目录浏览,打开本图进设备画布;「添加网元」上图 +- 前端:左侧站点→物理/自定义图;右侧目录浏览,打开本图进设备画布;「添加网元」支持勾选批量上图(也可拖放单台) - MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web - BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO diff --git a/web/index.html b/web/index.html index c2ab048..ea18e86 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - NetX + NETX
diff --git a/web/public/favicon.svg b/web/public/favicon.svg index 6893eb1..7097b3e 100644 --- a/web/public/favicon.svg +++ b/web/public/favicon.svg @@ -1 +1,16 @@ - \ No newline at end of file + + + + + + + + + + diff --git a/web/src/components/HeaderMenu.tsx b/web/src/components/HeaderMenu.tsx index f6baf01..6f20d55 100644 --- a/web/src/components/HeaderMenu.tsx +++ b/web/src/components/HeaderMenu.tsx @@ -1,67 +1,20 @@ -import { useEffect, useRef, useState } from "react"; -import { useI18n, type Locale } from "../i18n"; +import { useI18n } from "../i18n"; +/** Compact locale toggle: shows current language, click switches ZH ↔ EN. */ export function HeaderMenu() { const { t, locale, setLocale } = useI18n(); - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - - useEffect(() => { - if (!open) return; - const onDocClick = (ev: MouseEvent) => { - if (!rootRef.current?.contains(ev.target as Node)) setOpen(false); - }; - const onKey = (ev: KeyboardEvent) => { - if (ev.key === "Escape") setOpen(false); - }; - document.addEventListener("mousedown", onDocClick); - document.addEventListener("keydown", onKey); - return () => { - document.removeEventListener("mousedown", onDocClick); - document.removeEventListener("keydown", onKey); - }; - }, [open]); - - const pick = (next: Locale) => { - setLocale(next); - setOpen(false); - }; + const next = locale === "zh" ? "en" : "zh"; + const label = locale === "zh" ? "ZH" : "EN"; return ( -
- - {open ? ( -
-
{t("layout.language")}
- - -
- ) : null} -
+ ); } diff --git a/web/src/components/WorkbenchCardIcon.tsx b/web/src/components/WorkbenchCardIcon.tsx index 09eeb7a..4b244cb 100644 --- a/web/src/components/WorkbenchCardIcon.tsx +++ b/web/src/components/WorkbenchCardIcon.tsx @@ -1,14 +1,60 @@ -import type { ModuleIconTone } from "../config/modules"; +import type { ModuleIconKind, ModuleIconTone } from "../config/modules"; type Props = { tone: ModuleIconTone; + kind: ModuleIconKind; }; -export function WorkbenchCardIcon({ tone }: Props) { +function IconPath({ kind }: { kind: ModuleIconKind }) { + switch (kind) { + case "sync": + return ( + + ); + case "server": + return ( + + ); + case "network": + return ( + + ); + case "topology": + return ( + + ); + case "terminal": + return ( + + ); + case "wall": + return ( + + ); + case "users": + return ( + + ); + case "audit": + return ( + + ); + case "key": + return ( + + ); + default: + return ( + + ); + } +} + +export function WorkbenchCardIcon({ tone, kind }: Props) { return ( - + ); diff --git a/web/src/config/modules.ts b/web/src/config/modules.ts index 9f9ebff..f8c37f3 100644 --- a/web/src/config/modules.ts +++ b/web/src/config/modules.ts @@ -3,6 +3,16 @@ */ export type ModuleIconTone = "blue" | "green" | "amber" | "slate"; +export type ModuleIconKind = + | "sync" + | "server" + | "network" + | "topology" + | "terminal" + | "wall" + | "users" + | "audit" + | "key"; export type WorkbenchSection = "monitoring" | "operations" | "system"; export type ModuleDefinition = { @@ -12,6 +22,7 @@ export type ModuleDefinition = { labelKey: string; descKey?: string; iconTone: ModuleIconTone; + iconKind: ModuleIconKind; titleKey: string; /** Required capability scope to show in workbench (admin bypasses). */ requiredScope?: string; @@ -28,6 +39,7 @@ export const MODULES: readonly ModuleDefinition[] = [ labelKey: "workbench.cards.umeSync", descKey: "workbench.cards.umeSyncDesc", iconTone: "blue", + iconKind: "sync", titleKey: "layout.titleUme", requiredScope: "alarms:read", }, @@ -38,6 +50,7 @@ export const MODULES: readonly ModuleDefinition[] = [ labelKey: "workbench.cards.managedNe", descKey: "workbench.cards.managedNeDesc", iconTone: "green", + iconKind: "server", titleKey: "layout.titleManagedNe", requiredScope: "ne:read", }, @@ -48,6 +61,7 @@ export const MODULES: readonly ModuleDefinition[] = [ labelKey: "workbench.cards.network", descKey: "workbench.cards.networkDesc", iconTone: "slate", + iconKind: "network", titleKey: "layout.titleNetwork", requiredScope: "ne:read", }, @@ -58,6 +72,7 @@ export const MODULES: readonly ModuleDefinition[] = [ labelKey: "workbench.cards.topology", descKey: "workbench.cards.topologyDesc", iconTone: "amber", + iconKind: "topology", titleKey: "layout.titleTopology", requiredScope: "ne:read", }, @@ -68,6 +83,7 @@ export const MODULES: readonly ModuleDefinition[] = [ labelKey: "workbench.cards.webcrt", descKey: "workbench.cards.webcrtDesc", iconTone: "slate", + iconKind: "terminal", titleKey: "layout.titleWebcrt", requiredScope: "webcrt:session", }, @@ -78,6 +94,7 @@ export const MODULES: readonly ModuleDefinition[] = [ labelKey: "workbench.cards.portTrafficWall", descKey: "workbench.cards.portTrafficWallDesc", iconTone: "amber", + iconKind: "wall", titleKey: "layout.titlePortTrafficWall", workbenchHidden: true, requiredScope: "ne:read", @@ -89,6 +106,7 @@ export const MODULES: readonly ModuleDefinition[] = [ labelKey: "workbench.cards.users", descKey: "workbench.cards.usersDesc", iconTone: "slate", + iconKind: "users", titleKey: "layout.titleUsers", adminOnly: true, }, @@ -99,6 +117,7 @@ export const MODULES: readonly ModuleDefinition[] = [ labelKey: "workbench.cards.audit", descKey: "workbench.cards.auditDesc", iconTone: "amber", + iconKind: "audit", titleKey: "layout.titleAudit", }, { @@ -108,6 +127,7 @@ export const MODULES: readonly ModuleDefinition[] = [ labelKey: "workbench.cards.apiKeys", descKey: "workbench.cards.apiKeysDesc", iconTone: "green", + iconKind: "key", titleKey: "layout.titleApiKeys", }, ] as const satisfies readonly ModuleDefinition[]; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index f2f7ec0..c9c5322 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -14,6 +14,9 @@ const en = { }, workbench: { title: "Workbench", + modules: "Modules", + sections: "Sections", + moduleCount: "{{count}} modules", backToWorkbench: "Back to Workbench", openInNewTab: "Open in new tab", openModule: "Open or switch to this module tab", @@ -254,6 +257,13 @@ const en = { source: "Source", name: "Device", vendor: "Vendor", + ifname: "Interface", + bw: "BW", + upDown: "Admin / Phy / Prot", + admin: "Admin", + phy: "Phy", + prot: "Prot", + description: "Description", }, }, lldpLinks: { @@ -416,13 +426,16 @@ const en = { navUme: "UME", netxApi: "netx api", oclawBridge: "oclaw WSS", + activeTasks: "Active {{count}}", + activeTasksHint: "Open Audit · Task overview", language: "Language", + switchLanguage: "Switch language / 切换语言", moreMenu: "More", langZh: "中文", langEn: "English", }, auth: { - loginTitle: "Sign in to NetX", + loginTitle: "Sign in to NETX", loginHint: "Use a local account to access the ops platform", username: "Username", password: "Password", @@ -1278,7 +1291,12 @@ const en = { deleteMapConfirm: "Delete map \"{{name}}\"? Nodes and edges will be removed.", emptyMaps: "No regions yet. Create a region first.", palette: "NE library", - paletteHint: "Click or drag onto the canvas", + paletteHint: "Select multiple to add in batch, or drag one onto the canvas", + paletteDragHint: "Drag onto canvas to add", + selectAllVisible: "Select all visible", + addSelected: "Add selected ({{count}})", + addSelectedDone: "Added {{count}} NE(s)", + addingNe: "Adding…", filterPh: "Type to filter: name / IP / vendor", save: "Save", saveDirty: "Save *", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 3fa2743..b0cb436 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -14,6 +14,9 @@ const zh = { }, workbench: { title: "工作台", + modules: "可用模块", + sections: "功能分区", + moduleCount: "{{count}} 个模块", backToWorkbench: "返回工作台", openInNewTab: "在新标签页中打开", openModule: "打开或切换到该模块页签", @@ -251,6 +254,13 @@ const zh = { source: "来源", name: "设备", vendor: "厂商", + ifname: "接口", + bw: "带宽", + upDown: "Admin / Phy / Prot", + admin: "Admin", + phy: "Phy", + prot: "Prot", + description: "描述", }, }, lldpLinks: { @@ -413,13 +423,16 @@ const zh = { navUme: "UME 对接", netxApi: "netx api", oclawBridge: "oclaw WSS", + activeTasks: "活跃任务 {{count}}", + activeTasksHint: "打开操作审计 · 任务概览", language: "语言", + switchLanguage: "切换语言 / Switch language", moreMenu: "更多", langZh: "中文", langEn: "English", }, auth: { - loginTitle: "登录 NetX", + loginTitle: "登录 NETX", loginHint: "使用本地账号访问运维平台", username: "用户名", password: "密码", @@ -1272,7 +1285,12 @@ const zh = { deleteMapConfirm: "删除拓扑图「{{name}}」?节点与链路会一并删除。", emptyMaps: "还没有区域,先点「新建区域」。", palette: "网元库", - paletteHint: "点击或拖到画布添加", + paletteHint: "勾选后批量添加;也可拖到画布添加单台", + paletteDragHint: "拖到画布添加", + selectAllVisible: "全选当前列表", + addSelected: "添加选中({{count}})", + addSelectedDone: "已添加 {{count}} 台网元", + addingNe: "添加中…", filterPh: "输入即筛:名称 / IP / 厂商", save: "保存", saveDirty: "保存 *", diff --git a/web/src/index.css b/web/src/index.css index 47092d7..d2d8474 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -33,7 +33,7 @@ body { .app-brand__logo { font-size: 18px; font-weight: 700; - letter-spacing: 0.6px; + letter-spacing: 0.12em; color: #fff; text-decoration: none; flex-shrink: 0; @@ -116,59 +116,159 @@ body { } .workbench { - max-width: 1280px; + width: 100%; + max-width: none; +} + +.app-main:has(> .workbench) { + padding: 16px 20px 28px; +} + +.wb-head { + display: flex; + align-items: center; + margin-bottom: 18px; + padding-bottom: 12px; + border-bottom: 1px solid #d5deea; +} + +.wb-head__title { + margin: 0; + font-size: 18px; + font-weight: 700; + color: #0f2744; + letter-spacing: 0.02em; } .wb-section { - margin-bottom: 28px; + margin-bottom: 22px; +} + +.wb-section__head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; } .wb-section__title { - margin: 0 0 14px; - padding-bottom: 10px; - font-size: 15px; - font-weight: 600; + margin: 0; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 650; color: #334155; - border-bottom: 1px solid #d8dee8; +} + +.wb-section__title::before { + content: ""; + width: 3px; + height: 12px; + border-radius: 2px; + background: #64748b; +} + +.wb-section--monitoring .wb-section__title::before { + background: #1565c0; +} + +.wb-section--operations .wb-section__title::before { + background: #0f766e; +} + +.wb-section--system .wb-section__title::before { + background: #b45309; +} + +.wb-section__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: #e8eef5; + color: #64748b; + font-size: 11px; + font-weight: 650; + line-height: 1; } .wb-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 14px; + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: stretch; } -.wb-card { - display: flex; +.wb-card, +.app-main .wb-card { + display: inline-flex; align-items: center; - gap: 14px; - min-height: 72px; - padding: 14px 16px; - border: 1px solid #e2e8f0; - border-radius: 4px; + gap: 10px; + width: auto; + min-width: 132px; + max-width: 100%; + min-height: 0; + margin: 0; + padding: 12px 16px 12px 12px; + border: 1px solid #d8e0ea; + border-radius: 10px; background: #fff; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); + box-shadow: none; cursor: pointer; text-align: left; text-decoration: none; color: inherit; - transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; + transition: border-color 0.14s ease, background 0.14s ease, box-shadow 0.14s ease; } -.wb-card:hover { +.wb-card:hover, +.wb-card:focus-visible { + border-color: #8fb4df; + background: #f7faff; + box-shadow: 0 2px 10px rgba(15, 39, 68, 0.08); + transform: none; + outline: none; +} + +.wb-card--blue:hover, +.wb-card--blue:focus-visible { border-color: #90caf9; - box-shadow: 0 4px 14px rgba(33, 150, 243, 0.15); - transform: translateY(-1px); +} + +.wb-card--green:hover, +.wb-card--green:focus-visible { + border-color: #81c784; +} + +.wb-card--amber:hover, +.wb-card--amber:focus-visible { + border-color: #ffd54f; +} + +.wb-card--slate:hover, +.wb-card--slate:focus-visible { + border-color: #90a4ae; } .wb-card__icon { display: inline-flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; - border-radius: 4px; + width: 34px; + height: 38px; + border-radius: 0; flex-shrink: 0; + /* Flat-top hexagon */ + clip-path: polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%); +} + +.wb-card__icon svg { + width: 15px; + height: 15px; } .wb-card__icon--blue { @@ -191,23 +291,14 @@ body { color: #455a64; } -.wb-card__text { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} - .wb-card__label { - font-size: 14px; - font-weight: 600; - color: #1e293b; -} - -.wb-card__desc { - font-size: 12px; - color: #64748b; - line-height: 1.35; + min-width: 0; + font-size: 13px; + font-weight: 650; + color: #0f2744; + line-height: 1.25; + white-space: nowrap; + padding-right: 2px; } .header-menu__panel--light { @@ -658,6 +749,21 @@ pre { display: none; } +.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks { + cursor: pointer; + font: inherit; + font-size: 11px; + font-weight: 400; +} + +.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:hover, +.app-brand button.conn-pill.conn-pill--on-brand.conn-pill--tasks:focus-visible { + border-color: rgba(255, 255, 255, 0.28); + background: rgba(255, 255, 255, 0.14); + color: #fff; + outline: none; +} + .app-brand .header-menu__trigger.header-menu__trigger--on-brand { display: inline-flex; align-items: center; @@ -683,6 +789,14 @@ pre { letter-spacing: 0.06em; } +.app-brand .header-menu__trigger.header-menu__trigger--on-brand.header-menu__trigger--locale { + min-width: 36px; + padding: 0 8px; + font-size: 11px; + font-weight: 650; + letter-spacing: 0.04em; +} + .app-brand .header-menu__trigger.header-menu__trigger--on-brand:hover, .app-brand .header-menu__trigger.header-menu__trigger--on-brand:focus-visible { background: rgba(255, 255, 255, 0.14); @@ -4424,10 +4538,50 @@ pre { justify-content: space-between; gap: 8px; } +.topo-modal__hint { + margin: 0; +} +.topo-modal__selectbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.topo-modal__selectall { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--nm-muted, #5b6b7c); + cursor: pointer; + user-select: none; +} .topo-modal__list { max-height: 360px; overflow: auto; } +.topo-modal__foot { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding-top: 6px; + border-top: 1px solid #e2e8f0; +} +.topo-palette__check { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0 8px; + border-right: 1px solid #cdd6e2; + cursor: pointer; +} +.topo-palette__row.is-selected { + border-color: var(--nm-accent, #2563eb); + background: var(--nm-nav-active, #e8f0fb); + box-shadow: inset 2px 0 0 var(--nm-accent, #2563eb); +} .topo-map-list, .topo-palette { list-style: none; @@ -6565,6 +6719,27 @@ pre { margin-top: 2px; } +.pt-port-updown { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.pt-port-updown .conn-pill { + min-width: 2.4em; + justify-content: center; + text-transform: lowercase; + font-variant-numeric: tabular-nums; +} + +.pt-wizard__confirm-port { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} + .pt-list-status--ok, .pt-list-status--up, .pt-list-status--connected { diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx index 7dfb0b5..e6b2c2b 100644 --- a/web/src/layout/AppLayout.tsx +++ b/web/src/layout/AppLayout.tsx @@ -1,10 +1,13 @@ import { Link, useLocation } from "react-router-dom"; import type { ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; import { AppsGridIcon } from "../components/AppsGridIcon"; import { HeaderMenu } from "../components/HeaderMenu"; import { getPageTitleKey, isWorkbenchPath } from "../config/modules"; import { useAppWindowRegistration } from "../hooks/useAppWindowRegistration"; import { useI18n } from "../i18n"; +import { fetchOpsTasks } from "../services/api"; +import { openOrFocusModule } from "../utils/moduleWindows"; import { returnToWorkbench } from "../utils/workbench"; import { useAuth } from "../auth/AuthContext"; @@ -30,6 +33,14 @@ export function AppLayout({ connections, children }: Props) { const { user, logout } = useAuth(); const onWorkbench = isWorkbenchPath(pathname); const pageTitle = t(getPageTitleKey(pathname)); + const opsTasksQuery = useQuery({ + queryKey: ["opsTasks"], + queryFn: fetchOpsTasks, + enabled: Boolean(user), + refetchInterval: 4000, + staleTime: 1500, + }); + const activeTasks = opsTasksQuery.data?.active ?? 0; const netxSuffix = typeof connections.netxApiLatencyMs === "number" ? ` (${connections.netxApiLatencyMs}ms)` : ""; const oclawSuffix = @@ -55,10 +66,10 @@ export function AppLayout({ connections, children }: Props) {
{onWorkbench ? ( - NetX + NETX ) : ( - NetX + NETX )} {!onWorkbench ? ( <> @@ -94,6 +105,14 @@ export function AppLayout({ connections, children }: Props) { <>
+
void onSubmit(e)}> -
NetX
+
NETX

{t("auth.forceChangeTitle")}

{t("auth.forceChangeHint", { user: user?.username || "admin" })} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index e5fb67e..7baf53f 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -33,7 +33,7 @@ export function LoginPage() { return (

void onSubmit(e)}> -
NetX
+
NETX

{t("auth.loginTitle")}

{t("auth.loginHint")}