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({ 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 ( ); } function NeNode({ data, selected }: NodeProps>) { 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 (
{/* Centered on the disc so edges meet the circular icon, not the caption. */}
{bits.join(" · ")}
); } const nodeTypes = { neNode: NeNode }; function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[]) { const rfNodes: Node[] = 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[], 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(""); const [keyword, setKeyword] = useState(""); const [selectedNodeId, setSelectedNodeId] = useState(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("managed"); const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const rfRef = useRef, 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 (
{maps.find((m) => m.id === mapId)?.name || t("topology.selectMap")} {mapId ? ( ) : null}
{t("topology.canvasHint")}
{selectedNode ? (
{t("topology.selected")}: {selectedNode.data.label} {selectedNode.data.ne_ip ? ` (${selectedNode.data.ne_ip})` : ""}
) : null}
{mapId ? ( { 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"]} > ) : (
{t("topology.selectMap")}
)}
); }