From c13ab48ba78d3141549bcd60f2109dbc81dbf4e4 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 3 Aug 2026 19:24:43 +0800 Subject: [PATCH] Improve topology UX, traffic discover logs, and CLI wrap parsing. Paginate LLDP job items, surface port-traffic raw logs with a 300s default, harden topology discover/edit/zoom/fullscreen, and join mid-token CLI wraps before TextFSM. Co-authored-by: Cursor --- netx_api/cli_templates/README.md | 19 ++- .../zte_zxros_show_interface_brief.textfsm | 4 +- netx_api/cli_wrap.py | 124 +++++++++++++++ netx_api/lldp_collect_router.py | 9 +- netx_api/lldp_collect_service.py | 6 +- netx_api/models/port_traffic.py | 2 +- netx_api/ntc_parse.py | 6 +- netx_api/port_traffic_devices.py | 64 +++++--- netx_api/port_traffic_schemas.py | 6 +- netx_api/topology_discover_common.py | 37 ++++- netx_api/topology_schemas.py | 3 + tests/test_cli_wrap.py | 135 ++++++++++++++++ web/src/constants/queryKeys.ts | 2 +- web/src/i18n/en.ts | 7 + web/src/i18n/zh.ts | 7 + web/src/index.css | 44 ++++++ web/src/pages/TopologyPage.tsx | 149 ++++++++++++++++-- web/src/pages/network/LldpLinksPage.tsx | 28 +++- web/src/pages/network/PortTrafficPage.tsx | 84 +++++++++- web/src/pages/topology/toolMode.ts | 12 +- web/src/services/api.ts | 13 +- web/src/types.ts | 7 + 22 files changed, 693 insertions(+), 75 deletions(-) create mode 100644 netx_api/cli_wrap.py create mode 100644 tests/test_cli_wrap.py diff --git a/netx_api/cli_templates/README.md b/netx_api/cli_templates/README.md index a34f9dc..034bad9 100644 --- a/netx_api/cli_templates/README.md +++ b/netx_api/cli_templates/README.md @@ -18,10 +18,16 @@ cli_templates/ ## Lookup order -1. **This tree** (`index` → `vendor/*.textfsm`) -2. Community `ntc-templates` package +1. **Wrap-line join** (`netx_api.cli_wrap`) — flatten last-column wraps for registered + platform+command pairs (ZTE interface brief / LLDP brief today). Other vendors can + register rules the same way; do **not** apply blindly (Huawei LLDP detail uses real + indented fields). +2. **This tree** (`index` → `vendor/*.textfsm`) +3. Community `ntc-templates` package -No regex CLI parsers. If both miss, the call returns empty — fix or add a template. +No regex CLI parsers for field extraction. If both template trees miss, the call returns +empty — fix or add a template. Wrap joining is the supported escape hatch when TextFSM +alone cannot merge mid-token column wraps. ## Vendor coverage (blind fill from community + NetX) @@ -45,7 +51,10 @@ No regex CLI parsers. If both miss, the call returns empty — fix or add a temp 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/` +5. If the CLI wraps the last column onto the next line, add a `CliWrapRule` in + `netx_api/cli_wrap.py` (platform + command scoped) — do not try to solve mid-token + wraps in TextFSM alone +6. 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 @@ -54,4 +63,4 @@ 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). +`netx_api.ntc_parse` (wrap-join → fresh `CliTable` + process lock; never share instances across threads). diff --git a/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm b/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm index c3f1b71..4fd23b7 100644 --- a/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm +++ b/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm @@ -8,13 +8,13 @@ Value PROT (up|down) Value DESCRIPTION (.*) Start + ^Interface\s+\S+\s+Mode\s+.*Admin -> Ports ^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 + ^${INTERFACE}\s+${ATTRIBUTE}\s+${MODE}(?:\s+${BW})?\s+${ADMIN}\s+${PHY}\s+${PROT}(?:\s+${DESCRIPTION})?\s*$$ -> Record ^\s*$$ ^\S+\s*#\s*$$ -> End ^.*#\s*$$ -> End diff --git a/netx_api/cli_wrap.py b/netx_api/cli_wrap.py new file mode 100644 index 0000000..d16a0ab --- /dev/null +++ b/netx_api/cli_wrap.py @@ -0,0 +1,124 @@ +"""Pre-TextFSM CLI wrap-line joiner (vendor-agnostic). + +Many vendor CLIs wrap the last column (description / system name) onto the next +line with leading spaces. Pure TextFSM cannot reliably merge those mid-token wraps +without creating duplicate or incomplete records — especially when the wrap +breaks inside a hostname (``AL5458-ACC-612`` + ``0HS``). + +This module flattens matching wrap lines **before** TextFSM runs. Rules are +registered per platform + command so indented multi-field blocks (e.g. Huawei +LLDP detail) are left alone. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CliWrapRule: + """Join indented continuation lines into the previous table row.""" + + id: str + platforms: frozenset[str] + # Match ntc / NetX command string (already the resolved show/display command). + command: re.Pattern[str] + # A data row that may be continued (typically starts with interface name). + record_line: re.Pattern[str] + # Continuation fragment: leading whitespace + non-empty payload. + cont_line: re.Pattern[str] + # ZTE column wraps usually split mid-token → join with "" ; use " " if needed. + join_sep: str = "" + # Lines that must never be treated as records or continuations. + skip_line: re.Pattern[str] | None = None + + +# ZTE ZXROS table rows: interface-like token at column 0. +_ZTE_RECORD = re.compile(r"^(?P[A-Za-z][\w./:-]*)\s+\S+") +_ZTE_CONT = re.compile(r"^\s{8,}(?P\S.*?)\s*$") +_ZTE_SKIP = re.compile( + r"^(?:" + r"-{3,}" + r"|Local\s+Interface\b" + r"|Interface\s+\S+\s+Mode\b" + r"|Interface\s+Attribute\b" + r"|Scope\s+codes\s*:" + r"|Total\s+neighbors\b" + r"|NB\s+=\s+" + r"|NC\s+=\s+" + r"|NTPMR\s+=\s+" + r"|PHY:\s*" + r"|.*#\s*$" + r")", + re.I, +) + +CLI_WRAP_RULES: tuple[CliWrapRule, ...] = ( + CliWrapRule( + id="zte_zxros_show_interface_brief", + platforms=frozenset({"zte_zxros"}), + command=re.compile(r"^show\s+interface\s+brief\b", re.I), + record_line=_ZTE_RECORD, + cont_line=_ZTE_CONT, + join_sep="", + skip_line=_ZTE_SKIP, + ), + CliWrapRule( + id="zte_zxros_show_lldp_neighbor_brief", + platforms=frozenset({"zte_zxros"}), + command=re.compile(r"^show\s+lldp\s+neighbor(?:s)?\s+brief\b", re.I), + record_line=_ZTE_RECORD, + cont_line=_ZTE_CONT, + join_sep="", + skip_line=_ZTE_SKIP, + ), +) + + +def _rules_for(*, platform: str, command: str) -> list[CliWrapRule]: + plat = str(platform or "").strip().lower() + cmd = str(command or "").strip() + if not plat or not cmd: + return [] + return [ + rule + for rule in CLI_WRAP_RULES + if plat in rule.platforms and rule.command.search(cmd) + ] + + +def _is_skip(line: str, rule: CliWrapRule) -> bool: + if not rule.skip_line: + return False + if rule.skip_line.match(line): + return True + stripped = line.strip() + return bool(stripped and rule.skip_line.match(stripped)) + + +def _apply_rule(text: str, rule: CliWrapRule) -> str: + lines = str(text or "").splitlines() + if not lines: + return str(text or "") + out: list[str] = [] + for ln in lines: + if _is_skip(ln, rule): + out.append(ln) + continue + cont = rule.cont_line.match(ln) + if cont and out: + prev = out[-1] + if rule.record_line.match(prev) and not _is_skip(prev, rule): + out[-1] = f"{prev.rstrip()}{rule.join_sep}{cont.group('tail')}" + continue + out.append(ln) + return "\n".join(out) + + +def apply_cli_wrap(text: str, *, platform: str = "", command: str = "") -> str: + """Return text with registered wrap continuations flattened for TextFSM.""" + raw = str(text or "") + for rule in _rules_for(platform=platform, command=command): + raw = _apply_rule(raw, rule) + return raw diff --git a/netx_api/lldp_collect_router.py b/netx_api/lldp_collect_router.py index ba6887c..2811084 100644 --- a/netx_api/lldp_collect_router.py +++ b/netx_api/lldp_collect_router.py @@ -53,5 +53,10 @@ def api_list_jobs( @router.get("/jobs/{job_id}") -def api_get_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: - return get_job_detail(db, job_id) +def api_get_job( + job_id: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_db), +) -> dict[str, Any]: + return get_job_detail(db, job_id, page=page, page_size=page_size) diff --git a/netx_api/lldp_collect_service.py b/netx_api/lldp_collect_service.py index 99330bd..d58f35d 100644 --- a/netx_api/lldp_collect_service.py +++ b/netx_api/lldp_collect_service.py @@ -287,5 +287,7 @@ def list_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict: } -def get_job_detail(db: Session, job_id: str) -> dict: - return get_discover_job(db, job_id).model_dump() +def get_job_detail( + db: Session, job_id: str, *, page: int | None = None, page_size: int | None = None +) -> dict: + return get_discover_job(db, job_id, page=page, page_size=page_size).model_dump() diff --git a/netx_api/models/port_traffic.py b/netx_api/models/port_traffic.py index 9161a5b..98e2ab2 100644 --- a/netx_api/models/port_traffic.py +++ b/netx_api/models/port_traffic.py @@ -33,7 +33,7 @@ class PortTrafficDevice(Base): vendor: Mapped[str] = mapped_column(String(64), default="") note: Mapped[str] = mapped_column(String(256), default="") # optional remark status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|running|paused|stopped - interval_sec: Mapped[int] = mapped_column(Integer, default=60) + interval_sec: Mapped[int] = mapped_column(Integer, default=300) retention_days: Mapped[int] = mapped_column(Integer, default=7) concurrency: Mapped[int] = mapped_column(Integer, default=1) collect_running: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/netx_api/ntc_parse.py b/netx_api/ntc_parse.py index b1001cd..6fa35d3 100644 --- a/netx_api/ntc_parse.py +++ b/netx_api/ntc_parse.py @@ -13,6 +13,8 @@ from functools import lru_cache from pathlib import Path from typing import Any, Callable, TypeVar +from .cli_wrap import apply_cli_wrap + _log = logging.getLogger("netx.ntc_parse") _PARSE_LOCK = threading.Lock() @@ -107,10 +109,10 @@ def parse_cli( command: str, text: str, ) -> list[dict[str, Any]]: - """Parse CLI text: custom templates first, then community ntc-templates.""" + """Parse CLI text: wrap-join → custom templates → community ntc-templates.""" plat = str(platform or "").strip() cmd = str(command or "").strip() - raw = str(text or "") + raw = apply_cli_wrap(str(text or ""), platform=plat, command=cmd) if not plat or not cmd or not raw.strip(): return [] diff --git a/netx_api/port_traffic_devices.py b/netx_api/port_traffic_devices.py index d2146d0..7310f3a 100644 --- a/netx_api/port_traffic_devices.py +++ b/netx_api/port_traffic_devices.py @@ -481,30 +481,58 @@ def discover_ports(db: Session, body: DiscoverPortsRequest) -> DiscoverPortsResp detail=f"vendor_not_supported_for_port_traffic: {vendor or 'unknown'}", ) - per_cmd = int(settings.ne_collect_read_timeout_sec or 120) - conn = open_netmiko_connection(creds, session_timeout=per_cmd + 60) - try: - raw = send_show_command(conn, cmds.brief, read_timeout=per_cmd) - finally: - close_netmiko_connection(conn) - - ports = [ - DiscoverPortItem(**brief_port_to_dict(p)) - for p in parse_interface_brief( - raw, - cmds.vendor_key, - command=cmds.brief, - device_type=device_type, - ) - ] - return DiscoverPortsResponse( + command = str(cmds.brief or "") + base = DiscoverPortsResponse( source=body.source, id=body.id, ne_name=ne_name, ne_ip=ne_ip, vendor=vendor, vendor_key=cmds.vendor_key, - ports=ports, + command=command, ) + per_cmd = int(settings.ne_collect_read_timeout_sec or 120) + raw = "" + try: + conn = open_netmiko_connection(creds, session_timeout=per_cmd + 60) + try: + raw = send_show_command(conn, command, read_timeout=per_cmd) + finally: + close_netmiko_connection(conn) + except Exception as exc: + preview = str(raw or "") + if len(preview) > 12_000: + preview = f"{preview[:12_000]}\n...[truncated preview 12000/{len(raw)} chars]" + base.ok = False + base.error = f"cli_failed: {exc}" + base.raw_preview = preview + return base + + preview = str(raw or "") + if len(preview) > 12_000: + preview = f"{preview[:12_000]}\n...[truncated preview 12000/{len(raw)} chars]" + base.raw_preview = preview + + try: + ports = [ + DiscoverPortItem(**brief_port_to_dict(p)) + for p in parse_interface_brief( + raw, + cmds.vendor_key, + command=command, + device_type=device_type, + ) + ] + except Exception as exc: + base.ok = False + base.error = f"parse_failed: {exc}" + return base + + base.ports = ports + if not ports: + base.ok = False + base.error = "no_ports_parsed" + return base + diff --git a/netx_api/port_traffic_schemas.py b/netx_api/port_traffic_schemas.py index 20e1568..3c6d208 100644 --- a/netx_api/port_traffic_schemas.py +++ b/netx_api/port_traffic_schemas.py @@ -34,7 +34,7 @@ class PortTrafficDeviceCreate(BaseModel): ne_ip: str = "" vendor: str = "" note: str = Field(default="", max_length=256) - interval_sec: int = Field(default=60, ge=15, le=3600) + interval_sec: int = Field(default=300, ge=15, le=3600) retention_days: int = Field(default=7, ge=1, le=90) concurrency: int = Field(default=1, ge=1, le=5) interfaces: list[PortTrafficIfaceIn] = Field(default_factory=list) @@ -147,6 +147,10 @@ class DiscoverPortsResponse(BaseModel): vendor: str = "" vendor_key: str = "" ports: list[DiscoverPortItem] = Field(default_factory=list) + ok: bool = True + command: str = "" + raw_preview: str = "" + error: str = "" class PortTrafficSamplePoint(BaseModel): diff --git a/netx_api/topology_discover_common.py b/netx_api/topology_discover_common.py index 2b4f501..02cf107 100644 --- a/netx_api/topology_discover_common.py +++ b/netx_api/topology_discover_common.py @@ -24,15 +24,33 @@ def _raw_preview(raw: str, *, limit: int = _RAW_PREVIEW_MAX) -> str: return f"{text[:limit]}\n...[truncated preview {limit}/{len(text)} chars]" -def _job_out(db: Session, job: TopoDiscoverJob, *, include_items: bool = True) -> FabricDiscoverJobOut: +def _job_out( + db: Session, + job: TopoDiscoverJob, + *, + include_items: bool = True, + page: int | None = None, + page_size: int | None = None, +) -> FabricDiscoverJobOut: items_out: list[FabricDiscoverJobItemOut] = [] + items_total = 0 + items_page = 1 + items_page_size = 0 if include_items: - items = ( + q = ( db.query(TopoDiscoverJobItem) .filter(TopoDiscoverJobItem.job_id == job.id) .order_by(TopoDiscoverJobItem.created_at.asc()) - .all() ) + items_total = int(q.count()) + if page is not None and page_size is not None: + items_page = max(1, int(page or 1)) + items_page_size = max(1, min(100, int(page_size or 20))) + items = q.offset((items_page - 1) * items_page_size).limit(items_page_size).all() + else: + items = q.all() + items_page = 1 + items_page_size = items_total for it in items: unmatched = [ FabricDiscoverUnmatched.model_validate(x) for x in (it.unmatched_json or [])[:40] @@ -74,14 +92,23 @@ def _job_out(db: Session, job: TopoDiscoverJob, *, include_items: bool = True) - started_at=job.started_at, ended_at=job.ended_at, items=items_out, + items_total=items_total, + items_page=items_page, + items_page_size=items_page_size, ) -def get_discover_job(db: Session, job_id: str) -> FabricDiscoverJobOut: +def get_discover_job( + db: Session, + job_id: str, + *, + page: int | None = None, + page_size: int | None = None, +) -> FabricDiscoverJobOut: job = db.get(TopoDiscoverJob, str(job_id or "").strip()) if job is None: raise HTTPException(status_code=404, detail="discover_job_not_found") - return _job_out(db, job) + return _job_out(db, job, page=page, page_size=page_size) def _ume_target_dict(db: Session, uid: str, default_profile: Any) -> dict[str, str] | None: diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index a564b3a..b9576d9 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -132,6 +132,9 @@ class FabricDiscoverJobOut(BaseModel): started_at: datetime | None = None ended_at: datetime | None = None items: list[FabricDiscoverJobItemOut] = Field(default_factory=list) + items_total: int = 0 + items_page: int = 1 + items_page_size: int = 0 # --------------------------------------------------------------------------- diff --git a/tests/test_cli_wrap.py b/tests/test_cli_wrap.py new file mode 100644 index 0000000..5f8003e --- /dev/null +++ b/tests/test_cli_wrap.py @@ -0,0 +1,135 @@ +"""Tests for pre-TextFSM wrap-line joining and ZTE wrapped CLI samples.""" + +from __future__ import annotations + +import unittest + +from netx_api.cli_wrap import apply_cli_wrap +from netx_api.ntc_parse import parse_cli +from netx_api.port_traffic_parsers import parse_interface_brief +from netx_api import topology_lldp as lldp + + +ZTE_BRIEF_PORTATTRIBUTE_WRAP = """\ +CSR3_6120WA#show interface brief +Interface Portattribute Mode BW(Mbps) Admin Phy Prot Description +gei-1/1/0/1 electric Duplex/full 100 up up up +xgei-1/1/0/28 optical Duplex/full 100 up up up BJX_Xinert-8/9 +xgei-1/1/0/31 optical Duplex/full 100 up up up css-5/5-4 +smartgroup101 N/A N/A 100 up up up yangzhen_CSR3_IX + IA +CSR3_6120WA# +""" + +ZTE_LLDP_WRAP_NAME = """\ +MER1#show ll n b +11:21:03 Beijing Mon Aug 3 2026 +Scope codes: + NB = Nearest Bridge + NC = Nearest Customer Bridge + NTPMR = Nearest non-TPMR Bridge + +Total neighbors: 1 +Local Interface Scope Chassis ID Port ID Holdtime System Name +-------------------------------------------------------------------------------- +mgmt_eth NB fc44.9f67.4214 gei-0/1/1/16 99 NJ-NZ-N8-F3-AC + C.R&D-3H3-6 +MER1# +""" + +ZTE_LLDP_WRAP_MIDWORD = """\ +AL5458-ACC-6120HS#show ll n b +10:47:56 Africa/Algiers Mon Aug 3 2026 +Scope codes: + NB = Nearest Bridge + NC = Nearest Customer Bridge + NTPMR = Nearest non-TPMR Bridge + +Total neighbors: 2 +Local Interface Scope Chassis ID Port ID Holdtime System Name +-------------------------------------------------------------------------------- +xgei-1/1/0/1 NB 00d0.0000.088f xgei-1/1/0/1 93 AL5458-ACC-612 + 0HS +xgei-1/1/0/6 NB 00d0.0000.088f xgei-1/1/0/6 93 AL5458-ACC-612 + 0HS +AL5458-ACC-6120HS# +""" + +ZTE_LLDP_NO_WRAP = """\ +CSR3_6120WA#show ll n b +Scope codes: + NB = Nearest Bridge + NC = Nearest Customer Bridge + NTPMR = Nearest non-TPMR Bridge + +Total neighbors: 4 +Local Interface Scope Chassis ID Port ID Holdtime System Name +-------------------------------------------------------------------------------- +gei-1/1/0/1 NB 00d0.0000.081f gei-1/1/0/1 115 OLT/CPE_6180H +gei-1/1/0/2 NB 00d0.0000.081f gei-1/1/0/2 103 OLT/CPE_6180H +xgei-1/1/0/27 NB d80a.e69d.6e30 xgei-1/1/0/28 110 CSR4_6120HSC +xgei-1/1/0/30 NB 0247.8a3e.f910 xgei-1/1/0/2 96 PAG3_6120HS +CSR3_6120WA# +""" + + +class CliWrapUnitTests(unittest.TestCase): + def test_join_interface_brief_description(self) -> None: + flat = apply_cli_wrap( + ZTE_BRIEF_PORTATTRIBUTE_WRAP, + platform="zte_zxros", + command="show interface brief", + ) + self.assertIn("yangzhen_CSR3_IXIA", flat) + self.assertNotIn("\n IA", flat) + + def test_join_lldp_system_name_midword(self) -> None: + flat = apply_cli_wrap( + ZTE_LLDP_WRAP_MIDWORD, + platform="zte_zxros", + command="show lldp neighbor brief", + ) + self.assertIn("xgei-1/1/0/1 NB 00d0.0000.088f xgei-1/1/0/1 93 AL5458-ACC-6120HS", flat) + self.assertNotRegex(flat, r"AL5458-ACC-612\s*\n") + + def test_no_rule_for_unrelated_command(self) -> None: + raw = "iface\n cont" + self.assertEqual( + apply_cli_wrap(raw, platform="zte_zxros", command="show interface xgei-1/1/0/1"), + raw, + ) + + +class ZteWrapParseTests(unittest.TestCase): + def test_brief_portattribute_header_and_wrap(self) -> None: + ports = parse_interface_brief(ZTE_BRIEF_PORTATTRIBUTE_WRAP, "zte") + by_name = {p.ifname: p for p in ports} + self.assertIn("smartgroup101", by_name) + self.assertEqual(by_name["smartgroup101"].description, "yangzhen_CSR3_IXIA") + self.assertEqual(by_name["xgei-1/1/0/28"].description, "BJX_Xinert-8/9") + self.assertEqual(by_name["gei-1/1/0/1"].admin, "up") + + def test_lldp_wrapped_system_name(self) -> None: + hits = lldp.parse_zte_lldp(ZTE_LLDP_WRAP_NAME) + self.assertEqual(len(hits), 1) + self.assertEqual(hits[0].local_port, "mgmt_eth") + self.assertEqual(hits[0].remote_name, "NJ-NZ-N8-F3-ACC.R&D-3H3-6") + self.assertEqual(hits[0].remote_port, "gei-0/1/1/16") + + def test_lldp_wrapped_midword_hostname(self) -> None: + hits = lldp.parse_zte_lldp(ZTE_LLDP_WRAP_MIDWORD) + self.assertEqual(len(hits), 2) + self.assertTrue(all(h.remote_name == "AL5458-ACC-6120HS" for h in hits)) + + def test_lldp_unwrapped_still_works(self) -> None: + rows = parse_cli( + platform="zte_zxros", + command="show lldp neighbor brief", + text=ZTE_LLDP_NO_WRAP, + ) + self.assertEqual(len(rows), 4) + self.assertEqual(rows[0].get("neighbor_name"), "OLT/CPE_6180H") + + +if __name__ == "__main__": + unittest.main() diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index 1ac3f82..ee964fe 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -66,7 +66,7 @@ export const queryKeys = { lldpCollectJobsAll: ["lldpCollectJobs"] as const, lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const, lldpCollectJobAll: ["lldpCollectJob"] as const, - lldpCollectJob: (jobId: string) => ["lldpCollectJob", jobId] as const, + lldpCollectJob: (jobId: string, page = 1) => ["lldpCollectJob", jobId, page] as const, fabricEdgesAll: ["fabricEdges"] as const, fabricEdges: (status: string, keyword: string, page: number) => ["fabricEdges", status, keyword, page] as const, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index eb855dc..3ebc152 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -159,6 +159,11 @@ const en = { selectedPorts: "{{count}} interface(s) selected", pickNeFirst: "Select one NE first", discover: "Discover interfaces", + discoverDetail: "Discover detail", + discoverCommand: "Command", + discoverRaw: "Raw log", + discoverOk: "Discover succeeded", + discoverFail: "Discover failed", confirmCreate: "Start monitoring", wallTask: "Task", wallPort: "Interface", @@ -1344,6 +1349,8 @@ const en = { alignNeedSelection: "Select at least two nodes", snapGrid: "Snap to grid", autoLayoutDiscover: "Auto-layout after discover", + discoverAutoAddUnmatched: "Auto-add unmatched NEs to fabric on discover", + discoverProjectNeighbors: "Project direct neighbors onto canvas after discover", undo: "Undo", redo: "Redo", discoverSelected: "Discover selected ({{count}})", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index fbab604..29b58eb 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -159,6 +159,11 @@ const zh = { selectedPorts: "已选 {{count}} 个接口", pickNeFirst: "请先选择一台网元", discover: "拉取接口", + discoverDetail: "拉取详情", + discoverCommand: "命令", + discoverRaw: "原始日志", + discoverOk: "拉取成功", + discoverFail: "拉取失败", confirmCreate: "开始监控", wallTask: "任务", wallDevice: "设备", @@ -1338,6 +1343,8 @@ const zh = { alignNeedSelection: "请至少选中两个节点", snapGrid: "网格吸附", autoLayoutDiscover: "发现后自动布局", + discoverAutoAddUnmatched: "发现时自动入库未匹配网元", + discoverProjectNeighbors: "发现后将直连邻居加入画布", undo: "撤销", redo: "重做", discoverSelected: "发现选中 ({{count}})", diff --git a/web/src/index.css b/web/src/index.css index d2d8474..d0559e1 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -6181,6 +6181,50 @@ pre { max-height: 14px; } +.topo-fs-toolbar { + position: absolute; + top: 12px; + left: 12px; + z-index: 20; + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(15, 23, 42, 0.12); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); + pointer-events: auto; +} + +.topo-fs-toolbar__btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: #fff; + color: #0f172a; + font-size: 12px; + cursor: pointer; +} + +.topo-fs-toolbar__btn:hover { + background: #f8fafc; +} + +.topo-fs-toolbar__btn.is-active { + border-color: #0ea5e9; + background: #e0f2fe; + color: #075985; +} + +.topo-fs-toolbar__btn kbd { + font-size: 10px; + opacity: 0.7; +} + .topo-canvas__empty { display: grid; place-items: center; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 4c9694e..e2f67a6 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -422,25 +422,35 @@ type EdgeDefaults = Record; const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults"; const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover"; +const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched"; +const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors"; -function loadAutoLayoutAfterDiscover(): boolean { +function loadBoolFlag(key: string, defaultValue: boolean): boolean { try { - const raw = localStorage.getItem(AUTO_LAYOUT_DISCOVER_KEY); - if (raw === null) return false; + const raw = localStorage.getItem(key); + if (raw === null) return defaultValue; return raw === "1" || raw === "true"; } catch { - return false; + return defaultValue; } } -function persistAutoLayoutAfterDiscover(value: boolean) { +function persistBoolFlag(key: string, value: boolean) { try { - localStorage.setItem(AUTO_LAYOUT_DISCOVER_KEY, value ? "1" : "0"); + localStorage.setItem(key, value ? "1" : "0"); } catch { /* ignore */ } } +function loadAutoLayoutAfterDiscover(): boolean { + return loadBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, false); +} + +function persistAutoLayoutAfterDiscover(value: boolean) { + persistBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, value); +} + const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = { manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" }, discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" }, @@ -592,11 +602,18 @@ function applyViewGraph( defaults: EdgeDefaults, setNodes: (ns: Node[]) => void, setEdges: (es: Edge[]) => void, + localPositions?: Map, ) { const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults); - setNodes(rfNodes); + const merged = localPositions?.size + ? rfNodes.map((n) => { + const p = localPositions.get(n.id); + return p ? { ...n, position: { ...p } } : n; + }) + : rfNodes; + setNodes(merged); setEdges(rfEdges); - return { rfNodes, rfEdges }; + return { rfNodes: merged, rfEdges }; } export function TopologyPage() { @@ -636,6 +653,12 @@ export function TopologyPage() { const [toolMode, setToolMode] = useState("select"); const [snapToGrid, setSnapToGrid] = useState(true); const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(loadAutoLayoutAfterDiscover); + const [discoverAutoAddUnmatched, setDiscoverAutoAddUnmatched] = useState(() => + loadBoolFlag(DISCOVER_AUTO_ADD_KEY, false), + ); + const [discoverProjectNeighbors, setDiscoverProjectNeighbors] = useState(() => + loadBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, false), + ); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [addNeOpen, setAddNeOpen] = useState(false); const [paletteSource, setPaletteSource] = useState("managed"); @@ -668,6 +691,7 @@ export function TopologyPage() { const rfRef = useRef, Edge> | null>(null); const canvasRef = useRef(null); const dirtyRef = useRef(false); + const appliedMapIdRef = useRef(""); const historyRef = useRef([]); const redoRef = useRef([]); const historyLockRef = useRef(false); @@ -889,9 +913,16 @@ export function TopologyPage() { setSearchParams({}, { replace: true }); }, [regions, searchParams, setSearchParams]); + useEffect(() => { + appliedMapIdRef.current = ""; + }, [mapId]); + useEffect(() => { if (!canvasMode) return; - if (!graphQuery.data) return; + if (!mapId || !graphQuery.data) return; + // Only hydrate React Flow from server when entering a map — never clobber unsaved local positions. + if (appliedMapIdRef.current === mapId) return; + appliedMapIdRef.current = mapId; const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults); historyLockRef.current = true; setNodes(rfNodes); @@ -1199,7 +1230,7 @@ export function TopologyPage() { scope: "ne_ids", ne_ids, concurrency: 4, - auto_add_unmatched: true, + auto_add_unmatched: discoverAutoAddUnmatched, trigger_mode: "topology", }); let job: TopologyDiscoverJob = jobStart; @@ -1221,9 +1252,18 @@ export function TopologyPage() { if (job.status === "failed") { throw new Error(job.error || "discover_failed"); } - const projected = await projectTopologyNeighbors(mapId); + const projected = discoverProjectNeighbors + ? await projectTopologyNeighbors(mapId) + : await fetchTopologyGraph(mapId); queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected); + appliedMapIdRef.current = mapId; let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults); + // Keep existing node positions when we did not auto-layout. + const localPos = new Map(nodes.map((n) => [n.id, n.position])); + rfNodes = rfNodes.map((n) => { + const p = localPos.get(n.id); + return p ? { ...n, position: { ...p } } : n; + }); let didAutoLayout = false; if (autoLayoutAfterDiscover && rfNodes.length > 1) { rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb"); @@ -1271,7 +1311,7 @@ export function TopologyPage() { setDiscovering(false); } }, - [mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, edgeDefaults, clearDirty, markDirty], + [mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty], ); const discoverResults = discoverReport?.results?.length @@ -1580,10 +1620,16 @@ export function TopologyPage() { pushHistory(); try { if (nodeIds.length) { + if (dirtyRef.current) { + await patchTopologyPositions(mapId, flowToPositions(nodes)); + clearDirty(); + } + const localPos = new Map(nodes.map((n) => [n.id, n.position])); const graph = await removeTopologyViewNodes(mapId, nodeIds); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); + appliedMapIdRef.current = mapId; historyLockRef.current = true; - applyViewGraph(graph, edgeDefaults, setNodes, setEdges); + applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos); historyLockRef.current = false; clearDirty(); } else if (edgeIds.size) { @@ -1670,10 +1716,16 @@ export function TopologyPage() { pushHistory(); closeCtxMenu(); try { + if (dirtyRef.current) { + await patchTopologyPositions(mapId, flowToPositions(nodes)); + clearDirty(); + } + const localPos = new Map(nodes.map((n) => [n.id, n.position])); const graph = await removeTopologyViewNodes(mapId, [nodeId]); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); + appliedMapIdRef.current = mapId; historyLockRef.current = true; - applyViewGraph(graph, edgeDefaults, setNodes, setEdges); + applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos); historyLockRef.current = false; clearDirty(); } catch (err) { @@ -2473,7 +2525,6 @@ export function TopologyPage() { [ ["select", t("topology.toolSelect"), "V"], ["pan", t("topology.toolPan"), "H"], - ["drag", t("topology.toolDrag"), "A"], ["connect", t("topology.toolConnect"), "C"], ] as const ).map(([mode, label, key]) => ( @@ -2544,6 +2595,30 @@ export function TopologyPage() { /> {t("topology.autoLayoutDiscover")} + +
{t("topology.edgeDefaults")} @@ -2955,6 +3030,48 @@ export function TopologyPage() { onDragOver={onCanvasDragOver} onDrop={onCanvasDrop} > + {fullscreen ? ( +
+ {( + [ + ["select", t("topology.toolSelect"), "V"], + ["pan", t("topology.toolPan"), "H"], + ["connect", t("topology.toolConnect"), "C"], + ] as const + ).map(([mode, label, key]) => ( + + ))} + + +
+ ) : null} {treeRoot ? ( (null); + const ITEM_PAGE_SIZE = 20; const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all"); const [edgeKeyword, setEdgeKeyword] = useState(""); @@ -90,8 +92,8 @@ export function LldpLinksPage() { }); const jobDetailQuery = useQuery({ - queryKey: queryKeys.lldpCollectJob(expandedJobId), - queryFn: () => fetchLldpCollectJob(expandedJobId), + queryKey: queryKeys.lldpCollectJob(expandedJobId, itemPage), + queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: ITEM_PAGE_SIZE }), enabled: Boolean(expandedJobId), staleTime: 800, refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false), @@ -193,6 +195,8 @@ export function LldpLinksPage() { const jobPages = pageCount(jobTotal, 10); const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]); const detailItems = jobDetailQuery.data?.items ?? []; + const itemTotal = Number(jobDetailQuery.data?.items_total ?? detailItems.length); + const itemPages = pageCount(itemTotal, ITEM_PAGE_SIZE); const edgeItems = edgesQuery.data?.items ?? []; const edgeTotal = Number(edgesQuery.data?.total || 0); const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE); @@ -546,6 +550,7 @@ export function LldpLinksPage() { onClick={() => { setItemDetail(null); setExpandedJobId(open ? "" : job.id); + setItemPage(1); }} > {open ? "−" : "+"} @@ -653,6 +658,25 @@ export function LldpLinksPage() { ) : null} +
+ + + {t("common.pagerMeta", { + total: String(itemTotal), + page: String(itemPage), + pages: String(itemPages), + })} + + +
) : null} diff --git a/web/src/pages/network/PortTrafficPage.tsx b/web/src/pages/network/PortTrafficPage.tsx index 7b0e8a0..9c6294d 100644 --- a/web/src/pages/network/PortTrafficPage.tsx +++ b/web/src/pages/network/PortTrafficPage.tsx @@ -126,7 +126,7 @@ export function PortTrafficPage() { // Wizard / edit const [editDeviceId, setEditDeviceId] = useState(""); const [editDeviceSnap, setEditDeviceSnap] = useState(null); - const [intervalSec, setIntervalSec] = useState(60); + const [intervalSec, setIntervalSec] = useState(300); const [retentionDays, setRetentionDays] = useState(7); const [concurrency, setConcurrency] = useState(1); const [note, setNote] = useState(""); @@ -137,6 +137,12 @@ export function PortTrafficPage() { const [ports, setPorts] = useState([]); const [portsLoading, setPortsLoading] = useState(false); const [portsError, setPortsError] = useState(""); + const [discoverMeta, setDiscoverMeta] = useState<{ + ok: boolean; + command: string; + raw_preview: string; + error: string; + } | null>(null); const [pickedIfnames, setPickedIfnames] = useState>({}); const [deepLinkHint, setDeepLinkHint] = useState(""); const [deepLinkSource, setDeepLinkSource] = useState<"managed" | "ume" | "">(""); @@ -366,7 +372,7 @@ export function PortTrafficPage() { const resetWizard = () => { setWizardStep(1); - setIntervalSec(60); + setIntervalSec(300); setRetentionDays(7); setConcurrency(1); setNote(""); @@ -374,6 +380,7 @@ export function PortTrafficPage() { setSelectedNe(null); setPorts([]); setPortsError(""); + setDiscoverMeta(null); setPickedIfnames({}); setNeKeyword(""); setNePage(1); @@ -404,6 +411,7 @@ export function PortTrafficPage() { setPickedIfnames({}); setPorts([]); setPortsError(""); + setDiscoverMeta(null); setView("edit"); }; @@ -434,11 +442,23 @@ export function PortTrafficPage() { if (!id) return; setPortsLoading(true); setPortsError(""); + setDiscoverMeta(null); try { const res = await discoverPortTrafficPorts({ source, id }); - setPorts(res.ports); + setPorts(res.ports || []); + setDiscoverMeta({ + ok: res.ok !== false, + command: res.command || "", + raw_preview: res.raw_preview || "", + error: res.error || "", + }); + if (res.ok === false) { + const msg = res.error || t("portTraffic.discoverFail"); + setPortsError(msg); + showError(msg); + } if (deepLinkIfname) { - const hit = res.ports.find((p) => p.ifname === deepLinkIfname); + const hit = (res.ports || []).find((p) => p.ifname === deepLinkIfname); if (hit) { setPickedIfnames((prev) => ({ ...prev, @@ -455,6 +475,12 @@ export function PortTrafficPage() { const msg = e instanceof Error ? e.message : "discover_failed"; setPorts([]); setPortsError(msg); + setDiscoverMeta({ + ok: false, + command: "", + raw_preview: "", + error: msg, + }); showError(msg); } finally { setPortsLoading(false); @@ -841,6 +867,29 @@ export function PortTrafficPage() { {portsError ? {portsError} : null}
+ {discoverMeta ? ( +
+ + {t("portTraffic.discoverDetail")} + {" · "} + {discoverMeta.ok ? t("portTraffic.discoverOk") : t("portTraffic.discoverFail")} + + {discoverMeta.command ? ( +

+ {t("portTraffic.discoverCommand")}: {discoverMeta.command} +

+ ) : null} + {discoverMeta.error ? ( +

+ {discoverMeta.error} +

+ ) : null} +

{t("portTraffic.discoverRaw")}

+
+                        {discoverMeta.raw_preview || "—"}
+                      
+
+ ) : null} {ports.length ? ( @@ -894,7 +943,7 @@ export function PortTrafficPage() { min={15} max={3600} value={intervalSec} - onChange={(e) => setIntervalSec(Number(e.target.value) || 60)} + onChange={(e) => setIntervalSec(Number(e.target.value) || 300)} />
diff --git a/web/src/pages/topology/toolMode.ts b/web/src/pages/topology/toolMode.ts index 9187241..62bb214 100644 --- a/web/src/pages/topology/toolMode.ts +++ b/web/src/pages/topology/toolMode.ts @@ -1,6 +1,6 @@ /** Topology editor interaction modes (NMS-style). */ -export type ToolMode = "select" | "pan" | "drag" | "connect"; +export type ToolMode = "select" | "pan" | "connect"; export type ToolModeBehavior = { nodesDraggable: boolean; @@ -23,15 +23,6 @@ export function behaviorForMode(mode: ToolMode): ToolModeBehavior { selectionOnDrag: false, panOnScroll: true, }; - case "drag": - return { - nodesDraggable: true, - nodesConnectable: false, - elementsSelectable: true, - panOnDrag: [1, 2], - selectionOnDrag: false, - panOnScroll: true, - }; case "connect": return { nodesDraggable: false, @@ -58,7 +49,6 @@ export function toolModeFromKey(key: string): ToolMode | null { const k = key.toLowerCase(); if (k === "v") return "select"; if (k === "h") return "pan"; - if (k === "a") return "drag"; if (k === "c") return "connect"; return null; } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index dea11ce..77ee68c 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1153,8 +1153,17 @@ export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number ); }; -export const fetchLldpCollectJob = (jobId: string) => - apiGet(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}`); +export const fetchLldpCollectJob = ( + jobId: string, + params?: { page?: number; pageSize?: number }, +) => { + const p = new URLSearchParams(); + p.set("page", String(Math.max(1, Number(params?.page || 1)))); + p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20))))); + return apiGet( + `/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`, + ); +}; export const createFabricManualEdge = (body: { a_node_id: string; diff --git a/web/src/types.ts b/web/src/types.ts index 3b90aad..3c3a491 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -590,6 +590,9 @@ export type TopologyDiscoverJob = { ended_at?: string | null; created_at?: string | null; items: TopologyDiscoverJobItem[]; + items_total?: number; + items_page?: number; + items_page_size?: number; }; /** @deprecated alias — prefer TopologyViewItem */ @@ -887,6 +890,10 @@ export type PortTrafficDiscoverResponse = { vendor: string; vendor_key: string; ports: PortTrafficDiscoverPort[]; + ok?: boolean; + command?: string; + raw_preview?: string; + error?: string; }; export type PortTrafficSamplePoint = {