From bfc10ee8cf43262f14aadcea14d38db12957e03d Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 30 Jul 2026 00:18:07 +0800 Subject: [PATCH] Add ZTE LLDP brief parsing and UME NEs to topology palette. ZXROS uses show lldp neighbor brief; topology library can switch between managed and UME inventory. Co-authored-by: Cursor --- netx_api/topology_lldp.py | 63 +++++++++- netx_api/topology_service.py | 59 ++++++++-- tests/test_topology.py | 40 ++++++- web/src/i18n/en.ts | 4 + web/src/i18n/zh.ts | 4 + web/src/index.css | 26 +++++ web/src/pages/TopologyPage.tsx | 207 ++++++++++++++++++++++++++++----- 7 files changed, 359 insertions(+), 44 deletions(-) diff --git a/netx_api/topology_lldp.py b/netx_api/topology_lldp.py index cc70a44..62d778a 100644 --- a/netx_api/topology_lldp.py +++ b/netx_api/topology_lldp.py @@ -91,8 +91,8 @@ VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = { ), "zte": VendorLldpProfile( key="zte", - lldp_command="show lldp neighbors", - notes="device_type zte_*; confirm ZXROS keyword on lab.", + lldp_command="show lldp neighbor brief", + notes="device_type zte_*; ZXROS brief table (Local Interface / Port ID / System Name).", ), "juniper": VendorLldpProfile( key="juniper", @@ -203,8 +203,10 @@ def parse_h3c_lldp(text: str) -> list[NeighborHit]: def parse_zte_lldp(text: str) -> list[NeighborHit]: - """ZTE ZXROS LLDP — placeholder; brief table may work as interim.""" - # TODO: replace with ZXROS-specific parser using lab echo. + """ZTE ZXROS `show lldp neighbor brief` table.""" + hits = _parse_zte_lldp_brief(text) + if hits: + return hits return _parse_lldp_brief_table(text) @@ -338,6 +340,59 @@ def _parse_cisco_lldp_detail(text: str) -> list[NeighborHit]: return hits +def _parse_zte_lldp_brief(text: str) -> list[NeighborHit]: + """ZTE ZXROS `show lldp neighbor brief`. + + Columns: Local Interface | Scope | Chassis ID | Port ID | Holdtime | System Name + Example: + cgei-1/1/0/34 NB 744a.a42d.8970 cgei-1/1/0/36 91 KND-VKAU-EN1-Z20HS + """ + raw = str(text or "") + if not re.search(r"(?i)Local\s+Interface", raw) or not re.search(r"(?i)System\s+Name", raw): + return [] + + # Scope codes seen on ZXROS: NB / NC / NTPMR (and possibly others). + row_re = re.compile( + r"^(?P\S+)\s+" + r"(?P[A-Za-z]{2,8})\s+" + r"(?P\S+)\s+" + r"(?P\S+)\s+" + r"(?P\d+)\s+" + r"(?P\S.*?)\s*$" + ) + 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", + ) + ) + return hits + + 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()] diff --git a/netx_api/topology_service.py b/netx_api/topology_service.py index 2ec6cf3..2fc4248 100644 --- a/netx_api/topology_service.py +++ b/netx_api/topology_service.py @@ -9,7 +9,7 @@ from uuid import uuid4 from fastapi import HTTPException from sqlalchemy.orm import Session -from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode +from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE from .ne_exec import execute_managed_ne_commands from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command from .topology_schemas import ( @@ -113,10 +113,41 @@ def _ne_lookup(db: Session, ne_ids: set[str]) -> dict[str, ManagedNE]: return {r.id: r for r in rows} -def _node_out(n: TopologyNode, ne: ManagedNE | None) -> TopologyNodeOut: +def _ume_lookup(db: Session, ume_ids: set[str]) -> dict[str, UmeInventoryNE]: + ids = {str(x).strip() for x in ume_ids if str(x).strip()} + if not ids: + return {} + rows = db.query(UmeInventoryNE).filter(UmeInventoryNE.ne_id.in_(list(ids))).all() + return {str(r.ne_id): r for r in rows} + + +def _node_out( + n: TopologyNode, + ne: ManagedNE | None, + ume: UmeInventoryNE | None = None, +) -> TopologyNodeOut: label = (n.label or "").strip() - if not label and ne is not None: - label = (ne.name or ne.ip_address or n.id)[:256] + ne_name = "" + ne_ip = "" + vendor = "" + protocol = "" + connect_status = "" + if ne is not None: + if not label: + label = (ne.name or ne.ip_address or n.id)[:256] + ne_name = ne.name or "" + ne_ip = ne.ip_address or "" + vendor = ne.vendor or "" + protocol = ne.protocol or "" + connect_status = ne.connect_status or "" + elif ume is not None: + ume_name = (ume.host_name or ume.ne_name or ume.user_label or "").strip() + if not label: + label = (ume_name or ume.ip_address or n.id)[:256] + ne_name = ume_name + ne_ip = ume.ip_address or "" + vendor = (ume.vendor or "ZTE").strip() or "ZTE" + connect_status = ume.connection_status or "" return TopologyNodeOut( id=n.id, map_id=n.map_id, @@ -125,11 +156,11 @@ def _node_out(n: TopologyNode, ne: ManagedNE | None) -> TopologyNodeOut: label=label, x=float(n.x or 0), y=float(n.y or 0), - ne_name=(ne.name if ne else ""), - ne_ip=(ne.ip_address if ne else ""), - vendor=(ne.vendor if ne else ""), - protocol=(ne.protocol if ne else ""), - connect_status=(ne.connect_status if ne else ""), + ne_name=ne_name, + ne_ip=ne_ip, + vendor=vendor, + protocol=protocol, + connect_status=connect_status, ) @@ -151,9 +182,17 @@ def get_graph(db: Session, map_id: str) -> TopologyGraphOut: nodes = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all() edges = db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all() nes = _ne_lookup(db, {str(n.managed_ne_id or "") for n in nodes if n.managed_ne_id}) + umes = _ume_lookup(db, {str(n.ume_ne_id or "") for n in nodes if n.ume_ne_id}) return TopologyGraphOut( map=_map_out(row, node_count=len(nodes), edge_count=len(edges)), - nodes=[_node_out(n, nes.get(str(n.managed_ne_id or ""))) for n in nodes], + nodes=[ + _node_out( + n, + nes.get(str(n.managed_ne_id or "")), + umes.get(str(n.ume_ne_id or "")), + ) + for n in nodes + ], edges=[_edge_out(e) for e in edges], ) diff --git a/tests/test_topology.py b/tests/test_topology.py index 0bb48de..7186409 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -117,6 +117,30 @@ Ethernet1/0/2 has 0 neighbor(s) GigabitEthernet0/0/0 has 0 neighbor(s) """ +ZTE_LLDP_BRIEF = """ +KND-PUN-EN1-Z20HS#show lldp neighbor brief +23:10:28 Indonesia Wed Jul 29 2026 +Scope codes: + NB = Nearest Bridge + NC = Nearest Customer Bridge + NTPMR = Nearest non-TPMR Bridge + +Total neighbors: 11 +Local Interface Scope Chassis ID Port ID Holdtime System Name +---------------------------------------------------------------------------------------------------------------------------------- +cgei-1/1/0/34 NB 744a.a42d.8970 cgei-1/1/0/36 91 KND-VKAU-EN1-Z20HS +cgei-1/1/0/36 NB 744a.a42c.d600 cgei-1/1/0/33 99 KND-SAMA-EN1-Z20HS +xxvgei-1/1/0/15 NB d4c1.c893.4350 xgei-0/0/0/7 102 KND-KLK-AN1-ZM8S +xxvgei-1/1/0/16 NB 744a.a430.1540 xxvgei-1/1/0/28 115 KND-PGGL-EN1-Z20HS +xxvgei-1/1/0/17 NB 744a.a42d.6948 xxvgei-1/1/0/16 112 KND-IWEA-EN1-Z20HS +xxvgei-1/1/0/18 NB 744a.a42d.6948 xxvgei-1/1/0/17 112 KND-IWEA-EN1-Z20HS +xxvgei-1/1/0/21 NB d4c1.c893.4350 xgei-0/0/0/1 108 KND-KLK-AN1-ZM8S +xxvgei-1/1/0/22 NB fc44.9f82.1b18 xgei-1/1/0/2 95 MKS-BLB-EN1-Z680H +xxvgei-1/1/0/23 NB 744a.a42c.d600 xxvgei-1/1/0/28 99 KND-SAMA-EN1-Z20HS +xxvgei-1/1/0/24 NB 744a.a432.deb8 xxvgei-1/1/0/28 119 KND-AWOA-EN1-Z20HS +xxvgei-1/1/0/28 NB 744a.a42d.8970 xxvgei-1/1/0/28 91 KND-VKAU-EN1-Z20HS +""" + class TopologyLldpParseTests(unittest.TestCase): def test_parse_cisco_lldp_brief_fallback(self) -> None: @@ -158,6 +182,20 @@ class TopologyLldpParseTests(unittest.TestCase): self.assertEqual(lab[0].remote_name, "R2.example.com") self.assertEqual(lab[0].remote_ip, "192.168.0.128") + def test_parse_zte_lldp_brief_lab(self) -> None: + hits = lldp.parse_neighbor_output( + ZTE_LLDP_BRIEF, protocol="lldp", vendor="ZTE", device_type="zte_zxros" + ) + self.assertEqual(len(hits), 11) + self.assertEqual(hits[0].local_port, "cgei-1/1/0/34") + self.assertEqual(hits[0].remote_port, "cgei-1/1/0/36") + self.assertEqual(hits[0].remote_name, "KND-VKAU-EN1-Z20HS") + self.assertEqual(hits[2].local_port, "xxvgei-1/1/0/15") + self.assertEqual(hits[2].remote_port, "xgei-0/0/0/7") + self.assertEqual(hits[2].remote_name, "KND-KLK-AN1-ZM8S") + self.assertEqual(hits[-1].remote_name, "KND-VKAU-EN1-Z20HS") + self.assertEqual(hits[-1].local_port, "xxvgei-1/1/0/28") + def test_pick_command_auto(self) -> None: cmd, proto = lldp.pick_neighbor_command(protocol="auto", vendor="Cisco", device_type="cisco_ios") self.assertEqual(proto, "lldp") @@ -174,7 +212,7 @@ class TopologyLldpParseTests(unittest.TestCase): "cisco": "show lldp neighbors detail", "huawei": "display lldp neighbor", "h3c": "display lldp neighbor-information list", - "zte": "show lldp neighbors", + "zte": "show lldp neighbor brief", "juniper": "show lldp neighbors", "nokia": "show system lldp neighbor", "ericsson": "show lldp neighbors", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 1717406..a94b83a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -633,6 +633,10 @@ const en = { hideAdded: "Hide added", paletteEmpty: "No matching NEs.", paletteHiddenAll: "Added NEs are hidden; uncheck to show them again.", + paletteSource: "NE source", + paletteManaged: "Managed", + paletteUme: "UME", + paletteLoading: "Loading…", }, }; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1bc1889..455fc83 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -631,6 +631,10 @@ const zh = { hideAdded: "隐藏已添加", paletteEmpty: "没有匹配的网元。", paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。", + paletteSource: "网元来源", + paletteManaged: "托管网元", + paletteUme: "UME 网元", + paletteLoading: "加载中…", }, }; diff --git a/web/src/index.css b/web/src/index.css index 5597635..1927806 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1928,6 +1928,32 @@ pre { gap: 4px; } +.topo-palette-source { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + padding: 2px; + background: #f1f5f9; + border-radius: 8px; +} + +.topo-palette-source__btn { + border: 0; + background: transparent; + border-radius: 6px; + padding: 6px 8px; + font-size: 12px; + font-weight: 600; + color: #64748b; + cursor: pointer; +} + +.topo-palette-source__btn.is-active { + background: #fff; + color: #0f172a; + box-shadow: 0 1px 2px rgb(15 23 42 / 8%); +} + .topo-map-list, .topo-palette { list-style: none; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index db5ecf9..fb1b84e 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -26,6 +26,7 @@ import { fetchManagedNe, fetchTopologyGraph, fetchTopologyMaps, + fetchUmeNe, putTopologyGraph, updateTopologyMap, } from "../services/api"; @@ -33,7 +34,7 @@ import { queryKeys } from "../constants/queryKeys"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows"; -import type { ManagedNeItem, TopologyEdgeItem, TopologyNodeItem } from "../types"; +import type { ManagedNeItem, TopologyEdgeItem, TopologyNodeItem, UmeNeItem } from "../types"; type NeNodeData = { label: string; @@ -44,6 +45,20 @@ type NeNodeData = { connect_status: string; }; +type PaletteSource = "managed" | "ume"; + +type PaletteItem = { + key: string; + source: PaletteSource; + managed_ne_id: string; + ume_ne_id: string; + name: string; + ip: string; + vendor: string; + meta: string; + connect_status: string; +}; + type TopoDisplayOpts = { hideIp: boolean; hideVendor: boolean; @@ -197,6 +212,7 @@ export function TopologyPage() { const [edgeFlow, setEdgeFlow] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [hideAddedNes, setHideAddedNes] = useState(true); + const [paletteSource, setPaletteSource] = useState("managed"); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const rfRef = useRef, Edge> | null>(null); @@ -236,6 +252,18 @@ export function TopologyPage() { page: 1, pageSize: 100, }), + enabled: paletteSource === "managed", + }); + + const umeQuery = useQuery({ + queryKey: ["umeInventoryNe", "topology-palette", keyword], + queryFn: () => + fetchUmeNe({ + keyword: keyword.trim(), + page: 1, + pageSize: 100, + }), + enabled: paletteSource === "ume", }); useEffect(() => { @@ -356,7 +384,7 @@ export function TopologyPage() { [setEdges], ); - const addNeToCanvas = (ne: ManagedNeItem) => { + const addManagedNeToCanvas = (ne: ManagedNeItem) => { if (!mapId) { showError(t("topology.selectMap")); return; @@ -384,6 +412,45 @@ export function TopologyPage() { ]); }; + const addUmeNeToCanvas = (ne: UmeNeItem) => { + if (!mapId) { + showError(t("topology.selectMap")); + return; + } + if (nodes.some((n) => n.data.ume_ne_id === ne.ne_id)) { + return; + } + const offset = nodes.length * 24; + const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim(); + dirtyRef.current = true; + setNodes((prev) => [ + ...prev, + { + id: newId(), + type: "neNode", + position: { x: 80 + offset, y: 80 + offset }, + data: { + label: name, + managed_ne_id: "", + ume_ne_id: ne.ne_id, + ne_ip: ne.ip_address || "", + vendor: "ZTE", + connect_status: ne.connection_status || "", + }, + }, + ]); + }; + + 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 selectedNode = useMemo( () => nodes.find((n) => n.id === selectedNodeId) || null, [nodes, selectedNodeId], @@ -398,39 +465,94 @@ export function TopologyPage() { }; const openWebcrt = () => { - const neId = selectedNode?.data.managed_ne_id; - if (!neId) { - showError(t("topology.noNeLink")); + const managedId = selectedNode?.data.managed_ne_id; + const umeId = selectedNode?.data.ume_ne_id; + if (managedId) { + openNewModuleWindow({ + moduleId: "webcrt", + path: `/webcrt?ne_id=${encodeURIComponent(managedId)}`, + }); return; } - openNewModuleWindow({ - moduleId: "webcrt", - path: `/webcrt?ne_id=${encodeURIComponent(neId)}`, - }); + if (umeId) { + openNewModuleWindow({ + moduleId: "webcrt", + path: `/webcrt?ne_id=${encodeURIComponent(umeId)}&source=ume`, + }); + return; + } + showError(t("topology.noNeLink")); }; const openNe = () => { - const neId = selectedNode?.data.managed_ne_id; - if (!neId) { - showError(t("topology.noNeLink")); + const managedId = selectedNode?.data.managed_ne_id; + const umeId = selectedNode?.data.ume_ne_id; + if (managedId) { + openOrFocusModule({ + moduleId: "managed-ne", + path: "/ne", + }); return; } - openOrFocusModule({ - moduleId: "managed-ne", - path: "/ne", - }); + if (umeId) { + openOrFocusModule({ + moduleId: "ume", + path: "/ume", + }); + return; + } + showError(t("topology.noNeLink")); }; const maps = mapsQuery.data?.items || []; - const palette = neQuery.data?.items || []; - const onCanvasIds = useMemo( + const onCanvasManagedIds = useMemo( () => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)), [nodes], ); - const paletteVisible = useMemo( - () => (hideAddedNes ? palette.filter((ne) => !onCanvasIds.has(ne.id)) : palette), - [palette, hideAddedNes, onCanvasIds], + const onCanvasUmeIds = useMemo( + () => new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean)), + [nodes], ); + const palette = useMemo((): PaletteItem[] => { + if (paletteSource === "ume") { + return (umeQuery.data?.items || []).map((ne) => { + const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim(); + return { + key: `ume:${ne.ne_id}`, + source: "ume" as const, + managed_ne_id: "", + ume_ne_id: ne.ne_id, + name, + ip: ne.ip_address || "", + vendor: "ZTE", + meta: `${ne.ip_address || "-"} · ${ne.ne_type || "UME"}`, + connect_status: ne.connection_status || "", + }; + }); + } + return (neQuery.data?.items || []).map((ne) => ({ + key: `managed:${ne.id}`, + source: "managed" as const, + managed_ne_id: ne.id, + ume_ne_id: "", + name: ne.name || ne.ip_address, + ip: ne.ip_address, + vendor: ne.vendor, + meta: `${ne.ip_address} · ${ne.vendor}`, + connect_status: ne.connect_status, + })); + }, [paletteSource, neQuery.data, umeQuery.data]); + const paletteVisible = useMemo(() => { + if (!hideAddedNes) return palette; + return palette.filter((item) => + item.source === "managed" + ? !onCanvasManagedIds.has(item.managed_ne_id) + : !onCanvasUmeIds.has(item.ume_ne_id), + ); + }, [palette, hideAddedNes, onCanvasManagedIds, onCanvasUmeIds]); + const paletteLoading = + (paletteSource === "managed" && neQuery.isLoading) || + (paletteSource === "ume" && umeQuery.isLoading); return (
@@ -529,6 +651,26 @@ export function TopologyPage() { {t("topology.hideAdded")}
+
+ + +

{t("topology.paletteHint")}

    - {paletteVisible.length === 0 ? ( + {paletteLoading ? ( +
  • + {t("topology.paletteLoading")} +
  • + ) : paletteVisible.length === 0 ? (
  • {palette.length === 0 @@ -546,20 +692,23 @@ export function TopologyPage() {
  • ) : ( - paletteVisible.map((ne) => { - const onCanvas = onCanvasIds.has(ne.id); + paletteVisible.map((item) => { + const onCanvas = + item.source === "managed" + ? onCanvasManagedIds.has(item.managed_ne_id) + : onCanvasUmeIds.has(item.ume_ne_id); return ( -
  • +