mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 22:20:58 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
60186a12e3
commit
393fd0e0b4
41 changed files with 1771 additions and 1075 deletions
57
netx_api/cli_templates/README.md
Normal file
57
netx_api/cli_templates/README.md
Normal file
|
|
@ -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).
|
||||
0
netx_api/cli_templates/cisco/.gitkeep
Normal file
0
netx_api/cli_templates/cisco/.gitkeep
Normal file
|
|
@ -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
|
||||
0
netx_api/cli_templates/h3c/.gitkeep
Normal file
0
netx_api/cli_templates/h3c/.gitkeep
Normal file
0
netx_api/cli_templates/huawei/.gitkeep
Normal file
0
netx_api/cli_templates/huawei/.gitkeep
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
25
netx_api/cli_templates/index
Normal file
25
netx_api/cli_templates/index
Normal file
|
|
@ -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)
|
||||
0
netx_api/cli_templates/juniper/.gitkeep
Normal file
0
netx_api/cli_templates/juniper/.gitkeep
Normal file
30
netx_api/cli_templates/zte/zte_zxros_show_interface.textfsm
Normal file
30
netx_api/cli_templates/zte/zte_zxros_show_interface.textfsm
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ SUPPORTED_VENDORS: tuple[str, ...] = (
|
|||
"ZTE",
|
||||
"Huawei",
|
||||
"Cisco",
|
||||
"H3C",
|
||||
"Juniper",
|
||||
"Nokia",
|
||||
"Other",
|
||||
|
|
|
|||
176
netx_api/ntc_parse.py
Normal file
176
netx_api/ntc_parse.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"(?<![\d.])(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)(?![\d.])"
|
||||
)
|
||||
|
||||
# device_type (Netmiko) -> 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<local>\S+)\s+"
|
||||
r"(?P<scope>[A-Za-z]{2,8})\s+"
|
||||
r"(?P<chassis>\S+)\s+"
|
||||
r"(?P<port>\S+)\s+"
|
||||
r"(?P<hold>\d+)\s+"
|
||||
r"(?P<name>\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 "<ifname> 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
146
tests/test_ntc_parse.py
Normal file
146
tests/test_ntc_parse.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NetX</title>
|
||||
<title>NETX</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 655 B |
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div className="header-menu header-menu--on-brand" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="header-menu__trigger header-menu__trigger--on-brand header-menu__trigger--icon"
|
||||
aria-label={t("layout.moreMenu")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="header-menu__panel header-menu__panel--light" role="menu">
|
||||
<div className="header-menu__label header-menu__label--light">{t("layout.language")}</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
className={`header-menu__item header-menu__item--light${locale === "zh" ? " header-menu__item--active-light" : ""}`}
|
||||
aria-checked={locale === "zh"}
|
||||
onClick={() => pick("zh")}
|
||||
>
|
||||
{t("layout.langZh")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
className={`header-menu__item header-menu__item--light${locale === "en" ? " header-menu__item--active-light" : ""}`}
|
||||
aria-checked={locale === "en"}
|
||||
onClick={() => pick("en")}
|
||||
>
|
||||
{t("layout.langEn")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="header-menu__trigger header-menu__trigger--on-brand header-menu__trigger--locale"
|
||||
title={t("layout.switchLanguage")}
|
||||
aria-label={t("layout.switchLanguage")}
|
||||
onClick={() => setLocale(next)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" />
|
||||
);
|
||||
case "server":
|
||||
return (
|
||||
<path d="M4 4h16a1 1 0 0 1 1 1v5H3V5a1 1 0 0 1 1-1zm-1 8h18v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5zm3 1.25a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zm0-8a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5zM8 6.5h6v1.25H8V6.5zm0 8h6v1.25H8V14.5z" />
|
||||
);
|
||||
case "network":
|
||||
return (
|
||||
<path d="M12 2a3 3 0 0 1 1 5.83V10h3a3 3 0 1 1 0 2h-3v2.17A3.001 3.001 0 1 1 11 17.83V14H8a3 3 0 1 1 0-2h3v-2.17A3.001 3.001 0 0 1 12 2zm0 2a1 1 0 1 0 .001 2.001A1 1 0 0 0 12 4zM6 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm12 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm-6 6a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
|
||||
);
|
||||
case "topology":
|
||||
return (
|
||||
<path d="M7 3a3 3 0 0 1 2.83 4H11v2h2V7h1.17A3.001 3.001 0 1 1 17 10.83V12h2v2h-2v1.17A3.001 3.001 0 1 1 13.17 18H12v-2h-1.17A3.001 3.001 0 1 1 7 13.17V12H5v-2h2V8.83A3.001 3.001 0 0 1 7 3zm10 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2zM7 5a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm0 12a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm10 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2z" />
|
||||
);
|
||||
case "terminal":
|
||||
return (
|
||||
<path d="M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5zm2 0v14h12V5H6zm2.3 3.2 1.4-1.4L13.1 10l-3.4 3.2-1.4-1.4L10.3 10 8.3 8.2zM12 15h4v1.8h-4V15z" />
|
||||
);
|
||||
case "wall":
|
||||
return (
|
||||
<path d="M3 5h8v6H3V5zm10 0h8v6h-8V5zM3 13h8v6H3v-6zm10 0h8v6h-8v-6zm2-6v2h4V7h-4zm0 8v2h4v-2h-4zM5 7v2h4V7H5zm0 8v2h4v-2H5z" />
|
||||
);
|
||||
case "users":
|
||||
return (
|
||||
<path d="M12 12a4 4 0 1 0-4-4 4 4 0 0 0 4 4zm0 2c-3.33 0-6 1.34-6 3v1h12v-1c0-1.66-2.67-3-6-3zm7.5-5.5a2.5 2.5 0 1 0-2.45-3H17a2.5 2.5 0 0 0 2.5 3.5zM19 12c1.66 0 3 .67 3 1.5V14h-2.1c-.3-.7-1-1.2-1.9-1.5v-.5z" />
|
||||
);
|
||||
case "audit":
|
||||
return (
|
||||
<path d="M7 3h7l5 5v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zm7 1.5V9h4.5L14 4.5zM9 12h6v1.5H9V12zm0 3.5h6V17H9v-1.5zm0-7h3V10H9V8.5z" />
|
||||
);
|
||||
case "key":
|
||||
return (
|
||||
<path d="M12.65 10A5.99 5.99 0 0 0 7 4a6 6 0 0 0-1 11.92V20l2 2 2-2 1-1v-2.08c.33.05.66.08 1 .08a6 6 0 0 0 5.65-8H12.65zM7 8a2 2 0 1 1 0 4 2 2 0 0 1 0-4z" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<path d="M12 2a2 2 0 0 1 2 2v1.07A7.002 7.002 0 0 1 19.93 11H21a2 2 0 0 1 0 4h-1.07A7.002 7.002 0 0 1 13 20.93V22a2 2 0 0 1-4 0v-1.07A7.002 7.002 0 0 1 4.07 15H3a2 2 0 0 1 0-4h1.07A7.002 7.002 0 0 1 11 5.07V4a2 2 0 0 1 2-2zm0 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkbenchCardIcon({ tone, kind }: Props) {
|
||||
return (
|
||||
<span className={`wb-card__icon wb-card__icon--${tone}`} aria-hidden>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||
<path d="M12 2a2 2 0 0 1 2 2v1.07A7.002 7.002 0 0 1 19.93 11H21a2 2 0 0 1 0 4h-1.07A7.002 7.002 0 0 1 13 20.93V22a2 2 0 0 1-4 0v-1.07A7.002 7.002 0 0 1 4.07 15H3a2 2 0 0 1 0-4h1.07A7.002 7.002 0 0 1 11 5.07V4a2 2 0 0 1 2-2zm0 6a4 4 0 1 0 0 8 4 4 0 0 0 0-8z" />
|
||||
<IconPath kind={kind} />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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 *",
|
||||
|
|
|
|||
|
|
@ -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: "保存 *",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<div className="app-brand__start">
|
||||
{onWorkbench ? (
|
||||
<Link to="/" className="app-brand__logo">
|
||||
NetX
|
||||
NETX
|
||||
</Link>
|
||||
) : (
|
||||
<span className="app-brand__logo app-brand__logo--static">NetX</span>
|
||||
<span className="app-brand__logo app-brand__logo--static">NETX</span>
|
||||
)}
|
||||
{!onWorkbench ? (
|
||||
<>
|
||||
|
|
@ -94,6 +105,14 @@ export function AppLayout({ connections, children }: Props) {
|
|||
<>
|
||||
<span className="app-brand__actions-sep" aria-hidden />
|
||||
<div className="app-brand__actions-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`conn-pill conn-pill--on-brand conn-pill--tasks conn-pill--${activeTasks > 0 ? "up" : "unknown"}`}
|
||||
title={t("layout.activeTasksHint")}
|
||||
onClick={() => openOrFocusModule({ moduleId: "audit", path: "/audit/tasks" })}
|
||||
>
|
||||
{t("layout.activeTasks", { count: activeTasks })}
|
||||
</button>
|
||||
<span
|
||||
className="conn-pill conn-pill--on-brand conn-pill--user"
|
||||
title={user.role}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function ForceChangePasswordPage() {
|
|||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="login-card__brand">NetX</div>
|
||||
<div className="login-card__brand">NETX</div>
|
||||
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
|
||||
<p className="login-card__hint">
|
||||
{t("auth.forceChangeHint", { user: user?.username || "admin" })}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export function LoginPage() {
|
|||
return (
|
||||
<div className="login-page">
|
||||
<form className="login-card" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="login-card__brand">NetX</div>
|
||||
<div className="login-card__brand">NETX</div>
|
||||
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
|
||||
<p className="login-card__hint">{t("auth.loginHint")}</p>
|
||||
<label className="login-card__label">
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ import { useToast } from "../hooks/useToast";
|
|||
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||
import type {
|
||||
FabricNodeSearchHit,
|
||||
ManagedNeItem,
|
||||
TopologyDiscoverJob,
|
||||
TopologyDiscoverNeResult,
|
||||
TopologyDiscoverOut,
|
||||
|
|
@ -64,7 +63,6 @@ import type {
|
|||
TopologyViewNodeItem,
|
||||
TopologyViewKind,
|
||||
TopologyViewRole,
|
||||
UmeNeItem,
|
||||
} from "../types";
|
||||
import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph";
|
||||
import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode";
|
||||
|
|
@ -641,6 +639,8 @@ export function TopologyPage() {
|
|||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [addNeOpen, setAddNeOpen] = useState(false);
|
||||
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
||||
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
|
||||
const [paletteAdding, setPaletteAdding] = useState(false);
|
||||
const [discoverOpen, setDiscoverOpen] = useState(false);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
|
||||
|
|
@ -1136,7 +1136,19 @@ export function TopologyPage() {
|
|||
);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: () => patchTopologyPositions(mapId, flowToPositions(nodes)),
|
||||
mutationFn: async () => {
|
||||
if (!mapId) throw new Error(t("topology.selectMap"));
|
||||
// Positions patch only updates listed nodes; sync canvas removals first.
|
||||
const serverIds = (graphQuery.data?.nodes || [])
|
||||
.map((n) => n.fabric_node_id)
|
||||
.filter(Boolean);
|
||||
const localIds = new Set(nodes.map((n) => n.id));
|
||||
const toRemove = serverIds.filter((id) => !localIds.has(id));
|
||||
if (toRemove.length) {
|
||||
await removeTopologyViewNodes(mapId, toRemove);
|
||||
}
|
||||
return patchTopologyPositions(mapId, flowToPositions(nodes));
|
||||
},
|
||||
onSuccess: async (graph) => {
|
||||
clearDirty();
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
|
|
@ -1336,47 +1348,60 @@ export function TopologyPage() {
|
|||
[setEdges, setNodes, pushHistory, edgeDefaults, isValidConnection, mapId, queryClient, clearDirty, showError],
|
||||
);
|
||||
|
||||
const addNodeAt = useCallback(
|
||||
async (item: PaletteItem, position: { x: number; y: number }) => {
|
||||
const addPaletteItems = useCallback(
|
||||
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => {
|
||||
if (!mapId) {
|
||||
showError(t("topology.selectMap"));
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (items.length === 0 || paletteAdding) return false;
|
||||
|
||||
const onCanvasManaged = new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean));
|
||||
const onCanvasUme = new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean));
|
||||
const managedIds = [
|
||||
...new Set(
|
||||
items
|
||||
.filter((item) => item.source === "managed")
|
||||
.map((item) => item.managed_ne_id)
|
||||
.filter((id) => id && !onCanvasManaged.has(id)),
|
||||
),
|
||||
];
|
||||
const umeIds = [
|
||||
...new Set(
|
||||
items
|
||||
.filter((item) => item.source === "ume")
|
||||
.map((item) => item.ume_ne_id)
|
||||
.filter((id) => id && !onCanvasUme.has(id)),
|
||||
),
|
||||
];
|
||||
if (managedIds.length === 0 && umeIds.length === 0) return false;
|
||||
|
||||
setPaletteAdding(true);
|
||||
try {
|
||||
if (item.source === "managed") {
|
||||
if (nodes.some((n) => n.data.managed_ne_id === item.managed_ne_id)) return;
|
||||
const graph = await addTopologyViewNodes(mapId, { managed_ne_ids: [item.managed_ne_id] });
|
||||
const added = graph.nodes.find((n) => n.managed_ne_id === item.managed_ne_id);
|
||||
if (added) {
|
||||
await patchTopologyPositions(mapId, [
|
||||
{
|
||||
fabric_node_id: added.fabric_node_id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
label: added.label || added.name || "",
|
||||
},
|
||||
]);
|
||||
}
|
||||
const refreshed = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
return;
|
||||
}
|
||||
if (nodes.some((n) => n.data.ume_ne_id === item.ume_ne_id)) return;
|
||||
const graph = await addTopologyViewNodes(mapId, { ume_ne_ids: [item.ume_ne_id] });
|
||||
const added = graph.nodes.find((n) => n.ume_ne_id === item.ume_ne_id);
|
||||
if (added) {
|
||||
await patchTopologyPositions(mapId, [
|
||||
{
|
||||
fabric_node_id: added.fabric_node_id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
label: added.label || added.name || "",
|
||||
},
|
||||
]);
|
||||
const graph = await addTopologyViewNodes(mapId, {
|
||||
managed_ne_ids: managedIds,
|
||||
ume_ne_ids: umeIds,
|
||||
layout: "grid",
|
||||
});
|
||||
const managedSet = new Set(managedIds);
|
||||
const umeSet = new Set(umeIds);
|
||||
const added = graph.nodes.filter(
|
||||
(n) =>
|
||||
(n.managed_ne_id && managedSet.has(n.managed_ne_id)) ||
|
||||
(n.ume_ne_id && umeSet.has(n.ume_ne_id)),
|
||||
);
|
||||
if (added.length > 0) {
|
||||
const start = origin || { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 };
|
||||
const cols = Math.max(1, Math.ceil(Math.sqrt(added.length)));
|
||||
await patchTopologyPositions(
|
||||
mapId,
|
||||
added.map((n, i) => ({
|
||||
fabric_node_id: n.fabric_node_id,
|
||||
x: start.x + (i % cols) * 180,
|
||||
y: start.y + Math.floor(i / cols) * 120,
|
||||
label: n.label || n.name || "",
|
||||
})),
|
||||
);
|
||||
}
|
||||
const refreshed = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
|
||||
|
|
@ -1384,57 +1409,37 @@ export function TopologyPage() {
|
|||
applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
const count = added.length || managedIds.length + umeIds.length;
|
||||
showOk(t("topology.addSelectedDone").replace("{{count}}", String(count)));
|
||||
return count > 0;
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setPaletteAdding(false);
|
||||
}
|
||||
},
|
||||
[mapId, nodes, setNodes, setEdges, showError, t, queryClient, edgeDefaults, clearDirty],
|
||||
[
|
||||
mapId,
|
||||
nodes,
|
||||
paletteAdding,
|
||||
setNodes,
|
||||
setEdges,
|
||||
showError,
|
||||
showOk,
|
||||
t,
|
||||
queryClient,
|
||||
edgeDefaults,
|
||||
clearDirty,
|
||||
],
|
||||
);
|
||||
|
||||
const addManagedNeToCanvas = (ne: ManagedNeItem) => {
|
||||
addNodeAt(
|
||||
{
|
||||
key: `managed:${ne.id}`,
|
||||
source: "managed",
|
||||
managed_ne_id: ne.id,
|
||||
ume_ne_id: "",
|
||||
name: ne.name || ne.ip_address,
|
||||
ip: ne.ip_address,
|
||||
vendor: ne.vendor,
|
||||
meta: "",
|
||||
connect_status: ne.connect_status,
|
||||
},
|
||||
{ x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 },
|
||||
);
|
||||
};
|
||||
|
||||
const addUmeNeToCanvas = (ne: UmeNeItem) => {
|
||||
const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim();
|
||||
addNodeAt(
|
||||
{
|
||||
key: `ume:${ne.ne_id}`,
|
||||
source: "ume",
|
||||
managed_ne_id: "",
|
||||
ume_ne_id: ne.ne_id,
|
||||
name,
|
||||
ip: ne.ip_address || "",
|
||||
vendor: "ZTE",
|
||||
meta: "",
|
||||
connect_status: ne.connection_status || "",
|
||||
},
|
||||
{ x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 },
|
||||
);
|
||||
};
|
||||
|
||||
const addPaletteItem = (item: PaletteItem) => {
|
||||
if (item.source === "managed") {
|
||||
const ne = (neQuery.data?.items || []).find((x) => x.id === item.managed_ne_id);
|
||||
if (ne) addManagedNeToCanvas(ne);
|
||||
return;
|
||||
}
|
||||
const ne = (umeQuery.data?.items || []).find((x) => x.ne_id === item.ume_ne_id);
|
||||
if (ne) addUmeNeToCanvas(ne);
|
||||
};
|
||||
const addNodeAt = useCallback(
|
||||
async (item: PaletteItem, position: { x: number; y: number }) => {
|
||||
await addPaletteItems([item], position);
|
||||
},
|
||||
[addPaletteItems],
|
||||
);
|
||||
|
||||
const onPaletteDragStart = (e: React.DragEvent, item: PaletteItem) => {
|
||||
e.dataTransfer.setData(PALETTE_DND, JSON.stringify(item));
|
||||
|
|
@ -1660,12 +1665,20 @@ export function TopologyPage() {
|
|||
};
|
||||
}, [ctxMenu, closeCtxMenu]);
|
||||
|
||||
const removeNodeById = (nodeId: string) => {
|
||||
const removeNodeById = async (nodeId: string) => {
|
||||
if (!mapId) return;
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
|
||||
setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId));
|
||||
closeCtxMenu();
|
||||
try {
|
||||
const graph = await removeTopologyViewNodes(mapId, [nodeId]);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
clearDirty();
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const removeEdgeById = (edgeId: string) => {
|
||||
|
|
@ -2403,6 +2416,7 @@ export function TopologyPage() {
|
|||
className="btn btn--sm"
|
||||
onClick={() => {
|
||||
setKeyword("");
|
||||
setPaletteSelectedKeys([]);
|
||||
setAddNeOpen(true);
|
||||
}}
|
||||
>
|
||||
|
|
@ -3049,23 +3063,38 @@ export function TopologyPage() {
|
|||
</main>
|
||||
|
||||
{addNeOpen && canvasMode ? (
|
||||
|
||||
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
|
||||
<div className="topo-modal__backdrop" onClick={() => setAddNeOpen(false)} />
|
||||
<div
|
||||
className="topo-modal__backdrop"
|
||||
onClick={() => {
|
||||
if (paletteAdding) return;
|
||||
setAddNeOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div className="topo-modal__panel">
|
||||
<div className="topo-modal__head">
|
||||
<strong>{t("topology.addNe")}</strong>
|
||||
<button type="button" className="btn btn--sm btn--ghost" onClick={() => setAddNeOpen(false)}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={paletteAdding}
|
||||
onClick={() => setAddNeOpen(false)}
|
||||
>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
|
||||
<div className="topo-palette-source" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={paletteSource === "managed"}
|
||||
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
|
||||
onClick={() => setPaletteSource("managed")}
|
||||
disabled={paletteAdding}
|
||||
onClick={() => {
|
||||
setPaletteSource("managed");
|
||||
setPaletteSelectedKeys([]);
|
||||
}}
|
||||
>
|
||||
{t("topology.paletteManaged")}
|
||||
</button>
|
||||
|
|
@ -3074,7 +3103,11 @@ export function TopologyPage() {
|
|||
role="tab"
|
||||
aria-selected={paletteSource === "ume"}
|
||||
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
|
||||
onClick={() => setPaletteSource("ume")}
|
||||
disabled={paletteAdding}
|
||||
onClick={() => {
|
||||
setPaletteSource("ume");
|
||||
setPaletteSelectedKeys([]);
|
||||
}}
|
||||
>
|
||||
{t("topology.paletteUme")}
|
||||
</button>
|
||||
|
|
@ -3082,10 +3115,40 @@ export function TopologyPage() {
|
|||
<input
|
||||
className="input"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setPaletteSelectedKeys([]);
|
||||
}}
|
||||
placeholder={t("topology.filterPh")}
|
||||
disabled={paletteAdding}
|
||||
autoFocus
|
||||
/>
|
||||
{paletteVisible.length > 0 ? (
|
||||
<div className="topo-modal__selectbar">
|
||||
<label className="topo-modal__selectall">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
paletteVisible.length > 0 &&
|
||||
paletteVisible.every((item) => paletteSelectedKeys.includes(item.key))
|
||||
}
|
||||
disabled={paletteAdding}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setPaletteSelectedKeys(paletteVisible.map((item) => item.key));
|
||||
return;
|
||||
}
|
||||
setPaletteSelectedKeys([]);
|
||||
}}
|
||||
aria-label={t("topology.selectAllVisible")}
|
||||
/>
|
||||
<span>{t("topology.selectAllVisible")}</span>
|
||||
</label>
|
||||
<span className="panel__hint">
|
||||
{t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<ul className="topo-palette topo-modal__list">
|
||||
{paletteLoading ? (
|
||||
<li className="topo-palette__empty">
|
||||
|
|
@ -3096,23 +3159,77 @@ export function TopologyPage() {
|
|||
<span className="panel__hint">{t("topology.paletteEmpty")}</span>
|
||||
</li>
|
||||
) : (
|
||||
paletteVisible.map((item) => (
|
||||
<li key={item.key}>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-palette__item"
|
||||
onClick={() => {
|
||||
addPaletteItem(item);
|
||||
setAddNeOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="topo-palette__name">{item.name}</span>
|
||||
<span className="topo-palette__meta">{item.meta}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
paletteVisible.map((item) => {
|
||||
const checked = paletteSelectedKeys.includes(item.key);
|
||||
return (
|
||||
<li key={item.key}>
|
||||
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
|
||||
<label className="topo-palette__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={paletteAdding}
|
||||
onChange={(e) => {
|
||||
setPaletteSelectedKeys((prev) =>
|
||||
e.target.checked
|
||||
? [...new Set([...prev, item.key])]
|
||||
: prev.filter((key) => key !== item.key),
|
||||
);
|
||||
}}
|
||||
aria-label={item.name}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-palette__item"
|
||||
draggable={!paletteAdding}
|
||||
disabled={paletteAdding}
|
||||
onClick={() => {
|
||||
setPaletteSelectedKeys((prev) =>
|
||||
prev.includes(item.key)
|
||||
? prev.filter((key) => key !== item.key)
|
||||
: [...prev, item.key],
|
||||
);
|
||||
}}
|
||||
onDragStart={(e) => onPaletteDragStart(e, item)}
|
||||
title={t("topology.paletteDragHint")}
|
||||
>
|
||||
<span className="topo-palette__name">{item.name}</span>
|
||||
<span className="topo-palette__meta">{item.meta}</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
<div className="topo-modal__foot">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={paletteAdding}
|
||||
onClick={() => setAddNeOpen(false)}
|
||||
>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={paletteSelectedKeys.length === 0 || paletteAdding}
|
||||
onClick={async () => {
|
||||
const selected = paletteVisible.filter((item) => paletteSelectedKeys.includes(item.key));
|
||||
if (selected.length === 0) return;
|
||||
const ok = await addPaletteItems(selected);
|
||||
if (!ok) return;
|
||||
setPaletteSelectedKeys([]);
|
||||
setAddNeOpen(false);
|
||||
}}
|
||||
>
|
||||
{paletteAdding
|
||||
? t("topology.addingNe")
|
||||
: t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
|
||||
import { modulesInSection, type WorkbenchSection } from "../config/modules";
|
||||
import { modulesInSection, type ModuleDefinition, type WorkbenchSection } from "../config/modules";
|
||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
|
|
@ -10,34 +11,49 @@ export function WorkbenchPage() {
|
|||
const { t } = useI18n();
|
||||
const { isAdmin, hasScope } = useAuth();
|
||||
|
||||
const visibleBySection = useMemo(() => {
|
||||
const canSee = (mod: ModuleDefinition) =>
|
||||
!mod.workbenchHidden &&
|
||||
(!mod.adminOnly || isAdmin) &&
|
||||
(!mod.requiredScope || hasScope(mod.requiredScope) || isAdmin);
|
||||
|
||||
return Object.fromEntries(
|
||||
SECTIONS.map((section) => [section, modulesInSection(section).filter(canSee)]),
|
||||
) as Record<WorkbenchSection, ModuleDefinition[]>;
|
||||
}, [isAdmin, hasScope]);
|
||||
|
||||
return (
|
||||
<div className="workbench">
|
||||
{SECTIONS.map((section) => (
|
||||
<section key={section} className="wb-section">
|
||||
<h2 className="wb-section__title">{t(`workbench.${section}`)}</h2>
|
||||
<div className="wb-grid">
|
||||
{modulesInSection(section)
|
||||
.filter((mod) => !mod.workbenchHidden)
|
||||
.filter((mod) => !mod.adminOnly || isAdmin)
|
||||
.filter((mod) => !mod.requiredScope || hasScope(mod.requiredScope) || isAdmin)
|
||||
.map((mod) => (
|
||||
<button
|
||||
key={mod.moduleId}
|
||||
type="button"
|
||||
className="wb-card"
|
||||
title={t("workbench.openModule")}
|
||||
onClick={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
|
||||
>
|
||||
<WorkbenchCardIcon tone={mod.iconTone} />
|
||||
<span className="wb-card__text">
|
||||
<header className="wb-head">
|
||||
<h1 className="wb-head__title">{t("workbench.title")}</h1>
|
||||
</header>
|
||||
|
||||
{SECTIONS.map((section) => {
|
||||
const mods = visibleBySection[section];
|
||||
if (mods.length === 0) return null;
|
||||
return (
|
||||
<section key={section} className={`wb-section wb-section--${section}`}>
|
||||
<div className="wb-section__head">
|
||||
<h2 className="wb-section__title">{t(`workbench.${section}`)}</h2>
|
||||
<span className="wb-section__count">{mods.length}</span>
|
||||
</div>
|
||||
<div className="wb-grid">
|
||||
{mods.map((mod) => (
|
||||
<button
|
||||
key={mod.moduleId}
|
||||
type="button"
|
||||
className={`wb-card wb-card--${mod.iconTone}`}
|
||||
title={t("workbench.openModule")}
|
||||
onClick={() => openOrFocusModule({ moduleId: mod.moduleId, path: mod.path })}
|
||||
>
|
||||
<WorkbenchCardIcon tone={mod.iconTone} kind={mod.iconKind} />
|
||||
<span className="wb-card__label">{t(mod.labelKey)}</span>
|
||||
{mod.descKey ? <span className="wb-card__desc">{t(mod.descKey)}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,42 @@ function statusTone(status: string): "running" | "paused" | "stopped" | "other"
|
|||
return "other";
|
||||
}
|
||||
|
||||
function upDownPillClass(value: string): string {
|
||||
const v = String(value || "").trim().toLowerCase();
|
||||
if (v === "up") return "conn-pill conn-pill--up";
|
||||
if (v === "down") return "conn-pill conn-pill--down";
|
||||
return "conn-pill conn-pill--unknown";
|
||||
}
|
||||
|
||||
function PortUpDownCell({
|
||||
admin,
|
||||
phy,
|
||||
prot,
|
||||
labels,
|
||||
}: {
|
||||
admin?: string;
|
||||
phy?: string;
|
||||
prot?: string;
|
||||
labels: { admin: string; phy: string; prot: string };
|
||||
}) {
|
||||
const a = String(admin || "").trim() || "—";
|
||||
const p = String(phy || "").trim() || "—";
|
||||
const r = String(prot || "").trim() || "—";
|
||||
return (
|
||||
<span className="pt-port-updown" title={`${labels.admin}/${labels.phy}/${labels.prot}`}>
|
||||
<span className={upDownPillClass(a)} title={labels.admin}>
|
||||
{a}
|
||||
</span>
|
||||
<span className={upDownPillClass(p)} title={labels.phy}>
|
||||
{p}
|
||||
</span>
|
||||
<span className={upDownPillClass(r)} title={labels.prot}>
|
||||
{r}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPortTrafficLogMessage(
|
||||
raw: string,
|
||||
t: (key: string, vars?: Record<string, string | number>) => string,
|
||||
|
|
@ -442,6 +478,15 @@ export function PortTrafficPage() {
|
|||
};
|
||||
|
||||
const pickedList = useMemo(() => Object.values(pickedIfnames), [pickedIfnames]);
|
||||
const discoveredByIfname = useMemo(
|
||||
() => Object.fromEntries(ports.map((p) => [p.ifname, p])),
|
||||
[ports],
|
||||
);
|
||||
const upDownLabels = {
|
||||
admin: t("portTraffic.col.admin"),
|
||||
phy: t("portTraffic.col.phy"),
|
||||
prot: t("portTraffic.col.prot"),
|
||||
};
|
||||
|
||||
const submitWizard = () => {
|
||||
if (!selectedNe) {
|
||||
|
|
@ -801,10 +846,10 @@ export function PortTrafficPage() {
|
|||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>Interface</th>
|
||||
<th>BW</th>
|
||||
<th>Admin/Phy/Prot</th>
|
||||
<th>Description</th>
|
||||
<th>{t("portTraffic.col.ifname")}</th>
|
||||
<th>{t("portTraffic.col.bw")}</th>
|
||||
<th>{t("portTraffic.col.upDown")}</th>
|
||||
<th>{t("portTraffic.col.description")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -820,7 +865,12 @@ export function PortTrafficPage() {
|
|||
<td>{p.ifname}</td>
|
||||
<td>{p.bw_raw || formatBw(p.bw_bps)}</td>
|
||||
<td>
|
||||
{p.admin}/{p.phy}/{p.prot}
|
||||
<PortUpDownCell
|
||||
admin={p.admin}
|
||||
phy={p.phy}
|
||||
prot={p.prot}
|
||||
labels={upDownLabels}
|
||||
/>
|
||||
</td>
|
||||
<td>{p.description || "—"}</td>
|
||||
</tr>
|
||||
|
|
@ -887,11 +937,24 @@ export function PortTrafficPage() {
|
|||
</p>
|
||||
<p>{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</p>
|
||||
<ul className="pt-wizard__confirm-list">
|
||||
{pickedList.slice(0, 40).map((p) => (
|
||||
<li key={p.ifname}>
|
||||
{p.ifname} ({formatBw(p.bw_bps || 0)})
|
||||
</li>
|
||||
))}
|
||||
{pickedList.slice(0, 40).map((p) => {
|
||||
const disc = discoveredByIfname[p.ifname];
|
||||
return (
|
||||
<li key={p.ifname} className="pt-wizard__confirm-port">
|
||||
<span>
|
||||
{p.ifname} ({formatBw(p.bw_bps || 0)})
|
||||
</span>
|
||||
{disc ? (
|
||||
<PortUpDownCell
|
||||
admin={disc.admin}
|
||||
phy={disc.phy}
|
||||
prot={disc.prot}
|
||||
labels={upDownLabels}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{pickedList.length > 40 ? <li>… +{pickedList.length - 40}</li> : null}
|
||||
</ul>
|
||||
<button type="button" className="btn-primary" disabled={createMut.isPending} onClick={submitWizard}>
|
||||
|
|
@ -982,9 +1045,10 @@ export function PortTrafficPage() {
|
|||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>Interface</th>
|
||||
<th>BW</th>
|
||||
<th>Description</th>
|
||||
<th>{t("portTraffic.col.ifname")}</th>
|
||||
<th>{t("portTraffic.col.bw")}</th>
|
||||
<th>{t("portTraffic.col.upDown")}</th>
|
||||
<th>{t("portTraffic.col.description")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -999,6 +1063,14 @@ export function PortTrafficPage() {
|
|||
</td>
|
||||
<td>{p.ifname}</td>
|
||||
<td>{p.bw_raw || formatBw(p.bw_bps)}</td>
|
||||
<td>
|
||||
<PortUpDownCell
|
||||
admin={p.admin}
|
||||
phy={p.phy}
|
||||
prot={p.prot}
|
||||
labels={upDownLabels}
|
||||
/>
|
||||
</td>
|
||||
<td>{p.description || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export function PortTrafficWallPage() {
|
|||
useEffect(() => {
|
||||
if (!board?.name) return;
|
||||
try {
|
||||
document.title = "NetX";
|
||||
document.title = "NETX";
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue