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:
oliver 2026-07-30 00:18:07 +08:00
parent d3fd840637
commit bfc10ee8cf
7 changed files with 359 additions and 44 deletions

View file

@ -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()]

View file

@ -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],
) )

View file

@ -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",

View file

@ -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…",
}, },
}; };

View file

@ -631,6 +631,10 @@ const zh = {
hideAdded: "隐藏已添加", hideAdded: "隐藏已添加",
paletteEmpty: "没有匹配的网元。", paletteEmpty: "没有匹配的网元。",
paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。", paletteHiddenAll: "已添加网元已隐藏;取消勾选可重新显示。",
paletteSource: "网元来源",
paletteManaged: "托管网元",
paletteUme: "UME 网元",
paletteLoading: "加载中…",
}, },
}; };

View file

@ -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;

View file

@ -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>