netx/web/src/pages/TopologyPage.tsx
oliver bfc10ee8cf 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>
2026-07-30 00:18:07 +08:00

867 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ReactFlow,
Background,
Controls,
MiniMap,
addEdge,
useEdgesState,
useNodesState,
Handle,
Position,
MarkerType,
ConnectionMode,
type Connection,
type Edge,
type Node,
type NodeProps,
type ReactFlowInstance,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
createTopologyMap,
deleteTopologyMap,
discoverTopologyNeighbors,
fetchManagedNe,
fetchTopologyGraph,
fetchTopologyMaps,
fetchUmeNe,
putTopologyGraph,
updateTopologyMap,
} from "../services/api";
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, UmeNeItem } from "../types";
type NeNodeData = {
label: string;
managed_ne_id: string;
ume_ne_id: string;
ne_ip: string;
vendor: 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 = {
hideIp: boolean;
hideVendor: boolean;
hidePorts: boolean;
};
const TopoDisplayContext = createContext<TopoDisplayOpts>({
hideIp: true,
hideVendor: true,
hidePorts: true,
});
function newId(): string {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID().replace(/-/g, "");
}
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
}
function vendorTone(vendor: string): string {
const v = String(vendor || "").trim().toLowerCase();
if (v.includes("cisco")) return "cisco";
if (v.includes("huawei")) return "huawei";
if (v.includes("zte")) return "zte";
if (v.includes("juniper")) return "juniper";
if (v.includes("nokia") || v.includes("alcatel")) return "nokia";
if (v.includes("ericsson")) return "ericsson";
if (v.includes("h3c") || v.includes("comware")) return "h3c";
return "other";
}
/** Classic Visio/Packet Tracer style router: disc + four outbound arrows. */
function RouterIcon() {
return (
<svg className="topo-node__icon" viewBox="0 0 64 64" aria-hidden="true">
<ellipse cx="32" cy="39" rx="19" ry="8.5" className="topo-node__icon-shadow" />
<path
d="M13 31 A19 9.5 0 0 0 51 31 L51 36.5 A19 9.5 0 0 1 13 36.5 Z"
className="topo-node__icon-rim"
/>
<ellipse cx="32" cy="31" rx="19" ry="9.5" className="topo-node__icon-body" />
<ellipse cx="32" cy="30" rx="15.5" ry="7" className="topo-node__icon-face" />
<g className="topo-node__icon-arrows">
{/* NW / NE / SW / SE */}
<path d="M31 29 L19 18 L24.5 18.8 L23.2 23.8 Z" />
<path d="M33 29 L45 18 L39.5 18.8 L40.8 23.8 Z" />
<path d="M31 31 L19 42 L24.5 41.2 L23.2 36.2 Z" />
<path d="M33 31 L45 42 L39.5 41.2 L40.8 36.2 Z" />
</g>
</svg>
);
}
function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
const { hideIp, hideVendor } = useContext(TopoDisplayContext);
const tone = vendorTone(data.vendor);
const name = data.label || (!hideIp ? data.ne_ip : "") || "NE";
const bits = [
name,
hideIp ? "" : data.ne_ip,
hideVendor ? "" : data.vendor,
].filter((x, i, arr) => {
const s = String(x || "").trim();
if (!s) return false;
return arr.findIndex((y) => String(y || "").trim() === s) === i;
});
return (
<div className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}`}>
<div className="topo-node__glyph">
{/* Centered on the disc so edges meet the circular icon, not the caption. */}
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--center" />
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--center" />
<RouterIcon />
</div>
<div className="topo-node__caption">{bits.join(" · ")}</div>
</div>
);
}
const nodeTypes = { neNode: NeNode };
function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) {
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => ({
id: n.id,
type: "neNode",
position: { x: n.x || 0, y: n.y || 0 },
data: {
label: n.label || n.ne_name || n.ne_ip || n.id,
managed_ne_id: n.managed_ne_id || "",
ume_ne_id: n.ume_ne_id || "",
ne_ip: n.ne_ip || "",
vendor: n.vendor || "",
connect_status: n.connect_status || "",
},
}));
const rfEdges: Edge[] = edges.map((e) => {
const discovered = e.source === "lldp" || e.source === "cdp";
const label = [e.source_port, e.target_port].filter(Boolean).join(" ↔ ");
return {
id: e.id,
source: e.source_node_id,
target: e.target_node_id,
type: "straight",
label: label || undefined,
animated: false,
style: discovered
? { stroke: "#0ea5e9", strokeDasharray: "6 4" }
: { stroke: "#64748b" },
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
data: {
source: e.source || "manual",
source_port: e.source_port || "",
target_port: e.target_port || "",
},
};
});
return { rfNodes, rfEdges };
}
function flowToGraphPayload(nodes: Node<NeNodeData>[], edges: Edge[]) {
return {
nodes: nodes.map((n) => ({
id: n.id,
managed_ne_id: n.data.managed_ne_id || "",
ume_ne_id: n.data.ume_ne_id || "",
label: n.data.label || "",
x: n.position.x,
y: n.position.y,
})),
edges: edges.map((e) => ({
id: e.id,
source_node_id: e.source,
target_node_id: e.target,
source_port: String((e.data as { source_port?: string } | undefined)?.source_port || ""),
target_port: String((e.data as { target_port?: string } | undefined)?.target_port || ""),
source: String((e.data as { source?: string } | undefined)?.source || "manual"),
})),
};
}
export function TopologyPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [mapId, setMapId] = useState<string>("");
const [keyword, setKeyword] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [hideIp, setHideIp] = useState(true);
const [hideVendor, setHideVendor] = useState(true);
const [hidePorts, setHidePorts] = useState(true);
const [edgeFlow, setEdgeFlow] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [hideAddedNes, setHideAddedNes] = useState(true);
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
const [nodes, setNodes, onNodesChange] = useNodesState<Node<NeNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
const dirtyRef = useRef(false);
const displayOpts = useMemo(
() => ({ hideIp, hideVendor, hidePorts }),
[hideIp, hideVendor, hidePorts],
);
const displayEdges = useMemo(
() =>
edges.map((e) => ({
...e,
label: hidePorts ? undefined : e.label,
animated: edgeFlow,
})),
[edges, hidePorts, edgeFlow],
);
const mapsQuery = useQuery({
queryKey: queryKeys.topologyMaps,
queryFn: fetchTopologyMaps,
});
const graphQuery = useQuery({
queryKey: queryKeys.topologyGraph(mapId),
queryFn: () => fetchTopologyGraph(mapId),
enabled: Boolean(mapId),
});
const neQuery = useQuery({
queryKey: [...queryKeys.managedNeAll, "topology-palette", keyword],
queryFn: () =>
fetchManagedNe({
keyword: keyword.trim(),
vendor: "",
connectStatus: "",
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(() => {
if (!mapId && mapsQuery.data?.items?.length) {
setMapId(mapsQuery.data.items[0].id);
}
}, [mapId, mapsQuery.data]);
useEffect(() => {
if (!graphQuery.data) return;
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges);
setNodes(rfNodes);
setEdges(rfEdges);
dirtyRef.current = false;
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
}, [graphQuery.data, setNodes, setEdges]);
const renameMapMut = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
updateTopologyMap(id, { name }),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(t("topology.renamed"));
},
onError: (err) => showError(String(err)),
});
const promptRenameMap = useCallback(
(id: string, currentName: string) => {
const next = window.prompt(t("topology.renamePrompt"), currentName);
if (next == null) return;
const name = next.trim();
if (!name || name === currentName) return;
renameMapMut.mutate({ id, name });
},
[renameMapMut, t],
);
const createMapMut = useMutation({
mutationFn: () => createTopologyMap({ name: t("topology.newMapName") }),
onSuccess: async (row) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
setMapId(row.id);
showOk(t("topology.newMap"));
// Prompt rename right after create — default name is a placeholder.
window.setTimeout(() => promptRenameMap(row.id, row.name), 0);
},
onError: (err) => showError(String(err)),
});
const deleteMapMut = useMutation({
mutationFn: (id: string) => deleteTopologyMap(id),
onSuccess: async (_out, id) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
if (mapId === id) {
setMapId("");
setNodes([]);
setEdges([]);
}
},
onError: (err) => showError(String(err)),
});
const saveMut = useMutation({
mutationFn: () => putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)),
onSuccess: async (graph) => {
dirtyRef.current = false;
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(t("topology.saved"));
},
onError: (err) => showError(String(err)),
});
const discoverMut = useMutation({
mutationFn: async () => {
if (dirtyRef.current) {
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
}
return discoverTopologyNeighbors(mapId, { protocol: "auto" });
},
onSuccess: async (out) => {
if (out.graph) {
queryClient.setQueryData(queryKeys.topologyGraph(mapId), out.graph);
const { rfNodes, rfEdges } = graphToFlow(out.graph.nodes, out.graph.edges);
setNodes(rfNodes);
setEdges(rfEdges);
dirtyRef.current = false;
}
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(
t("topology.discovered")
.replace("{{added}}", String(out.edges_added))
.replace("{{updated}}", String(out.edges_updated)),
);
},
onError: (err) =>
showError(t("topology.discoverFail").replace("{{detail}}", String(err))),
});
const onConnect = useCallback(
(connection: Connection) => {
dirtyRef.current = true;
setEdges((eds) =>
addEdge(
{
...connection,
id: newId(),
type: "straight",
markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16 },
style: { stroke: "#64748b" },
data: { source: "manual", source_port: "", target_port: "" },
},
eds,
),
);
},
[setEdges],
);
const addManagedNeToCanvas = (ne: ManagedNeItem) => {
if (!mapId) {
showError(t("topology.selectMap"));
return;
}
if (nodes.some((n) => n.data.managed_ne_id === ne.id)) {
return;
}
const offset = nodes.length * 24;
dirtyRef.current = true;
setNodes((prev) => [
...prev,
{
id: newId(),
type: "neNode",
position: { x: 80 + offset, y: 80 + offset },
data: {
label: ne.name || ne.ip_address,
managed_ne_id: ne.id,
ume_ne_id: "",
ne_ip: ne.ip_address,
vendor: ne.vendor,
connect_status: ne.connect_status,
},
},
]);
};
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],
);
const removeSelected = () => {
if (!selectedNodeId) return;
dirtyRef.current = true;
setNodes((ns) => ns.filter((n) => n.id !== selectedNodeId));
setEdges((es) => es.filter((e) => e.source !== selectedNodeId && e.target !== selectedNodeId));
setSelectedNodeId(null);
};
const openWebcrt = () => {
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;
}
if (umeId) {
openNewModuleWindow({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(umeId)}&source=ume`,
});
return;
}
showError(t("topology.noNeLink"));
};
const openNe = () => {
const managedId = selectedNode?.data.managed_ne_id;
const umeId = selectedNode?.data.ume_ne_id;
if (managedId) {
openOrFocusModule({
moduleId: "managed-ne",
path: "/ne",
});
return;
}
if (umeId) {
openOrFocusModule({
moduleId: "ume",
path: "/ume",
});
return;
}
showError(t("topology.noNeLink"));
};
const maps = mapsQuery.data?.items || [];
const onCanvasManagedIds = useMemo(
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
[nodes],
);
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 (
<div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
{sidebarCollapsed ? (
<button
type="button"
className="topo-sidebar__rail"
title={t("topology.expandSidebar")}
aria-label={t("topology.expandSidebar")}
onClick={() => setSidebarCollapsed(false)}
>
<span className="topo-sidebar__rail-icon" aria-hidden="true">
›
</span>
<span className="topo-sidebar__rail-label">{t("topology.maps")}</span>
</button>
) : (
<>
<div className="topo-sidebar__section">
<div className="topo-sidebar__head">
<strong>{t("topology.maps")}</strong>
<div className="topo-sidebar__head-actions">
<button
type="button"
className="btn btn--sm"
onClick={() => createMapMut.mutate()}
disabled={createMapMut.isPending}
>
{t("topology.newMap")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
title={t("topology.collapseSidebar")}
aria-label={t("topology.collapseSidebar")}
onClick={() => setSidebarCollapsed(true)}
>
‹
</button>
</div>
</div>
{maps.length === 0 ? (
<p className="panel__hint">{t("topology.emptyMaps")}</p>
) : (
<ul className="topo-map-list">
{maps.map((m) => (
<li key={m.id} className={mapId === m.id ? "is-active" : ""}>
<button
type="button"
className="topo-map-list__item"
onClick={() => setMapId(m.id)}
onDoubleClick={() => promptRenameMap(m.id, m.name)}
title={t("topology.renameHint")}
>
<span className="topo-map-list__name">{m.name}</span>
<span className="topo-map-list__meta">
{m.node_count}N / {m.edge_count}E
</span>
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
title={t("topology.rename")}
disabled={renameMapMut.isPending}
onClick={() => promptRenameMap(m.id, m.name)}
>
✎
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
title={t("topology.deleteMap")}
onClick={() => {
const msg = t("topology.deleteMapConfirm").replace("{{name}}", m.name);
if (window.confirm(msg)) deleteMapMut.mutate(m.id);
}}
>
×
</button>
</li>
))}
</ul>
)}
</div>
<div className="topo-sidebar__section topo-sidebar__section--grow">
<div className="topo-sidebar__head">
<strong>{t("topology.palette")}</strong>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={hideAddedNes}
onChange={(e) => setHideAddedNes(e.target.checked)}
/>
{t("topology.hideAdded")}
</label>
</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>
<input
className="input"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={t("topology.filterPh")}
/>
<ul className="topo-palette">
{paletteLoading ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteLoading")}</span>
</li>
) : paletteVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">
{palette.length === 0
? t("topology.paletteEmpty")
: t("topology.paletteHiddenAll")}
</span>
</li>
) : (
paletteVisible.map((item) => {
const onCanvas =
item.source === "managed"
? onCanvasManagedIds.has(item.managed_ne_id)
: onCanvasUmeIds.has(item.ume_ne_id);
return (
<li key={item.key}>
<button
type="button"
className="topo-palette__item"
disabled={!mapId || onCanvas}
onClick={() => addPaletteItem(item)}
title={`${item.name}\n${item.ip}`}
>
<span className="topo-palette__name">{item.name}</span>
<span className="topo-palette__meta">
{item.meta}
{onCanvas ? " ✓" : ""}
</span>
</button>
</li>
);
})
)}
</ul>
</div>
</>
)}
</aside>
<main className="topo-main">
<div className="topo-toolbar">
<div className="topo-toolbar__left">
<div className="topo-toolbar__title">
<strong>{maps.find((m) => m.id === mapId)?.name || t("topology.selectMap")}</strong>
{mapId ? (
<button
type="button"
className="btn btn--sm btn--ghost"
title={t("topology.rename")}
disabled={renameMapMut.isPending}
onClick={() => {
const current = maps.find((m) => m.id === mapId)?.name || "";
promptRenameMap(mapId, current);
}}
>
{t("topology.rename")}
</button>
) : null}
</div>
<span className="panel__hint">{t("topology.canvasHint")}</span>
</div>
<div className="topo-toolbar__actions">
<div className="topo-display-toggles" role="group" aria-label={t("topology.display")}>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={hideIp}
onChange={(e) => setHideIp(e.target.checked)}
/>
{t("topology.hideIp")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={hideVendor}
onChange={(e) => setHideVendor(e.target.checked)}
/>
{t("topology.hideVendor")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={hidePorts}
onChange={(e) => setHidePorts(e.target.checked)}
/>
{t("topology.hidePorts")}
</label>
<label className="topo-display-toggles__item">
<input
type="checkbox"
checked={edgeFlow}
onChange={(e) => setEdgeFlow(e.target.checked)}
/>
{t("topology.edgeFlow")}
</label>
</div>
<button
type="button"
className="btn btn--sm"
disabled={!mapId || saveMut.isPending}
onClick={() => saveMut.mutate()}
>
{saveMut.isPending ? t("topology.saving") : t("topology.save")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={!mapId || discoverMut.isPending}
onClick={() => discoverMut.mutate()}
>
{discoverMut.isPending ? t("topology.discovering") : t("topology.discover")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!mapId}
onClick={() => rfRef.current?.fitView({ padding: 0.2 })}
>
{t("topology.fit")}
</button>
</div>
</div>
{selectedNode ? (
<div className="topo-selection">
<span>
{t("topology.selected")}: <strong>{selectedNode.data.label}</strong>
{selectedNode.data.ne_ip ? ` (${selectedNode.data.ne_ip})` : ""}
</span>
<div className="topo-selection__actions">
<button type="button" className="btn btn--sm" onClick={openWebcrt}>
{t("topology.openWebcrt")}
</button>
<button type="button" className="btn btn--sm" onClick={openNe}>
{t("topology.openNe")}
</button>
<button type="button" className="btn btn--sm btn--ghost" onClick={removeSelected}>
{t("topology.removeNode")}
</button>
</div>
</div>
) : null}
<div className="topo-canvas">
{mapId ? (
<TopoDisplayContext.Provider value={displayOpts}>
<ReactFlow
nodes={nodes}
edges={displayEdges}
nodeTypes={nodeTypes}
connectionMode={ConnectionMode.Loose}
defaultEdgeOptions={{ type: "straight" }}
onNodesChange={(changes) => {
dirtyRef.current = true;
onNodesChange(changes);
}}
onEdgesChange={(changes) => {
dirtyRef.current = true;
onEdgesChange(changes);
}}
onConnect={onConnect}
onNodeClick={(_e, node) => setSelectedNodeId(node.id)}
onPaneClick={() => setSelectedNodeId(null)}
onInit={(inst) => {
rfRef.current = inst;
}}
fitView
deleteKeyCode={["Backspace", "Delete"]}
>
<Background gap={18} size={1} />
<Controls />
<MiniMap pannable zoomable />
</ReactFlow>
</TopoDisplayContext.Provider>
) : (
<div className="topo-canvas__empty">{t("topology.selectMap")}</div>
)}
</div>
</main>
</div>
);
}