import { Fragment, lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Connection, Edge, Node, Position, ReactFlowInstance, } from "@xyflow/react"; 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 type { FabricNodeSearchHit, TopologyDiscoverJob, TopologyDiscoverNeResult, TopologyDiscoverOut, TopologyTreeFolderItem, TopologyTreeViewItem, TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem, TopologyWorldTransform, } from "../types"; import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph"; import { buildLinkDisplayEdges, formatPortPairLabel, isAggregateEdgeId, physicalIdsForDisplayEdge, type LinkEdgeData, type LinkMember, } from "./topology/linkDisplay"; import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode"; import type { NeNodeData } from "./topology/TopologyReactFlowView"; /** xyflow + canvas nodes — only loaded when a map canvas is open. */ const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowView")); function isPlaceholderSource(source: string | undefined, neIp: string): boolean { const src = String(source || "").trim().toLowerCase(); if (src === "lldp" || src === "topology") return true; return !String(neIp || "").trim() && Boolean(src); } const LAST_LEAF_KEY = "netx.topology.lastLeafViewId"; const TREE_EXPAND_KEY = "netx.topology.treeExpanded"; const SIDEBAR_WIDTH_KEY = "netx.topology.sidebarWidth"; const SIDEBAR_WIDTH_DEFAULT = 260; const SIDEBAR_WIDTH_MIN = 180; const SIDEBAR_WIDTH_MAX = 560; function loadSidebarWidth(): number { try { const n = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY)); if (Number.isFinite(n) && n >= SIDEBAR_WIDTH_MIN) { return Math.min(Math.round(n), SIDEBAR_WIDTH_MAX); } } catch { /* ignore */ } return SIDEBAR_WIDTH_DEFAULT; } function saveSidebarWidth(width: number): void { try { localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(width))); } catch { /* ignore */ } } function clampSidebarWidth(width: number, pageWidth?: number): number { const maxByPage = pageWidth && pageWidth > 0 ? Math.max(SIDEBAR_WIDTH_MIN, Math.floor(pageWidth * 0.55)) : SIDEBAR_WIDTH_MAX; const maxW = Math.min(SIDEBAR_WIDTH_MAX, maxByPage); return Math.max(SIDEBAR_WIDTH_MIN, Math.min(maxW, Math.round(width))); } 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 (leaf → 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; } /** Ancestors + self (rootward → leaf) for breadcrumb display. */ function folderPathFolders( folders: TopologyTreeFolderItem[], folderId: string, ): TopologyTreeFolderItem[] { if (!folderId) return []; const byId = indexFoldersById(folders); return folderPathIds(folders, folderId) .map((id) => byId.get(id)) .filter((f): f is TopologyTreeFolderItem => Boolean(f)) .reverse(); } /** 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 === "完整世界地图" || n === "World map"; } /** Flat world-map canvas (LOD / scatter). Temporarily offline in the UI. */ const WORLD_MAP_ENABLED = false; function isManualRootMapName(name: string | undefined | null): boolean { const n = String(name || "").trim(); return n === "根图" || n === "Root map"; } function displayViewName(name: string | undefined | null, t: (key: string) => string): string { if (isWorldFlatViewName(name)) return t("topology.worldMapName"); if (isManualRootMapName(name)) return t("topology.rootMapName"); return String(name || "").trim(); } /** UME structural folders (not user-deletable): container + World drill. */ function isUmeStructuralFolder(folder: TopologyTreeFolderItem | null | undefined): boolean { if (!folder) return false; if (isUmeWorldContainer(folder) || isWorldDrillFolder(folder)) return true; const ext = String(folder.external_ref || "").trim(); return ext === "ume:world" || ext === "ume:world:drill"; } /** Auto「根图」under a manual 根 — rename OK, delete only with parent 根. */ function isManualRootMapFolder(folder: TopologyTreeFolderItem | null | undefined): boolean { if (!folder || isUmeStructuralFolder(folder)) return false; return Boolean(folder.is_system) && !String(folder.external_ref || "").trim(); } /** UME-synced SBN under World — deletable like a manual sub-region. */ function isUmeSyncedSubRegion(folder: TopologyTreeFolderItem | null | undefined): boolean { if (!folder || isUmeStructuralFolder(folder)) return false; return Boolean(String(folder.external_ref || "").trim()); } /** 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, t?: (key: string) => string, ): string { if (!region) return ""; const raw = String(region.name || "").trim(); if (t && isManualRootMapName(raw)) return t("topology.rootMapName"); return raw; } /** Directory NE badge: API already returns distinct-subtree ne_count. */ function folderNeCount(folder: TopologyTreeFolderItem | null | undefined): number { if (!folder) return 0; return Math.max(0, Math.floor(Number(folder.ne_count) || 0)); } function formatNeCount(count: number): string { const n = Math.max(0, Math.floor(Number(count) || 0)); return `${n}N`; } /** * 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"; /** 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 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 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 }; 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:"); } 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))); } /** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). * Caption is absolutely positioned under the glyph so the RF box is icon-only. */ const TOPO_NODE_W = 80; const TOPO_NODE_H = 25; /** Must match `.topo-node__glyph` (25×25, top-centered) + `--center` handles. */ const TOPO_ICON = 25; /** region-building.png is 195×133 — keep aspect so icon center == handle. */ const TOPO_REGION_ICON_H = Math.round((TOPO_ICON * 133) / 195); const TOPO_HANDLE_X = TOPO_NODE_W / 2; const TOPO_HANDLE_Y = TOPO_ICON / 2; const TOPO_REGION_HANDLE_X = TOPO_NODE_W / 2; const TOPO_REGION_HANDLE_Y = TOPO_REGION_ICON_H / 2; /** API x/y is icon center; RF position is top-left of the node box. */ function iconFlowPosition( apiX: number, apiY: number, handleX: number, handleY: number, ): { x: number; y: number } { return { x: apiX - handleX, y: apiY - handleY }; } function iconApiPosition( flowX: number, flowY: number, handleX: number, handleY: number, ): { x: number; y: number } { return { x: flowX + handleX, y: flowY + handleY }; } function regionFlowPosition(apiX: number, apiY: number): { x: number; y: number } { return iconFlowPosition(apiX, apiY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y); } function regionApiPosition(flowX: number, flowY: number): { x: number; y: number } { return iconApiPosition(flowX, flowY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y); } function neFlowPosition(apiX: number, apiY: number): { x: number; y: number } { return iconFlowPosition(apiX, apiY, TOPO_HANDLE_X, TOPO_HANDLE_Y); } function neApiPosition(flowX: number, flowY: number): { x: number; y: number } { return iconApiPosition(flowX, flowY, TOPO_HANDLE_X, TOPO_HANDLE_Y); } type EdgeStyleData = LinkEdgeData; type EdgeLineStyle = "solid" | "dashed" | "dotted"; type EdgeSourceKind = "manual" | "ume" | "discovered" | "stale"; type EdgeDefaultStyle = { stroke_color: string; stroke_width: number; line_style: EdgeLineStyle; }; type EdgeDefaults = Record; const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults"; const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover"; const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched.v2"; const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors.v2"; const SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth"; const SHOW_PLACEHOLDER_BADGE_KEY = "netx.topology.showPlaceholderBadge"; const CANVAS_BG_KEY = "netx.topology.canvasBg"; const DEFAULT_CANVAS_BG = "#0f172a"; /** Previous light default — migrate so existing sessions pick up dark canvas. */ const LEGACY_CANVAS_BG = "#dbeafe"; const LABEL_COLORS_KEY = "netx.topology.labelColors"; const VENDOR_COLORS_KEY = "netx.topology.vendorColors"; const VENDOR_TONE_KEYS = [ "cisco", "huawei", "zte", "juniper", "nokia", "ericsson", "h3c", "ruijie", "mikrotik", "gray", ] as const; type VendorToneKey = (typeof VENDOR_TONE_KEYS)[number]; type VendorColors = Record; type LabelColors = { name: string; edgeLabel: string }; const DEFAULT_LABEL_COLORS: LabelColors = { name: "#f1f5f9", edgeLabel: "#e2e8f0", }; const DEFAULT_VENDOR_COLORS: VendorColors = { cisco: "#049fd9", huawei: "#cf0a2c", zte: "#0091da", juniper: "#84b135", nokia: "#124191", ericsson: "#1e3a5f", h3c: "#7ac143", ruijie: "#7c3aed", mikrotik: "#ea580c", gray: "#94a3b8", }; function isHexColor(value: string): boolean { return /^#[0-9a-fA-F]{6}$/.test(value); } function canvasBgLuminance(bg: string): number { const hex = String(bg || "").replace("#", "").trim(); if (hex.length !== 6) return 0.1; const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); if (![r, g, b].every((n) => Number.isFinite(n))) return 0.1; return (0.299 * r + 0.587 * g + 0.114 * b) / 255; } /** Floating chrome (fs toolbar / view tools / controls) follows canvas bg like MiniMap. */ function canvasFloatChromeVars(bg: string): CSSProperties { const light = canvasBgLuminance(bg) > 0.55; if (light) { return { "--topo-float-bg": "rgba(255, 255, 255, 0.92)", "--topo-float-border": "rgba(15, 23, 42, 0.08)", "--topo-float-shadow": "0 8px 24px rgba(15, 23, 42, 0.12)", "--topo-float-fg": "#0f172a", "--topo-float-muted": "#64748b", "--topo-float-btn-bg": "transparent", "--topo-float-btn-border": "transparent", "--topo-float-btn-hover": "rgba(15, 23, 42, 0.06)", "--topo-float-active-bg": "rgba(37, 99, 235, 0.16)", "--topo-float-active-fg": "#1d4ed8", "--topo-float-active-border": "#3b82f6", "--topo-float-control-bg": "transparent", "--topo-float-control-hover": "rgba(15, 23, 42, 0.06)", "--topo-float-control-fill": "#334155", "--topo-float-input-bg": "#ffffff", "--topo-float-input-fg": "#0f172a", "--topo-float-input-border": "rgba(15, 23, 42, 0.16)", } as CSSProperties; } return { "--topo-float-bg": "rgba(15, 23, 42, 0.92)", "--topo-float-border": "rgba(148, 163, 184, 0.18)", "--topo-float-shadow": "0 8px 24px rgba(0, 0, 0, 0.35)", "--topo-float-fg": "#e2e8f0", "--topo-float-muted": "#94a3b8", "--topo-float-btn-bg": "transparent", "--topo-float-btn-border": "transparent", "--topo-float-btn-hover": "rgba(30, 41, 59, 0.9)", "--topo-float-active-bg": "rgba(37, 99, 235, 0.28)", "--topo-float-active-fg": "#93c5fd", "--topo-float-active-border": "#3b82f6", "--topo-float-control-bg": "transparent", "--topo-float-control-hover": "rgba(30, 41, 59, 0.9)", "--topo-float-control-fill": "#cbd5e1", "--topo-float-input-bg": "#0b1220", "--topo-float-input-fg": "#e2e8f0", "--topo-float-input-border": "rgba(148, 163, 184, 0.28)", } as CSSProperties; } function loadCanvasBg(): string { try { const raw = String(localStorage.getItem(CANVAS_BG_KEY) || "").trim().toLowerCase(); if (raw === LEGACY_CANVAS_BG) { persistCanvasBg(DEFAULT_CANVAS_BG); return DEFAULT_CANVAS_BG; } if (isHexColor(raw)) return raw; } catch { /* ignore */ } return DEFAULT_CANVAS_BG; } function persistCanvasBg(value: string) { try { localStorage.setItem(CANVAS_BG_KEY, value); } catch { /* ignore */ } } function loadLabelColors(): LabelColors { try { const raw = localStorage.getItem(LABEL_COLORS_KEY); if (!raw) return { ...DEFAULT_LABEL_COLORS }; const parsed = JSON.parse(raw) as Partial; return { name: isHexColor(String(parsed.name || "")) ? String(parsed.name).toLowerCase() : DEFAULT_LABEL_COLORS.name, edgeLabel: isHexColor(String(parsed.edgeLabel || "")) ? String(parsed.edgeLabel).toLowerCase() : DEFAULT_LABEL_COLORS.edgeLabel, }; } catch { return { ...DEFAULT_LABEL_COLORS }; } } function persistLabelColors(value: LabelColors) { try { localStorage.setItem(LABEL_COLORS_KEY, JSON.stringify(value)); } catch { /* ignore */ } } function loadVendorColors(): VendorColors { try { const raw = localStorage.getItem(VENDOR_COLORS_KEY); if (!raw) return { ...DEFAULT_VENDOR_COLORS }; const parsed = JSON.parse(raw) as Partial; const out = { ...DEFAULT_VENDOR_COLORS }; for (const key of VENDOR_TONE_KEYS) { const c = String(parsed?.[key] || "").trim().toLowerCase(); if (isHexColor(c)) out[key] = c; } return out; } catch { return { ...DEFAULT_VENDOR_COLORS }; } } function persistVendorColors(value: VendorColors) { try { localStorage.setItem(VENDOR_COLORS_KEY, JSON.stringify(value)); } catch { /* ignore */ } } function loadBoolFlag(key: string, defaultValue: boolean): boolean { try { const raw = localStorage.getItem(key); if (raw === null) return defaultValue; return raw === "1" || raw === "true"; } catch { return defaultValue; } } function persistBoolFlag(key: string, value: boolean) { try { localStorage.setItem(key, value ? "1" : "0"); } catch { /* ignore */ } } function loadAutoLayoutAfterDiscover(): boolean { return loadBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, false); } function persistAutoLayoutAfterDiscover(value: boolean) { persistBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, value); } const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = { // UME is authority when present — solid blue. ume: { stroke_color: "#2563eb", stroke_width: 2, line_style: "solid" }, // LLDP/CDP only (no UME) — dashed sky blue. discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" }, // Gone / not seen — red dashed. stale: { stroke_color: "#dc2626", stroke_width: 2, line_style: "dashed" }, // Hand-drawn — gray solid. manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" }, }; function sourceKind(source: string): EdgeSourceKind { const src = (source || "manual").toLowerCase(); if (src === "stale" || src === "missing") return "stale"; if (src === "ume") return "ume"; if (src === "lldp" || src === "cdp") return "discovered"; return "manual"; } function loadEdgeDefaults(): EdgeDefaults { try { const raw = localStorage.getItem(EDGE_DEFAULTS_KEY); if (!raw) { return { ume: { ...BUILTIN_EDGE_DEFAULTS.ume }, discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered }, stale: { ...BUILTIN_EDGE_DEFAULTS.stale }, manual: { ...BUILTIN_EDGE_DEFAULTS.manual }, }; } const parsed = JSON.parse(raw) as Partial; const pick = (kind: EdgeSourceKind): EdgeDefaultStyle => { const base = BUILTIN_EDGE_DEFAULTS[kind]; const cur = parsed?.[kind]; const color = String(cur?.stroke_color || base.stroke_color).trim() || base.stroke_color; const width = Math.max(1, Math.min(12, Number(cur?.stroke_width || base.stroke_width) || base.stroke_width)); const line = String(cur?.line_style || base.line_style).toLowerCase(); const line_style: EdgeLineStyle = line === "dashed" || line === "dotted" || line === "solid" ? line : base.line_style; return { stroke_color: color, stroke_width: width, line_style }; }; return { ume: pick("ume"), discovered: pick("discovered"), stale: pick("stale"), manual: pick("manual"), }; } catch { return { ume: { ...BUILTIN_EDGE_DEFAULTS.ume }, discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered }, stale: { ...BUILTIN_EDGE_DEFAULTS.stale }, manual: { ...BUILTIN_EDGE_DEFAULTS.manual }, }; } } function persistEdgeDefaults(next: EdgeDefaults) { try { localStorage.setItem(EDGE_DEFAULTS_KEY, JSON.stringify(next)); } catch { /* ignore quota */ } } function dashForLineStyle(lineStyle: string): string | undefined { const s = (lineStyle || "").trim().toLowerCase(); if (s === "dashed") return "6 4"; if (s === "dotted") return "2 2"; return undefined; } function edgeStyleBySource( source: string, defaults: EdgeDefaults, ): { stroke: string; strokeDasharray?: string; strokeWidth: number } { const d = defaults[sourceKind(source)]; return { stroke: d.stroke_color, strokeWidth: d.stroke_width, strokeDasharray: dashForLineStyle(d.line_style), }; } function resolveEdgeStyle( data: EdgeStyleData | undefined, defaults: EdgeDefaults, ): { stroke: string; strokeDasharray?: string; strokeWidth: number } { const base = edgeStyleBySource(data?.source || "manual", defaults); const color = String(data?.stroke_color || "").trim(); const width = Number(data?.stroke_width || 0); const line = String(data?.line_style || "").trim().toLowerCase(); return { stroke: color || base.stroke, strokeWidth: width > 0 ? width : base.strokeWidth, strokeDasharray: line ? dashForLineStyle(line) : base.strokeDasharray, }; } function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge { const data = (edge.data || {}) as EdgeStyleData; const style = resolveEdgeStyle(data, defaults); // RF EdgeText defaults labelShowBg=true with a white rect — keep digits only. return { ...edge, style, markerEnd: undefined, labelShowBg: false }; } function graphToFlow( nodes: TopologyViewNodeItem[], edges: TopologyViewEdgeItem[], defaults: EdgeDefaults, ) { const rfNodes: Node[] = nodes.map((n) => { const isRegion = n.kind === "region" || n.device_type === "region" || String(n.fabric_node_id || "").startsWith("region:"); const apiX = Number(n.x) || 0; const apiY = Number(n.y) || 0; // API coords are icon-center (UME + region); RF position is top-left of the icon box. const position = isRegion ? regionFlowPosition(apiX, apiY) : neFlowPosition(apiX, apiY); const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X; const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y; return { id: n.fabric_node_id, type: "neNode", position, width: TOPO_NODE_W, height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H, // Predetermined handles must match DOM anchors (icon center). handles: [ { type: "target", position: "left" as Position, x: hx, y: hy }, { type: "source", position: "right" as Position, x: hx, y: hy }, ], data: { label: n.label || n.name || n.ip || n.fabric_node_id, managed_ne_id: n.managed_ne_id || "", ume_ne_id: n.ume_ne_id || "", ne_ip: n.ip || "", vendor: n.vendor || "", connect_status: n.connect_status || "", managed_source: n.managed_source || "", kind: (n.kind as NeNodeData["kind"]) || (isRegion ? "region" : "ne"), folder_id: n.folder_id || "", view_id: n.view_id || "", node_count: n.node_count || 0, }, }; }); const rfEdges: Edge[] = edges.map((e) => { const src = e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual"; const label = formatPortPairLabel(e.a_port || "", e.b_port || "", e.display_label || ""); const data: EdgeStyleData = { source: src, source_port: e.a_port || "", target_port: e.b_port || "", stroke_color: e.stroke_color || "", stroke_width: Number(e.stroke_width || 0), line_style: e.line_style || "", discovered_at: e.discovered_at ?? null, display_label: e.display_label || "", }; return withEdgeVisual( { id: e.id, source: e.a_node_id, target: e.b_node_id, type: "straight", label: label || undefined, animated: false, data, }, defaults, ); }); return { rfNodes, rfEdges }; } function flowToPositions(nodes: Node[]) { return nodes.map((n) => { const isRegion = n.data.kind === "region" || String(n.id || "").startsWith("region:"); const pos = isRegion ? regionApiPosition(n.position.x, n.position.y) : neApiPosition(n.position.x, n.position.y); return { fabric_node_id: n.id, x: pos.x, y: pos.y, label: n.data.label || "", }; }); } /** Stable signature so live poll only re-applies when the server graph actually changed. */ function graphFingerprint(graph: TopologyViewGraph): string { const nodes = [...graph.nodes] .map( (n) => `${n.fabric_node_id}:${Math.round(Number(n.x) || 0)}:${Math.round(Number(n.y) || 0)}:${n.label || n.name || ""}`, ) .sort() .join("|"); const edges = [...graph.edges] .map((e) => `${e.id}:${e.a_node_id}:${e.b_node_id}:${e.a_port || ""}:${e.b_port || ""}:${e.status || ""}`) .sort() .join("|"); return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}#${graph.scatter?.length || 0}#${graph.world_transform?.total || 0}`; } function applyViewGraph( graph: TopologyViewGraph, defaults: EdgeDefaults, setNodes: (ns: Node[]) => void, setEdges: (es: Edge[]) => void, localPositions?: Map, ) { const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults); const merged = localPositions?.size ? rfNodes.map((n) => { const p = localPositions.get(n.id); return p ? { ...n, position: { ...p } } : n; }) : rfNodes; setNodes(merged); setEdges(rfEdges); return { rfNodes: merged, rfEdges }; } export function TopologyPage() { const { t, locale } = useI18n(); const { showOk, showError } = useToast(); const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); // mapId set → fabric canvas; otherwise browse directory (region or view folder). const [mapId, setMapId] = useState(""); /** When mapId is the World canvas, optional SBN region folder to zoom/filter. */ const [worldFocusFolderId, setWorldFocusFolderId] = useState(""); const [worldViewId, setWorldViewId] = useState(""); const [selectedFolderId, setSelectedFolderId] = useState(""); /** Hover sync key between left tree and right hex browser: `region:` | `view:`. */ const [hotBrowseKey, setHotBrowseKey] = useState(""); const [pendingHighlightNe, setPendingHighlightNe] = useState(""); const [expandedIds, setExpandedIds] = useState>({}); const [keyword, setKeyword] = useState(""); const [debouncedKeyword, setDebouncedKeyword] = useState(""); const [treeNeQuery, setTreeNeQuery] = useState(""); const [debouncedTreeNeQuery, setDebouncedTreeNeQuery] = useState(""); const [treeSearchOpen, setTreeSearchOpen] = useState(false); const treeSearchRef = useRef(null); const [dirty, setDirty] = useState(false); const [historyTick, setHistoryTick] = useState(0); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [ctxMenu, setCtxMenu] = useState(null); const ctxMenuRef = useRef(null); const [hideIp, setHideIp] = useState(true); const [hideVendor, setHideVendor] = useState(true); const [hidePorts, setHidePorts] = useState(true); /** Default: logical aggregate of parallel links; expand to see each physical edge. */ const [expandPhysicalLinks, setExpandPhysicalLinks] = useState(false); /** When aggregating, optionally thicken the line by member count (off = single-line width). */ const [scaleBundleWidth, setScaleBundleWidth] = useState(() => loadBoolFlag(SCALE_BUNDLE_WIDTH_KEY, false), ); /** TOPO/LLDP corner badge hidden by default; toggle in Display menu. */ const [showPlaceholderBadge, setShowPlaceholderBadge] = useState(() => loadBoolFlag(SHOW_PLACEHOLDER_BADGE_KEY, false), ); const [edgeFlow, setEdgeFlow] = useState(false); const [edgeDefaults, setEdgeDefaults] = useState(() => loadEdgeDefaults()); const [canvasBg, setCanvasBg] = useState(loadCanvasBg); const [labelColors, setLabelColors] = useState(() => loadLabelColors()); const [vendorColors, setVendorColors] = useState(() => loadVendorColors()); const [toolMode, setToolMode] = useState("pan"); const [snapToGrid, setSnapToGrid] = useState(true); const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(loadAutoLayoutAfterDiscover); const [discoverAutoAddUnmatched, setDiscoverAutoAddUnmatched] = useState(() => loadBoolFlag(DISCOVER_AUTO_ADD_KEY, true), ); const [discoverProjectNeighbors, setDiscoverProjectNeighbors] = useState(() => loadBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, true), ); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => loadSidebarWidth()); const [sidebarResizing, setSidebarResizing] = useState(false); const sidebarDragRef = useRef<{ startX: number; startW: number } | null>(null); const topoPageRef = useRef(null); const [addNeOpen, setAddNeOpen] = useState(false); const [outsidePeersOpen, setOutsidePeersOpen] = useState(false); const [outsidePeerSelectedIds, setOutsidePeerSelectedIds] = useState([]); const [outsidePeerQuery, setOutsidePeerQuery] = useState(""); const [outsidePeersAdding, setOutsidePeersAdding] = useState(false); const [paletteSource, setPaletteSource] = useState("managed"); const [paletteSelectedKeys, setPaletteSelectedKeys] = useState([]); const [paletteAdding, setPaletteAdding] = useState(false); const [createNeDialog, setCreateNeDialog] = useState<{ flowX: number; flowY: number; name: string; ip_address: string; } | null>(null); const [createNeBusy, setCreateNeBusy] = useState(false); const [newRootDialog, setNewRootDialog] = useState<{ name: string } | null>(null); const [discoverOpen, setDiscoverOpen] = useState(false); const [discovering, setDiscovering] = useState(false); const [discoverReport, setDiscoverReport] = useState(null); const [discoverLiveResults, setDiscoverLiveResults] = useState([]); const [discoverProgress, setDiscoverProgress] = useState({ index: 0, total: 0, neName: "", neIp: "", edgesAdded: 0, edgesUpdated: 0, }); const [discoverError, setDiscoverError] = useState(""); const [discoverJobId, setDiscoverJobId] = useState(""); const discoverAbortRef = useRef(false); const [fullscreen, setFullscreen] = useState(false); /** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */ const [liveSync, setLiveSync] = useState(false); const [canvasQuery, setCanvasQuery] = useState(""); const [searchHitIds, setSearchHitIds] = useState([]); const [findOpen, setFindOpen] = useState(false); const [findActiveIdx, setFindActiveIdx] = useState(0); const searchHitTimerRef = useRef(null); const findBoxRef = useRef(null); const displayMenuRef = useRef(null); const [viewToolsToolbarSlot, setViewToolsToolbarSlot] = useState(null); const findJustLocatedRef = useRef(false); const [nodes, setNodes] = useState[]>([]); const [edges, setEdges] = useState([]); const rfRef = useRef, Edge> | null>(null); const canvasRef = useRef(null); const dirtyRef = useRef(false); const appliedMapIdRef = useRef(""); const appliedGraphFpRef = useRef(""); /** Fit once after graph hydrate — world coords are far outside the default viewport. */ const pendingFitRef = useRef(false); const nodesRef = useRef[]>([]); const historyRef = useRef([]); const redoRef = useRef([]); const historyLockRef = useRef(false); /** Fabric edge ids removed locally; flushed on Save (not when only removing nodes from view). */ const pendingEdgeDeletesRef = useRef>(new Set()); /** Local `local:*` edge ids added on canvas; flushed on Save via createFabricManualEdge. */ const pendingEdgeCreatesRef = useRef>(new Set()); const connectClickRef = useRef(null); const canUndo = historyTick >= 0 && historyRef.current.length > 0; const canRedo = historyTick >= 0 && redoRef.current.length > 0; nodesRef.current = nodes; const markDirty = useCallback(() => { dirtyRef.current = true; setDirty(true); }, []); const clearDirty = useCallback(() => { dirtyRef.current = false; setDirty(false); }, []); const bumpHistory = useCallback(() => { setHistoryTick((n) => n + 1); }, []); const confirmDiscardIfDirty = useCallback(() => { if (!dirtyRef.current) return true; return window.confirm(t("topology.unsavedConfirm")); }, [t]); const selectMap = useCallback( (id: string) => { if (id === mapId) return; if (!confirmDiscardIfDirty()) return; setMapId(id); }, [mapId, confirmDiscardIfDirty], ); useEffect(() => { const timer = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 150); return () => window.clearTimeout(timer); }, [keyword]); useEffect(() => { const onBeforeUnload = (e: BeforeUnloadEvent) => { if (!dirtyRef.current) return; e.preventDefault(); e.returnValue = ""; }; window.addEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload); }, []); const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]); const [canvasZoom, setCanvasZoom] = useState(1); const worldTransformRef = useRef(null); const flatLodFetchGenRef = useRef(0); const flatLodTimerRef = useRef(null); const isWorldFlatCanvasRef = useRef(false); const mapIdRef = useRef(mapId); const treeQuery = useQuery({ queryKey: queryKeys.topologyTree, queryFn: fetchTopologyTree, staleTime: liveSync ? 0 : 30_000, refetchOnWindowFocus: liveSync, refetchInterval: liveSync ? 5000 : false, refetchIntervalInBackground: false, }); const treeFlatMap = useMemo(() => { if (!mapId) return false; const kids = treeQuery.data?.root?.children || []; return isWorldFlatViewName(findViewInRegion(kids, mapId)?.view?.name); }, [mapId, treeQuery.data?.root?.children]); useEffect(() => { const timer = window.setTimeout(() => setDebouncedTreeNeQuery(treeNeQuery.trim()), 200); return () => window.clearTimeout(timer); }, [treeNeQuery]); const treeNeSearchQuery = useQuery({ queryKey: queryKeys.fabricNodeSearch(debouncedTreeNeQuery, 1), queryFn: () => searchFabricNodes({ q: debouncedTreeNeQuery, page: 1, pageSize: 30 }), enabled: debouncedTreeNeQuery.length >= 1, }); const graphQuery = useQuery({ queryKey: queryKeys.topologyGraph(mapId), queryFn: () => fetchTopologyGraph( mapId, WORLD_MAP_ENABLED && treeFlatMap ? { lod: "overview" } : undefined, ), // Offline: never fetch the flat world-map graph (LOD / scatter). enabled: Boolean(mapId) && (WORLD_MAP_ENABLED || !treeFlatMap), staleTime: liveSync ? 0 : 30_000, refetchOnWindowFocus: liveSync && !treeFlatMap, refetchInterval: liveSync && mapId && !treeFlatMap ? 3000 : false, refetchIntervalInBackground: false, }); const isWorldFlatCanvas = WORLD_MAP_ENABLED && (treeFlatMap || isWorldFlatViewName(graphQuery.data?.view?.name) || Boolean(graphQuery.data?.view?.filter?.world_flat)); isWorldFlatCanvasRef.current = isWorldFlatCanvas; mapIdRef.current = mapId; const worldVisualLod = isWorldFlatCanvas ? worldVisualLodFromZoom(canvasZoom) : "full"; const displayOpts = useMemo( () => ({ hideIp, hideVendor, hidePorts, connectMode: toolMode === "connect", showPlaceholderBadge, worldVisualLod, }), [hideIp, hideVendor, hidePorts, toolMode, showPlaceholderBadge, worldVisualLod], ); const displayEdges = useMemo(() => { if (isWorldFlatCanvas && worldVisualLod !== "full") return []; const built = buildLinkDisplayEdges(edges, expandPhysicalLinks, hidePorts, scaleBundleWidth).map((e) => withEdgeVisual(e, edgeDefaults), ); return built.map((e) => { const d = (e.data || {}) as EdgeStyleData; const selected = e.id === selectedEdgeId || Boolean(d.members?.some((m: LinkMember) => m.id === selectedEdgeId)); return { ...e, selected, // Empty string clears RF's previous edge-text; undefined can leave a stale label. label: e.label ? String(e.label) : "", animated: edgeFlow, }; }); }, [ edges, expandPhysicalLinks, hidePorts, scaleBundleWidth, edgeFlow, edgeDefaults, selectedEdgeId, isWorldFlatCanvas, worldVisualLod, ]); useEffect(() => { if (graphQuery.data?.world_transform) { worldTransformRef.current = graphQuery.data.world_transform; } }, [graphQuery.data?.world_transform]); const refreshFlatViewport = useCallback(async () => { if (!WORLD_MAP_ENABLED || !mapId || !isWorldFlatCanvas || dirtyRef.current) return; const inst = rfRef.current; if (!inst) return; const zoom = inst.getZoom(); setCanvasZoom(zoom); const gen = ++flatLodFetchGenRef.current; const commit = (g: TopologyViewGraph, centerX: number, centerY: number) => { if (g.world_transform) worldTransformRef.current = g.world_transform; queryClient.setQueryData(queryKeys.topologyGraph(mapId), (prev: TopologyViewGraph | undefined) => mergeFlatWorldGraph(prev, g, { centerX, centerY }), ); }; try { // Far / mid zoom: starfield scatter only (screen-space canvas). Drop heavy RF tiles. if (zoom < 0.12) { const prev = queryClient.getQueryData(queryKeys.topologyGraph(mapId)); if (!prev?.scatter?.length) { const g = await fetchTopologyGraph(mapId, { lod: "overview" }); if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return; const bounds = worldDisplayBounds(g.world_transform); const cx = bounds ? bounds.x + bounds.width / 2 : 0; const cy = bounds ? bounds.y + bounds.height / 2 : 0; commit(g, cx, cy); } else if (prev.nodes.length > 0) { // Always shed RF tiles when leaving close-up so scatter is the sole layer. commit( { ...prev, nodes: [], edges: [], scatter: prev.scatter, world_transform: prev.world_transform ? { ...prev.world_transform, lod: "overview" } : prev.world_transform, }, 0, 0, ); } return; } const pane = canvasRef.current?.getBoundingClientRect(); const w = pane?.width || 1200; const h = pane?.height || 800; const vp = inst.getViewport(); const z = Math.max(vp.zoom || 0.01, 0.01); const min_x = -vp.x / z; const min_y = -vp.y / z; const max_x = (w - vp.x) / z; const max_y = (h - vp.y) / z; const g = await fetchTopologyGraph(mapId, { lod: "detail", min_x, max_x, min_y, max_y, }); if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return; commit(g, (min_x + max_x) / 2, (min_y + max_y) / 2); } catch { /* keep last good sample */ } }, [mapId, isWorldFlatCanvas, queryClient]); const scheduleFlatViewportRefresh = useCallback(() => { if (!WORLD_MAP_ENABLED || !isWorldFlatCanvas) return; if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current); flatLodTimerRef.current = window.setTimeout(() => { flatLodTimerRef.current = null; void refreshFlatViewport(); }, 200); }, [isWorldFlatCanvas, refreshFlatViewport]); const fitCanvas = useCallback(() => { const inst = rfRef.current; if (!inst) return; const bounds = isWorldFlatCanvas ? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform) : null; if (bounds) inst.fitBounds(bounds, { padding: 0.12, duration: 0 }); else inst.fitView({ ...FIT_VIEW_OPTS }); setCanvasZoom(inst.getZoom()); }, [isWorldFlatCanvas, graphQuery.data?.world_transform]); useEffect(() => { return () => { if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current); }; }, []); useEffect(() => { try { localStorage.setItem(TREE_EXPAND_KEY, JSON.stringify(expandedIds)); } catch { /* ignore */ } }, [expandedIds]); useEffect(() => { try { if (mapId) localStorage.setItem(LAST_LEAF_KEY, mapId); else localStorage.removeItem(LAST_LEAF_KEY); } catch { /* ignore */ } }, [mapId]); // Keep canvas "auto placeholder" in sync with topology LLDP collect policy (shared strategy). useEffect(() => { let alive = true; void fetchLldpCollectDashboard() .then((dash) => { if (!alive || !dash?.policy) return; const next = Boolean(dash.policy.auto_add_unmatched); setDiscoverAutoAddUnmatched(next); persistBoolFlag(DISCOVER_AUTO_ADD_KEY, next); }) .catch(() => { /* keep local default */ }); return () => { alive = false; }; }, []); const neQuery = useQuery({ queryKey: [...queryKeys.managedNeAll, "topology-add-ne", debouncedKeyword], queryFn: () => fetchManagedNe({ keyword: debouncedKeyword, vendor: "", connectStatus: "", page: 1, pageSize: 100, }), enabled: addNeOpen && paletteSource === "managed", }); const umeQuery = useQuery({ queryKey: ["umeInventoryNe", "topology-add-ne", debouncedKeyword], queryFn: () => fetchUmeNe({ keyword: debouncedKeyword, page: 1, pageSize: 100, }), enabled: addNeOpen && paletteSource === "ume", }); const treeRoot = treeQuery.data?.root || null; const regions = useMemo(() => treeRoot?.children || [], [treeRoot]); const treeLoading = treeQuery.isPending && !treeQuery.data; const treeFailed = treeQuery.isError && !treeQuery.data; useEffect(() => { if (!treeRoot || !regions.length) return; if (mapId) { const hit = findViewInRegion(regions, mapId); if (hit && selectedFolderId !== hit.region.id) { setSelectedFolderId(hit.region.id); setExpandedIds((prev) => ({ ...prev, [hit.region.id]: true })); } return; } // Keep root (all regions) as the default landing view — do not auto-pick regions[0]. if (selectedFolderId && !regions.some((r) => r.id === selectedFolderId)) { setSelectedFolderId(""); } }, [mapId, selectedFolderId, treeRoot, regions]); const rootFolderId = String(treeRoot?.id || "").trim(); const canvasMode = Boolean(mapId); useEffect(() => { if (!canvasMode) return; void import("./topology/TopologyReactFlowView"); }, [canvasMode]); const activeRegion = useMemo(() => { if (!selectedFolderId) return null; return findFolderInTree(regions, selectedFolderId); }, [regions, selectedFolderId]); const activeView = useMemo(() => { if (!mapId) return null; return findViewInRegion(regions, mapId)?.view || null; }, [mapId, regions]); const browseEntries = useMemo( (): TopologyTreeViewItem[] => { // Region === canvas: no map subdirectory listing. // UME World / top-level containers: hex shows modules (handled separately). if ( !activeRegion || isRegionCanvasFolder(activeRegion, rootFolderId) || isUmeWorldContainer(activeRegion) ) { return []; } return activeRegion.views || []; }, [activeRegion, rootFolderId], ); const hexBrowseRegion = useMemo(() => { // Root hex, or nav container hex (UME World / top-level manual). if (!activeRegion) return null; if (isUmeWorldContainer(activeRegion)) return activeRegion; if (isRegionCanvasFolder(activeRegion, rootFolderId)) return null; return activeRegion; }, [activeRegion, rootFolderId]); const umeWorldHexModules = useMemo(() => { if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null; const drill = (activeRegion.children || []).find((c) => isWorldDrillFolder(c)) || null; const flatView = WORLD_MAP_ENABLED ? (activeRegion.views || []).find((v) => isWorldFlatViewName(v.name)) || null : null; return { drill, flatView }; }, [activeRegion]); // Resolve World drill view id for shortcuts; do not auto-open canvas. useEffect(() => { if (!WORLD_MAP_ENABLED) { setWorldViewId(""); return; } let cancelled = false; fetchTopologyWorld() .then((w) => { if (cancelled) return; setWorldViewId(w.view_id); }) .catch(() => { /* world not seeded yet — keep classic browse */ }); return () => { cancelled = true; }; }, []); const expandFolderPath = useCallback( (folderId: string) => { const path = folderPathIds(regions, folderId); if (!path.length) return; setExpandedIds((p) => { const next = { ...p }; for (const id of path) next[id] = true; return next; }); }, [regions], ); const goUmeWorldNav = useCallback(() => { if (!confirmDiscardIfDirty()) return; const container = regions.find( (r) => r.external_ref === "ume:world" || r.name === "UME World", ); if (!container) return; setWorldFocusFolderId(""); setSelectedFolderId(container.id); setExpandedIds((p) => ({ ...p, [container.id]: true })); setMapId(""); clearDirty(); }, [confirmDiscardIfDirty, clearDirty, regions]); // Leave flat world map if still open after the feature was taken offline. useEffect(() => { if (WORLD_MAP_ENABLED || !mapId || !treeFlatMap) return; const container = regions.find( (r) => r.external_ref === "ume:world" || r.name === "UME World", ); setWorldFocusFolderId(""); if (container) { setSelectedFolderId(container.id); setExpandedIds((p) => ({ ...p, [container.id]: true })); } setMapId(""); clearDirty(); }, [mapId, treeFlatMap, regions, clearDirty]); const goWorld = useCallback(() => { if (!WORLD_MAP_ENABLED) return; if (!confirmDiscardIfDirty()) return; if (!worldViewId) return; setWorldFocusFolderId(""); const container = regions.find( (r) => r.external_ref === "ume:world" || r.name === "UME World", ); let drillFolder = (container?.children || []).find((c) => isWorldDrillFolder(c)) || null; if (!drillFolder) { for (const r of regions) { if (isWorldDrillFolder(r)) { drillFolder = r; break; } const nested = (r.children || []).find((c) => isWorldDrillFolder(c)); if (nested) { drillFolder = nested; break; } } } if (drillFolder) { setSelectedFolderId(drillFolder.id); } else if (container) { setSelectedFolderId(container.id); } setMapId(worldViewId); clearDirty(); }, [confirmDiscardIfDirty, clearDirty, worldViewId, regions]); const primaryViewOfFolder = useCallback((folder: TopologyTreeFolderItem | null | undefined) => { if (!folder) return null; const views = folder.views || []; if (isWorldDrillFolder(folder)) { return views.find((v) => v.name === "World") || views[0] || null; } // UME World container: World canvas lives on the drill child, not here. if (folder.external_ref === "ume:world" || folder.name === "UME World") { const drill = (folder.children || []).find((c) => isWorldDrillFolder(c)); if (drill) { const dv = drill.views || []; return dv.find((v) => v.name === "World") || dv[0] || null; } return views.find((v) => !isWorldFlatViewName(v.name)) || null; } // Region / 根图: the physical view is the canvas. Custom siblings are rare // extras — never prefer them over physical for the default open target. const physical = views.find((v) => String(v.kind) === "physical") || null; return physical || views[0] || null; }, []); const goRegion = useCallback( (folderId: string) => { if (!confirmDiscardIfDirty()) return; setSelectedFolderId(folderId); expandFolderPath(folderId); const folder = findFolderInTree(regions, folderId); // Region === canvas: open its primary map (no map subdirectory / hex list). if (isRegionCanvasFolder(folder, String(treeRoot?.id || ""))) { setWorldFocusFolderId(""); const view = primaryViewOfFolder(folder); if (view) { setMapId(view.id); clearDirty(); return; } } // UME World container: fall through to empty selection (use goWorld from tree). setMapId(""); setWorldFocusFolderId(""); clearDirty(); setNodes([]); setEdges([]); }, [ confirmDiscardIfDirty, clearDirty, setNodes, setEdges, regions, primaryViewOfFolder, expandFolderPath, treeRoot?.id, ], ); const goRoot = useCallback(() => { if (!confirmDiscardIfDirty()) return; setSelectedFolderId(""); setWorldFocusFolderId(""); setMapId(""); clearDirty(); setNodes([]); setEdges([]); }, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges]); const goCanvas = useCallback( (viewId: string, folderId?: string) => { if (!confirmDiscardIfDirty()) return; const hit = findViewInRegion(regions, viewId); if (!WORLD_MAP_ENABLED && isWorldFlatViewName(hit?.view?.name)) { showError(t("topology.worldMapOffline")); return; } const regionId = folderId || hit?.region.id || selectedFolderId; if (regionId) { setSelectedFolderId(regionId); expandFolderPath(regionId); } setWorldFocusFolderId(""); setMapId(viewId); }, [confirmDiscardIfDirty, regions, selectedFolderId, expandFolderPath, showError, t], ); const goBackBrowse = useCallback(() => { if (!confirmDiscardIfDirty()) return; const folder = findFolderInTree(regions, selectedFolderId); const parentId = String(folder?.parent_id || "").trim(); const parent = parentId ? findFolderInTree(regions, parentId) : null; // One level up: parent canvas if nested, else parent hex nav, else roots. if (parent) { setSelectedFolderId(parent.id); expandFolderPath(parent.id); if (isRegionCanvasFolder(parent, String(treeRoot?.id || ""))) { setWorldFocusFolderId(""); const view = primaryViewOfFolder(parent); if (view) { setMapId(view.id); clearDirty(); return; } } setWorldFocusFolderId(""); setMapId(""); clearDirty(); setNodes([]); setEdges([]); return; } setSelectedFolderId(""); setWorldFocusFolderId(""); setMapId(""); clearDirty(); setNodes([]); setEdges([]); }, [ confirmDiscardIfDirty, clearDirty, setNodes, setEdges, regions, selectedFolderId, treeRoot?.id, expandFolderPath, primaryViewOfFolder, ]); // Deep link from classify search: /topology?view=&ne= useEffect(() => { const viewId = String(searchParams.get("view") || "").trim(); const neId = String(searchParams.get("ne") || "").trim(); if (!viewId || !regions.length) return; const hit = findViewInRegion(regions, viewId); if (!hit) return; setSelectedFolderId(hit.region.id); setExpandedIds((p) => ({ ...p, [hit.region.id]: true })); setMapId(viewId); if (neId) setPendingHighlightNe(neId); setSearchParams({}, { replace: true }); }, [regions, searchParams, setSearchParams]); useEffect(() => { appliedMapIdRef.current = ""; appliedGraphFpRef.current = ""; }, [mapId]); // Keep the left tree scrolled to the active view/folder after drill-down. useEffect(() => { if (!mapId && !selectedFolderId) return; const root = document.querySelector(".topo-region-list"); if (!root) return; const hit = root.querySelector("li.is-active") || root.querySelector(".topo-region-list__block.is-branch-active"); if (hit && "scrollIntoView" in hit) { (hit as HTMLElement).scrollIntoView({ block: "nearest", behavior: "smooth" }); } }, [mapId, selectedFolderId, expandedIds]); useEffect(() => { if (!canvasMode) return; if (!mapId || !graphQuery.data) return; const entering = appliedMapIdRef.current !== mapId; // Live poll while on the same map: apply server graph only if no unsaved local edits. if (!entering && dirtyRef.current) return; const fp = graphFingerprint(graphQuery.data); if (!entering && fp === appliedGraphFpRef.current) return; const selectedIds = entering ? new Set() : new Set(nodesRef.current.filter((n) => n.selected).map((n) => n.id)); const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults); const nextNodes = selectedIds.size > 0 ? rfNodes.map((n) => (selectedIds.has(n.id) ? { ...n, selected: true } : n)) : rfNodes; historyLockRef.current = true; setNodes(nextNodes); setEdges(rfEdges); appliedGraphFpRef.current = fp; if (entering) { appliedMapIdRef.current = mapId; historyRef.current = []; redoRef.current = []; pendingEdgeDeletesRef.current = new Set(); pendingEdgeCreatesRef.current = new Set(); clearDirty(); bumpHistory(); // World / large canvases use absolute coords far outside the default RF // viewport — without an initial fit the canvas looks empty after load. const hasScatter = (graphQuery.data.scatter?.length || 0) > 0; const hasWorld = Boolean(worldDisplayBounds(graphQuery.data.world_transform)); pendingFitRef.current = nextNodes.length > 0 || hasScatter || hasWorld; } historyLockRef.current = false; }, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]); useEffect(() => { if (!pendingFitRef.current) return; const wt = graphQuery.data?.world_transform; const bounds = isWorldFlatCanvas ? worldDisplayBounds(wt) : null; const canFitNodes = nodes.length > 0; if (!bounds && !canFitNodes) return; let cancelled = false; let tries = 0; const run = () => { if (cancelled) return; const inst = rfRef.current; if (!inst) { if (tries++ < 40) window.setTimeout(run, 40); return; } pendingFitRef.current = false; if (bounds) { inst.fitBounds(bounds, { padding: 0.12, duration: 0 }); } else { // duration 0 — animated fit with 400+ nodes often never paints the first frame. inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 }); } setCanvasZoom(inst.getZoom()); }; const t = window.setTimeout(run, 0); return () => { cancelled = true; window.clearTimeout(t); }; }, [nodes, mapId, isWorldFlatCanvas, graphQuery.data?.world_transform]); useEffect(() => { setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults))); }, [edgeDefaults, setEdges]); const updateEdgeDefault = useCallback((kind: EdgeSourceKind, patch: Partial) => { setEdgeDefaults((prev) => { const next: EdgeDefaults = { ...prev, [kind]: { ...prev[kind], ...patch }, }; persistEdgeDefaults(next); return next; }); }, []); const resetEdgeDefaults = useCallback(() => { const next: EdgeDefaults = { ume: { ...BUILTIN_EDGE_DEFAULTS.ume }, discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered }, stale: { ...BUILTIN_EDGE_DEFAULTS.stale }, manual: { ...BUILTIN_EDGE_DEFAULTS.manual }, }; persistEdgeDefaults(next); setEdgeDefaults(next); }, []); const pushHistory = useCallback(() => { if (historyLockRef.current) return; historyRef.current = [ ...historyRef.current.slice(-(UNDO_MAX - 1)), { nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), edges: edges.map((e) => ({ ...e })), pendingEdgeDeletes: [...pendingEdgeDeletesRef.current], pendingEdgeCreates: [...pendingEdgeCreatesRef.current], }, ]; redoRef.current = []; bumpHistory(); }, [nodes, edges, bumpHistory]); const undo = useCallback(() => { const prev = historyRef.current.pop(); if (!prev) return; redoRef.current.push({ nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), edges: edges.map((e) => ({ ...e })), pendingEdgeDeletes: [...pendingEdgeDeletesRef.current], pendingEdgeCreates: [...pendingEdgeCreatesRef.current], }); historyLockRef.current = true; setNodes(prev.nodes); setEdges(prev.edges); pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes); pendingEdgeCreatesRef.current = new Set(prev.pendingEdgeCreates || []); markDirty(); bumpHistory(); historyLockRef.current = false; }, [nodes, edges, setNodes, setEdges, markDirty, bumpHistory]); const redo = useCallback(() => { const next = redoRef.current.pop(); if (!next) return; historyRef.current.push({ nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), edges: edges.map((e) => ({ ...e })), pendingEdgeDeletes: [...pendingEdgeDeletesRef.current], pendingEdgeCreates: [...pendingEdgeCreatesRef.current], }); historyLockRef.current = true; setNodes(next.nodes); setEdges(next.edges); pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes); pendingEdgeCreatesRef.current = new Set(next.pendingEdgeCreates || []); markDirty(); bumpHistory(); historyLockRef.current = false; }, [nodes, edges, setNodes, setEdges, markDirty, bumpHistory]); const applyLayout = useCallback( async (kind: LayoutKind, opts?: { onlySelected?: boolean; persist?: boolean }) => { const onlyIds = opts?.onlySelected ? new Set(nodes.filter((n) => n.selected).map((n) => n.id)) : undefined; if (onlyIds && onlyIds.size === 0) { showError(t("topology.layoutNeedSelection")); return; } pushHistory(); const next = await layoutGraph(nodes, edges, kind, { onlyIds }); setNodes(next); markDirty(); if (opts?.persist && mapId) { try { const graph = await patchTopologyPositions(mapId, flowToPositions(next)); if ( pendingEdgeDeletesRef.current.size === 0 && pendingEdgeCreatesRef.current.size === 0 ) { clearDirty(); } queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); } catch (err) { showError(String(err)); } } }, [nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t, markDirty, clearDirty], ); const applyAlign = useCallback( (kind: Parameters[2]) => { const ids = nodes.filter((n) => n.selected).map((n) => n.id); if (ids.length < 2) { showError(t("topology.alignNeedSelection")); return; } pushHistory(); setNodes(alignNodes(nodes, ids, kind)); markDirty(); }, [nodes, setNodes, pushHistory, showError, t, markDirty], ); const renameMapMut = useMutation({ mutationFn: ({ id, name }: { id: string; name: string }) => updateTopologyMap(id, { name }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); 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 createRegionMut = useMutation({ mutationFn: (input: { name: string; parent_id?: string }) => { const parentId = String(input.parent_id || "").trim(); // Top-level root: omit parent_id so API bootstraps system root even if tree query failed. // locale drives auto「根图」/ "Root map" label under a new root. return createTopologyFolder( parentId ? { name: input.name, kind: "region", parent_id: parentId, locale } : { name: input.name, kind: "region", locale }, ); }, onSuccess: async (folder, input) => { setNewRootDialog(null); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); const tree = await fetchTopologyTree(); const regionsList = tree.root?.children || []; const hit = findFolderInTree(regionsList, folder.id); const parent = String(input.parent_id || folder.parent_id || hit?.parent_id || "").trim(); const rootId = String(tree.root?.id || "").trim(); const isNested = Boolean(parent && parent !== rootId); setWorldFocusFolderId(""); setExpandedIds((prev) => { const next = { ...prev, [folder.id]: true }; if (parent) next[parent] = true; return next; }); if (isNested) { // Stay on parent canvas so the new region building icon is visible. setSelectedFolderId(parent); expandFolderPath(parent); const parentHit = findFolderInTree(regionsList, parent); const parentView = primaryViewOfFolder(parentHit) || (parentHit?.views || []).find((v) => String(v.kind) === "physical") || (parentHit?.views || [])[0]; const stayViewId = mapId || parentView?.id || ""; if (stayViewId) { setMapId(stayViewId); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(stayViewId), }); } } else { // Top-level = nav container (UME World pattern): stay on hex browse, do not open canvas. setSelectedFolderId(folder.id); setMapId(""); } showOk(t("topology.regionCreated")); }, onError: (err) => showError(String(err)), }); const renameRegionMut = useMutation({ mutationFn: ({ id, name }: { id: string; name: string }) => updateTopologyFolder(id, { name }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); showOk(t("topology.regionRenamed")); }, onError: (err) => showError(String(err)), }); const promptRenameRegion = useCallback( (id: string, currentName: string) => { const next = window.prompt(t("topology.renameRegionPrompt"), currentName); if (next == null) return; const name = next.trim(); if (!name || name === currentName) return; renameRegionMut.mutate({ id, name }); }, [renameRegionMut, t], ); const deleteMapMut = useMutation({ mutationFn: (id: string) => deleteTopologyMap(id), onSuccess: async (_out, id) => { await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); if (mapId === id) { clearDirty(); setMapId(""); setNodes([]); setEdges([]); } }, onError: (err) => showError(String(err)), }); const deleteFolderMut = useMutation({ mutationFn: (id: string) => deleteTopologyFolder(id, false), onSuccess: async (_out, id) => { await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); if (selectedFolderId === id) { setSelectedFolderId(""); setMapId(""); } showOk(t("topology.regionDeleted")); }, onError: (err) => showError(String(err)), }); const applyWorldMut = useMutation({ mutationFn: () => applyUmeTopologyToFabric(), onSuccess: async () => { showOk(t("topology.worldApplyOk")); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) }); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree }); pendingFitRef.current = true; }, onError: (err) => showError(String(err)), }); const promptNewRegion = useCallback(() => { setNewRootDialog({ name: t("topology.newRegionName") }); }, [t]); const submitNewRoot = useCallback(() => { const name = String(newRootDialog?.name || "").trim(); if (!name) { showError(t("topology.newRegionPrompt")); return; } createRegionMut.mutate({ name }); }, [newRootDialog, createRegionMut, showError, t]); const promptNewSubRegion = useCallback(() => { if (isWorldFlatViewName(activeView?.name)) { showError(t("topology.subRegionNotOnFlat")); return; } let parentId = selectedFolderId; if (!parentId && worldViewId && mapId === worldViewId) { const container = regions.find( (r) => r.external_ref === "ume:world" || r.name === "UME World", ); const drill = (container?.children || []).find((c) => isWorldDrillFolder(c)); parentId = drill?.id || container?.id || ""; } if (!parentId) { showError(t("topology.subRegionNeedParent")); return; } const name = window.prompt(t("topology.newSubRegionPrompt"), t("topology.newSubRegionName")); if (!name?.trim()) return; createRegionMut.mutate({ name: name.trim(), parent_id: parentId }); setCtxMenu(null); }, [activeView?.name, selectedFolderId, worldViewId, mapId, regions, createRegionMut, t, showError]); const renderWorldNavFolder = useCallback( (folder: TopologyTreeFolderItem, depth: number): ReactNode => { const kids = folder.children || []; const containerFolder = folder.external_ref === "ume:world" || folder.name === "UME World"; const drillFolder = isWorldDrillFolder(folder); const umeNav = isUmeWorldNavFolder(folder); // Region === canvas: hide the physical primary row (region click opens it), // but keep sibling custom canvases visible (e.g. MCP LPG-Full next to 根图). // UME World container: only「世界地图」as a sibling view (when enabled). const canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || "")); const visibleViews = ( canvasRegion ? (folder.views || []).filter((v) => String(v.kind) !== "physical") : containerFolder ? (folder.views || []).filter((v) => isWorldFlatViewName(v.name)) : folder.views || [] ).filter((v) => WORLD_MAP_ENABLED || !isWorldFlatViewName(v.name)); const hasKids = kids.length > 0 || visibleViews.length > 0; const open = Boolean(expandedIds[folder.id]); const viewActiveUnder = (folder.views || []).some((v) => v.id === mapId) || (drillFolder && Boolean(worldViewId) && mapId === worldViewId); const isSelected = selectedFolderId === folder.id; const focused = viewActiveUnder || (isSelected && Boolean(mapId)); const regionActive = isSelected && !mapId; const hot = hotBrowseKey === `region:${folder.id}`; const renderViewRow = (v: TopologyTreeViewItem) => { const isPhysical = String(v.kind) === "physical"; const viewHot = hotBrowseKey === `view:${v.id}`; const viewActive = mapId === v.id; return (
  • setHotBrowseKey(`view:${v.id}`)} onMouseLeave={() => setHotBrowseKey((k) => (k === `view:${v.id}` ? "" : k)) } > {/* Same width as folder chevron so leaf canvas aligns under parent title. */}
  • ); }; return (
  • setHotBrowseKey(`region:${folder.id}`)} onMouseLeave={() => setHotBrowseKey((k) => (k === `region:${folder.id}` ? "" : k)) } > {(() => { const canRenameFolder = !isUmeStructuralFolder(folder) && (!folder.is_system || isManualRootMapFolder(folder)); const canDeleteFolder = !isUmeStructuralFolder(folder) && !isManualRootMapFolder(folder) && (!folder.is_system || isUmeSyncedSubRegion(folder)); if (!canRenameFolder && !canDeleteFolder) return null; return (
    {canRenameFolder ? ( ) : null} {canDeleteFolder ? ( ) : null}
    ); })()}
    {open ? (
      {containerFolder ? ( <> {kids.map((child) => renderWorldNavFolder(child, depth + 1))} {visibleViews.map(renderViewRow)} ) : ( <> {visibleViews.map(renderViewRow)} {kids.map((child) => renderWorldNavFolder(child, depth + 1))} )}
    ) : null}
  • ); }, [ expandedIds, mapId, worldViewId, selectedFolderId, hotBrowseKey, dirty, goUmeWorldNav, goWorld, goRegion, goCanvas, promptRenameRegion, promptRenameMap, renameRegionMut.isPending, renameMapMut.isPending, deleteFolderMut, deleteMapMut, t, treeRoot?.id, ], ); const saveMut = useMutation({ mutationFn: async () => { if (!mapId) throw new Error(t("topology.selectMap")); const createIds = [...pendingEdgeCreatesRef.current]; for (const id of createIds) { const e = edges.find((x) => x.id === id); if (!e) continue; const d = (e.data || {}) as EdgeStyleData; await createFabricManualEdge({ a_node_id: e.source, b_node_id: e.target, a_port: String(d.source_port || ""), b_port: String(d.target_port || ""), }); } pendingEdgeCreatesRef.current.clear(); const pendingEdges = [...pendingEdgeDeletesRef.current].filter((id) => !isLocalPendingEdgeId(id)); if (pendingEdges.length) { await deleteFabricEdges(pendingEdges); } pendingEdgeDeletesRef.current.clear(); const serverIds = (graphQuery.data?.nodes || []) .map((n) => n.fabric_node_id) .filter(Boolean); const localIds = nodes.map((n) => n.id); const localSet = new Set(localIds); const serverSet = new Set(serverIds); const isRegionNode = (id: string) => id.startsWith("region:"); const toAdd = localIds.filter((id) => !serverSet.has(id) && !isRegionNode(id)); const toRemove = serverIds.filter((id) => !localSet.has(id) && !isRegionNode(id)); // Region icons are synthetic placements — persist via positions only. // Add first so a save that both adds and removes never flashes an empty view. if (toAdd.length) { await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd }); } if (toRemove.length) { await removeTopologyViewNodes(mapId, toRemove); } return patchTopologyPositions(mapId, flowToPositions(nodes)); }, onSuccess: async (graph) => { clearDirty(); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); showOk(t("topology.saved")); }, onError: (err) => showError(String(err)), }); const runDiscover = useCallback( async (neIds?: string[]) => { if (!mapId || discovering) return; // Topology canvas: single-NE (or explicit id list) only — bulk collect lives under Network → LLDP. const filterIds = (neIds || []).map((x) => String(x || "").trim()).filter(Boolean); if (!filterIds.length) { showError(t("topology.discoverOneNeedNe")); return; } setDiscoverOpen(true); setDiscovering(true); setDiscoverReport(null); setDiscoverLiveResults([]); setDiscoverError(""); setDiscoverJobId(""); discoverAbortRef.current = false; const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id)); const scoped = scannable.filter( (n) => filterIds.includes(String(n.data.managed_ne_id || "")) || filterIds.includes(String(n.data.ume_ne_id || "")), ); setDiscoverProgress({ index: 0, total: scoped.length, neName: "", neIp: "", edgesAdded: 0, edgesUpdated: 0, }); try { if (dirtyRef.current) { const createIds = [...pendingEdgeCreatesRef.current]; for (const id of createIds) { const e = edges.find((x) => x.id === id); if (!e) continue; const d = (e.data || {}) as EdgeStyleData; await createFabricManualEdge({ a_node_id: e.source, b_node_id: e.target, a_port: String(d.source_port || ""), b_port: String(d.target_port || ""), }); } pendingEdgeCreatesRef.current.clear(); const pendingEdges = [...pendingEdgeDeletesRef.current].filter( (id) => !isLocalPendingEdgeId(id), ); if (pendingEdges.length) { await deleteFabricEdges(pendingEdges); } pendingEdgeDeletesRef.current.clear(); const serverIds = (graphQuery.data?.nodes || []) .map((n) => n.fabric_node_id) .filter(Boolean); const localIds = nodes.map((n) => n.id); const localSet = new Set(localIds); const serverSet = new Set(serverIds); const toAdd = localIds.filter((id) => !serverSet.has(id)); const toRemove = serverIds.filter((id) => !localSet.has(id)); if (toAdd.length) { await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd }); } if (toRemove.length) { await removeTopologyViewNodes(mapId, toRemove); } await patchTopologyPositions(mapId, flowToPositions(nodes)); clearDirty(); } const ne_ids = filterIds; if (!scoped.length) { throw new Error(t("topology.discoverOneNeedNe")); } const jobStart = await startLldpDiscover({ scope: "ne_ids", ne_ids, concurrency: 4, auto_add_unmatched: discoverAutoAddUnmatched, trigger_mode: "topology", }); setDiscoverJobId(jobStart.id); let job: TopologyDiscoverJob = jobStart; let cancelled = false; for (let i = 0; i < 600; i++) { if (discoverAbortRef.current) { cancelled = true; break; } await new Promise((r) => window.setTimeout(r, 500)); if (discoverAbortRef.current) { cancelled = true; break; } job = await fetchLldpDiscoverJob(jobStart.id, { page: 1, pageSize: 5 }); setDiscoverProgress((p) => ({ ...p, index: job.done, total: job.total || p.total, edgesAdded: job.edges_added, edgesUpdated: job.edges_updated, neName: job.items?.[job.items.length - 1]?.ne_name || p.neName, neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp, })); setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]); if ( job.status === "done" || job.status === "failed" || job.status === "cancelled" || job.status === "stopped" ) { if (job.status === "cancelled" || job.status === "stopped") cancelled = true; break; } } if (cancelled || discoverAbortRef.current) { setDiscoverError(t("topology.discoverCancelled")); setDiscoverReport({ map_id: mapId, protocol: "lldp", job_id: job.id, scanned: job.done, edges_added: job.edges_added, edges_updated: job.edges_updated, edges_stale: job.edges_missing ?? job.edges_stale, results: (job.items || []) as TopologyDiscoverNeResult[], graph: null, }); return; } if (job.status === "failed") { throw new Error(job.error || "discover_failed"); } // Final page: pull a fuller item slice for the summary panel. try { job = await fetchLldpDiscoverJob(jobStart.id, { page: 1, pageSize: 100 }); } catch { /* keep last polled job */ } // Refresh base graph from server (LLDP may have new edges), then dry-run project. const baseGraph = await fetchTopologyGraph(mapId); const projected = discoverProjectNeighbors ? await projectTopologyNeighbors(mapId, { seed_fabric_node_ids: scoped.map((n) => n.id), dry_run: true, }) : baseGraph; // Keep server query as committed graph (dry_run must not poison cache as persisted). queryClient.setQueryData(queryKeys.topologyGraph(mapId), baseGraph); if (discoverProjectNeighbors && projected.truncate_reason === "membership_frozen") { showError(t("topology.truncatedFrozen")); } appliedMapIdRef.current = mapId; let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults); // Keep existing node positions when we did not auto-layout. const localPos = new Map(nodes.map((n) => [n.id, n.position])); const serverNodeIds = new Set((baseGraph.nodes || []).map((n) => n.fabric_node_id)); rfNodes = rfNodes.map((n) => { const p = localPos.get(n.id); return p ? { ...n, position: { ...p } } : n; }); let didAutoLayout = false; if (autoLayoutAfterDiscover && rfNodes.length > 1) { rfNodes = await layoutGraph(rfNodes, rfEdges, "hierarchical-tb"); didAutoLayout = true; } historyLockRef.current = true; setNodes(rfNodes); setEdges(rfEdges); historyLockRef.current = false; const localOnly = rfNodes.some((n) => !serverNodeIds.has(n.id)); if (localOnly || didAutoLayout) { markDirty(); } else { clearDirty(); } const out: TopologyDiscoverOut = { map_id: mapId, protocol: "lldp", job_id: job.id, scanned: job.done, edges_added: job.edges_added, edges_updated: job.edges_updated, edges_stale: job.edges_missing ?? job.edges_stale, results: (job.items || []) as TopologyDiscoverNeResult[], graph: projected, }; setDiscoverReport(out); await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps }); await queryClient.invalidateQueries({ queryKey: queryKeys.fabricSummary }); showOk( t("topology.discovered") .replace("{{added}}", String(out.edges_added)) .replace("{{updated}}", String(out.edges_updated)) .replace("{{stale}}", String(out.edges_stale || 0)), ); } catch (err) { if (discoverAbortRef.current) { setDiscoverError(t("topology.discoverCancelled")); } else { setDiscoverError(String(err)); showError(t("topology.discoverFail").replace("{{detail}}", String(err))); } } finally { setDiscovering(false); } }, [mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty, graphQuery.data], ); const cancelDiscover = useCallback(async () => { discoverAbortRef.current = true; const jobId = discoverJobId; if (jobId) { try { await stopLldpCollectJob(jobId); } catch { /* best-effort stop; local poll also exits */ } } }, [discoverJobId]); const discoverResults = discoverReport?.results?.length ? discoverReport.results : discoverLiveResults; const discoverCounts = useMemo(() => { let ok = 0; let warn = 0; let fail = 0; for (const r of discoverResults) { const k = discoverResultKind(r); if (k === "fail") fail += 1; else if (k === "warn") warn += 1; else ok += 1; } return { ok, warn, fail, issues: warn + fail }; }, [discoverResults]); const discoverSummary = discoverReport ? { scanned: discoverReport.scanned, added: discoverReport.edges_added, updated: discoverReport.edges_updated, missing: discoverReport.edges_stale || 0, failed: discoverCounts.fail, } : { scanned: discoverLiveResults.length, added: discoverProgress.edgesAdded, updated: discoverProgress.edgesUpdated, missing: 0, failed: discoverCounts.fail, }; const discoverPct = discovering && discoverProgress.total > 0 ? Math.min(100, Math.round((discoverProgress.index / discoverProgress.total) * 100)) : discoverReport ? 100 : 0; const isValidConnection = useCallback( (connection: Connection | Edge) => { const source = String(connection.source || ""); const target = String(connection.target || ""); if (!source || !target || source === target) return false; return !edges.some((e) => e.source === source && e.target === target); }, [edges], ); const onConnect = useCallback( (connection: Connection) => { if (!mapId || !isValidConnection(connection)) return; const source = String(connection.source || ""); const target = String(connection.target || ""); if (!source || !target) return; pushHistory(); connectClickRef.current = null; const id = newLocalEdgeId(); const data: EdgeStyleData = { source: "manual", source_port: "", target_port: "", stroke_color: "", stroke_width: 0, line_style: "", discovered_at: null, }; const edge = withEdgeVisual( { id, source, target, // Center anchors only — omit handle ids so attach matches discovered edges after Save. sourceHandle: null, targetHandle: null, type: "straight", animated: false, data, }, edgeDefaults, ); pendingEdgeCreatesRef.current.add(id); setEdges((es) => [...es, edge]); markDirty(); }, [mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty], ); const addPaletteItems = useCallback( async (items: PaletteItem[], origin?: { x: number; y: number }): Promise => { if (!mapId) { showError(t("topology.selectMap")); return false; } if (isWorldFlatViewName(activeView?.name)) { showError(t("topology.worldMapNoDirectNes")); return false; } if (items.length === 0 || paletteAdding) return false; const onCanvasManaged = new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)); const onCanvasUme = new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean)); const managedIds = [ ...new Set( items .filter((item) => item.source === "managed") .map((item) => item.managed_ne_id) .filter((id) => id && !onCanvasManaged.has(id)), ), ]; const umeIds = [ ...new Set( items .filter((item) => item.source === "ume") .map((item) => item.ume_ne_id) .filter((id) => id && !onCanvasUme.has(id)), ), ]; if (managedIds.length === 0 && umeIds.length === 0) return false; setPaletteAdding(true); try { const graph = await addTopologyViewNodes(mapId, { managed_ne_ids: managedIds, ume_ne_ids: umeIds, layout: "grid", }); const managedSet = new Set(managedIds); const umeSet = new Set(umeIds); const added = graph.nodes.filter( (n) => (n.managed_ne_id && managedSet.has(n.managed_ne_id)) || (n.ume_ne_id && umeSet.has(n.ume_ne_id)), ); if (added.length > 0) { const start = origin || { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 }; const cols = Math.max(1, Math.ceil(Math.sqrt(added.length))); await patchTopologyPositions( mapId, added.map((n, i) => ({ fabric_node_id: n.fabric_node_id, x: start.x + (i % cols) * 180, y: start.y + Math.floor(i / cols) * 120, label: n.label || n.name || "", })), ); } const refreshed = await fetchTopologyGraph(mapId); queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed); historyLockRef.current = true; applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges); historyLockRef.current = false; clearDirty(); const count = added.length || managedIds.length + umeIds.length; showOk(t("topology.addSelectedDone").replace("{{count}}", String(count))); return count > 0; } catch (err) { showError(String(err)); return false; } finally { setPaletteAdding(false); } }, [ mapId, nodes, paletteAdding, setNodes, setEdges, showError, showOk, t, queryClient, edgeDefaults, clearDirty, activeView?.name, ], ); const addNodeAt = useCallback( async (item: PaletteItem, position: { x: number; y: number }) => { await addPaletteItems([item], position); }, [addPaletteItems], ); const onPaletteDragStart = (e: React.DragEvent, item: PaletteItem) => { e.dataTransfer.setData(PALETTE_DND, JSON.stringify(item)); e.dataTransfer.effectAllowed = "copy"; }; const onCanvasDragOver = (e: React.DragEvent) => { if (!e.dataTransfer.types.includes(PALETTE_DND)) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }; const onCanvasDrop = (e: React.DragEvent) => { e.preventDefault(); const raw = e.dataTransfer.getData(PALETTE_DND); if (!raw || !rfRef.current) return; try { const item = JSON.parse(raw) as PaletteItem; const pos = rfRef.current.screenToFlowPosition({ x: e.clientX, y: e.clientY }); addNodeAt(item, pos); } catch { /* ignore */ } }; const selectedNodes = useMemo(() => nodes.filter((n) => n.selected), [nodes]); const selectedNode = selectedNodes[0] || null; const selectedNodeIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]); const selectedEdge = useMemo(() => { if (!selectedEdgeId) return null; return ( displayEdges.find((e) => e.id === selectedEdgeId) || edges.find((e) => e.id === selectedEdgeId) || null ); }, [selectedEdgeId, displayEdges, edges]); const selectedEdgeData = (selectedEdge?.data || {}) as EdgeStyleData; const selectedEdgeResolved = selectedEdge ? resolveEdgeStyle(selectedEdgeData, edgeDefaults) : null; const selectedEdgeSourceNode = selectedEdge ? nodes.find((n) => n.id === selectedEdge.source) : null; const selectedEdgeTargetNode = selectedEdge ? nodes.find((n) => n.id === selectedEdge.target) : null; const patchSelectedEdgeStyle = useCallback( ( patch: Partial< Pick >, opts?: { skipHistory?: boolean }, ) => { if (!selectedEdgeId || !mapId) return; const display = displayEdges.find((e) => e.id === selectedEdgeId) || selectedEdge; const targetIds = physicalIdsForDisplayEdge(display, edges); if (!targetIds.length) return; // Port edits only make sense on a single physical link. if ( (isAggregateEdgeId(selectedEdgeId) || targetIds.length > 1) && (patch.source_port !== undefined || patch.target_port !== undefined) ) { return; } if (!opts?.skipHistory) pushHistory(); setEdges((eds) => eds.map((e) => { if (!targetIds.includes(e.id)) return e; const prev = (e.data || {}) as EdgeStyleData; const data: EdgeStyleData = { ...prev, stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "", stroke_width: patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0), line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "", source_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "", target_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "", }; const portLabel = formatPortPairLabel( data.source_port || "", data.target_port || "", data.display_label, ); return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults); }), ); const edge = edges.find((e) => e.id === targetIds[0]); const prev = (edge?.data || {}) as EdgeStyleData; for (const fabricEdgeId of targetIds) { void patchTopologyEdgeStyle(mapId, { fabric_edge_id: fabricEdgeId, stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "", stroke_width: patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0), line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "", }).catch((err) => showError(String(err))); } }, [selectedEdgeId, selectedEdge, mapId, edges, displayEdges, setEdges, pushHistory, edgeDefaults, showError], ); const renameSelectedNode = useCallback(() => { if (!selectedNode) return; const next = window.prompt(t("topology.renameNodePrompt"), selectedNode.data.label || ""); if (next == null) return; const label = next.trim(); if (!label || label === selectedNode.data.label) { setCtxMenu(null); return; } pushHistory(); markDirty(); setNodes((ns) => ns.map((n) => (n.id === selectedNode.id ? { ...n, data: { ...n.data, label } } : n)), ); setCtxMenu(null); }, [selectedNode, t, pushHistory, markDirty, setNodes]); const closeCtxMenu = useCallback(() => setCtxMenu(null), []); useEffect(() => { const syncFs = () => { const el = canvasRef.current; const active = Boolean(el && document.fullscreenElement === el); setFullscreen(active); }; document.addEventListener("fullscreenchange", syncFs); return () => document.removeEventListener("fullscreenchange", syncFs); }, []); const toggleFullscreen = useCallback(async () => { const el = canvasRef.current; if (!el) return; try { if (document.fullscreenElement === el) { await document.exitFullscreen(); } else { await el.requestFullscreen(); } } catch (err) { showError(String(err)); } }, [showError]); const clearSelection = useCallback(() => { setSelectedEdgeId(null); setNodes((ns) => ns.map((n) => ({ ...n, selected: false }))); setEdges((es) => es.map((e) => ({ ...e, selected: false }))); connectClickRef.current = null; }, [setNodes, setEdges]); const selectAllNodes = useCallback(() => { setSelectedEdgeId(null); setNodes((ns) => ns.map((n) => ({ ...n, selected: true }))); setEdges((es) => es.map((e) => ({ ...e, selected: false }))); }, [setNodes, setEdges]); const queueDeleteEdges = useCallback( ( edgeIds: string[], opts?: { confirmKey?: string; okKey?: string; skipConfirm?: boolean }, ) => { if (!edgeIds.length) return false; if (!opts?.skipConfirm) { const confirmMsg = t(opts?.confirmKey || "topology.deleteEdgeConfirm"); if (!window.confirm(confirmMsg)) return false; } pushHistory(); for (const id of edgeIds) { if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) { pendingEdgeCreatesRef.current.delete(id); } else { pendingEdgeDeletesRef.current.add(id); } } const idSet = new Set(edgeIds); setEdges((es) => es.filter((e) => !idSet.has(e.id))); setSelectedEdgeId((cur) => (cur && idSet.has(cur) ? null : cur)); markDirty(); showOk( t(opts?.okKey || "topology.edgeDeleted").replace("{{count}}", String(edgeIds.length)), ); return true; }, [pushHistory, setEdges, markDirty, showOk, t], ); const removeSelected = useCallback(() => { if (!mapId) return; const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id); const selectedDisplay = displayEdges.filter((e) => e.selected); const edgeIds = new Set(); for (const de of selectedDisplay) { for (const id of physicalIdsForDisplayEdge(de, edges)) edgeIds.add(id); } for (const e of edges) { if (e.selected) edgeIds.add(e.id); } if (!nodeIds.length && !edgeIds.size) return; if (nodeIds.length && edgeIds.size) { const msg = t("topology.deleteSelectionConfirm") .replace("{{nodes}}", String(nodeIds.length)) .replace("{{edges}}", String(edgeIds.size)); if (!window.confirm(msg)) return; pushHistory(); const nodeSet = new Set(nodeIds); for (const id of edgeIds) { if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) { pendingEdgeCreatesRef.current.delete(id); } else { pendingEdgeDeletesRef.current.add(id); } } setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id))); setEdges((es) => es.filter( (e) => !edgeIds.has(e.id) && !nodeSet.has(e.source) && !nodeSet.has(e.target), ), ); setSelectedEdgeId(null); markDirty(); showOk( t("topology.selectionDeleted") .replace("{{nodes}}", String(nodeIds.length)) .replace("{{edges}}", String(edgeIds.size)), ); return; } if (!nodeIds.length && edgeIds.size) { queueDeleteEdges([...edgeIds]); return; } pushHistory(); const nodeSet = new Set(nodeIds); setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id))); // Drop incident edges from the canvas only — do not queue Fabric deletes. setEdges((es) => { const next = es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target)); for (const e of es) { if (next.some((x) => x.id === e.id)) continue; pendingEdgeCreatesRef.current.delete(e.id); } return next; }); setSelectedEdgeId(null); markDirty(); }, [ mapId, nodes, edges, displayEdges, setNodes, setEdges, pushHistory, markDirty, showOk, queueDeleteEdges, t, ]); const removeEdgeById = (edgeId: string) => { const display = displayEdges.find((e) => e.id === edgeId); const ids = physicalIdsForDisplayEdge(display, edges); const list = ids.length ? ids : [edgeId]; queueDeleteEdges(list); closeCtxMenu(); }; const staleEdgeIds = useMemo(() => { return edges .filter((e) => { const src = String((e.data as EdgeStyleData | undefined)?.source || ""); return src === "stale"; }) .map((e) => e.id); }, [edges]); const removeStaleEdges = useCallback(() => { if (!staleEdgeIds.length) return; queueDeleteEdges(staleEdgeIds, { confirmKey: "topology.removeStaleHint", okKey: "topology.staleRemoved", }); }, [staleEdgeIds, queueDeleteEdges]); const addOutsidePeers = useCallback( async (fabricNodeIds: string[]): Promise => { if (!mapId) return false; const ids = [...new Set(fabricNodeIds.map((x) => String(x || "").trim()).filter(Boolean))]; if (!ids.length || outsidePeersAdding) return false; setOutsidePeersAdding(true); try { const onCanvas = new Set(nodes.map((n) => n.id)); const toAdd = ids.filter((id) => !onCanvas.has(id)); if (!toAdd.length) { setOutsidePeersOpen(false); setOutsidePeerSelectedIds([]); return false; } const graph = await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd, layout: "grid", }); if (graph.truncate_reason === "membership_frozen") { showError(t("topology.truncatedFrozen")); return false; } const addedSet = new Set(toAdd); const added = (graph.nodes || []).filter((n) => addedSet.has(n.fabric_node_id)); if (added.length > 0) { const peerList = graphQuery.data?.outside_peers || []; const viaAnchors = new Map( peerList .filter((p) => addedSet.has(p.fabric_node_id)) .map((p) => [p.fabric_node_id, p.via_node_id] as const), ); const cols = Math.max(1, Math.ceil(Math.sqrt(added.length))); await patchTopologyPositions( mapId, added.map((n, i) => { const viaId = viaAnchors.get(n.fabric_node_id); const viaNode = viaId ? nodes.find((x) => x.id === viaId) : undefined; const base = viaNode ? { x: viaNode.position.x + 200, y: viaNode.position.y } : { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 }; return { fabric_node_id: n.fabric_node_id, x: base.x + (i % cols) * 180, y: base.y + Math.floor(i / cols) * 120, label: n.label || n.name || "", }; }), ); } const refreshed = await fetchTopologyGraph(mapId); queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed); historyLockRef.current = true; applyViewGraph(refreshed, edgeDefaults, setNodes, setEdges); historyLockRef.current = false; clearDirty(); const count = added.length || toAdd.length; showOk(t("topology.projectedNeighbors").replace("{{count}}", String(count))); setOutsidePeerSelectedIds([]); setOutsidePeersOpen(false); return count > 0; } catch (err) { showError(String(err)); return false; } finally { setOutsidePeersAdding(false); } }, [ mapId, nodes, graphQuery.data?.outside_peers, outsidePeersAdding, edgeDefaults, setNodes, setEdges, clearDirty, queryClient, showOk, showError, t, ], ); useEffect(() => { const onKey = (e: KeyboardEvent) => { const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); if (tag === "input" || tag === "textarea" || tag === "select" || (e.target as HTMLElement)?.isContentEditable) { return; } if (e.key === "Escape") { closeCtxMenu(); clearSelection(); connectClickRef.current = null; setToolMode((m) => (m === "connect" ? "select" : m)); if (displayMenuRef.current?.open) displayMenuRef.current.open = false; setFindOpen(false); return; } const mode = toolModeFromKey(e.key); if (mode && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); setToolMode(mode); connectClickRef.current = null; return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { e.preventDefault(); if (mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate(); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) { e.preventDefault(); undo(); return; } if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) { e.preventDefault(); redo(); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { e.preventDefault(); selectAllNodes(); return; } if (e.key === "Delete" || e.key === "Backspace") { if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) { e.preventDefault(); void removeSelected(); } } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut]); useEffect(() => { if (!ctxMenu) return; const onScroll = (e: Event) => { const t = e.target; if (t instanceof Node && ctxMenuRef.current?.contains(t)) return; closeCtxMenu(); }; const onPointerDown = (e: MouseEvent) => { const el = e.target as HTMLElement | null; if (el?.closest?.(".topo-ctx")) return; closeCtxMenu(); }; window.addEventListener("scroll", onScroll, true); window.addEventListener("mousedown", onPointerDown, true); return () => { window.removeEventListener("scroll", onScroll, true); window.removeEventListener("mousedown", onPointerDown, true); }; }, [ctxMenu, closeCtxMenu]); useLayoutEffect(() => { if (!ctxMenu || !ctxMenuRef.current) return; const el = ctxMenuRef.current; const pad = 8; const rect = el.getBoundingClientRect(); let x = ctxMenu.x; let y = ctxMenu.y; if (x + rect.width > window.innerWidth - pad) { x = Math.max(pad, window.innerWidth - rect.width - pad); } if (y + rect.height > window.innerHeight - pad) { y = Math.max(pad, window.innerHeight - rect.height - pad); } if (x !== ctxMenu.x || y !== ctxMenu.y) { setCtxMenu((prev) => (prev ? { ...prev, x, y } : null)); } }, [ctxMenu]); const removeNodeById = (nodeId: string) => { if (!mapId) return; pushHistory(); closeCtxMenu(); setNodes((ns) => ns.filter((n) => n.id !== nodeId)); setEdges((es) => { const next = es.filter((e) => e.source !== nodeId && e.target !== nodeId); for (const e of es) { if (next.some((x) => x.id === e.id)) continue; pendingEdgeCreatesRef.current.delete(e.id); } return next; }); markDirty(); }; const purgePlaceholderById = useCallback( async (nodeId: string) => { if (!mapId) return; const node = nodes.find((n) => n.id === nodeId); if (!node || !isPlaceholderSource(node.data.managed_source, node.data.ne_ip)) { return; } if (!window.confirm(t("topology.deletePlaceholderConfirm"))) return; closeCtxMenu(); try { if (dirtyRef.current) { const createIds = [...pendingEdgeCreatesRef.current]; for (const id of createIds) { const e = edges.find((x) => x.id === id); if (!e || e.source === nodeId || e.target === nodeId) continue; const d = (e.data || {}) as EdgeStyleData; await createFabricManualEdge({ a_node_id: e.source, b_node_id: e.target, a_port: String(d.source_port || ""), b_port: String(d.target_port || ""), }); } pendingEdgeCreatesRef.current.clear(); const pendingEdges = [...pendingEdgeDeletesRef.current].filter( (id) => !isLocalPendingEdgeId(id), ); if (pendingEdges.length) { await deleteFabricEdges(pendingEdges); } pendingEdgeDeletesRef.current.clear(); const serverIds = (graphQuery.data?.nodes || []) .map((n) => n.fabric_node_id) .filter(Boolean); const localIds = new Set(nodes.map((n) => n.id)); const toRemove = serverIds.filter((id) => !localIds.has(id) && id !== nodeId); if (toRemove.length) { await removeTopologyViewNodes(mapId, toRemove); } await patchTopologyPositions( mapId, flowToPositions(nodes.filter((n) => n.id !== nodeId)), ); } await purgePlaceholderFabricNodes([nodeId]); const graph = await fetchTopologyGraph(mapId); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); appliedMapIdRef.current = mapId; const localPos = new Map( nodes.filter((n) => n.id !== nodeId).map((n) => [n.id, n.position]), ); historyLockRef.current = true; applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos); historyLockRef.current = false; pendingEdgeDeletesRef.current = new Set(); pendingEdgeCreatesRef.current = new Set(); historyRef.current = []; redoRef.current = []; bumpHistory(); clearDirty(); showOk(t("topology.deletePlaceholderDone")); } catch (err) { showError(String(err)); } }, [ mapId, nodes, edges, graphQuery.data, closeCtxMenu, queryClient, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory, showOk, showError, t, ], ); const openNeInventory = (opts: { neId?: string; create?: boolean; name?: string; ip_address?: string; vendor?: string; }) => { const q = new URLSearchParams(); if (opts.neId) { q.set("ne_id", opts.neId); } else if (opts.create) { q.set("create", "1"); if (opts.name) q.set("name", opts.name); if (opts.ip_address) q.set("ip_address", opts.ip_address); if (opts.vendor) q.set("vendor", opts.vendor); } const qs = q.toString(); openOrFocusModule({ moduleId: "ne", path: qs ? `/ne?${qs}` : "/ne", }); }; const openWebcrtFor = (node: Node | null) => { closeCtxMenu(); if (!node) return; const managedId = String(node.data.managed_ne_id || "").trim(); const umeId = String(node.data.ume_ne_id || "").trim(); if (managedId) { void (async () => { try { const ne = await fetchManagedNeById(managedId); const src = String(ne.source || "").trim().toLowerCase(); const hasIp = Boolean(String(ne.ip_address || "").trim()); // Ready inventory hosts: reuse stored credentials (incl. hop/proxy). // LLDP / topology placeholders / incomplete rows → full NE edit form. const needsSetup = !hasIp || src === "lldp" || src === "topology"; if (!needsSetup) { openOrFocusModule({ moduleId: "webcrt", path: `/webcrt?ne_id=${encodeURIComponent(managedId)}`, }); return; } showOk(t("topology.completeNeFirst")); openNeInventory({ neId: managedId }); } catch (err) { showError(String(err)); } })(); return; } if (umeId) { openOrFocusModule({ moduleId: "webcrt", path: `/webcrt?ne_id=${encodeURIComponent(umeId)}&source=ume`, }); return; } // No inventory link — open NE create with topology hint fields. showOk(t("topology.completeNeFirst")); openNeInventory({ create: true, name: String(node.data.label || "").trim(), ip_address: String(node.data.ne_ip || "").trim(), vendor: String(node.data.vendor || "").trim(), }); }; const openNeFor = (node: Node | null) => { closeCtxMenu(); const managedId = String(node?.data.managed_ne_id || "").trim(); const umeId = String(node?.data.ume_ne_id || "").trim(); if (managedId) { openNeInventory({ neId: managedId }); return; } if (umeId) { openOrFocusModule({ moduleId: "ume", path: "/ume", }); return; } showError(t("topology.noNeLink")); }; const openPortTrafficForEdge = (edge: Edge | null) => { closeCtxMenu(); if (!edge) { showError(t("topology.noNeLink")); return; } const srcNode = nodes.find((n) => n.id === edge.source); const data = (edge.data || {}) as EdgeStyleData; const ifname = String(data.source_port || "").trim(); const managedId = String(srcNode?.data.managed_ne_id || "").trim(); const umeId = String(srcNode?.data.ume_ne_id || "").trim(); if (managedId) { const q = new URLSearchParams({ ne_id: managedId, source: "managed" }); if (ifname) q.set("ifname", ifname); openOrFocusModule({ moduleId: "network", path: `/network/tasks/port-traffic?${q.toString()}`, }); return; } if (umeId) { const q = new URLSearchParams({ ne_id: umeId, source: "ume" }); if (ifname) q.set("ifname", ifname); openOrFocusModule({ moduleId: "network", path: `/network/tasks/port-traffic?${q.toString()}`, }); return; } showError(t("topology.noNeLink")); }; const discoverOneFor = (node: Node | null) => { closeCtxMenu(); const id = String(node?.data.managed_ne_id || node?.data.ume_ne_id || "").trim(); if (!id) { showError(t("topology.discoverOneNeedNe")); return; } void runDiscover([id]); }; const openCreateNeAt = (flowX: number, flowY: number) => { if (isWorldFlatViewName(activeView?.name)) { showError(t("topology.worldMapNoDirectNes")); return; } closeCtxMenu(); setCreateNeDialog({ flowX, flowY, name: "", ip_address: "" }); }; const placeCtxMenu = (clientX: number, clientY: number, size?: { w?: number; h?: number }): { x: number; y: number } => { const pad = 8; const w = size?.w ?? 200; const h = size?.h ?? 240; const x = Math.min(clientX, window.innerWidth - w - pad); const y = Math.min(clientY, window.innerHeight - h - pad); return { x: Math.max(pad, x), y: Math.max(pad, y) }; }; const focusNode = useCallback( (nodeId: string, additive: boolean) => { setSelectedEdgeId(null); setNodes((ns) => ns.map((n) => { if (additive) { if (n.id === nodeId) return { ...n, selected: !n.selected }; return n; } return { ...n, selected: n.id === nodeId }; }), ); setEdges((es) => es.map((e) => ({ ...e, selected: false }))); }, [setNodes, setEdges], ); const submitCreateNe = useCallback(async () => { if (!mapId || !createNeDialog) return; const name = createNeDialog.name.trim(); if (!name) { showError(t("topology.createNeNameRequired")); return; } setCreateNeBusy(true); try { const graph = await createTopologyPlaceholder(mapId, { name, ip_address: createNeDialog.ip_address.trim(), x: createNeDialog.flowX, y: createNeDialog.flowY, }); queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); historyLockRef.current = true; applyViewGraph(graph, edgeDefaults, setNodes, setEdges); historyLockRef.current = false; clearDirty(); const created = graph.nodes.find( (n) => String(n.name || "").trim() === name && Math.abs(Number(n.x) - createNeDialog.flowX) < 0.5 && Math.abs(Number(n.y) - createNeDialog.flowY) < 0.5, ); if (created?.fabric_node_id) { focusNode(created.fabric_node_id, false); } setCreateNeDialog(null); showOk(t("topology.createNeDone").replace("{{name}}", name)); void queryClient.invalidateQueries({ queryKey: ["managedNe"] }); void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); } catch (err) { showError(String(err)); } finally { setCreateNeBusy(false); } }, [ mapId, createNeDialog, queryClient, edgeDefaults, setNodes, setEdges, clearDirty, showError, showOk, t, focusNode, ]); const focusWorldAround = useCallback( async (nodeId: string, displayX: number, displayY: number) => { const viewId = mapIdRef.current; if (!viewId || !isWorldFlatCanvasRef.current || dirtyRef.current) return; if (flatLodTimerRef.current) { window.clearTimeout(flatLodTimerRef.current); flatLodTimerRef.current = null; } const gen = ++flatLodFetchGenRef.current; // Zoom immediately so the user isn't stuck on overview while detail loads. const inst = rfRef.current; if (inst) { setCanvasZoom(WORLD_LOCATE_ZOOM); inst.setCenter(displayX, displayY, { zoom: WORLD_LOCATE_ZOOM, duration: 200 }); } const half = WORLD_LOCATE_HALF; try { const g = await fetchTopologyGraph(viewId, { lod: "detail", min_x: displayX - half, max_x: displayX + half, min_y: displayY - half, max_y: displayY + half, }); if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return; if (g.world_transform) worldTransformRef.current = g.world_transform; queryClient.setQueryData(queryKeys.topologyGraph(viewId), (prev: TopologyViewGraph | undefined) => mergeFlatWorldGraph(prev, g, { centerX: displayX, centerY: displayY }), ); } catch { /* keep last sample */ } if (gen !== flatLodFetchGenRef.current) return; // Re-center after graph replace (fit/apply can nudge viewport) and re-select. window.requestAnimationFrame(() => { if (flatLodFetchGenRef.current !== gen) return; rfRef.current?.setCenter(displayX, displayY, { zoom: WORLD_LOCATE_ZOOM, duration: 0 }); setCanvasZoom(WORLD_LOCATE_ZOOM); focusNode(nodeId, false); setSearchHitIds([nodeId]); if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current); searchHitTimerRef.current = window.setTimeout(() => { setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur)); searchHitTimerRef.current = null; }, 8000); }); }, [queryClient, focusNode], ); const locateNode = useCallback( (nodeId: string, opts?: { worldX?: number; worldY?: number }) => { const onCanvas = nodesRef.current.find((n) => n.id === nodeId); const wt = worldTransformRef.current; const originX = Number(wt?.origin_x) || 0; const originY = Number(wt?.origin_y) || 0; const scale = Math.max(Number(wt?.scale) || 1, 1e-9); let displayX: number | undefined; let displayY: number | undefined; if (onCanvas) { displayX = onCanvas.position.x + TOPO_HANDLE_X; displayY = onCanvas.position.y + TOPO_HANDLE_Y; } else if (opts?.worldX != null && opts?.worldY != null) { displayX = (Number(opts.worldX) - originX) * scale; displayY = (Number(opts.worldY) - originY) * scale; } else { showError(t("topology.locateNotOnCanvas")); return; } focusNode(nodeId, false); setSearchHitIds([nodeId]); if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current); searchHitTimerRef.current = window.setTimeout(() => { setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur)); searchHitTimerRef.current = null; }, 8000); if (isWorldFlatCanvasRef.current) { void focusWorldAround(nodeId, displayX, displayY); return; } window.setTimeout(() => { rfRef.current?.fitView({ nodes: [{ id: nodeId }], padding: 0.45, duration: 280, includeHiddenNodes: true, }); }, 30); }, [focusNode, focusWorldAround, showError, t], ); const pendingLocateWorldRef = useRef<{ id: string; worldX?: number; worldY?: number; } | null>(null); const jumpToTreeSearchHit = useCallback( ( hit: FabricNodeSearchHit, view?: NonNullable[number], ) => { const views = hit.views || []; const worldCoords = hit.world_x != null && hit.world_y != null ? { worldX: Number(hit.world_x), worldY: Number(hit.world_y) } : undefined; const onCurrent = Boolean(mapId) && (views.some((v) => v.view_id === mapId) || nodes.some((n) => n.id === hit.id) || (isWorldFlatCanvasRef.current && worldCoords != null)); if (onCurrent && mapId && (!view || view.view_id === mapId)) { if (isWorldFlatCanvasRef.current) { locateNode(hit.id, worldCoords); } else if (nodes.some((n) => n.id === hit.id)) { locateNode(hit.id); } else { setPendingHighlightNe(hit.id); } setTreeNeQuery(""); setDebouncedTreeNeQuery(""); setTreeSearchOpen(false); return; } const target = view || (WORLD_MAP_ENABLED ? views.find((v) => isWorldFlatViewName(v.view_name)) : undefined) || views.find((v) => !isWorldFlatViewName(v.view_name) && String(v.kind) === "physical") || views.find((v) => !isWorldFlatViewName(v.view_name)) || views[0] || null; if (!target) { showError(t("topology.treeSearchNotOnMap")); return; } if (!WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name)) { showError(t("topology.worldMapOffline")); return; } if (!confirmDiscardIfDirty()) return; const regionId = target.folder_id || findViewInRegion(regions, target.view_id)?.region.id; if (regionId) { setSelectedFolderId(regionId); setExpandedIds((p) => ({ ...p, [regionId]: true })); } if (WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name) && worldCoords) { pendingLocateWorldRef.current = { id: hit.id, ...worldCoords }; } else { pendingLocateWorldRef.current = null; } setMapId(target.view_id); setPendingHighlightNe(hit.id); setTreeNeQuery(""); setDebouncedTreeNeQuery(""); setTreeSearchOpen(false); }, [ mapId, nodes, locateNode, showError, t, confirmDiscardIfDirty, regions, ], ); useEffect(() => { if (!treeSearchOpen) return; const onDoc = (e: MouseEvent) => { const el = treeSearchRef.current; if (el && e.target instanceof Element && !el.contains(e.target)) { setTreeSearchOpen(false); } }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [treeSearchOpen]); useEffect(() => { if (!pendingHighlightNe || !canvasMode) return; const nodeId = pendingHighlightNe; const pendingWorld = pendingLocateWorldRef.current; if ( pendingWorld && pendingWorld.id === nodeId && isWorldFlatCanvasRef.current && rfRef.current ) { pendingLocateWorldRef.current = null; setPendingHighlightNe(""); window.setTimeout( () => locateNode(nodeId, { worldX: pendingWorld.worldX, worldY: pendingWorld.worldY, }), 80, ); return; } if (!nodes.length || !nodes.some((n) => n.id === nodeId)) return; pendingLocateWorldRef.current = null; setPendingHighlightNe(""); window.setTimeout(() => locateNode(nodeId), 80); }, [pendingHighlightNe, canvasMode, nodes, locateNode, mapId, graphQuery.data]); const canvasHits = useMemo(() => { const q = canvasQuery.trim(); if (!q) return []; return nodes.filter((n) => nodeMatchesQuery(n, q)); }, [canvasQuery, nodes]); useEffect(() => { const q = canvasQuery.trim(); if (!q) { setFindActiveIdx(0); setFindOpen(false); if (findJustLocatedRef.current) { findJustLocatedRef.current = false; return; } setSearchHitIds([]); return; } setFindOpen(true); setSearchHitIds(canvasHits.map((n) => n.id)); setFindActiveIdx((idx) => (canvasHits.length ? Math.min(idx, canvasHits.length - 1) : 0)); }, [canvasQuery, canvasHits]); const findOnCanvas = useCallback( (nodeId?: string) => { const q = canvasQuery.trim(); if (!q) return; if (!canvasHits.length) { showError(t("topology.findNoMatch")); return; } const id = nodeId || canvasHits[Math.max(0, Math.min(findActiveIdx, canvasHits.length - 1))]?.id || canvasHits[0].id; findJustLocatedRef.current = true; setFindOpen(false); setCanvasQuery(""); setFindActiveIdx(0); locateNode(id); }, [canvasQuery, canvasHits, findActiveIdx, locateNode, showError, t], ); useEffect(() => { const onDoc = (e: MouseEvent) => { const target = e.target as HTMLElement | null; if (findBoxRef.current && target && !findBoxRef.current.contains(target)) { setFindOpen(false); } const details = displayMenuRef.current; if (details?.open && target && !details.contains(target)) { details.open = false; } }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, []); const locatePaletteItem = useCallback( (item: PaletteItem) => { const node = nodes.find((n) => item.source === "managed" ? n.data.managed_ne_id === item.managed_ne_id : n.data.ume_ne_id === item.ume_ne_id, ); if (!node) { showError(t("topology.findNoMatch")); return; } locateNode(node.id); }, [nodes, locateNode, showError, t], ); const focusEdge = useCallback( (edgeId: string) => { setSelectedEdgeId(edgeId); setNodes((ns) => ns.map((n) => ({ ...n, selected: false }))); // Selection is applied in displayEdges; keep store flags clear. setEdges((es) => es.map((e) => ({ ...e, selected: false }))); }, [setNodes, setEdges], ); const onNodeClick = useCallback( (e: React.MouseEvent, node: Node) => { setCtxMenu(null); if (node.data.kind === "region") { const viewId = String(node.data.view_id || "").trim(); const folderId = String(node.data.folder_id || "").trim(); if (viewId) { goCanvas(viewId, folderId || undefined); return; } if (folderId) { goRegion(folderId); return; } } if (toolMode === "connect") { focusNode(node.id, false); return; } focusNode(node.id, e.shiftKey || e.metaKey || e.ctrlKey); }, [toolMode, focusNode, goCanvas, goRegion], ); const onNodeDoubleClick = useCallback( (_e: React.MouseEvent, node: Node) => { // Regions already drill on single click; keep dblclick as no-op alias. if (node.data.kind === "region") { const viewId = String(node.data.view_id || "").trim(); const folderId = String(node.data.folder_id || "").trim(); if (viewId) goCanvas(viewId, folderId || undefined); else if (folderId) goRegion(folderId); } }, [goCanvas, goRegion], ); const outsidePeers = graphQuery.data?.outside_peers || []; const outsidePeerNameById = useMemo(() => { const m = new Map(); for (const n of nodes) { const label = String(n.data?.label || n.data?.subtitle || "").trim(); m.set(n.id, label || n.id.slice(0, 8)); } return m; }, [nodes]); const outsidePeersVisible = useMemo(() => { const q = outsidePeerQuery.trim().toLowerCase(); if (!q) return outsidePeers; return outsidePeers.filter((p) => { const viaName = outsidePeerNameById.get(p.via_node_id) || p.via_node_id; const bits = [p.name, p.ip, viaName, p.fabric_node_id]; return bits.some((b) => String(b || "").toLowerCase().includes(q)); }); }, [outsidePeers, outsidePeerQuery, outsidePeerNameById]); const graphTruncated = Boolean(graphQuery.data?.truncated); const truncateReason = String(graphQuery.data?.truncate_reason || "").trim(); const worldScatter = graphQuery.data?.scatter || []; const worldTotal = Number(graphQuery.data?.world_transform?.total || 0); const worldDockMe = Number(graphQuery.data?.world_transform?.dock_me_count || 0); const showWorldScatter = isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0; const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data); const canvasGraphEmpty = Boolean(mapId) && graphQuery.isSuccess && !graphQuery.isFetching && (graphQuery.data?.nodes?.length ?? 0) === 0 && worldScatter.length === 0 && nodes.length === 0 && worldTotal === 0; const worldNeedsApply = isWorldFlatCanvas && canvasGraphEmpty && worldDockMe > 0; const canvasGraphRefreshing = Boolean(mapId) && liveSync && graphQuery.isFetching && Boolean(graphQuery.data) && !canvasGraphLoading; const truncateBannerText = useMemo(() => { if (!graphTruncated) return ""; // World map soft-caps viewport nodes; the banner is noise there. if (isWorldFlatViewName(activeView?.name) || Boolean(graphQuery.data?.view?.filter?.world_flat)) { return ""; } if (truncateReason === "membership_cap") return t("topology.truncatedMembership"); if (truncateReason === "membership_frozen") return t("topology.truncatedFrozen"); if (truncateReason === "too_many_view_nodes") return t("topology.truncatedNodes"); if (truncateReason === "too_many_edges") return t("topology.truncatedEdges"); return t("topology.truncatedGeneric"); }, [graphTruncated, truncateReason, activeView?.name, graphQuery.data?.view?.filter?.world_flat, t]); const activeLeafName = useMemo(() => { if (!mapId) return ""; if (activeView?.name) return displayViewName(activeView.name, t); return displayViewName(graphQuery.data?.view?.name, t); }, [mapId, activeView, graphQuery.data?.view?.name, t]); /** Full folder chain for breadcrumb (rootward → leaf). */ const breadcrumbFolders = useMemo( () => folderPathFolders(regions, selectedFolderId), [regions, selectedFolderId], ); const titleText = useMemo(() => { if (canvasMode) return activeLeafName || t("topology.selectMap"); if (activeRegion) return regionDisplayName(activeRegion, t); return t("topology.rootName"); }, [canvasMode, activeLeafName, activeRegion, t]); 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 || "-"}${SEP}${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}${SEP}${ne.vendor}`, connect_status: ne.connect_status, })); }, [paletteSource, neQuery.data, umeQuery.data]); const paletteVisible = useMemo(() => { const tokens = keyword .trim() .toLowerCase() .split(/\s+/) .filter(Boolean); let list = palette; if (tokens.length) { list = list.filter((item) => { const bits = [item.name, item.ip, item.vendor, item.managed_ne_id, item.ume_ne_id, item.meta]; return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok))); }); } return list.filter((item) => item.source === "managed" ? !onCanvasManagedIds.has(item.managed_ne_id) : !onCanvasUmeIds.has(item.ume_ne_id), ); }, [palette, onCanvasManagedIds, onCanvasUmeIds, keyword]); const paletteLoading = (paletteSource === "managed" && neQuery.isLoading) || (paletteSource === "ume" && umeQuery.isLoading); const onSidebarResizeDown = useCallback( (e: ReactMouseEvent) => { if (sidebarCollapsed) return; e.preventDefault(); sidebarDragRef.current = { startX: e.clientX, startW: sidebarWidth }; setSidebarResizing(true); const onMove = (ev: MouseEvent) => { const drag = sidebarDragRef.current; if (!drag) return; const pageW = topoPageRef.current?.clientWidth || window.innerWidth; const next = clampSidebarWidth(drag.startW + (ev.clientX - drag.startX), pageW); setSidebarWidth(next); }; const onUp = () => { sidebarDragRef.current = null; setSidebarResizing(false); document.body.style.cursor = ""; document.body.style.userSelect = ""; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); setSidebarWidth((w) => { const pageW = topoPageRef.current?.clientWidth || window.innerWidth; const next = clampSidebarWidth(w, pageW); saveSidebarWidth(next); return next; }); }; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }, [sidebarCollapsed, sidebarWidth], ); return (
    {canvasMode ? (
    {breadcrumbFolders.map((folder, idx) => { const isLast = idx === breadcrumbFolders.length - 1; const primaryView = primaryViewOfFolder(folder); const showExtraView = isLast && Boolean(activeView) && !( isRegionCanvasFolder(folder, rootFolderId) && primaryView?.id === activeView?.id ); const asCurrent = isLast && !showExtraView; return ( / {asCurrent ? ( {regionDisplayName(folder, t)} {dirty ? " *" : ""} ) : ( )} ); })} {activeView && !( activeRegion && isRegionCanvasFolder(activeRegion, rootFolderId) && primaryViewOfFolder(activeRegion)?.id === activeView.id ) ? ( <> / {displayViewName(activeView.name, t)} {dirty ? " *" : ""} ) : null}
    {selectedNodes.length > 0 || selectedEdgeId ? ( {selectedNodes.length > 0 ? t("topology.selectedCount").replace("{{count}}", String(selectedNodes.length)) : t("topology.selectedEdge")} ) : null}
    {( [ ["select", t("topology.toolSelect"), "V"], ["pan", t("topology.toolPan"), "H"], ["connect", t("topology.toolConnect"), "C"], ] as const ).map(([mode, label, key]) => ( ))}
    {!fullscreen ? (
    ) : null} {outsidePeers.length > 0 ? ( ) : null}
    ) : null} {discoverOpen ? (
    {t("topology.discoverReport")}
    {discovering && discoverProgress.total > 0 ? (
    {t("topology.discoverProgressLive") .replace("{{index}}", String(discoverProgress.index)) .replace("{{total}}", String(discoverProgress.total)) .replace( "{{name}}", discoverProgress.neName || discoverProgress.neIp || "?", )} {discoverPct}%
    ) : null} {discovering && discoverProgress.total <= 0 ? (

    {t("topology.discoverNoTargets")}

    ) : null} {!discovering && discoverReport && discoverReport.scanned === 0 ? (

    {t("topology.discoverNoTargets")}

    ) : null} {discoverError ?

    {discoverError}

    : null} {discoverResults.length > 0 || discoverReport ? ( <>

    {t("topology.discoverSummary") .replace("{{scanned}}", String(discoverSummary.scanned)) .replace("{{added}}", String(discoverSummary.added)) .replace("{{updated}}", String(discoverSummary.updated)) .replace("{{stale}}", String(discoverSummary.missing)) .replace("{{failed}}", String(discoverSummary.failed))}

    ) : null}
    ) : null} {!canvasMode ? (
    {hexBrowseRegion ? (
    {breadcrumbFolders.map((folder, idx) => { const isLast = idx === breadcrumbFolders.length - 1; return ( / {isLast ? ( {regionDisplayName(folder, t)} ) : ( )} ); })}
    ) : ( {titleText} )}

    {hexBrowseRegion ? isUmeWorldContainer(hexBrowseRegion) && umeWorldHexModules ? t("topology.browserRegionSub").replace( "{{count}}", String( (umeWorldHexModules.drill ? 1 : 0) + (umeWorldHexModules.flatView ? 1 : 0), ), ) : t("topology.browserRegionSub").replace( "{{count}}", String(browseEntries.length), ) : t("topology.browserRegionsSub").replace( "{{count}}", String(regions.length), )}

    {!hexBrowseRegion ? ( treeLoading ? (
    ) : treeFailed ? (

    {t("topology.treeLoadFailed")}

    ) : regions.length === 0 ? (

    {t("topology.emptyMaps")}

    ) : (
    {regions.map((region, idx) => ( ))}
    ) ) : isUmeWorldContainer(hexBrowseRegion) && umeWorldHexModules ? (
    {umeWorldHexModules.drill ? ( ) : null} {umeWorldHexModules.flatView ? ( ) : null}
    ) : (
    {(hexBrowseRegion.children || []).map((region, idx) => ( ))} {browseEntries.map((v, idx) => { const isPhysical = String(v.kind) === "physical"; const tone = isPhysical ? "physical" : String((idx + 1) % 5); return ( ); })} {/* Top-level「根」is nav-only with unique「根图」— no extra L2 / custom map. */} {String(hexBrowseRegion.parent_id || "") !== rootFolderId ? ( ) : null}
    )}
    ) : (
    0.55 ? " is-canvas-light" : ""}`} ref={canvasRef} style={ { "--topo-canvas-bg": canvasBg, "--topo-label-color": labelColors.name, "--topo-edge-label-color": labelColors.edgeLabel, "--topo-vendor-cisco": vendorColors.cisco, "--topo-vendor-huawei": vendorColors.huawei, "--topo-vendor-zte": vendorColors.zte, "--topo-vendor-juniper": vendorColors.juniper, "--topo-vendor-nokia": vendorColors.nokia, "--topo-vendor-ericsson": vendorColors.ericsson, "--topo-vendor-h3c": vendorColors.h3c, "--topo-vendor-ruijie": vendorColors.ruijie, "--topo-vendor-mikrotik": vendorColors.mikrotik, "--topo-vendor-gray": vendorColors.gray, ...canvasFloatChromeVars(canvasBg), } as CSSProperties } onDragOver={onCanvasDragOver} onDrop={onCanvasDrop} > {truncateBannerText ? (
    {truncateBannerText}
    ) : null} {(() => { const viewToolsBody = ( <>
    {t("topology.display")}
    {t("topology.canvasBg")}
    {t("topology.canvasBgColor")} { const next = e.target.value; setCanvasBg(next); persistCanvasBg(next); }} /> { const raw = e.target.value.trim(); if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; setCanvasBg(raw); if (isHexColor(raw)) persistCanvasBg(raw.toLowerCase()); }} onBlur={() => { if (!isHexColor(canvasBg)) { setCanvasBg(loadCanvasBg()); } }} />
    {t("topology.textColors")}
    {( [ ["name", t("topology.labelColor")], ["edgeLabel", t("topology.edgeLabelColor")], ] as const ).map(([key, label]) => (
    {label} { const next = { ...labelColors, [key]: e.target.value.toLowerCase() }; setLabelColors(next); persistLabelColors(next); }} /> { const raw = e.target.value.trim(); if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; const next = { ...labelColors, [key]: raw }; setLabelColors(next); if (isHexColor(raw)) persistLabelColors({ ...next, [key]: raw.toLowerCase() }); }} onBlur={() => { if (!isHexColor(labelColors[key])) { setLabelColors(loadLabelColors()); } }} />
    ))}
    {t("topology.vendorColors")}
    {VENDOR_TONE_KEYS.map((key) => { const label = t(`topology.vendorTone.${key}`); return (
    {label} { const next = { ...vendorColors, [key]: e.target.value.toLowerCase() }; setVendorColors(next); persistVendorColors(next); }} /> { const raw = e.target.value.trim(); if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; const next = { ...vendorColors, [key]: raw }; setVendorColors(next); if (isHexColor(raw)) { persistVendorColors({ ...next, [key]: raw.toLowerCase() }); } }} onBlur={() => { if (!isHexColor(vendorColors[key])) { setVendorColors(loadVendorColors()); } }} />
    ); })}
    {t("topology.edgeDefaults")}
    {( [ ["ume", t("topology.edgeUme")], ["discovered", t("topology.edgeDiscovered")], ["stale", t("topology.edgeStale")], ["manual", t("topology.edgeManual")], ] as const ).map(([kind, label]) => { const d = edgeDefaults[kind]; return (
    {label} updateEdgeDefault(kind, { stroke_color: e.target.value })} />
    ); })}
    setCanvasQuery(e.target.value)} onFocus={() => { if (canvasQuery.trim()) setFindOpen(true); }} onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); if (!canvasHits.length) return; setFindOpen(true); setFindActiveIdx((i) => (i + 1) % canvasHits.length); return; } if (e.key === "ArrowUp") { e.preventDefault(); if (!canvasHits.length) return; setFindOpen(true); setFindActiveIdx((i) => (i - 1 + canvasHits.length) % canvasHits.length); return; } if (e.key === "Escape") { setFindOpen(false); return; } if (e.key === "Enter") { e.preventDefault(); findOnCanvas(); } }} placeholder={t("topology.findNodePh")} disabled={!mapId || nodes.length === 0} aria-label={t("topology.findNode")} aria-autocomplete="list" aria-expanded={findOpen} /> {findOpen && canvasQuery.trim() ? (
    {canvasHits.length === 0 ? (
    {t("topology.findNoMatch")}
    ) : ( canvasHits.slice(0, 12).map((n, idx) => ( )) )} {canvasHits.length > 12 ? (
    {t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))}
    ) : null}
    ) : null}
    ); if (!fullscreen) { if (!viewToolsToolbarSlot) return null; return createPortal(viewToolsBody, viewToolsToolbarSlot); } return (
    {viewToolsBody}
    ); })()} {fullscreen ? (
    {( [ ["select", t("topology.toolSelect"), "V"], ["pan", t("topology.toolPan"), "H"], ["connect", t("topology.toolConnect"), "C"], ] as const ).map(([mode, label, key]) => ( ))}
    ) : null} {treeRoot ? ( <> {isWorldFlatCanvas ? (
    {t("topology.worldHud") .replace("{{total}}", String(worldTotal || worldScatter.length || 0)) .replace("{{cached}}", String(nodes.length)) .replace("{{zoom}}", canvasZoom.toFixed(3)) .replace("{{lod}}", worldVisualLod)} {graphTruncated ? ( · {truncateReason || "truncated"} ) : null}
    ) : null}
    } > ) : treeLoading ? (
    ) : treeFailed ? (

    {t("topology.treeLoadFailed")}

    ) : (

    {t("topology.emptyMaps")}

    )} {treeRoot && canvasGraphLoading ? (
    ) : null} {treeRoot && canvasGraphEmpty ? (

    {worldNeedsApply ? t("topology.worldEmptyCoords") : t("topology.canvasEmpty")}

    {worldNeedsApply ? t("topology.worldEmptyCoordsHint").replace("{{dock}}", String(worldDockMe)) : t("topology.canvasEmptyHint")}

    {worldNeedsApply ? (
    ) : null}
    ) : null} {treeRoot && canvasGraphRefreshing ? (
    ) : null}
    )}
    {newRootDialog ? (
    setNewRootDialog(null)} />
    {t("topology.newRegion")}

    {t("topology.folderHint")}

    ) : null} {createNeDialog && canvasMode ? (
    { if (createNeBusy) return; setCreateNeDialog(null); }} />
    {t("topology.createNeTitle")}

    {t("topology.createNeHint")}

    ) : null} {outsidePeersOpen && canvasMode ? (
    { if (outsidePeersAdding) return; setOutsidePeersOpen(false); }} />
    {t("topology.outsidePeersTitle")} · {outsidePeers.length}

    {t("topology.outsidePeersHint")}

    { setOutsidePeerQuery(e.target.value); }} placeholder={t("topology.outsidePeersFilterPh")} disabled={outsidePeersAdding} autoFocus /> {outsidePeersVisible.length > 0 ? (
    {t("topology.selectedCount").replace( "{{count}}", String(outsidePeerSelectedIds.length), )}
    ) : null}
      {outsidePeersVisible.length === 0 ? (
    • {t("topology.outsidePeersEmpty")}
    • ) : ( outsidePeersVisible.map((peer) => { const checked = outsidePeerSelectedIds.includes(peer.fabric_node_id); const viaName = outsidePeerNameById.get(peer.via_node_id) || peer.via_node_id.slice(0, 8); const title = peer.name || peer.ip || peer.fabric_node_id.slice(0, 8); return (
    • ); }) )}
    ) : null} {addNeOpen && canvasMode ? (
    { if (paletteAdding) return; setAddNeOpen(false); }} />
    {t("topology.addNe")}

    {t("topology.paletteHint")}

    { setKeyword(e.target.value); setPaletteSelectedKeys([]); }} placeholder={t("topology.filterPh")} disabled={paletteAdding} autoFocus /> {paletteVisible.length > 0 ? (
    {t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
    ) : null}
      {paletteLoading ? (
    • {t("topology.paletteLoading")}
    • ) : paletteVisible.length === 0 ? (
    • {t("topology.paletteEmpty")}
    • ) : ( paletteVisible.map((item) => { const checked = paletteSelectedKeys.includes(item.key); return (
    • ); }) )}
    ) : null} {ctxMenu ? createPortal(
      e.preventDefault()} > {ctxMenu.kind === "selection" ? ( <>
    • {t("topology.selectionMenu")}
    • ) : ctxMenu.kind === "pane" ? ( <>
    • {t("topology.paneMenu")}
    • {!isWorldFlatViewName(activeView?.name) ? (
    • ) : null}
    • ) : ctxMenu.kind === "node" ? ( <>
    • {t("topology.nodeMenu")}
    • {selectedNode && isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? (
    • ) : null}
    • ) : ( <>
    • {t("topology.edgeMenu")}
    • e.stopPropagation()}>
      {t("topology.edgeStyle")}
      {selectedEdgeResolved ? (
      {Number(selectedEdgeData.member_count || 1) > 1 ? (
      {t("topology.linkMembers").replace( "{{count}}", String(selectedEdgeData.member_count || 0), )}
        {(selectedEdgeData.members || []).map((m) => (
      • {formatPortPairLabel(m.a_port, m.b_port, m.display_label) || m.id.slice(0, 8)}
      • ))}
      {!expandPhysicalLinks ? ( ) : null}
      ) : null}
      ) : null}
    • )}
    , canvasRef.current || document.body, ) : null}
    ); }