mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 14:12:51 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
d3fd840637
commit
bfc10ee8cf
7 changed files with 359 additions and 44 deletions
|
|
@ -91,8 +91,8 @@ VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = {
|
||||||
),
|
),
|
||||||
"zte": VendorLldpProfile(
|
"zte": VendorLldpProfile(
|
||||||
key="zte",
|
key="zte",
|
||||||
lldp_command="show lldp neighbors",
|
lldp_command="show lldp neighbor brief",
|
||||||
notes="device_type zte_*; confirm ZXROS keyword on lab.",
|
notes="device_type zte_*; ZXROS brief table (Local Interface / Port ID / System Name).",
|
||||||
),
|
),
|
||||||
"juniper": VendorLldpProfile(
|
"juniper": VendorLldpProfile(
|
||||||
key="juniper",
|
key="juniper",
|
||||||
|
|
@ -203,8 +203,10 @@ def parse_h3c_lldp(text: str) -> list[NeighborHit]:
|
||||||
|
|
||||||
|
|
||||||
def parse_zte_lldp(text: str) -> list[NeighborHit]:
|
def parse_zte_lldp(text: str) -> list[NeighborHit]:
|
||||||
"""ZTE ZXROS LLDP — placeholder; brief table may work as interim."""
|
"""ZTE ZXROS `show lldp neighbor brief` table."""
|
||||||
# TODO: replace with ZXROS-specific parser using lab echo.
|
hits = _parse_zte_lldp_brief(text)
|
||||||
|
if hits:
|
||||||
|
return hits
|
||||||
return _parse_lldp_brief_table(text)
|
return _parse_lldp_brief_table(text)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -338,6 +340,59 @@ def _parse_cisco_lldp_detail(text: str) -> list[NeighborHit]:
|
||||||
return hits
|
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*$"
|
||||||
|
)
|
||||||
|
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]:
|
def _parse_lldp_brief_table(text: str) -> list[NeighborHit]:
|
||||||
"""Cisco/ZTE-style brief table: Device ID / Local Intf / ... / Port ID."""
|
"""Cisco/ZTE-style brief table: Device ID / Local Intf / ... / Port ID."""
|
||||||
lines = [ln.rstrip() for ln in str(text or "").splitlines()]
|
lines = [ln.rstrip() for ln in str(text or "").splitlines()]
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from uuid import uuid4
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy.orm import Session
|
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 .ne_exec import execute_managed_ne_commands
|
||||||
from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command
|
from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command
|
||||||
from .topology_schemas import (
|
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}
|
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()
|
label = (n.label or "").strip()
|
||||||
if not label and ne is not None:
|
ne_name = ""
|
||||||
label = (ne.name or ne.ip_address or n.id)[:256]
|
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(
|
return TopologyNodeOut(
|
||||||
id=n.id,
|
id=n.id,
|
||||||
map_id=n.map_id,
|
map_id=n.map_id,
|
||||||
|
|
@ -125,11 +156,11 @@ def _node_out(n: TopologyNode, ne: ManagedNE | None) -> TopologyNodeOut:
|
||||||
label=label,
|
label=label,
|
||||||
x=float(n.x or 0),
|
x=float(n.x or 0),
|
||||||
y=float(n.y or 0),
|
y=float(n.y or 0),
|
||||||
ne_name=(ne.name if ne else ""),
|
ne_name=ne_name,
|
||||||
ne_ip=(ne.ip_address if ne else ""),
|
ne_ip=ne_ip,
|
||||||
vendor=(ne.vendor if ne else ""),
|
vendor=vendor,
|
||||||
protocol=(ne.protocol if ne else ""),
|
protocol=protocol,
|
||||||
connect_status=(ne.connect_status if ne else ""),
|
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()
|
nodes = db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all()
|
||||||
edges = db.query(TopologyEdge).filter(TopologyEdge.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})
|
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(
|
return TopologyGraphOut(
|
||||||
map=_map_out(row, node_count=len(nodes), edge_count=len(edges)),
|
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],
|
edges=[_edge_out(e) for e in edges],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,30 @@ Ethernet1/0/2 has 0 neighbor(s)
|
||||||
GigabitEthernet0/0/0 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):
|
class TopologyLldpParseTests(unittest.TestCase):
|
||||||
def test_parse_cisco_lldp_brief_fallback(self) -> None:
|
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_name, "R2.example.com")
|
||||||
self.assertEqual(lab[0].remote_ip, "192.168.0.128")
|
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:
|
def test_pick_command_auto(self) -> None:
|
||||||
cmd, proto = lldp.pick_neighbor_command(protocol="auto", vendor="Cisco", device_type="cisco_ios")
|
cmd, proto = lldp.pick_neighbor_command(protocol="auto", vendor="Cisco", device_type="cisco_ios")
|
||||||
self.assertEqual(proto, "lldp")
|
self.assertEqual(proto, "lldp")
|
||||||
|
|
@ -174,7 +212,7 @@ class TopologyLldpParseTests(unittest.TestCase):
|
||||||
"cisco": "show lldp neighbors detail",
|
"cisco": "show lldp neighbors detail",
|
||||||
"huawei": "display lldp neighbor",
|
"huawei": "display lldp neighbor",
|
||||||
"h3c": "display lldp neighbor-information list",
|
"h3c": "display lldp neighbor-information list",
|
||||||
"zte": "show lldp neighbors",
|
"zte": "show lldp neighbor brief",
|
||||||
"juniper": "show lldp neighbors",
|
"juniper": "show lldp neighbors",
|
||||||
"nokia": "show system lldp neighbor",
|
"nokia": "show system lldp neighbor",
|
||||||
"ericsson": "show lldp neighbors",
|
"ericsson": "show lldp neighbors",
|
||||||
|
|
|
||||||
|
|
@ -633,6 +633,10 @@ const en = {
|
||||||
hideAdded: "Hide added",
|
hideAdded: "Hide added",
|
||||||
paletteEmpty: "No matching NEs.",
|
paletteEmpty: "No matching NEs.",
|
||||||
paletteHiddenAll: "Added NEs are hidden; uncheck to show them again.",
|
paletteHiddenAll: "Added NEs are hidden; uncheck to show them again.",
|
||||||
|
paletteSource: "NE source",
|
||||||
|
paletteManaged: "Managed",
|
||||||
|
paletteUme: "UME",
|
||||||
|
paletteLoading: "Loading…",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -631,6 +631,10 @@ const zh = {
|
||||||
hideAdded: "隐藏已添加",
|
hideAdded: "隐藏已添加",
|
||||||
paletteEmpty: "没有匹配的网元。",
|
paletteEmpty: "没有匹配的网元。",
|
||||||
paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。",
|
paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。",
|
||||||
|
paletteSource: "网元来源",
|
||||||
|
paletteManaged: "托管网元",
|
||||||
|
paletteUme: "UME 网元",
|
||||||
|
paletteLoading: "加载中…",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1928,6 +1928,32 @@ pre {
|
||||||
gap: 4px;
|
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-map-list,
|
||||||
.topo-palette {
|
.topo-palette {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import {
|
||||||
fetchManagedNe,
|
fetchManagedNe,
|
||||||
fetchTopologyGraph,
|
fetchTopologyGraph,
|
||||||
fetchTopologyMaps,
|
fetchTopologyMaps,
|
||||||
|
fetchUmeNe,
|
||||||
putTopologyGraph,
|
putTopologyGraph,
|
||||||
updateTopologyMap,
|
updateTopologyMap,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
|
|
@ -33,7 +34,7 @@ import { queryKeys } from "../constants/queryKeys";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { useToast } from "../hooks/useToast";
|
import { useToast } from "../hooks/useToast";
|
||||||
import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows";
|
import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows";
|
||||||
import type { ManagedNeItem, TopologyEdgeItem, TopologyNodeItem } from "../types";
|
import type { ManagedNeItem, TopologyEdgeItem, TopologyNodeItem, UmeNeItem } from "../types";
|
||||||
|
|
||||||
type NeNodeData = {
|
type NeNodeData = {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -44,6 +45,20 @@ type NeNodeData = {
|
||||||
connect_status: string;
|
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 = {
|
type TopoDisplayOpts = {
|
||||||
hideIp: boolean;
|
hideIp: boolean;
|
||||||
hideVendor: boolean;
|
hideVendor: boolean;
|
||||||
|
|
@ -197,6 +212,7 @@ export function TopologyPage() {
|
||||||
const [edgeFlow, setEdgeFlow] = useState(false);
|
const [edgeFlow, setEdgeFlow] = useState(false);
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [hideAddedNes, setHideAddedNes] = useState(true);
|
const [hideAddedNes, setHideAddedNes] = useState(true);
|
||||||
|
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
||||||
|
|
@ -236,6 +252,18 @@ export function TopologyPage() {
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 100,
|
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(() => {
|
useEffect(() => {
|
||||||
|
|
@ -356,7 +384,7 @@ export function TopologyPage() {
|
||||||
[setEdges],
|
[setEdges],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addNeToCanvas = (ne: ManagedNeItem) => {
|
const addManagedNeToCanvas = (ne: ManagedNeItem) => {
|
||||||
if (!mapId) {
|
if (!mapId) {
|
||||||
showError(t("topology.selectMap"));
|
showError(t("topology.selectMap"));
|
||||||
return;
|
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(
|
const selectedNode = useMemo(
|
||||||
() => nodes.find((n) => n.id === selectedNodeId) || null,
|
() => nodes.find((n) => n.id === selectedNodeId) || null,
|
||||||
[nodes, selectedNodeId],
|
[nodes, selectedNodeId],
|
||||||
|
|
@ -398,39 +465,94 @@ export function TopologyPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const openWebcrt = () => {
|
const openWebcrt = () => {
|
||||||
const neId = selectedNode?.data.managed_ne_id;
|
const managedId = selectedNode?.data.managed_ne_id;
|
||||||
if (!neId) {
|
const umeId = selectedNode?.data.ume_ne_id;
|
||||||
showError(t("topology.noNeLink"));
|
if (managedId) {
|
||||||
|
openNewModuleWindow({
|
||||||
|
moduleId: "webcrt",
|
||||||
|
path: `/webcrt?ne_id=${encodeURIComponent(managedId)}`,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openNewModuleWindow({
|
if (umeId) {
|
||||||
moduleId: "webcrt",
|
openNewModuleWindow({
|
||||||
path: `/webcrt?ne_id=${encodeURIComponent(neId)}`,
|
moduleId: "webcrt",
|
||||||
});
|
path: `/webcrt?ne_id=${encodeURIComponent(umeId)}&source=ume`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError(t("topology.noNeLink"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openNe = () => {
|
const openNe = () => {
|
||||||
const neId = selectedNode?.data.managed_ne_id;
|
const managedId = selectedNode?.data.managed_ne_id;
|
||||||
if (!neId) {
|
const umeId = selectedNode?.data.ume_ne_id;
|
||||||
showError(t("topology.noNeLink"));
|
if (managedId) {
|
||||||
|
openOrFocusModule({
|
||||||
|
moduleId: "managed-ne",
|
||||||
|
path: "/ne",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openOrFocusModule({
|
if (umeId) {
|
||||||
moduleId: "managed-ne",
|
openOrFocusModule({
|
||||||
path: "/ne",
|
moduleId: "ume",
|
||||||
});
|
path: "/ume",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError(t("topology.noNeLink"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const maps = mapsQuery.data?.items || [];
|
const maps = mapsQuery.data?.items || [];
|
||||||
const palette = neQuery.data?.items || [];
|
const onCanvasManagedIds = useMemo(
|
||||||
const onCanvasIds = useMemo(
|
|
||||||
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
|
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
|
||||||
[nodes],
|
[nodes],
|
||||||
);
|
);
|
||||||
const paletteVisible = useMemo(
|
const onCanvasUmeIds = useMemo(
|
||||||
() => (hideAddedNes ? palette.filter((ne) => !onCanvasIds.has(ne.id)) : palette),
|
() => new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean)),
|
||||||
[palette, hideAddedNes, onCanvasIds],
|
[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 (
|
return (
|
||||||
<div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
|
<div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
|
||||||
|
|
@ -529,6 +651,26 @@ export function TopologyPage() {
|
||||||
{t("topology.hideAdded")}
|
{t("topology.hideAdded")}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="topo-palette-source" role="tablist" aria-label={t("topology.paletteSource")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={paletteSource === "managed"}
|
||||||
|
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
|
||||||
|
onClick={() => setPaletteSource("managed")}
|
||||||
|
>
|
||||||
|
{t("topology.paletteManaged")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={paletteSource === "ume"}
|
||||||
|
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
|
||||||
|
onClick={() => setPaletteSource("ume")}
|
||||||
|
>
|
||||||
|
{t("topology.paletteUme")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p className="panel__hint">{t("topology.paletteHint")}</p>
|
<p className="panel__hint">{t("topology.paletteHint")}</p>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
|
|
@ -537,7 +679,11 @@ export function TopologyPage() {
|
||||||
placeholder={t("topology.filterPh")}
|
placeholder={t("topology.filterPh")}
|
||||||
/>
|
/>
|
||||||
<ul className="topo-palette">
|
<ul className="topo-palette">
|
||||||
{paletteVisible.length === 0 ? (
|
{paletteLoading ? (
|
||||||
|
<li className="topo-palette__empty">
|
||||||
|
<span className="panel__hint">{t("topology.paletteLoading")}</span>
|
||||||
|
</li>
|
||||||
|
) : paletteVisible.length === 0 ? (
|
||||||
<li className="topo-palette__empty">
|
<li className="topo-palette__empty">
|
||||||
<span className="panel__hint">
|
<span className="panel__hint">
|
||||||
{palette.length === 0
|
{palette.length === 0
|
||||||
|
|
@ -546,20 +692,23 @@ export function TopologyPage() {
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
) : (
|
) : (
|
||||||
paletteVisible.map((ne) => {
|
paletteVisible.map((item) => {
|
||||||
const onCanvas = onCanvasIds.has(ne.id);
|
const onCanvas =
|
||||||
|
item.source === "managed"
|
||||||
|
? onCanvasManagedIds.has(item.managed_ne_id)
|
||||||
|
: onCanvasUmeIds.has(item.ume_ne_id);
|
||||||
return (
|
return (
|
||||||
<li key={ne.id}>
|
<li key={item.key}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="topo-palette__item"
|
className="topo-palette__item"
|
||||||
disabled={!mapId || onCanvas}
|
disabled={!mapId || onCanvas}
|
||||||
onClick={() => addNeToCanvas(ne)}
|
onClick={() => addPaletteItem(item)}
|
||||||
title={`${ne.name}\n${ne.ip_address}`}
|
title={`${item.name}\n${item.ip}`}
|
||||||
>
|
>
|
||||||
<span className="topo-palette__name">{ne.name || ne.ip_address}</span>
|
<span className="topo-palette__name">{item.name}</span>
|
||||||
<span className="topo-palette__meta">
|
<span className="topo-palette__meta">
|
||||||
{ne.ip_address} · {ne.vendor}
|
{item.meta}
|
||||||
{onCanvas ? " ✓" : ""}
|
{onCanvas ? " ✓" : ""}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue