import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ReactFlow, Background, BackgroundVariant, Controls, ControlButton, MiniMap, addEdge, useEdgesState, useNodesState, Handle, Position, ConnectionLineType, ConnectionMode, SelectionMode, type Connection, type Edge, type Node, type NodeProps, type ReactFlowInstance, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { addTopologyViewNodes, createFabricManualEdge, createTopologyFolder, createTopologyPlaceholder, deleteFabricEdges, purgePlaceholderFabricNodes, deleteTopologyFolder, deleteTopologyMap, fetchLldpCollectDashboard, fetchLldpDiscoverJob, fetchManagedNe, fetchManagedNeById, fetchTopologyGraph, fetchTopologyTree, fetchTopologyWorld, fetchUmeNe, patchTopologyEdgeStyle, patchTopologyPositions, projectTopologyNeighbors, removeTopologyViewNodes, searchFabricNodes, startLldpDiscover, stopLldpCollectJob, updateLldpCollectPolicy, updateTopologyFolder, updateTopologyMap, applyUmeTopologyToFabric, } from "../services/api"; import { queryKeys } from "../constants/queryKeys"; import { HelpHint } from "../components/HelpHint"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; import { openOrFocusModule } from "../utils/moduleWindows"; import { WorldScatterLayer } from "./topology/WorldScatterLayer"; import type { FabricNodeSearchHit, TopologyDiscoverJob, TopologyDiscoverNeResult, TopologyDiscoverOut, TopologyEdgeItem, TopologyNodeItem, TopologyTreeFolderItem, TopologyTreeViewItem, TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem, TopologyViewRole, TopologyWorldTransform, } from "../types"; import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph"; import { ParallelEdge } from "./topology/ParallelEdge"; import { buildLinkDisplayEdges, formatPortPairLabel, isAggregateEdgeId, physicalIdsForDisplayEdge, type LinkEdgeData, type LinkMember, } from "./topology/linkDisplay"; import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode"; const LAST_LEAF_KEY = "netx.topology.lastLeafViewId"; const TREE_EXPAND_KEY = "netx.topology.treeExpanded"; function findViewInRegion( regions: TopologyTreeFolderItem[], viewId: string, ): { region: TopologyTreeFolderItem; view: TopologyTreeViewItem } | null { for (const region of regions) { const view = (region.views || []).find((v) => v.id === viewId); if (view) return { region, view }; const nested = findViewInRegion(region.children || [], viewId); if (nested) return nested; } return null; } function findFolderInTree( folders: TopologyTreeFolderItem[], folderId: string, ): TopologyTreeFolderItem | null { for (const folder of folders) { if (folder.id === folderId) return folder; const nested = findFolderInTree(folder.children || [], folderId); if (nested) return nested; } return null; } function indexFoldersById( folders: TopologyTreeFolderItem[], acc: Map = new Map(), ): Map { for (const folder of folders) { acc.set(folder.id, folder); indexFoldersById(folder.children || [], acc); } return acc; } /** Self + ancestors (rootward), for expanding the left tree path. */ function folderPathIds( folders: TopologyTreeFolderItem[], folderId: string, ): string[] { const byId = indexFoldersById(folders); const path: string[] = []; let cur: string | undefined = folderId; const seen = new Set(); while (cur && !seen.has(cur)) { seen.add(cur); path.push(cur); const folder = byId.get(cur); const parent = String(folder?.parent_id || "").trim(); // Stop at topology root (kind=root) — regions list only contains region folders. if (!folder || !parent || !byId.has(parent)) break; cur = parent; } return path; } /** UME World container only — hex nav, not a canvas. */ function isUmeWorldContainer(folder: TopologyTreeFolderItem | null | undefined): boolean { if (!folder) return false; const ext = String(folder.external_ref || "").trim(); return ext === "ume:world" || folder.name === "UME World"; } /** UME World / SBN nav folders share drill canvases; manual regions keep their own maps. */ function isUmeWorldNavFolder(folder: TopologyTreeFolderItem | null | undefined): boolean { if (!folder) return false; const ext = String(folder.external_ref || "").trim(); if (isUmeWorldContainer(folder) || ext === "ume:world:drill") return true; return Boolean(ext); } function isWorldDrillFolder(folder: TopologyTreeFolderItem | null | undefined): boolean { if (!folder) return false; const ext = String(folder.external_ref || "").trim(); return ext === "ume:world:drill" || (folder.name === "World" && Boolean(folder.is_system)); } function isWorldFlatViewName(name: string | undefined | null): boolean { const n = String(name || "").trim(); return n === "世界地图" || n === "完整世界地图"; } /** Visual LOD for 1:1 world coords (~1e5 span). fitView overview is ~0.01. */ function worldVisualLodFromZoom(zoom: number): "dot" | "pin" | "full" { if (zoom < 0.04) return "dot"; if (zoom < 0.12) return "pin"; return "full"; } function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): { x: number; y: number; width: number; height: number; } | null { if (!wt) return null; const scale = Number(wt.scale) || 1; const width = Math.max(1, (Number(wt.full_max_x) - Number(wt.full_min_x)) * scale); const height = Math.max(1, (Number(wt.full_max_y) - Number(wt.full_min_y)) * scale); if (!Number.isFinite(width) || !Number.isFinite(height)) return null; return { x: 0, y: 0, width, height }; } /** * Region row IS the canvas for nested / UME SBN / World drill. * Top-level under Network root (and UME World container) are hex-nav only — same as UME World. */ function isRegionCanvasFolder( folder: TopologyTreeFolderItem | null | undefined, rootId?: string, ): boolean { if (!folder) return false; if (isUmeWorldContainer(folder)) return false; const rid = String(rootId || "").trim(); const parent = String(folder.parent_id || "").trim(); if (rid && parent === rid) return false; return true; } function ChevronIcon({ open }: { open: boolean }) { return ( ); } function regionDisplayName(region: TopologyTreeFolderItem | null | undefined): string { if (!region) return ""; return region.name || ""; } function formatUpdatedAt(value?: string | null): string { if (!value) return ""; try { return new Date(value).toLocaleString(); } catch { return String(value); } } const SNAP_GRID: [number, number] = [16, 16]; /** * onlyRenderVisibleElements leaves off-screen nodes unmeasured; default fitView * skips those. includeHiddenNodes uses declared width/height so fit still works. */ const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const; /** Zoom after locate on flat world — neighborhood visible with full icons, not a lone pin. */ const WORLD_LOCATE_ZOOM = 0.22; /** Half-extent (display/world units) fetched around the target on locate. */ const WORLD_LOCATE_HALF = 12000; /** Soft cap for accumulated flat-world tiles (Google-Earth style keep-alive). */ const WORLD_FLAT_ACCUM_CAP = 8000; /** Merge newly fetched flat LOD into the existing cache so visited areas stay. */ function mergeFlatWorldGraph( prev: TopologyViewGraph | undefined, next: TopologyViewGraph, opts?: { centerX?: number; centerY?: number }, ): TopologyViewGraph { const scatter = next.scatter?.length ? next.scatter : prev?.scatter; if (!prev?.nodes?.length) { return scatter?.length && !next.scatter?.length ? { ...next, scatter } : next; } // Overview / zoom-out payloads have empty RF nodes — keep (or refresh) scatter, // drop heavy tiles when the server intentionally cleared them. if (!next?.nodes?.length) { return { ...prev, view: next.view || prev.view, nodes: [], edges: [], scatter: scatter || [], world_transform: next.world_transform ?? prev.world_transform, truncated: Boolean(next.truncated || prev.truncated), truncate_reason: next.truncate_reason || prev.truncate_reason || "", outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers, }; } const nodeMap = new Map(); for (const n of prev.nodes) nodeMap.set(String(n.fabric_node_id), n); for (const n of next.nodes) nodeMap.set(String(n.fabric_node_id), n); const edgeMap = new Map(); for (const e of prev.edges || []) edgeMap.set(String(e.id), e); for (const e of next.edges || []) edgeMap.set(String(e.id), e); let nodes = [...nodeMap.values()]; const cx = opts?.centerX; const cy = opts?.centerY; if (nodes.length > WORLD_FLAT_ACCUM_CAP && cx != null && cy != null) { const prefer = new Set(next.nodes.map((n) => String(n.fabric_node_id))); const kept = next.nodes.slice(); const extras = nodes .filter((n) => !prefer.has(String(n.fabric_node_id))) .sort((a, b) => { const da = (Number(a.x) - cx) ** 2 + (Number(a.y) - cy) ** 2; const db = (Number(b.x) - cx) ** 2 + (Number(b.y) - cy) ** 2; return da - db; }); const room = Math.max(0, WORLD_FLAT_ACCUM_CAP - kept.length); nodes = kept.concat(extras.slice(0, room)); } const idSet = new Set(nodes.map((n) => String(n.fabric_node_id))); const edges = [...edgeMap.values()].filter( (e) => idSet.has(String(e.a_node_id)) && idSet.has(String(e.b_node_id)), ); return { ...next, view: next.view || prev.view, nodes, edges, world_transform: next.world_transform ?? prev.world_transform, scatter: scatter || [], truncated: Boolean(prev.truncated || next.truncated), truncate_reason: next.truncate_reason || prev.truncate_reason || "", outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers, }; } const UNDO_MAX = 40; const PALETTE_DND = "application/x-netx-topo-palette"; function FullscreenIcon({ exit }: { exit?: boolean }) { if (exit) { return ( ); } return ( ); } /** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */ const SEP = " / "; /** Left-panel collapse / expand control (simple chevron). */ function SidebarFoldIcon({ expand }: { expand?: boolean }) { return ( ); } function PencilIcon() { return ( ); } function PlusIcon() { return ( ); } function CloseIcon() { return ( ); } function RegionGlyph({ size = 16 }: { size?: number }) { return ( ); } function LayerGlyph({ role, size = 16 }: { role?: string; size?: number }) { const r = String(role || "core").toLowerCase(); if (r === "aggregation") { return ( ); } if (r === "access") { return ( ); } return ( ); } type NeNodeData = { label: string; managed_ne_id: string; ume_ne_id: string; ne_ip: string; vendor: string; connect_status: string; managed_source?: string; /** Canvas navigation node (not a fabric NE). */ kind?: "ne" | "region" | "layer"; folder_id?: string; view_id?: string; role?: TopologyViewRole | string; subtitle?: string; node_count?: number; }; type HistorySnap = { nodes: Node[]; edges: Edge[]; pendingEdgeDeletes: string[]; pendingEdgeCreates: 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; connectMode: boolean; /** Show TOPO/LLDP placeholder corner badge on nodes. */ showPlaceholderBadge: boolean; /** Flat world map visual LOD (Google-Earth style). */ worldVisualLod: "dot" | "pin" | "full"; }; type CtxMenu = | { kind: "node"; id: string; x: number; y: number } | { kind: "edge"; id: string; x: number; y: number } | { kind: "selection"; x: number; y: number } | { kind: "pane"; x: number; y: number; flowX: number; flowY: number }; const TopoDisplayContext = createContext({ hideIp: true, hideVendor: true, hidePorts: true, connectMode: false, showPlaceholderBadge: false, worldVisualLod: "full", }); 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)}`; } /** Client-only edge id until Save calls createFabricManualEdge. */ function newLocalEdgeId(): string { return `local:${newId()}`; } function isLocalPendingEdgeId(id: string): boolean { return String(id || "").startsWith("local:"); } /** * Icon tone for /topo/ne-router.png (base = blue). * Unmanaged (no managed/ume) and unknown vendors → gray. * UME-linked NEs default to ZTE when vendor is empty/unknown. */ function nodeIconTone(vendor: string, managedNeId: string, umeNeId = ""): string { const hasManaged = Boolean(String(managedNeId || "").trim()); const hasUme = Boolean(String(umeNeId || "").trim()); if (!hasManaged && !hasUme) return "gray"; const v = String(vendor || "").trim().toLowerCase(); if (!v || v === "other" || v === "unknown" || v === "generic") { return hasUme ? "zte" : "gray"; } 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"; if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie"; if (v.includes("mikrotik")) return "mikrotik"; return hasUme ? "zte" : "gray"; } function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" { if (!r.ok) return "fail"; const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0); if (r.parser_stub || unmatchedCount > 0) return "warn"; return "ok"; } function normalizeSearchText(s: string): string { return String(s || "") .toLowerCase() .replace(/[\s_\-./]+/g, ""); } /** Case-insensitive substring + loose subsequence (e.g. r1 ? router-1). */ function fuzzyIncludes(haystack: string, needle: string): boolean { const h = normalizeSearchText(haystack); const n = normalizeSearchText(needle); if (!n) return true; if (!h) return false; if (h.includes(n)) return true; let i = 0; for (const ch of h) { if (ch === n[i]) i += 1; if (i >= n.length) return true; } return false; } function nodeMatchesQuery(n: Node, query: string): boolean { const tokens = String(query || "") .trim() .toLowerCase() .split(/\s+/) .filter(Boolean); if (!tokens.length) return false; const bits = [n.data.label, n.data.ne_ip, n.data.vendor, n.data.managed_ne_id, n.data.ume_ne_id]; return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok))); } /** * Keep the PNG’s white texture/highlights; tint brand color via mix-blend-mode. * Color comes from --topo-icon-color (vendor palette in Display settings). */ function RouterIcon() { return (