diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index dc9f39b..b281da6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1601,6 +1601,14 @@ const en = { edgeDiscovered: "LLDP", edgeStale: "Missing", fit: "Fit view", + export: "Export", + exportSvg: "SVG vector", + exportXml: "XML data", + exporting: "Exporting…", + exportEmpty: "Nothing on the canvas to export", + exportedSvg: "SVG exported", + exportedXml: "XML exported", + exportFailed: "Export failed: {{detail}}", liveSync: "Live sync", liveSyncOn: "Syncing…", liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 445b794..bc473fa 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1586,6 +1586,14 @@ const zh = { edgeDiscovered: "LLDP 发现", edgeStale: "未发现", fit: "适应画布", + export: "导出", + exportSvg: "SVG 矢量", + exportXml: "XML 数据", + exporting: "导出中…", + exportEmpty: "画布上没有可导出的内容", + exportedSvg: "已导出 SVG", + exportedXml: "已导出 XML", + exportFailed: "导出失败:{{detail}}", liveSync: "实时同步", liveSyncOn: "同步中…", liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新当前画布(观看 MCP 建图/画拓扑);默认关闭", diff --git a/web/src/index.css b/web/src/index.css index 55a6e2f..9150ede 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -6433,6 +6433,73 @@ pre { color: #e2e8f0; } +.topo-toolbar__export { + position: relative; +} + +.topo-toolbar__export > summary { + list-style: none; + cursor: pointer; + user-select: none; + height: 30px; + padding: 0 10px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: #0b1220; + color: #e2e8f0; + font-size: 12px; + font-weight: 600; + line-height: 1.2; + display: inline-flex; + align-items: center; +} + +.topo-toolbar__export > summary::-webkit-details-marker { + display: none; +} + +.topo-toolbar__export[open] > summary { + border-color: rgba(59, 130, 246, 0.55); + background: rgba(37, 99, 235, 0.22); + color: #93c5fd; +} + +.topo-export-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 20; + min-width: 140px; + padding: 4px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 8px; + background: #0b1220; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.45); +} + +.topo-export-menu__btn { + display: block; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: #e2e8f0; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.topo-export-menu__btn:hover:not(:disabled) { + background: rgba(37, 99, 235, 0.22); + color: #93c5fd; +} + +.topo-export-menu__btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .topo-mode-hint { position: absolute; top: 10px; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 1762c4c..5082118 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -1,5 +1,57 @@ import { - Fragment, + DISCOVER_AUTO_ADD_KEY, + DISCOVER_PROJECT_NEIGHBORS_KEY, + PALETTE_DND, + SCALE_BUNDLE_WIDTH_KEY, + SEP, + SHOW_PLACEHOLDER_BADGE_KEY, + WORLD_MAP_ENABLED, +} from "./topology/constants"; +import type { CtxMenu, PaletteItem, PaletteSource } from "./topology/pageTypes"; +import { clampSidebarWidth, loadSidebarWidth, saveSidebarWidth } from "./topology/sidebarLayout"; +import { + canvasBgLuminance, + canvasFloatChromeVars, + findViewInRegion, + isWorldFlatViewName, + regionDisplayName, +} from "./topology/treeUtils"; +import { + loadAutoLayoutAfterDiscover, + loadBoolFlag, + loadCanvasBg, + loadLabelColors, + loadVendorColors, + persistBoolFlag, + type LabelColors, + type VendorColors, +} from "./topology/displayPrefs"; +import { + BUILTIN_EDGE_DEFAULTS, + type EdgeDefaultStyle, + type EdgeDefaults, + type EdgeSourceKind, + type EdgeStyleData, + loadEdgeDefaults, + persistEdgeDefaults, + resolveEdgeStyle, + withEdgeVisual, +} from "./topology/edgeStyle"; +import { applyViewGraph, flowToPositions } from "./topology/graphFlow"; +import { isLocalPendingEdgeId, newLocalEdgeId } from "./topology/idUtils"; +import { fuzzyIncludes, isPlaceholderSource, nodeMatchesQuery } from "./topology/searchUtils"; +import { TopologyDiscoverPanel } from "./topology/TopologyDiscoverPanel"; +import { TopologyHexBrowser } from "./topology/TopologyHexBrowser"; +import { TopologyToolbar } from "./topology/TopologyToolbar"; +import { TopologyViewTools } from "./topology/TopologyViewTools"; +import { TopologySidebar } from "./topology/TopologySidebar"; +import { TopologyModals } from "./topology/TopologyModals"; +import { TopologyCtxMenu } from "./topology/TopologyCtxMenu"; +import { useTopologyHistory } from "./topology/hooks/useTopologyHistory"; +import { useTopologyDiscover } from "./topology/hooks/useTopologyDiscover"; +import { useTopologyTreeNav } from "./topology/hooks/useTopologyTreeNav"; +import { useTopologyCanvas } from "./topology/hooks/useTopologyCanvas"; +import { lazy, Suspense, useCallback, @@ -10,1113 +62,62 @@ import { 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, + TopologyViewItem, } from "../types"; -import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph"; +import { alignNodes } 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"; +import { + buildExportGraph, + exportTopologySvg, + exportTopologyXml, +} from "./topology/exportTopology"; /** 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); @@ -1167,22 +168,6 @@ export function TopologyPage() { 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); @@ -1193,58 +178,107 @@ export function TopologyPage() { const searchHitTimerRef = useRef(null); const findBoxRef = useRef(null); const displayMenuRef = useRef(null); + const exportMenuRef = useRef(null); + const [exporting, setExporting] = useState(false); 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 { + dirty, + canUndo, + canRedo, + dirtyRef, + historyRef, + redoRef, + historyLockRef, + pendingEdgeDeletesRef, + pendingEdgeCreatesRef, + markDirty, + clearDirty, + bumpHistory, + confirmDiscardIfDirty, + pushHistory, + undo, + redo, + } = useTopologyHistory(nodes, edges, setNodes, setEdges, t); + + const tree = useTopologyTreeNav({ + liveSync, + searchParams, + setSearchParams, + locale, + queryClient, + confirmDiscardIfDirty, + clearDirty, + showOk, + showError, + t, + setNodes, + setEdges, + setCtxMenu, + }); + const { + mapId, + setMapId, + setWorldFocusFolderId, + worldViewId, + selectedFolderId, + setSelectedFolderId, + hotBrowseKey, + setHotBrowseKey, + pendingHighlightNe, + setPendingHighlightNe, + expandedIds, + setExpandedIds, + treeNeQuery, + setTreeNeQuery, + debouncedTreeNeQuery, + setDebouncedTreeNeQuery, + treeSearchOpen, + setTreeSearchOpen, + treeSearchRef, + newRootDialog, + setNewRootDialog, + treeQuery, + treeFlatMap, + treeNeSearchQuery, + treeRoot, + regions, + treeLoading, + treeFailed, + rootFolderId, + canvasMode, + activeRegion, + activeView, + browseEntries, + hexBrowseRegion, + umeWorldHexModules, + primaryViewOfFolder, + goUmeWorldNav, + goRegion, + goRoot, + goCanvas, + goBackBrowse, + renameMapMut, + promptRenameMap, + createRegionMut, + renameRegionMut, + promptRenameRegion, + deleteFolderMut, + promptNewRegion, + submitNewRoot, + promptNewSubRegion, + onTreeToggleExpand, + onTreeHotBrowseKey, + onTreeClearHotBrowseKey, + onTreeDeleteFolder, + onTreeDeleteMap, + breadcrumbFolders, + } = tree; + 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); @@ -1262,220 +296,111 @@ export function TopologyPage() { }, []); 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, - }; - }); - }, [ + const canvas = useTopologyCanvas({ + mapId, + treeFlatMap, + liveSync, + canvasMode, + activeView, + nodes, edges, - expandPhysicalLinks, + setNodes, + setEdges, + edgeDefaults, + toolMode, + hideIp, + hideVendor, hidePorts, + showPlaceholderBadge, + expandPhysicalLinks, scaleBundleWidth, edgeFlow, - edgeDefaults, selectedEdgeId, + setSelectedEdgeId, + setSearchHitIds, + searchHitTimerRef, + queryClient, + dirtyRef, + historyLockRef, + historyRef, + redoRef, + pendingEdgeDeletesRef, + pendingEdgeCreatesRef, + clearDirty, + bumpHistory, + showError, + t, + }); + const { + rfRef, + canvasRef, + nodesRef, + appliedMapIdRef, + pendingFitRef, + isWorldFlatCanvasRef, + worldTransformRef, + graphQuery, isWorldFlatCanvas, + canvasZoom, + setCanvasZoom, worldVisualLod, - ]); + displayOpts, + displayEdges, + scheduleFlatViewportRefresh, + fitCanvas, + focusNode, + locateNode, + worldScatter, + showWorldScatter, + canvasGraphLoading, + canvasGraphEmpty, + worldNeedsApply, + canvasGraphRefreshing, + truncateBannerText, + activeLeafName, + worldDockMe, + worldTotal, + graphTruncated, + truncateReason, + } = canvas; - 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]); + const discover = useTopologyDiscover({ + mapId, + nodes, + edges, + graphNodes: graphQuery.data?.nodes, + edgeDefaults, + autoLayoutAfterDiscover, + discoverAutoAddUnmatched, + discoverProjectNeighbors, + queryClient, + setNodes, + setEdges, + dirtyRef, + pendingEdgeDeletesRef, + pendingEdgeCreatesRef, + historyLockRef, + appliedMapIdRef, + markDirty, + clearDirty, + showOk, + showError, + t, + }); + const { + discoverOpen, + setDiscoverOpen, + discovering, + discoverReport, + discoverProgress, + discoverError, + discoverResults, + discoverSummary, + discoverPct, + runDiscover, + cancelDiscover, + } = discover; // Keep canvas "auto placeholder" in sync with topology LLDP collect policy (shared strategy). useEffect(() => { @@ -1519,410 +444,6 @@ export function TopologyPage() { 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 = { @@ -1945,91 +466,6 @@ export function TopologyPage() { 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); @@ -2043,128 +479,6 @@ export function TopologyPage() { }, [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(), @@ -2177,293 +491,6 @@ export function TopologyPage() { 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")); @@ -2513,264 +540,6 @@ export function TopologyPage() { 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 || ""); @@ -3623,23 +1392,6 @@ export function TopologyPage() { 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(); @@ -3692,102 +1444,6 @@ export function TopologyPage() { 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; @@ -3864,21 +1520,16 @@ export function TopologyPage() { t, confirmDiscardIfDirty, regions, + setSelectedFolderId, + setMapId, + setPendingHighlightNe, + setTreeNeQuery, + setDebouncedTreeNeQuery, + setTreeSearchOpen, + setExpandedIds, ], ); - 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; @@ -3965,21 +1616,6 @@ export function TopologyPage() { 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) => { @@ -4046,47 +1682,75 @@ export function TopologyPage() { 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 runTopologyExport = useCallback( + async (format: "svg" | "xml") => { + const view: TopologyViewItem | undefined = + graphQuery.data?.view ?? + (activeView + ? { + id: activeView.id, + name: activeView.name, + remark: "", + node_count: activeView.node_count, + kind: activeView.kind, + role: activeView.role, + sort_order: activeView.sort_order, + } + : undefined); + if (!view) { + showError(t("topology.selectMap")); + return; + } + if (format !== "xml" && nodes.length === 0 && !isWorldFlatCanvas) { + showError(t("topology.exportEmpty")); + return; + } + setExporting(true); + exportMenuRef.current?.removeAttribute("open"); + const filenameBase = activeLeafName || view.name || "topology"; + try { + if (format === "svg") { + await exportTopologySvg( + nodes, + displayEdges, + filenameBase, + { + labelColor: labelColors.name, + edgeLabelColor: labelColors.edgeLabel, + vendorColors, + hideIp, + hideVendor, + }, + filenameBase, + ); + showOk(t("topology.exportedSvg")); + } else { + exportTopologyXml(buildExportGraph(view, nodes, edges, graphQuery.data), filenameBase); + showOk(t("topology.exportedXml")); + } + } catch (err) { + showError(t("topology.exportFailed").replace("{{detail}}", String(err))); + } finally { + setExporting(false); + } + }, + [ + activeView, + graphQuery.data, + nodes, + edges, + displayEdges, + isWorldFlatCanvas, + activeLeafName, + labelColors, + vendorColors, + hideIp, + hideVendor, + showError, + showOk, + t, + ], ); const titleText = useMemo(() => { @@ -4201,769 +1865,160 @@ export function TopologyPage() { : ({ "--topo-sidebar-w": `${sidebarWidth}px` } as CSSProperties) } > - + setSidebarCollapsed(true)} + onExpand={() => setSidebarCollapsed(false)} + treeRoot={treeRoot} + treeLoading={treeLoading} + treeFailed={treeFailed} + treeError={treeQuery.error} + onTreeRetry={() => void treeQuery.refetch()} + regions={regions} + treeNav={{ + treeRootId: String(treeRoot?.id || ""), + expandedIds, + mapId, + worldViewId, + selectedFolderId, + hotBrowseKey, + dirty, + onToggleExpand: onTreeToggleExpand, + onHotBrowseKey: onTreeHotBrowseKey, + onClearHotBrowseKey: onTreeClearHotBrowseKey, + onWorldFocusClear: () => setWorldFocusFolderId(""), + goUmeWorldNav, + goRegion, + goCanvas, + promptRenameRegion, + promptRenameMap, + renameRegionPending: renameRegionMut.isPending, + renameMapPending: renameMapMut.isPending, + onDeleteFolder: onTreeDeleteFolder, + deleteFolderPending: deleteFolderMut.isPending, + onDeleteMap: onTreeDeleteMap, + }} + onResizeDown={onSidebarResizeDown} + />
    {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} -
    -
    + setLiveSync((v) => !v)} + isWorldFlatCanvas={isWorldFlatCanvas} + onAddNe={() => { + if (isWorldFlatCanvas) { + showError(t("topology.worldMapNoDirectNes")); + return; + } + setKeyword(""); + setPaletteSelectedKeys([]); + setAddNeOpen(true); + }} + onCreateNe={(flowX, flowY) => { + if (isWorldFlatCanvas) { + showError(t("topology.worldMapNoDirectNes")); + return; + } + openCreateNeAt(flowX, flowY); + }} + nodes={nodes} + rfRef={rfRef} + onBackUp={goBackBrowse} + canUndo={canUndo} + onUndo={undo} + canRedo={canRedo} + onRedo={redo} + savePending={saveMut.isPending} + onSave={() => saveMut.mutate()} + onFit={fitCanvas} + exportMenuRef={exportMenuRef} + exporting={exporting} + onExport={runTopologyExport} + mapId={mapId} + staleEdgeCount={staleEdgeIds.length} + onRemoveStale={removeStaleEdges} + toolMode={toolMode} + onToolModeChange={setToolMode} + onConnectClickReset={() => { + connectClickRef.current = null; + }} + fullscreen={fullscreen} + viewToolsToolbarSlotRef={setViewToolsToolbarSlot} + outsidePeerCount={outsidePeers.length} + onOpenOutsidePeers={() => { + setOutsidePeerQuery(""); + setOutsidePeerSelectedIds([]); + setOutsidePeersOpen(true); + }} + goRoot={goRoot} + goRegion={goRegion} + primaryViewOfFolder={primaryViewOfFolder} + /> ) : 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} -
    + 0 || Boolean(discoverReport)} + discoverSummary={discoverSummary} + onCancelDiscover={cancelDiscover} + onClose={() => setDiscoverOpen(false)} + /> ) : 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} -
    - )} -
    + void treeQuery.refetch()} + onHotBrowseKey={setHotBrowseKey} + onClearHotBrowseKey={(key) => setHotBrowseKey((k) => (k === key ? "" : k))} + goRoot={goRoot} + goRegion={goRegion} + goUmeWorldNav={goUmeWorldNav} + goCanvas={goCanvas} + promptNewRegion={promptNewRegion} + promptNewSubRegion={promptNewSubRegion} + onSelectFolder={setSelectedFolderId} + /> ) : (
    ) : 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 ? (
    {( @@ -5628,825 +2295,96 @@ export function TopologyPage() { )}
    - {newRootDialog ? ( -
    -
    setNewRootDialog(null)} /> -
    -
    - {t("topology.newRegion")} - -
    -

    {t("topology.folderHint")}

    -
    - -
    -
    - - -
    -
    -
    + setNewRootDialog({ name })} + onCloseNewRoot={() => setNewRootDialog(null)} + onSubmitNewRoot={submitNewRoot} + createRegionPending={createRegionMut.isPending} + createNeDialog={createNeDialog} + createNeBusy={createNeBusy} + onCreateNeChange={(patch) => + setCreateNeDialog((prev) => (prev ? { ...prev, ...patch } : prev)) + } + onCloseCreateNe={() => setCreateNeDialog(null)} + onSubmitCreateNe={submitCreateNe} + outsidePeersOpen={outsidePeersOpen} + outsidePeers={outsidePeers} + outsidePeersVisible={outsidePeersVisible} + outsidePeerQuery={outsidePeerQuery} + onOutsidePeerQueryChange={setOutsidePeerQuery} + outsidePeerSelectedIds={outsidePeerSelectedIds} + onOutsidePeerSelectedIdsChange={setOutsidePeerSelectedIds} + outsidePeerNameById={outsidePeerNameById} + outsidePeersAdding={outsidePeersAdding} + onCloseOutsidePeers={() => setOutsidePeersOpen(false)} + onAddOutsidePeers={addOutsidePeers} + addNeOpen={addNeOpen} + paletteSource={paletteSource} + onPaletteSourceChange={(source) => { + setPaletteSource(source); + setPaletteSelectedKeys([]); + }} + keyword={keyword} + onKeywordChange={(value) => { + setKeyword(value); + setPaletteSelectedKeys([]); + }} + paletteVisible={paletteVisible} + paletteSelectedKeys={paletteSelectedKeys} + onPaletteSelectedKeysChange={setPaletteSelectedKeys} + paletteLoading={paletteLoading} + paletteAdding={paletteAdding} + onCloseAddNe={() => setAddNeOpen(false)} + onPaletteDragStart={onPaletteDragStart} + onAddSelectedPalette={async () => { + const selected = paletteVisible.filter((item) => + paletteSelectedKeys.includes(item.key), + ); + if (selected.length === 0) return; + const ok = await addPaletteItems(selected); + if (!ok) return; + setPaletteSelectedKeys([]); + setAddNeOpen(false); + }} + /> + + {ctxMenu ? ( + setExpandPhysicalLinks(true)} + onPushHistory={pushHistory} + onPatchSelectedEdgeStyle={patchSelectedEdgeStyle} + onOpenPortTraffic={openPortTrafficForEdge} + onRemoveEdge={removeEdgeById} + /> ) : 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}
    ); } diff --git a/web/src/pages/topology/TopologyCtxMenu.tsx b/web/src/pages/topology/TopologyCtxMenu.tsx new file mode 100644 index 0000000..ea824a8 --- /dev/null +++ b/web/src/pages/topology/TopologyCtxMenu.tsx @@ -0,0 +1,439 @@ +import type { RefObject } from "react"; +import { createPortal } from "react-dom"; +import type { Edge, Node } from "@xyflow/react"; +import { useI18n } from "../../i18n"; +import type { CtxMenu } from "./pageTypes"; +import type { EdgeStyleData } from "./edgeStyle"; +import { formatPortPairLabel } from "./linkDisplay"; +import { isPlaceholderSource } from "./searchUtils"; +import type { NeNodeData } from "./TopologyReactFlowView"; + +export type ResolvedEdgeStyle = { + stroke: string; + strokeWidth: number; + strokeDasharray?: string; +}; + +export type TopologyCtxMenuProps = { + ctxMenu: CtxMenu; + menuRef: RefObject; + portalTarget: HTMLElement | null; + fullscreen: boolean; + selectedNodeCount: number; + selectedNode: Node | null; + selectedEdge: Edge | null; + selectedEdgeData: EdgeStyleData; + selectedEdgeResolved: ResolvedEdgeStyle | null; + selectedEdgeSourceNode: Node | null; + selectedEdgeTargetNode: Node | null; + expandPhysicalLinks: boolean; + discovering: boolean; + isWorldFlatCanvas: boolean; + createRegionPending: boolean; + onClose: () => void; + onToggleFullscreen: () => void; + onRemoveSelected: () => void; + onOpenCreateNe: (flowX: number, flowY: number) => void; + onPromptNewSubRegion: () => void; + onRenameSelectedNode: () => void; + onDiscoverOne: (node: Node | null) => void; + onOpenWebcrt: (node: Node | null) => void; + onOpenNe: (node: Node | null) => void; + onPurgePlaceholder: (id: string) => void; + onRemoveNode: (id: string) => void; + onExpandPhysicalLinks: () => void; + onPushHistory: () => void; + onPatchSelectedEdgeStyle: ( + patch: Partial, + opts?: { skipHistory?: boolean }, + ) => void; + onOpenPortTraffic: (edge: Edge | null) => void; + onRemoveEdge: (id: string) => void; +}; + +export function TopologyCtxMenu({ + ctxMenu, + menuRef, + portalTarget, + fullscreen, + selectedNodeCount, + selectedNode, + selectedEdge, + selectedEdgeData, + selectedEdgeResolved, + selectedEdgeSourceNode, + selectedEdgeTargetNode, + expandPhysicalLinks, + discovering, + isWorldFlatCanvas, + createRegionPending, + onClose, + onToggleFullscreen, + onRemoveSelected, + onOpenCreateNe, + onPromptNewSubRegion, + onRenameSelectedNode, + onDiscoverOne, + onOpenWebcrt, + onOpenNe, + onPurgePlaceholder, + onRemoveNode, + onExpandPhysicalLinks, + onPushHistory, + onPatchSelectedEdgeStyle, + onOpenPortTraffic, + onRemoveEdge, +}: TopologyCtxMenuProps) { + const { t } = useI18n(); + + return createPortal( +
      e.preventDefault()} + > + {ctxMenu.kind === "selection" ? ( + <> +
    • + {t("topology.selectionMenu")} +
    • +
    • + +
    • +
    • + +
    • + + ) : ctxMenu.kind === "pane" ? ( + <> +
    • + {t("topology.paneMenu")} +
    • +
    • + +
    • + {!isWorldFlatCanvas ? ( +
    • + +
    • + ) : 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} +
      +
    • +
    • +
    • + +
    • +
    • + +
    • +
    • + +
    • + + )} +
    , + portalTarget || document.body, + ); +} diff --git a/web/src/pages/topology/TopologyDiscoverPanel.tsx b/web/src/pages/topology/TopologyDiscoverPanel.tsx new file mode 100644 index 0000000..2552648 --- /dev/null +++ b/web/src/pages/topology/TopologyDiscoverPanel.tsx @@ -0,0 +1,118 @@ +import type { TopologyDiscoverOut } from "../../types"; +import { useI18n } from "../../i18n"; +import { openOrFocusModule } from "../../utils/moduleWindows"; + +export type DiscoverProgress = { + index: number; + total: number; + neName: string; + neIp: string; + edgesAdded: number; + edgesUpdated: number; +}; + +export type DiscoverSummary = { + scanned: number; + added: number; + updated: number; + missing: number; + failed: number; +}; + +export type TopologyDiscoverPanelProps = { + discovering: boolean; + discoverProgress: DiscoverProgress; + discoverPct: number; + discoverReport: TopologyDiscoverOut | null; + discoverError: string; + hasResults: boolean; + discoverSummary: DiscoverSummary; + onCancelDiscover: () => void; + onClose: () => void; +}; + +export function TopologyDiscoverPanel({ + discovering, + discoverProgress, + discoverPct, + discoverReport, + discoverError, + hasResults, + discoverSummary, + onCancelDiscover, + onClose, +}: TopologyDiscoverPanelProps) { + const { t } = useI18n(); + + return ( +
    +
    + {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} + {hasResults ? ( + <> +

    + {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} +
    + ); +} diff --git a/web/src/pages/topology/TopologyHexBrowser.tsx b/web/src/pages/topology/TopologyHexBrowser.tsx new file mode 100644 index 0000000..4584e8f --- /dev/null +++ b/web/src/pages/topology/TopologyHexBrowser.tsx @@ -0,0 +1,314 @@ +import { Fragment } from "react"; +import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types"; +import { useI18n } from "../../i18n"; +import { + displayViewName, + folderNeCount, + formatNeCount, + isUmeWorldContainer, + regionDisplayName, +} from "./treeUtils"; +import { LayerGlyph, RegionGlyph } from "./topologyIcons"; + +export type UmeWorldHexModules = { + drill: TopologyTreeFolderItem | null; + flatView: TopologyTreeViewItem | null; +}; + +export type TopologyHexBrowserProps = { + titleText: string; + breadcrumbFolders: TopologyTreeFolderItem[]; + hexBrowseRegion: TopologyTreeFolderItem | null; + umeWorldHexModules: UmeWorldHexModules | null; + browseEntries: TopologyTreeViewItem[]; + regions: TopologyTreeFolderItem[]; + rootFolderId: string; + selectedFolderId: string; + mapId: string; + hotBrowseKey: string; + treeLoading: boolean; + treeFailed: boolean; + treeError: unknown; + createRegionPending: boolean; + onTreeRetry: () => void; + onHotBrowseKey: (key: string) => void; + onClearHotBrowseKey: (key: string) => void; + goRoot: () => void; + goRegion: (folderId: string) => void; + goUmeWorldNav: () => void; + goCanvas: (viewId: string, folderId: string) => void; + promptNewRegion: () => void; + promptNewSubRegion: () => void; + onSelectFolder: (folderId: string) => void; +}; + +export function TopologyHexBrowser({ + titleText, + breadcrumbFolders, + hexBrowseRegion, + umeWorldHexModules, + browseEntries, + regions, + rootFolderId, + selectedFolderId, + mapId, + hotBrowseKey, + treeLoading, + treeFailed, + treeError, + createRegionPending, + onTreeRetry, + onHotBrowseKey, + onClearHotBrowseKey, + goRoot, + goRegion, + goUmeWorldNav, + goCanvas, + promptNewRegion, + promptNewSubRegion, + onSelectFolder, +}: TopologyHexBrowserProps) { + const { t } = useI18n(); + + const regionCountLabel = 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)); + + return ( +
    +
    +
    + {hexBrowseRegion ? ( +
    + + {breadcrumbFolders.map((folder, idx) => { + const isLast = idx === breadcrumbFolders.length - 1; + return ( + + / + {isLast ? ( + {regionDisplayName(folder, t)} + ) : ( + + )} + + ); + })} +
    + ) : ( + {titleText} + )} +

    {regionCountLabel}

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

    {t("topology.treeLoadFailed")}

    + {treeError ? ({String(treeError)}) : null} +
    + + +
    +
    + ) : 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 ( + + ); + })} + {String(hexBrowseRegion.parent_id || "") !== rootFolderId ? ( + + ) : null} +
    + )} +
    + ); +} diff --git a/web/src/pages/topology/TopologyModals.tsx b/web/src/pages/topology/TopologyModals.tsx new file mode 100644 index 0000000..17c62c1 --- /dev/null +++ b/web/src/pages/topology/TopologyModals.tsx @@ -0,0 +1,545 @@ +import type { DragEvent } from "react"; +import type { TopologyOutsidePeer } from "../../types"; +import { useI18n } from "../../i18n"; +import type { PaletteItem, PaletteSource } from "./pageTypes"; + +export type CreateNeDialogState = { + flowX: number; + flowY: number; + name: string; + ip_address: string; +}; + +export type TopologyModalsProps = { + canvasMode: boolean; + newRootDialog: { name: string } | null; + onNewRootNameChange: (name: string) => void; + onCloseNewRoot: () => void; + onSubmitNewRoot: () => void; + createRegionPending: boolean; + createNeDialog: CreateNeDialogState | null; + createNeBusy: boolean; + onCreateNeChange: (patch: Partial>) => void; + onCloseCreateNe: () => void; + onSubmitCreateNe: () => void; + outsidePeersOpen: boolean; + outsidePeers: TopologyOutsidePeer[]; + outsidePeersVisible: TopologyOutsidePeer[]; + outsidePeerQuery: string; + onOutsidePeerQueryChange: (q: string) => void; + outsidePeerSelectedIds: string[]; + onOutsidePeerSelectedIdsChange: (ids: string[] | ((prev: string[]) => string[])) => void; + outsidePeerNameById: Map; + outsidePeersAdding: boolean; + onCloseOutsidePeers: () => void; + onAddOutsidePeers: (ids: string[]) => void; + addNeOpen: boolean; + paletteSource: PaletteSource; + onPaletteSourceChange: (source: PaletteSource) => void; + keyword: string; + onKeywordChange: (keyword: string) => void; + paletteVisible: PaletteItem[]; + paletteSelectedKeys: string[]; + onPaletteSelectedKeysChange: (keys: string[] | ((prev: string[]) => string[])) => void; + paletteLoading: boolean; + paletteAdding: boolean; + onCloseAddNe: () => void; + onPaletteDragStart: (e: DragEvent, item: PaletteItem) => void; + onAddSelectedPalette: () => void; +}; + +export function TopologyModals({ + canvasMode, + newRootDialog, + onNewRootNameChange, + onCloseNewRoot, + onSubmitNewRoot, + createRegionPending, + createNeDialog, + createNeBusy, + onCreateNeChange, + onCloseCreateNe, + onSubmitCreateNe, + outsidePeersOpen, + outsidePeers, + outsidePeersVisible, + outsidePeerQuery, + onOutsidePeerQueryChange, + outsidePeerSelectedIds, + onOutsidePeerSelectedIdsChange, + outsidePeerNameById, + outsidePeersAdding, + onCloseOutsidePeers, + onAddOutsidePeers, + addNeOpen, + paletteSource, + onPaletteSourceChange, + keyword, + onKeywordChange, + paletteVisible, + paletteSelectedKeys, + onPaletteSelectedKeysChange, + paletteLoading, + paletteAdding, + onCloseAddNe, + onPaletteDragStart, + onAddSelectedPalette, +}: TopologyModalsProps) { + const { t } = useI18n(); + + return ( + <> + {newRootDialog ? ( +
    +
    +
    +
    + {t("topology.newRegion")} + +
    +

    {t("topology.folderHint")}

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

    {t("topology.createNeHint")}

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

    {t("topology.outsidePeersHint")}

    + onOutsidePeerQueryChange(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; + onCloseAddNe(); + }} + /> +
    +
    + {t("topology.addNe")} + +
    +

    {t("topology.paletteHint")}

    +
    + + +
    + onKeywordChange(e.target.value)} + 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} + + ); +} diff --git a/web/src/pages/topology/TopologyReactFlowView.tsx b/web/src/pages/topology/TopologyReactFlowView.tsx index 00295d6..ecee933 100644 --- a/web/src/pages/topology/TopologyReactFlowView.tsx +++ b/web/src/pages/topology/TopologyReactFlowView.tsx @@ -64,31 +64,8 @@ const TopoDisplayContext = createContext({ const SEP = " / "; -function nodeIconTone(vendor: string, managedNeId: string, umeNeId = ""): string { - const hasManaged = Boolean(String(managedNeId || "").trim()); - const hasUme = Boolean(String(umeNeId || "").trim()); - if (!hasManaged && !hasUme) return "gray"; - const v = String(vendor || "").trim().toLowerCase(); - if (!v || v === "other" || v === "unknown" || v === "generic") { - return hasUme ? "zte" : "gray"; - } - if (v.includes("cisco")) return "cisco"; - if (v.includes("huawei")) return "huawei"; - if (v.includes("zte")) return "zte"; - if (v.includes("juniper")) return "juniper"; - if (v.includes("nokia") || v.includes("alcatel")) return "nokia"; - if (v.includes("ericsson")) return "ericsson"; - if (v.includes("h3c") || v.includes("comware")) return "h3c"; - if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie"; - if (v.includes("mikrotik")) return "mikrotik"; - return hasUme ? "zte" : "gray"; -} - -function 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); -} +import { isPlaceholderSource } from "./searchUtils"; +import { nodeIconTone } from "./vendorTone"; export { isPlaceholderSource }; diff --git a/web/src/pages/topology/TopologySidebar.tsx b/web/src/pages/topology/TopologySidebar.tsx new file mode 100644 index 0000000..6536cb8 --- /dev/null +++ b/web/src/pages/topology/TopologySidebar.tsx @@ -0,0 +1,243 @@ +import type { MouseEvent as ReactMouseEvent, RefObject } from "react"; +import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types"; +import { useI18n } from "../../i18n"; +import { WORLD_MAP_ENABLED } from "./constants"; +import { isWorldFlatViewName } from "./treeUtils"; +import { PlusIcon, SidebarFoldIcon } from "./topologyIcons"; +import { + TopologyTreeNavFolder, + type TopologyTreeNavFolderProps, +} from "./TopologyTreeNavFolder"; + +export type TopologySidebarProps = { + collapsed: boolean; + resizing: boolean; + treeSearchRef: RefObject; + treeNeQuery: string; + onTreeNeQueryChange: (value: string) => void; + treeSearchOpen: boolean; + onTreeSearchOpen: (open: boolean) => void; + debouncedTreeNeQuery: string; + treeSearchFetching: boolean; + treeSearchItems: FabricNodeSearchHit[]; + treeSearchTotal: number; + onJumpToSearchHit: ( + hit: FabricNodeSearchHit, + view?: NonNullable[number], + ) => void; + promptNewRegion: () => void; + createRegionPending: boolean; + onCollapse: () => void; + onExpand: () => void; + treeRoot: TopologyTreeFolderItem | null | undefined; + treeLoading: boolean; + treeFailed: boolean; + treeError: unknown; + onTreeRetry: () => void; + regions: TopologyTreeFolderItem[]; + treeNav: Omit; + onResizeDown: (e: ReactMouseEvent) => void; +}; + +export function TopologySidebar({ + collapsed, + resizing, + treeSearchRef, + treeNeQuery, + onTreeNeQueryChange, + treeSearchOpen, + onTreeSearchOpen, + debouncedTreeNeQuery, + treeSearchFetching, + treeSearchItems, + treeSearchTotal, + onJumpToSearchHit, + promptNewRegion, + createRegionPending, + onCollapse, + onExpand, + treeRoot, + treeLoading, + treeFailed, + treeError, + onTreeRetry, + regions, + treeNav, + onResizeDown, +}: TopologySidebarProps) { + const { t } = useI18n(); + + return ( + + ); +} diff --git a/web/src/pages/topology/TopologyToolbar.tsx b/web/src/pages/topology/TopologyToolbar.tsx new file mode 100644 index 0000000..ab137e4 --- /dev/null +++ b/web/src/pages/topology/TopologyToolbar.tsx @@ -0,0 +1,322 @@ +import { Fragment, type RefObject } from "react"; +import type { Edge, Node, ReactFlowInstance } from "@xyflow/react"; +import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types"; +import { HelpHint } from "../../components/HelpHint"; +import { useI18n } from "../../i18n"; +import { displayViewName, isRegionCanvasFolder, regionDisplayName } from "./treeUtils"; +import type { NeNodeData } from "./TopologyReactFlowView"; +import type { ToolMode } from "./toolMode"; + +export type TopologyToolbarProps = { + breadcrumbFolders: TopologyTreeFolderItem[]; + activeView: TopologyTreeViewItem | null | undefined; + activeRegion: TopologyTreeFolderItem | null | undefined; + rootFolderId: string; + dirty: boolean; + selectedNodes: Node[]; + selectedEdgeId: string | null; + liveSync: boolean; + onLiveSyncToggle: () => void; + isWorldFlatCanvas: boolean; + onAddNe: () => void; + onCreateNe: (flowX: number, flowY: number) => void; + nodes: Node[]; + rfRef: RefObject, Edge> | null>; + onBackUp: () => void; + canUndo: boolean; + onUndo: () => void; + canRedo: boolean; + onRedo: () => void; + savePending: boolean; + onSave: () => void; + onFit: () => void; + exportMenuRef: RefObject; + exporting: boolean; + onExport: (kind: "svg" | "xml") => void; + mapId: string; + staleEdgeCount: number; + onRemoveStale: () => void; + toolMode: ToolMode; + onToolModeChange: (mode: ToolMode) => void; + onConnectClickReset: () => void; + fullscreen: boolean; + viewToolsToolbarSlotRef: (el: HTMLDivElement | null) => void; + outsidePeerCount: number; + onOpenOutsidePeers: () => void; + goRoot: () => void; + goRegion: (folderId: string) => void; + primaryViewOfFolder: ( + folder: TopologyTreeFolderItem | null | undefined, + ) => TopologyTreeViewItem | null | undefined; +}; + +export function TopologyToolbar({ + breadcrumbFolders, + activeView, + activeRegion, + rootFolderId, + dirty, + selectedNodes, + selectedEdgeId, + liveSync, + onLiveSyncToggle, + isWorldFlatCanvas, + onAddNe, + onCreateNe, + nodes, + rfRef, + onBackUp, + canUndo, + onUndo, + canRedo, + onRedo, + savePending, + onSave, + onFit, + exportMenuRef, + exporting, + onExport, + mapId, + staleEdgeCount, + onRemoveStale, + toolMode, + onToolModeChange, + onConnectClickReset, + fullscreen, + viewToolsToolbarSlotRef, + outsidePeerCount, + onOpenOutsidePeers, + goRoot, + goRegion, + primaryViewOfFolder, +}: TopologyToolbarProps) { + const { t } = useI18n(); + + const handleCreateNe = () => { + let flowX = 80 + nodes.length * 24; + let flowY = 80 + nodes.length * 24; + if (rfRef.current) { + const pane = document.querySelector(".react-flow__pane"); + const rect = pane?.getBoundingClientRect(); + if (rect && rect.width > 0 && rect.height > 0) { + const center = rfRef.current.screenToFlowPosition({ + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }); + flowX = center.x; + flowY = center.y; + } + } + onCreateNe(flowX, flowY); + }; + + return ( +
    +
    +
    +
    + + {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} + +
    +
    + + + + + + + + +
    + {exporting ? t("topology.exporting") : t("topology.export")} +
    + + +
    +
    + +
    +
    +
    +
    + {( + [ + ["select", t("topology.toolSelect"), "V"], + ["pan", t("topology.toolPan"), "H"], + ["connect", t("topology.toolConnect"), "C"], + ] as const + ).map(([mode, label, key]) => ( + + ))} +
    + {!fullscreen ? ( +
    + ) : null} + {outsidePeerCount > 0 ? ( + + ) : null} +
    +
    + ); +} diff --git a/web/src/pages/topology/TopologyTreeNavFolder.tsx b/web/src/pages/topology/TopologyTreeNavFolder.tsx new file mode 100644 index 0000000..f3a0d14 --- /dev/null +++ b/web/src/pages/topology/TopologyTreeNavFolder.tsx @@ -0,0 +1,393 @@ +import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types"; +import { useI18n } from "../../i18n"; +import { WORLD_MAP_ENABLED } from "./constants"; +import { + displayViewName, + folderNeCount, + formatNeCount, + isManualRootMapFolder, + isRegionCanvasFolder, + isUmeStructuralFolder, + isUmeSyncedSubRegion, + isUmeWorldNavFolder, + isWorldDrillFolder, + isWorldFlatViewName, + regionDisplayName, +} from "./treeUtils"; +import { + ChevronIcon, + CloseIcon, + LayerGlyph, + PencilIcon, + RegionGlyph, +} from "./topologyIcons"; + +export type TopologyTreeNavFolderProps = { + folder: TopologyTreeFolderItem; + depth: number; + treeRootId: string; + expandedIds: Record; + mapId: string; + worldViewId: string; + selectedFolderId: string; + hotBrowseKey: string; + dirty: boolean; + onToggleExpand: (folderId: string, nextOpen: boolean) => void; + onHotBrowseKey: (key: string) => void; + onClearHotBrowseKey: (key: string) => void; + onWorldFocusClear: () => void; + goUmeWorldNav: () => void; + goRegion: (folderId: string) => void; + goCanvas: (viewId: string, folderId: string) => void; + promptRenameRegion: (folderId: string, name: string) => void; + promptRenameMap: (viewId: string, name: string) => void; + renameRegionPending: boolean; + renameMapPending: boolean; + onDeleteFolder: (folderId: string) => void; + deleteFolderPending: boolean; + onDeleteMap: (viewId: string) => void; +}; + +function ViewRow({ + v, + folder, + umeNav, + mapId, + dirty, + hotBrowseKey, + renameMapPending, + onHotBrowseKey, + onClearHotBrowseKey, + onWorldFocusClear, + goCanvas, + promptRenameMap, + onDeleteMap, +}: { + v: TopologyTreeViewItem; + folder: TopologyTreeFolderItem; + umeNav: boolean; + mapId: string; + dirty: boolean; + hotBrowseKey: string; + renameMapPending: boolean; + onHotBrowseKey: (key: string) => void; + onClearHotBrowseKey: (key: string) => void; + onWorldFocusClear: () => void; + goCanvas: (viewId: string, folderId: string) => void; + promptRenameMap: (viewId: string, name: string) => void; + onDeleteMap: (viewId: string) => void; +}) { + const { t } = useI18n(); + const isPhysical = String(v.kind) === "physical"; + const viewHot = hotBrowseKey === `view:${v.id}`; + const viewActive = mapId === v.id; + + return ( +
  • +
    onHotBrowseKey(`view:${v.id}`)} + onMouseLeave={() => onClearHotBrowseKey(`view:${v.id}`)} + > +
    +
  • + ); +} + +export function TopologyTreeNavFolder({ + folder, + depth, + treeRootId, + expandedIds, + mapId, + worldViewId, + selectedFolderId, + hotBrowseKey, + dirty, + onToggleExpand, + onHotBrowseKey, + onClearHotBrowseKey, + onWorldFocusClear, + goUmeWorldNav, + goRegion, + goCanvas, + promptRenameRegion, + promptRenameMap, + renameRegionPending, + renameMapPending, + onDeleteFolder, + deleteFolderPending, + onDeleteMap, +}: TopologyTreeNavFolderProps) { + const { t } = useI18n(); + const kids = folder.children || []; + const containerFolder = folder.external_ref === "ume:world" || folder.name === "UME World"; + const drillFolder = isWorldDrillFolder(folder); + const umeNav = isUmeWorldNavFolder(folder); + const canvasRegion = isRegionCanvasFolder(folder, treeRootId); + 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 canRenameFolder = + !isUmeStructuralFolder(folder) && (!folder.is_system || isManualRootMapFolder(folder)); + const canDeleteFolder = + !isUmeStructuralFolder(folder) && + !isManualRootMapFolder(folder) && + (!folder.is_system || isUmeSyncedSubRegion(folder)); + + return ( +
  • +
    onHotBrowseKey(`region:${folder.id}`)} + onMouseLeave={() => onClearHotBrowseKey(`region:${folder.id}`)} + > + + + {canRenameFolder || canDeleteFolder ? ( +
    + {canRenameFolder ? ( + + ) : null} + {canDeleteFolder ? ( + + ) : null} +
    + ) : null} +
    + {open ? ( +
      + {containerFolder ? ( + <> + {kids.map((child) => ( + + ))} + {visibleViews.map((v) => ( + + ))} + + ) : ( + <> + {visibleViews.map((v) => ( + + ))} + {kids.map((child) => ( + + ))} + + )} +
    + ) : null} +
  • + ); +} diff --git a/web/src/pages/topology/TopologyViewTools.tsx b/web/src/pages/topology/TopologyViewTools.tsx new file mode 100644 index 0000000..b25d356 --- /dev/null +++ b/web/src/pages/topology/TopologyViewTools.tsx @@ -0,0 +1,558 @@ +import type { RefObject } from "react"; +import { createPortal } from "react-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Node } from "@xyflow/react"; +import { queryKeys } from "../../constants/queryKeys"; +import { useI18n } from "../../i18n"; +import { updateLldpCollectPolicy } from "../../services/api"; +import { + DEFAULT_CANVAS_BG, + DISCOVER_AUTO_ADD_KEY, + DISCOVER_PROJECT_NEIGHBORS_KEY, + SCALE_BUNDLE_WIDTH_KEY, + SEP, + SHOW_PLACEHOLDER_BADGE_KEY, +} from "./constants"; +import { + DEFAULT_LABEL_COLORS, + DEFAULT_VENDOR_COLORS, + VENDOR_TONE_KEYS, + isHexColor, + loadCanvasBg, + loadLabelColors, + loadVendorColors, + persistAutoLayoutAfterDiscover, + persistBoolFlag, + persistCanvasBg, + persistLabelColors, + persistVendorColors, + type LabelColors, + type VendorColors, +} from "./displayPrefs"; +import type { EdgeDefaultStyle, EdgeDefaults, EdgeLineStyle, EdgeSourceKind } from "./edgeStyle"; +import type { alignNodes } from "./layoutGraph"; +import type { NeNodeData } from "./TopologyReactFlowView"; + +export type AlignKind = Parameters[2]; + +export type TopologyViewToolsProps = { + fullscreen: boolean; + toolbarSlot: HTMLDivElement | null; + displayMenuRef: RefObject; + findBoxRef: RefObject; + hideIp: boolean; + onHideIpChange: (v: boolean) => void; + hideVendor: boolean; + onHideVendorChange: (v: boolean) => void; + showPlaceholderBadge: boolean; + onShowPlaceholderBadgeChange: (v: boolean) => void; + hidePorts: boolean; + onHidePortsChange: (v: boolean) => void; + expandPhysicalLinks: boolean; + onExpandPhysicalLinksChange: (v: boolean) => void; + scaleBundleWidth: boolean; + onScaleBundleWidthChange: (v: boolean) => void; + edgeFlow: boolean; + onEdgeFlowChange: (v: boolean) => void; + snapToGrid: boolean; + onSnapToGridChange: (v: boolean) => void; + autoLayoutAfterDiscover: boolean; + onAutoLayoutAfterDiscoverChange: (v: boolean) => void; + discoverAutoAddUnmatched: boolean; + onDiscoverAutoAddUnmatchedChange: (v: boolean) => void; + discoverProjectNeighbors: boolean; + onDiscoverProjectNeighborsChange: (v: boolean) => void; + canvasBg: string; + onCanvasBgChange: (v: string) => void; + labelColors: LabelColors; + onLabelColorsChange: (v: LabelColors) => void; + vendorColors: VendorColors; + onVendorColorsChange: (v: VendorColors) => void; + edgeDefaults: EdgeDefaults; + onUpdateEdgeDefault: (kind: EdgeSourceKind, patch: Partial) => void; + onResetEdgeDefaults: () => void; + selectedNodeCount: number; + onAlign: (kind: AlignKind) => void; + mapId: string; + nodeCount: number; + canvasQuery: string; + onCanvasQueryChange: (v: string) => void; + findOpen: boolean; + onFindOpenChange: (v: boolean) => void; + findActiveIdx: number; + onFindActiveIdxChange: (v: number | ((i: number) => number)) => void; + canvasHits: Node[]; + onFindOnCanvas: (nodeId?: string) => void; +}; + +export function TopologyViewTools(props: TopologyViewToolsProps) { + const { t } = useI18n(); + const queryClient = useQueryClient(); + const { + fullscreen, + toolbarSlot, + displayMenuRef, + findBoxRef, + hideIp, + onHideIpChange, + hideVendor, + onHideVendorChange, + showPlaceholderBadge, + onShowPlaceholderBadgeChange, + hidePorts, + onHidePortsChange, + expandPhysicalLinks, + onExpandPhysicalLinksChange, + scaleBundleWidth, + onScaleBundleWidthChange, + edgeFlow, + onEdgeFlowChange, + snapToGrid, + onSnapToGridChange, + autoLayoutAfterDiscover, + onAutoLayoutAfterDiscoverChange, + discoverAutoAddUnmatched, + onDiscoverAutoAddUnmatchedChange, + discoverProjectNeighbors, + onDiscoverProjectNeighborsChange, + canvasBg, + onCanvasBgChange, + labelColors, + onLabelColorsChange, + vendorColors, + onVendorColorsChange, + edgeDefaults, + onUpdateEdgeDefault, + onResetEdgeDefaults, + selectedNodeCount, + onAlign, + mapId, + nodeCount, + canvasQuery, + onCanvasQueryChange, + findOpen, + onFindOpenChange, + findActiveIdx, + onFindActiveIdxChange, + canvasHits, + onFindOnCanvas, + } = props; + + const body = ( + <> +
    + {t("topology.display")} +
    + + + + + + + + + + + +
    +
    + {t("topology.canvasBg")} + +
    +
    + {t("topology.canvasBgColor")} + { + const next = e.target.value; + onCanvasBgChange(next); + persistCanvasBg(next); + }} + /> + { + const raw = e.target.value.trim(); + if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; + onCanvasBgChange(raw); + if (isHexColor(raw)) persistCanvasBg(raw.toLowerCase()); + }} + onBlur={() => { + if (!isHexColor(canvasBg)) onCanvasBgChange(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() }; + onLabelColorsChange(next); + persistLabelColors(next); + }} + /> + { + const raw = e.target.value.trim(); + if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; + const next = { ...labelColors, [key]: raw }; + onLabelColorsChange(next); + if (isHexColor(raw)) persistLabelColors({ ...next, [key]: raw.toLowerCase() }); + }} + onBlur={() => { + if (!isHexColor(labelColors[key])) onLabelColorsChange(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() }; + onVendorColorsChange(next); + persistVendorColors(next); + }} + /> + { + const raw = e.target.value.trim(); + if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return; + const next = { ...vendorColors, [key]: raw }; + onVendorColorsChange(next); + if (isHexColor(raw)) { + persistVendorColors({ ...next, [key]: raw.toLowerCase() }); + } + }} + onBlur={() => { + if (!isHexColor(vendorColors[key])) onVendorColorsChange(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} + onUpdateEdgeDefault(kind, { stroke_color: e.target.value })} + /> + + +
    + ); + })} +
    +
    +
    + +
    + onCanvasQueryChange(e.target.value)} + onFocus={() => { + if (canvasQuery.trim()) onFindOpenChange(true); + }} + onKeyDown={(e) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (!canvasHits.length) return; + onFindOpenChange(true); + onFindActiveIdxChange((i) => (i + 1) % canvasHits.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + if (!canvasHits.length) return; + onFindOpenChange(true); + onFindActiveIdxChange((i) => (i - 1 + canvasHits.length) % canvasHits.length); + return; + } + if (e.key === "Escape") { + onFindOpenChange(false); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + onFindOnCanvas(); + } + }} + placeholder={t("topology.findNodePh")} + disabled={!mapId || nodeCount === 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 (!toolbarSlot) return null; + return createPortal(body, toolbarSlot); + } + + return ( +
    + {body} +
    + ); +} diff --git a/web/src/pages/topology/constants.ts b/web/src/pages/topology/constants.ts new file mode 100644 index 0000000..e65525d --- /dev/null +++ b/web/src/pages/topology/constants.ts @@ -0,0 +1,30 @@ +/** Flat world-map canvas (LOD / scatter). Temporarily offline in the UI. */ +export const WORLD_MAP_ENABLED = false; + +export const LAST_LEAF_KEY = "netx.topology.lastLeafViewId"; +export const TREE_EXPAND_KEY = "netx.topology.treeExpanded"; +export const SIDEBAR_WIDTH_KEY = "netx.topology.sidebarWidth"; +export const SIDEBAR_WIDTH_DEFAULT = 260; +export const SIDEBAR_WIDTH_MIN = 180; +export const SIDEBAR_WIDTH_MAX = 560; + +export const UNDO_MAX = 40; +export const PALETTE_DND = "application/x-netx-topo-palette"; +export const SEP = " / "; + +export const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const; +export const WORLD_LOCATE_ZOOM = 0.22; +export const WORLD_LOCATE_HALF = 12000; +export const WORLD_FLAT_ACCUM_CAP = 8000; + +export const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults"; +export const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover"; +export const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched.v2"; +export const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors.v2"; +export const SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth"; +export const SHOW_PLACEHOLDER_BADGE_KEY = "netx.topology.showPlaceholderBadge"; +export const CANVAS_BG_KEY = "netx.topology.canvasBg"; +export const DEFAULT_CANVAS_BG = "#0f172a"; +export const LEGACY_CANVAS_BG = "#dbeafe"; +export const LABEL_COLORS_KEY = "netx.topology.labelColors"; +export const VENDOR_COLORS_KEY = "netx.topology.vendorColors"; diff --git a/web/src/pages/topology/displayPrefs.ts b/web/src/pages/topology/displayPrefs.ts new file mode 100644 index 0000000..1456281 --- /dev/null +++ b/web/src/pages/topology/displayPrefs.ts @@ -0,0 +1,146 @@ +import type { CSSProperties } from "react"; +import { + AUTO_LAYOUT_DISCOVER_KEY, + CANVAS_BG_KEY, + DEFAULT_CANVAS_BG, + LABEL_COLORS_KEY, + LEGACY_CANVAS_BG, + VENDOR_COLORS_KEY, +} from "./constants"; + +export const VENDOR_TONE_KEYS = [ + "cisco", + "huawei", + "zte", + "juniper", + "nokia", + "ericsson", + "h3c", + "ruijie", + "mikrotik", + "gray", +] as const; + +export type VendorToneKey = (typeof VENDOR_TONE_KEYS)[number]; +export type VendorColors = Record; +export type LabelColors = { name: string; edgeLabel: string }; + +export const DEFAULT_LABEL_COLORS: LabelColors = { + name: "#f1f5f9", + edgeLabel: "#e2e8f0", +}; + +export 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", +}; + +export function isHexColor(value: string): boolean { + return /^#[0-9a-fA-F]{6}$/.test(value); +} + +export 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; +} + +export function persistCanvasBg(value: string) { + try { + localStorage.setItem(CANVAS_BG_KEY, value); + } catch { + /* ignore */ + } +} + +export 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 }; + } +} + +export function persistLabelColors(value: LabelColors) { + try { + localStorage.setItem(LABEL_COLORS_KEY, JSON.stringify(value)); + } catch { + /* ignore */ + } +} + +export 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 }; + } +} + +export function persistVendorColors(value: VendorColors) { + try { + localStorage.setItem(VENDOR_COLORS_KEY, JSON.stringify(value)); + } catch { + /* ignore */ + } +} + +export 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; + } +} + +export function persistBoolFlag(key: string, value: boolean) { + try { + localStorage.setItem(key, value ? "1" : "0"); + } catch { + /* ignore */ + } +} + +export function loadAutoLayoutAfterDiscover(): boolean { + return loadBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, false); +} + +export function persistAutoLayoutAfterDiscover(value: boolean) { + persistBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, value); +} + +export type { CSSProperties }; diff --git a/web/src/pages/topology/edgeStyle.ts b/web/src/pages/topology/edgeStyle.ts new file mode 100644 index 0000000..7d9161e --- /dev/null +++ b/web/src/pages/topology/edgeStyle.ts @@ -0,0 +1,118 @@ +import type { Edge } from "@xyflow/react"; +import type { LinkEdgeData } from "./linkDisplay"; +import { EDGE_DEFAULTS_KEY } from "./constants"; + +export type EdgeLineStyle = "solid" | "dashed" | "dotted"; +export type EdgeSourceKind = "manual" | "ume" | "discovered" | "stale"; + +export type EdgeDefaultStyle = { + stroke_color: string; + stroke_width: number; + line_style: EdgeLineStyle; +}; + +export type EdgeDefaults = Record; +export type EdgeStyleData = LinkEdgeData; + +const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = { + ume: { stroke_color: "#2563eb", stroke_width: 2, line_style: "solid" }, + discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" }, + stale: { stroke_color: "#dc2626", stroke_width: 2, line_style: "dashed" }, + manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" }, +}; + +export 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"; +} + +export 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 }, + }; + } +} + +export function persistEdgeDefaults(next: EdgeDefaults) { + try { + localStorage.setItem(EDGE_DEFAULTS_KEY, JSON.stringify(next)); + } catch { + /* ignore quota */ + } +} + +export 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; +} + +export 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), + }; +} + +export 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, + }; +} + +export function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge { + const data = (edge.data || {}) as EdgeStyleData; + const style = resolveEdgeStyle(data, defaults); + return { ...edge, style, markerEnd: undefined, labelShowBg: false }; +} + +export { BUILTIN_EDGE_DEFAULTS }; diff --git a/web/src/pages/topology/exportTopology.ts b/web/src/pages/topology/exportTopology.ts new file mode 100644 index 0000000..1746f6a --- /dev/null +++ b/web/src/pages/topology/exportTopology.ts @@ -0,0 +1,459 @@ +import { getStraightPath } from "@xyflow/react"; +import type { Edge, Node } from "@xyflow/react"; +import type { TopologyViewGraph, TopologyViewItem, TopologyViewNodeItem } from "../../types"; +import type { NeNodeData } from "./TopologyReactFlowView"; +import { isAggregateEdgeId, type LinkEdgeData } from "./linkDisplay"; +import { + TOPO_HANDLE_X, + TOPO_HANDLE_Y, + TOPO_ICON, + TOPO_NODE_W, + TOPO_REGION_HANDLE_X, + TOPO_REGION_HANDLE_Y, + TOPO_REGION_ICON_H, +} from "./topoGeometry"; +import type { VendorColors } from "./displayPrefs"; +import { vendorColorForNode } from "./vendorTone"; + +const NE_ICON_URL = "/topo/ne-router.png"; +const REGION_ICON_URL = "/topo/region-building.png"; + +export type TopologyVectorSvgTheme = { + labelColor: string; + edgeLabelColor: string; + vendorColors: VendorColors; + hideIp: boolean; + hideVendor: boolean; +}; + +type IconAssets = { + neTinted: Map; + regionPlain: string; +}; + +function sanitizeFilename(name: string): string { + const trimmed = String(name || "topology") + .trim() + .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") + .replace(/\s+/g, "_") + .slice(0, 80); + return trimmed || "topology"; +} + +function downloadBlob(filename: string, blob: Blob): void { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 2000); +} + +function downloadText(filename: string, text: string, mime: string): void { + downloadBlob(filename, new Blob([text], { type: mime })); +} + +function loadImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`failed to load icon: ${url}`)); + img.src = url; + }); +} + +/** Match canvas: `.topo-node__icon-art` + masked `.topo-node__icon-tint` (mix-blend-mode: color). */ +async function tintedIconDataUrl( + img: HTMLImageElement, + color: string, + w: number, + h: number, +): Promise { + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("canvas unsupported"); + + ctx.drawImage(img, 0, 0, w, h); + + const tint = document.createElement("canvas"); + tint.width = w; + tint.height = h; + const tctx = tint.getContext("2d"); + if (!tctx) throw new Error("canvas unsupported"); + tctx.fillStyle = color; + tctx.fillRect(0, 0, w, h); + tctx.globalCompositeOperation = "destination-in"; + tctx.drawImage(img, 0, 0, w, h); + + ctx.globalCompositeOperation = "color"; + ctx.drawImage(tint, 0, 0); + + return canvas.toDataURL("image/png"); +} + +async function plainIconDataUrl(img: HTMLImageElement, w: number, h: number): Promise { + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("canvas unsupported"); + ctx.drawImage(img, 0, 0, w, h); + return canvas.toDataURL("image/png"); +} + +async function prepareIconAssets( + nodes: Node[], + theme: TopologyVectorSvgTheme, +): Promise { + const [neImg, regionImg] = await Promise.all([loadImage(NE_ICON_URL), loadImage(REGION_ICON_URL)]); + const colors = new Set(); + for (const n of nodes) { + if (!isRegionNode(n)) colors.add(vendorColor(theme, n.data)); + } + const neTinted = new Map(); + await Promise.all( + [...colors].map(async (color) => { + neTinted.set(color, await tintedIconDataUrl(neImg, color, TOPO_ICON, TOPO_ICON)); + }), + ); + const regionPlain = await plainIconDataUrl(regionImg, TOPO_ICON, TOPO_REGION_ICON_H); + return { neTinted, regionPlain }; +} + +function isRegionNode(node: Node): boolean { + return node.data.kind === "region" || String(node.id || "").startsWith("region:"); +} + +function nodeApiPosition(node: Node): { x: number; y: number } { + const isRegion = isRegionNode(node); + const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X; + const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y; + return { x: node.position.x + hx, y: node.position.y + hy }; +} + +function nodeGlyphBox(node: Node): { x: number; y: number; w: number; h: number } { + const isRegion = isRegionNode(node); + const w = TOPO_ICON; + const h = isRegion ? TOPO_REGION_ICON_H : TOPO_ICON; + return { + x: node.position.x + (TOPO_NODE_W - w) / 2, + y: node.position.y, + w, + h, + }; +} + +function nodeHandleCenter(node: Node): { x: number; y: number } { + const box = nodeGlyphBox(node); + return { x: box.x + box.w / 2, y: box.y + box.h / 2 }; +} + +function vendorColor(theme: TopologyVectorSvgTheme, data: NeNodeData): string { + return vendorColorForNode(data, theme.vendorColors); +} + +function xmlEscape(value: string): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function xmlAttr(name: string, value: string | number | boolean | null | undefined): string { + if (value === null || value === undefined || value === "") return ""; + return ` ${name}="${xmlEscape(String(value))}"`; +} + +type Bounds = { minX: number; minY: number; maxX: number; maxY: number }; + +function growBounds(bounds: Bounds, x: number, y: number, pad = 0): void { + bounds.minX = Math.min(bounds.minX, x - pad); + bounds.minY = Math.min(bounds.minY, y - pad); + bounds.maxX = Math.max(bounds.maxX, x + pad); + bounds.maxY = Math.max(bounds.maxY, y + pad); +} + +function nodeCaptionLines(node: Node, theme: TopologyVectorSvgTheme): string[] { + const data = node.data; + const isRegion = isRegionNode(node); + const name = data.label || (!theme.hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE"); + const lines = [name]; + if (!isRegion) { + const meta = [ + theme.hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip, + theme.hideVendor || !data.vendor ? "" : data.vendor, + ].filter(Boolean); + if (meta.length) lines.push(meta.join(" / ")); + } + return lines; +} + +function computeGraphBounds( + nodes: Node[], + displayEdges: Edge[], + centers: Map, + theme: TopologyVectorSvgTheme, +): Bounds { + const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + for (const node of nodes) { + const box = nodeGlyphBox(node); + growBounds(bounds, box.x, box.y); + growBounds(bounds, box.x + box.w, box.y + box.h); + const lines = nodeCaptionLines(node, theme); + const captionY = box.y + box.h + 1; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + const approxWidth = Math.max(24, line.length * (i === 0 ? 5 : 4.2)); + const lineY = captionY + i * (i === 0 ? 10 : 8); + growBounds(bounds, box.x + box.w / 2 - approxWidth / 2, lineY); + growBounds(bounds, box.x + box.w / 2 + approxWidth / 2, lineY + (i === 0 ? 10 : 8)); + } + } + for (const edge of displayEdges) { + const a = centers.get(edge.source); + const b = centers.get(edge.target); + if (!a || !b) continue; + growBounds(bounds, a.x, a.y); + growBounds(bounds, b.x, b.y); + const label = String(edge.label || "").trim(); + if (label) { + const mx = (a.x + b.x) / 2; + const my = (a.y + b.y) / 2; + growBounds(bounds, mx, my, Math.max(20, label.length * 3)); + } + } + if (!Number.isFinite(bounds.minX)) { + return { minX: 0, minY: 0, maxX: 800, maxY: 600 }; + } + return bounds; +} + +function edgeVisual(edge: Edge): { stroke: string; strokeWidth: number; strokeDasharray?: string } { + const style = (edge.style || {}) as { + stroke?: string; + strokeWidth?: number; + strokeDasharray?: string; + }; + return { + stroke: String(style.stroke || "#64748b"), + strokeWidth: Number(style.strokeWidth || 2), + strokeDasharray: style.strokeDasharray ? String(style.strokeDasharray) : undefined, + }; +} + +function renderNodeIcon( + node: Node, + theme: TopologyVectorSvgTheme, + icons: IconAssets, +): string { + const box = nodeGlyphBox(node); + if (isRegionNode(node)) { + return ``; + } + const color = vendorColor(theme, node.data); + const href = icons.neTinted.get(color); + if (!href) return ""; + return ``; +} + +function renderNode(node: Node, theme: TopologyVectorSvgTheme, icons: IconAssets): string { + const box = nodeGlyphBox(node); + const centerX = box.x + box.w / 2; + const icon = renderNodeIcon(node, theme, icons); + const lines = nodeCaptionLines(node, theme); + const captionParts = lines.map((line, idx) => { + const y = box.y + box.h + 1 + (idx === 0 ? 8 : 8 + idx * 8); + const fontSize = idx === 0 ? 8 : 7; + const opacity = idx === 0 ? 1 : 0.72; + return `${xmlEscape(line)}`; + }); + return [ + ``, + icon, + ...captionParts, + ``, + ].join(""); +} + +function renderEdge( + edge: Edge, + centers: Map, + theme: TopologyVectorSvgTheme, +): string { + const a = centers.get(edge.source); + const b = centers.get(edge.target); + if (!a || !b) return ""; + const data = (edge.data || {}) as LinkEdgeData; + const index = Number(data.parallelIndex || 0); + const count = Math.max(1, Number(data.parallelCount || 1)); + const offset = (index - (count - 1) / 2) * 10; + const dx = b.x - a.x; + const dy = b.y - a.y; + const len = Math.hypot(dx, dy) || 1; + const ox = (-dy / len) * offset; + const oy = (dx / len) * offset; + const sx = a.x + ox; + const sy = a.y + oy; + const tx = b.x + ox; + const ty = b.y + oy; + const visual = edgeVisual(edge); + const dash = visual.strokeDasharray + ? ` stroke-dasharray="${xmlEscape(visual.strokeDasharray)}"` + : ""; + const [path, labelX, labelY] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: tx, targetY: ty }); + const label = String(edge.label || "").trim(); + const labelMarkup = label + ? `${xmlEscape(label)}` + : ""; + return [ + ``, + ``, + labelMarkup, + ``, + ].join(""); +} + +/** Build a vector SVG using the same PNG icons + vendor tint as the canvas. */ +export async function buildTopologyVectorSvg( + nodes: Node[], + displayEdges: Edge[], + theme: TopologyVectorSvgTheme, + title?: string, +): Promise { + const icons = await prepareIconAssets(nodes, theme); + const centers = new Map(nodes.map((n) => [n.id, nodeHandleCenter(n)] as const)); + const bounds = computeGraphBounds(nodes, displayEdges, centers, theme); + const pad = 48; + const minX = bounds.minX - pad; + const minY = bounds.minY - pad; + const width = bounds.maxX - bounds.minX + pad * 2; + const height = bounds.maxY - bounds.minY + pad * 2; + const edgeMarkup = displayEdges.map((e) => renderEdge(e, centers, theme)).join(""); + const nodeMarkup = nodes.map((n) => renderNode(n, theme, icons)).join(""); + const titleMarkup = title ? `${xmlEscape(title)}` : ""; + return [ + '', + ``, + titleMarkup, + `netx topology vector export`, + `${edgeMarkup}`, + `${nodeMarkup}`, + ``, + ].join("\n"); +} + +export function buildExportGraph( + view: TopologyViewItem, + nodes: Node[], + edges: Edge[], + serverGraph?: TopologyViewGraph | null, +): TopologyViewGraph { + const serverNodeMap = new Map( + (serverGraph?.nodes || []).map((n) => [n.fabric_node_id, n] as const), + ); + const serverEdgeMap = new Map((serverGraph?.edges || []).map((e) => [e.id, e] as const)); + + const exportNodes: TopologyViewNodeItem[] = nodes.map((n) => { + const pos = nodeApiPosition(n); + const server = serverNodeMap.get(n.id); + return { + fabric_node_id: n.id, + managed_ne_id: n.data.managed_ne_id || server?.managed_ne_id || "", + ume_ne_id: n.data.ume_ne_id || server?.ume_ne_id || "", + label: n.data.label || server?.label || "", + x: pos.x, + y: pos.y, + locked: server?.locked || false, + name: server?.name || n.data.label || "", + ip: n.data.ne_ip || server?.ip || "", + vendor: n.data.vendor || server?.vendor || "", + device_type: server?.device_type || (isRegionNode(n) ? "region" : ""), + connect_status: n.data.connect_status || server?.connect_status || "", + managed_source: n.data.managed_source || server?.managed_source, + kind: n.data.kind || server?.kind, + folder_id: n.data.folder_id || server?.folder_id, + view_id: n.data.view_id || server?.view_id, + node_count: n.data.node_count ?? server?.node_count, + }; + }); + + const physicalEdges = edges.filter((e) => !isAggregateEdgeId(String(e.id))); + const exportEdges = physicalEdges.map((e) => { + const data = (e.data || {}) as LinkEdgeData; + const server = serverEdgeMap.get(e.id); + return { + id: e.id, + a_node_id: e.source, + b_node_id: e.target, + a_port: data.source_port || server?.a_port || "", + b_port: data.target_port || server?.b_port || "", + source: data.source || server?.source || "manual", + status: server?.status || "active", + layer: server?.layer || "physical", + stroke_color: data.stroke_color || server?.stroke_color, + stroke_width: data.stroke_width || server?.stroke_width, + line_style: data.line_style || server?.line_style, + discovered_at: data.discovered_at ?? server?.discovered_at ?? null, + display_label: data.display_label || server?.display_label, + }; + }); + + return { + view, + nodes: exportNodes, + edges: exportEdges, + truncated: serverGraph?.truncated, + truncate_reason: serverGraph?.truncate_reason, + outside_peers: serverGraph?.outside_peers, + world_transform: serverGraph?.world_transform, + scatter: serverGraph?.scatter, + }; +} + +export function topologyGraphToXml(graph: TopologyViewGraph): string { + const view = graph.view; + const lines: string[] = [ + '', + ``, + " `, + ` `, + ]; + + for (const n of graph.nodes) { + lines.push( + ` `, + ); + } + lines.push(" "); + lines.push(` `); + + for (const e of graph.edges) { + lines.push( + ` `, + ); + } + lines.push(" "); + lines.push(""); + return lines.join("\n"); +} + +export async function exportTopologySvg( + nodes: Node[], + displayEdges: Edge[], + filenameBase: string, + theme: TopologyVectorSvgTheme, + title?: string, +): Promise { + const svg = await buildTopologyVectorSvg(nodes, displayEdges, theme, title); + downloadText(`${sanitizeFilename(filenameBase)}.svg`, svg, "image/svg+xml;charset=utf-8"); +} + +export function exportTopologyXml(graph: TopologyViewGraph, filenameBase: string): void { + const xml = topologyGraphToXml(graph); + downloadText(`${sanitizeFilename(filenameBase)}.xml`, xml, "application/xml;charset=utf-8"); +} diff --git a/web/src/pages/topology/graphFlow.ts b/web/src/pages/topology/graphFlow.ts new file mode 100644 index 0000000..4d5565c --- /dev/null +++ b/web/src/pages/topology/graphFlow.ts @@ -0,0 +1,135 @@ +import type { Edge, Node, Position } from "@xyflow/react"; +import type { TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem } from "../../types"; +import type { NeNodeData } from "./TopologyReactFlowView"; +import { formatPortPairLabel } from "./linkDisplay"; +import type { EdgeDefaults, EdgeStyleData } from "./edgeStyle"; +import { withEdgeVisual } from "./edgeStyle"; +import { + TOPO_NODE_H, + TOPO_NODE_W, + TOPO_REGION_HANDLE_X, + TOPO_REGION_HANDLE_Y, + TOPO_REGION_ICON_H, + TOPO_HANDLE_X, + TOPO_HANDLE_Y, + neApiPosition, + neFlowPosition, + regionApiPosition, + regionFlowPosition, +} from "./topoGeometry"; + +export 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; + 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, + 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 }; +} + +export 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 || "", + }; + }); +} + +export 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}`; +} + +export 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 }; +} diff --git a/web/src/pages/topology/hooks/useTopologyCanvas.ts b/web/src/pages/topology/hooks/useTopologyCanvas.ts new file mode 100644 index 0000000..0fd2d62 --- /dev/null +++ b/web/src/pages/topology/hooks/useTopologyCanvas.ts @@ -0,0 +1,560 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery, type QueryClient } from "@tanstack/react-query"; +import type { Edge, Node, ReactFlowInstance } from "@xyflow/react"; +import { fetchTopologyGraph } from "../../../services/api"; +import { queryKeys } from "../../../constants/queryKeys"; +import type { TopologyTreeViewItem, TopologyViewGraph, TopologyWorldTransform } from "../../../types"; +import { + FIT_VIEW_OPTS, + WORLD_LOCATE_HALF, + WORLD_LOCATE_ZOOM, + WORLD_MAP_ENABLED, +} from "../constants"; +import type { EdgeDefaults, EdgeStyleData } from "../edgeStyle"; +import { withEdgeVisual } from "../edgeStyle"; +import { graphFingerprint, graphToFlow } from "../graphFlow"; +import { + buildLinkDisplayEdges, + type LinkMember, +} from "../linkDisplay"; +import type { NeNodeData } from "../TopologyReactFlowView"; +import { TOPO_HANDLE_X, TOPO_HANDLE_Y } from "../topoGeometry"; +import type { ToolMode } from "../toolMode"; +import { + displayViewName, + isWorldFlatViewName, + worldDisplayBounds, + worldVisualLodFromZoom, +} from "../treeUtils"; +import { mergeFlatWorldGraph } from "../worldGraph"; +import type { HistorySnap } from "../pageTypes"; + +export type UseTopologyCanvasOptions = { + mapId: string; + treeFlatMap: boolean; + liveSync: boolean; + canvasMode: boolean; + activeView: TopologyTreeViewItem | null; + nodes: Node[]; + edges: Edge[]; + setNodes: React.Dispatch[]>>; + setEdges: React.Dispatch>; + edgeDefaults: EdgeDefaults; + toolMode: ToolMode; + hideIp: boolean; + hideVendor: boolean; + hidePorts: boolean; + showPlaceholderBadge: boolean; + expandPhysicalLinks: boolean; + scaleBundleWidth: boolean; + edgeFlow: boolean; + selectedEdgeId: string | null; + setSelectedEdgeId: React.Dispatch>; + setSearchHitIds: React.Dispatch>; + searchHitTimerRef: React.MutableRefObject; + queryClient: QueryClient; + dirtyRef: React.MutableRefObject; + historyLockRef: React.MutableRefObject; + historyRef: React.MutableRefObject; + redoRef: React.MutableRefObject; + pendingEdgeDeletesRef: React.MutableRefObject>; + pendingEdgeCreatesRef: React.MutableRefObject>; + clearDirty: () => void; + bumpHistory: () => void; + showError: (msg: string) => void; + t: (key: string) => string; +}; + +export function useTopologyCanvas(opts: UseTopologyCanvasOptions) { + const { + mapId, + treeFlatMap, + liveSync, + canvasMode, + activeView, + nodes, + edges, + setNodes, + setEdges, + edgeDefaults, + toolMode, + hideIp, + hideVendor, + hidePorts, + showPlaceholderBadge, + expandPhysicalLinks, + scaleBundleWidth, + edgeFlow, + selectedEdgeId, + setSelectedEdgeId, + setSearchHitIds, + searchHitTimerRef, + queryClient, + dirtyRef, + historyLockRef, + historyRef, + redoRef, + pendingEdgeDeletesRef, + pendingEdgeCreatesRef, + clearDirty, + bumpHistory, + showError, + t, + } = opts; + + const rfRef = useRef, Edge> | null>(null); + const canvasRef = useRef(null); + const appliedMapIdRef = useRef(""); + const appliedGraphFpRef = useRef(""); + const pendingFitRef = useRef(false); + const nodesRef = useRef[]>([]); + 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); + + nodesRef.current = nodes; + + const graphQuery = useQuery({ + queryKey: queryKeys.topologyGraph(mapId), + queryFn: () => + fetchTopologyGraph( + mapId, + WORLD_MAP_ENABLED && treeFlatMap ? { lod: "overview" } : undefined, + ), + 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, + 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 { + 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) { + 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, dirtyRef]); + + 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(() => { + if (!canvasMode) return; + void import("../TopologyReactFlowView"); + }, [canvasMode]); + + useEffect(() => { + appliedMapIdRef.current = ""; + appliedGraphFpRef.current = ""; + }, [mapId]); + + useEffect(() => { + if (!canvasMode) return; + if (!mapId || !graphQuery.data) return; + const entering = appliedMapIdRef.current !== mapId; + 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(); + 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, + dirtyRef, + historyLockRef, + historyRef, + redoRef, + pendingEdgeDeletesRef, + pendingEdgeCreatesRef, + ]); + + 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 { + inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 }); + } + setCanvasZoom(inst.getZoom()); + }; + const timer = window.setTimeout(run, 0); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [nodes, mapId, isWorldFlatCanvas, graphQuery.data?.world_transform]); + + useEffect(() => { + setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults))); + }, [edgeDefaults, setEdges]); + + 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, setSelectedEdgeId], + ); + + 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; + 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; + 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, dirtyRef, setSearchHitIds, searchHitTimerRef], + ); + + 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; + let displayY: number; + 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, setSearchHitIds, searchHitTimerRef], + ); + + 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 ""; + 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]); + + return { + rfRef, + canvasRef, + nodesRef, + appliedMapIdRef, + pendingFitRef, + isWorldFlatCanvasRef, + mapIdRef, + worldTransformRef, + graphQuery, + isWorldFlatCanvas, + canvasZoom, + setCanvasZoom, + worldVisualLod, + displayOpts, + displayEdges, + refreshFlatViewport, + scheduleFlatViewportRefresh, + fitCanvas, + focusNode, + locateNode, + graphTruncated, + truncateReason, + worldScatter, + worldTotal, + worldDockMe, + showWorldScatter, + canvasGraphLoading, + canvasGraphEmpty, + worldNeedsApply, + canvasGraphRefreshing, + truncateBannerText, + activeLeafName, + }; +} diff --git a/web/src/pages/topology/hooks/useTopologyDiscover.ts b/web/src/pages/topology/hooks/useTopologyDiscover.ts new file mode 100644 index 0000000..43168c7 --- /dev/null +++ b/web/src/pages/topology/hooks/useTopologyDiscover.ts @@ -0,0 +1,390 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { QueryClient } from "@tanstack/react-query"; +import type { Edge, Node } from "@xyflow/react"; +import { queryKeys } from "../../../constants/queryKeys"; +import { + addTopologyViewNodes, + createFabricManualEdge, + deleteFabricEdges, + fetchLldpDiscoverJob, + fetchTopologyGraph, + patchTopologyPositions, + projectTopologyNeighbors, + removeTopologyViewNodes, + startLldpDiscover, + stopLldpCollectJob, +} from "../../../services/api"; +import type { + TopologyDiscoverJob, + TopologyDiscoverNeResult, + TopologyDiscoverOut, + TopologyViewGraph, +} from "../../../types"; +import type { DiscoverProgress, DiscoverSummary } from "../TopologyDiscoverPanel"; +import type { EdgeDefaults, EdgeStyleData } from "../edgeStyle"; +import { flowToPositions, graphToFlow } from "../graphFlow"; +import { discoverResultKind, isLocalPendingEdgeId } from "../idUtils"; +import { layoutGraph } from "../layoutGraph"; +import type { NeNodeData } from "../TopologyReactFlowView"; + +export type UseTopologyDiscoverOptions = { + mapId: string; + nodes: Node[]; + edges: Edge[]; + graphNodes: TopologyViewGraph["nodes"] | undefined; + edgeDefaults: EdgeDefaults; + autoLayoutAfterDiscover: boolean; + discoverAutoAddUnmatched: boolean; + discoverProjectNeighbors: boolean; + queryClient: QueryClient; + setNodes: React.Dispatch[]>>; + setEdges: React.Dispatch>; + dirtyRef: React.MutableRefObject; + pendingEdgeDeletesRef: React.MutableRefObject>; + pendingEdgeCreatesRef: React.MutableRefObject>; + historyLockRef: React.MutableRefObject; + appliedMapIdRef: React.MutableRefObject; + markDirty: () => void; + clearDirty: () => void; + showOk: (msg: string) => void; + showError: (msg: string) => void; + t: (key: string) => string; +}; + +export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) { + const { + mapId, + nodes, + edges, + graphNodes, + edgeDefaults, + autoLayoutAfterDiscover, + discoverAutoAddUnmatched, + discoverProjectNeighbors, + queryClient, + setNodes, + setEdges, + dirtyRef, + pendingEdgeDeletesRef, + pendingEdgeCreatesRef, + historyLockRef, + appliedMapIdRef, + markDirty, + clearDirty, + showOk, + showError, + t, + } = opts; + + 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 flushDirtyCanvas = useCallback(async () => { + if (!mapId || !dirtyRef.current) return; + 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 = (graphNodes || []).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(); + }, [ + mapId, + edges, + graphNodes, + nodes, + dirtyRef, + pendingEdgeCreatesRef, + pendingEdgeDeletesRef, + clearDirty, + ]); + + const runDiscover = useCallback( + async (neIds?: string[]) => { + if (!mapId || discovering) return; + 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 { + await flushDirtyCanvas(); + if (!scoped.length) { + throw new Error(t("topology.discoverOneNeedNe")); + } + const jobStart = await startLldpDiscover({ + scope: "ne_ids", + ne_ids: filterIds, + 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"); + } + try { + job = await fetchLldpDiscoverJob(jobStart.id, { page: 1, pageSize: 100 }); + } catch { + /* keep last polled job */ + } + const baseGraph = await fetchTopologyGraph(mapId); + const projected = discoverProjectNeighbors + ? await projectTopologyNeighbors(mapId, { + seed_fabric_node_ids: scoped.map((n) => n.id), + dry_run: true, + }) + : baseGraph; + 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); + 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, + flushDirtyCanvas, + historyLockRef, + appliedMapIdRef, + ], + ); + + const cancelDiscover = useCallback(async () => { + discoverAbortRef.current = true; + const jobId = discoverJobId; + if (jobId) { + try { + await stopLldpCollectJob(jobId); + } catch { + /* best-effort */ + } + } + }, [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: 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; + + return { + discoverOpen, + setDiscoverOpen, + discovering, + discoverReport, + discoverProgress, + discoverError, + discoverResults, + discoverSummary, + discoverPct, + runDiscover, + cancelDiscover, + }; +} diff --git a/web/src/pages/topology/hooks/useTopologyHistory.ts b/web/src/pages/topology/hooks/useTopologyHistory.ts new file mode 100644 index 0000000..d57dcda --- /dev/null +++ b/web/src/pages/topology/hooks/useTopologyHistory.ts @@ -0,0 +1,108 @@ +import { useCallback, useRef, useState } from "react"; +import type { Edge, Node } from "@xyflow/react"; +import { UNDO_MAX } from "../constants"; +import type { HistorySnap } from "../pageTypes"; +import type { NeNodeData } from "../TopologyReactFlowView"; + +export function useTopologyHistory( + nodes: Node[], + edges: Edge[], + setNodes: React.Dispatch[]>>, + setEdges: React.Dispatch>, + t: (key: string) => string, +) { + const [dirty, setDirty] = useState(false); + const [historyTick, setHistoryTick] = useState(0); + const dirtyRef = useRef(false); + const historyRef = useRef([]); + const redoRef = useRef([]); + const historyLockRef = useRef(false); + const pendingEdgeDeletesRef = useRef>(new Set()); + const pendingEdgeCreatesRef = useRef>(new Set()); + + const canUndo = historyTick >= 0 && historyRef.current.length > 0; + const canRedo = historyTick >= 0 && redoRef.current.length > 0; + + 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 snapshot = useCallback( + (): HistorySnap => ({ + nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })), + edges: edges.map((e) => ({ ...e })), + pendingEdgeDeletes: [...pendingEdgeDeletesRef.current], + pendingEdgeCreates: [...pendingEdgeCreatesRef.current], + }), + [nodes, edges], + ); + + const pushHistory = useCallback(() => { + if (historyLockRef.current) return; + historyRef.current = [...historyRef.current.slice(-(UNDO_MAX - 1)), snapshot()]; + redoRef.current = []; + bumpHistory(); + }, [snapshot, bumpHistory]); + + const undo = useCallback(() => { + const prev = historyRef.current.pop(); + if (!prev) return; + redoRef.current.push(snapshot()); + 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; + }, [snapshot, setNodes, setEdges, markDirty, bumpHistory]); + + const redo = useCallback(() => { + const next = redoRef.current.pop(); + if (!next) return; + historyRef.current.push(snapshot()); + 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; + }, [snapshot, setNodes, setEdges, markDirty, bumpHistory]); + + return { + dirty, + canUndo, + canRedo, + dirtyRef, + historyRef, + redoRef, + historyLockRef, + pendingEdgeDeletesRef, + pendingEdgeCreatesRef, + markDirty, + clearDirty, + bumpHistory, + confirmDiscardIfDirty, + pushHistory, + undo, + redo, + }; +} diff --git a/web/src/pages/topology/hooks/useTopologyTreeNav.ts b/web/src/pages/topology/hooks/useTopologyTreeNav.ts new file mode 100644 index 0000000..7f9db2e --- /dev/null +++ b/web/src/pages/topology/hooks/useTopologyTreeNav.ts @@ -0,0 +1,670 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, type QueryClient } from "@tanstack/react-query"; +import type { Edge, Node } from "@xyflow/react"; +import type { SetURLSearchParams } from "react-router-dom"; +import { queryKeys } from "../../../constants/queryKeys"; +import { + createTopologyFolder, + deleteTopologyFolder, + deleteTopologyMap, + fetchTopologyTree, + fetchTopologyWorld, + searchFabricNodes, + updateTopologyFolder, + updateTopologyMap, +} from "../../../services/api"; +import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../../types"; +import { LAST_LEAF_KEY, TREE_EXPAND_KEY, WORLD_MAP_ENABLED } from "../constants"; +import type { CtxMenu } from "../pageTypes"; +import { + findFolderInTree, + findViewInRegion, + folderPathFolders, + folderPathIds, + isRegionCanvasFolder, + isUmeWorldContainer, + isWorldDrillFolder, + isWorldFlatViewName, + regionDisplayName, +} from "../treeUtils"; +import type { NeNodeData } from "../TopologyReactFlowView"; + +export type UseTopologyTreeNavOptions = { + liveSync: boolean; + searchParams: URLSearchParams; + setSearchParams: SetURLSearchParams; + locale: string; + queryClient: QueryClient; + confirmDiscardIfDirty: () => boolean; + clearDirty: () => void; + showOk: (msg: string) => void; + showError: (msg: string) => void; + t: (key: string) => string; + setNodes: React.Dispatch[]>>; + setEdges: React.Dispatch>; + setCtxMenu: React.Dispatch>; + activeViewName?: string; +}; + +export function useTopologyTreeNav(opts: UseTopologyTreeNavOptions) { + const { + liveSync, + searchParams, + setSearchParams, + locale, + queryClient, + confirmDiscardIfDirty, + clearDirty, + showOk, + showError, + t, + setNodes, + setEdges, + setCtxMenu, + activeViewName, + } = opts; + + const [mapId, setMapId] = useState(""); + const [worldFocusFolderId, setWorldFocusFolderId] = useState(""); + const [worldViewId, setWorldViewId] = useState(""); + const [selectedFolderId, setSelectedFolderId] = useState(""); + const [hotBrowseKey, setHotBrowseKey] = useState(""); + const [pendingHighlightNe, setPendingHighlightNe] = useState(""); + const [expandedIds, setExpandedIds] = useState>(() => { + try { + const raw = localStorage.getItem(TREE_EXPAND_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } + }); + const [treeNeQuery, setTreeNeQuery] = useState(""); + const [debouncedTreeNeQuery, setDebouncedTreeNeQuery] = useState(""); + const [treeSearchOpen, setTreeSearchOpen] = useState(false); + const treeSearchRef = useRef(null); + const [newRootDialog, setNewRootDialog] = useState<{ name: string } | null>(null); + + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedTreeNeQuery(treeNeQuery.trim()), 200); + return () => window.clearTimeout(timer); + }, [treeNeQuery]); + + 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]); + + const treeNeSearchQuery = useQuery({ + queryKey: queryKeys.fabricNodeSearch(debouncedTreeNeQuery, 1), + queryFn: () => searchFabricNodes({ q: debouncedTreeNeQuery, page: 1, pageSize: 30 }), + enabled: debouncedTreeNeQuery.length >= 1, + }); + + const treeRoot = treeQuery.data?.root || null; + const regions = useMemo(() => treeRoot?.children || [], [treeRoot]); + const treeLoading = treeQuery.isPending && !treeQuery.data; + const treeFailed = treeQuery.isError && !treeQuery.data; + const rootFolderId = String(treeRoot?.id || "").trim(); + const canvasModeLocal = Boolean(mapId); + + 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; + } + if (selectedFolderId && !regions.some((r) => r.id === selectedFolderId)) { + setSelectedFolderId(""); + } + }, [mapId, selectedFolderId, treeRoot, regions]); + + 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[] => { + if ( + !activeRegion || + isRegionCanvasFolder(activeRegion, rootFolderId) || + isUmeWorldContainer(activeRegion) + ) { + return []; + } + return activeRegion.views || []; + }, [activeRegion, rootFolderId]); + + const hexBrowseRegion = useMemo(() => { + 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]); + + 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 */ + }); + return () => { + cancelled = true; + }; + }, []); + + 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]); + + 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]); + + 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(() => { + 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 (!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]); + + 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 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; + } + 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; + } + const physical = views.find((v) => String(v.kind) === "physical") || null; + return physical || views[0] || null; + }, []); + + 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]); + + const goRegion = useCallback( + (folderId: string) => { + if (!confirmDiscardIfDirty()) return; + setSelectedFolderId(folderId); + expandFolderPath(folderId); + const folder = findFolderInTree(regions, folderId); + if (isRegionCanvasFolder(folder, String(treeRoot?.id || ""))) { + setWorldFocusFolderId(""); + const view = primaryViewOfFolder(folder); + if (view) { + setMapId(view.id); + clearDirty(); + return; + } + } + 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; + 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, + ]); + + 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(); + 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) { + 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 { + 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 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(() => { + const viewName = activeView?.name || activeViewName; + if (isWorldFlatViewName(viewName)) { + 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, + activeViewName, + selectedFolderId, + worldViewId, + mapId, + regions, + createRegionMut, + t, + showError, + setCtxMenu, + ]); + + const onTreeToggleExpand = useCallback((folderId: string, nextOpen: boolean) => { + setExpandedIds((p) => ({ ...p, [folderId]: nextOpen })); + }, []); + + const onTreeHotBrowseKey = useCallback((key: string) => { + setHotBrowseKey(key); + }, []); + + const onTreeClearHotBrowseKey = useCallback((key: string) => { + setHotBrowseKey((k) => (k === key ? "" : k)); + }, []); + + const onTreeDeleteFolder = useCallback( + (folderId: string) => { + deleteFolderMut.mutate(folderId); + }, + [deleteFolderMut], + ); + + const onTreeDeleteMap = useCallback( + (viewId: string) => { + deleteMapMut.mutate(viewId); + }, + [deleteMapMut], + ); + + const breadcrumbFolders = useMemo( + () => folderPathFolders(regions, selectedFolderId), + [regions, selectedFolderId], + ); + + const titleText = useMemo(() => { + if (activeRegion) return regionDisplayName(activeRegion, t); + return t("topology.rootName"); + }, [activeRegion, t]); + + return { + mapId, + setMapId, + worldFocusFolderId, + setWorldFocusFolderId, + worldViewId, + selectedFolderId, + setSelectedFolderId, + hotBrowseKey, + setHotBrowseKey, + expandedIds, + setExpandedIds, + treeNeQuery, + setTreeNeQuery, + debouncedTreeNeQuery, + setDebouncedTreeNeQuery, + treeSearchOpen, + setTreeSearchOpen, + treeSearchRef, + newRootDialog, + setNewRootDialog, + treeQuery, + treeFlatMap, + treeNeSearchQuery, + treeRoot, + regions, + treeLoading, + treeFailed, + rootFolderId, + canvasMode: canvasModeLocal, + activeRegion, + activeView, + browseEntries, + hexBrowseRegion, + umeWorldHexModules, + expandFolderPath, + primaryViewOfFolder, + goUmeWorldNav, + goRegion, + goRoot, + goCanvas, + goBackBrowse, + renameMapMut, + promptRenameMap, + createRegionMut, + renameRegionMut, + promptRenameRegion, + deleteMapMut, + deleteFolderMut, + promptNewRegion, + submitNewRoot, + promptNewSubRegion, + onTreeToggleExpand, + onTreeHotBrowseKey, + onTreeClearHotBrowseKey, + onTreeDeleteFolder, + onTreeDeleteMap, + breadcrumbFolders, + titleText, + pendingHighlightNe, + setPendingHighlightNe, + }; +} diff --git a/web/src/pages/topology/idUtils.ts b/web/src/pages/topology/idUtils.ts new file mode 100644 index 0000000..3ecf12a --- /dev/null +++ b/web/src/pages/topology/idUtils.ts @@ -0,0 +1,23 @@ +import type { TopologyDiscoverNeResult } from "../../types"; + +export 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)}`; +} + +export function newLocalEdgeId(): string { + return `local:${newId()}`; +} + +export function isLocalPendingEdgeId(id: string): boolean { + return String(id || "").startsWith("local:"); +} + +export 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"; +} diff --git a/web/src/pages/topology/pageTypes.ts b/web/src/pages/topology/pageTypes.ts new file mode 100644 index 0000000..7453a38 --- /dev/null +++ b/web/src/pages/topology/pageTypes.ts @@ -0,0 +1,29 @@ +import type { Edge, Node } from "@xyflow/react"; +import type { NeNodeData } from "./TopologyReactFlowView"; + +export type HistorySnap = { + nodes: Node[]; + edges: Edge[]; + pendingEdgeDeletes: string[]; + pendingEdgeCreates: string[]; +}; + +export type PaletteSource = "managed" | "ume"; + +export 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; +}; + +export 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 }; diff --git a/web/src/pages/topology/searchUtils.ts b/web/src/pages/topology/searchUtils.ts new file mode 100644 index 0000000..f3b4c12 --- /dev/null +++ b/web/src/pages/topology/searchUtils.ts @@ -0,0 +1,40 @@ +import type { Node } from "@xyflow/react"; +import type { NeNodeData } from "./TopologyReactFlowView"; + +export function normalizeSearchText(s: string): string { + return String(s || "") + .toLowerCase() + .replace(/[\s_\-./]+/g, ""); +} + +/** Case-insensitive substring + loose subsequence (e.g. r1 → router-1). */ +export 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; +} + +export 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))); +} + +export 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); +} diff --git a/web/src/pages/topology/sidebarLayout.ts b/web/src/pages/topology/sidebarLayout.ts new file mode 100644 index 0000000..9dcb4fa --- /dev/null +++ b/web/src/pages/topology/sidebarLayout.ts @@ -0,0 +1,35 @@ +import { + SIDEBAR_WIDTH_DEFAULT, + SIDEBAR_WIDTH_KEY, + SIDEBAR_WIDTH_MAX, + SIDEBAR_WIDTH_MIN, +} from "./constants"; + +export 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; +} + +export function saveSidebarWidth(width: number): void { + try { + localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(width))); + } catch { + /* ignore */ + } +} + +export 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))); +} diff --git a/web/src/pages/topology/topoGeometry.ts b/web/src/pages/topology/topoGeometry.ts new file mode 100644 index 0000000..0110716 --- /dev/null +++ b/web/src/pages/topology/topoGeometry.ts @@ -0,0 +1,49 @@ +/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */ +export const TOPO_NODE_W = 80; +export const TOPO_NODE_H = 25; +export const TOPO_ICON = 25; +/** region-building.png is 195×133 — keep aspect so icon center == handle. */ +export const TOPO_REGION_ICON_H = Math.round((TOPO_ICON * 133) / 195); +export const TOPO_HANDLE_X = TOPO_NODE_W / 2; +export const TOPO_HANDLE_Y = TOPO_ICON / 2; +export const TOPO_REGION_HANDLE_X = TOPO_NODE_W / 2; +export 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. */ +export function iconFlowPosition( + apiX: number, + apiY: number, + handleX: number, + handleY: number, +): { x: number; y: number } { + return { x: apiX - handleX, y: apiY - handleY }; +} + +export function iconApiPosition( + flowX: number, + flowY: number, + handleX: number, + handleY: number, +): { x: number; y: number } { + return { x: flowX + handleX, y: flowY + handleY }; +} + +export function regionFlowPosition(apiX: number, apiY: number): { x: number; y: number } { + return iconFlowPosition(apiX, apiY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y); +} + +export function regionApiPosition(flowX: number, flowY: number): { x: number; y: number } { + return iconApiPosition(flowX, flowY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y); +} + +export function neFlowPosition(apiX: number, apiY: number): { x: number; y: number } { + return iconFlowPosition(apiX, apiY, TOPO_HANDLE_X, TOPO_HANDLE_Y); +} + +export function neApiPosition(flowX: number, flowY: number): { x: number; y: number } { + return iconApiPosition(flowX, flowY, TOPO_HANDLE_X, TOPO_HANDLE_Y); +} + +export function isRegionFabricId(id: string): boolean { + return String(id || "").startsWith("region:"); +} diff --git a/web/src/pages/topology/topologyIcons.tsx b/web/src/pages/topology/topologyIcons.tsx new file mode 100644 index 0000000..d4c67a4 --- /dev/null +++ b/web/src/pages/topology/topologyIcons.tsx @@ -0,0 +1,120 @@ +export function ChevronIcon({ open }: { open: boolean }) { + return ( + + ); +} + +export function SidebarFoldIcon({ expand }: { expand?: boolean }) { + return ( + + ); +} + +export function PencilIcon() { + return ( + + ); +} + +export function PlusIcon() { + return ( + + ); +} + +export function CloseIcon() { + return ( + + ); +} + +export function RegionGlyph({ size = 16 }: { size?: number }) { + return ( + + ); +} + +export function LayerGlyph({ role, size = 16 }: { role?: string; size?: number }) { + const r = String(role || "core").toLowerCase(); + if (r === "aggregation") { + return ( + + ); + } + if (r === "access") { + return ( + + ); + } + return ( + + ); +} diff --git a/web/src/pages/topology/treeUtils.ts b/web/src/pages/topology/treeUtils.ts new file mode 100644 index 0000000..8d925ed --- /dev/null +++ b/web/src/pages/topology/treeUtils.ts @@ -0,0 +1,227 @@ +import type { CSSProperties } from "react"; +import type { TopologyTreeFolderItem, TopologyTreeViewItem, TopologyWorldTransform } from "../../types"; +import { WORLD_MAP_ENABLED } from "./constants"; + +export { WORLD_MAP_ENABLED }; + +export 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; +} + +export 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; +} + +export 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; +} + +export 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(); + if (!folder || !parent || !byId.has(parent)) break; + cur = parent; + } + return path; +} + +export 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(); +} + +export 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"; +} + +export 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); +} + +export 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)); +} + +export function isWorldFlatViewName(name: string | undefined | null): boolean { + const n = String(name || "").trim(); + return n === "世界地图" || n === "完整世界地图" || n === "World map"; +} + +export function isManualRootMapName(name: string | undefined | null): boolean { + const n = String(name || "").trim(); + return n === "根图" || n === "Root map"; +} + +export 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(); +} + +export 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"; +} + +export function isManualRootMapFolder(folder: TopologyTreeFolderItem | null | undefined): boolean { + if (!folder || isUmeStructuralFolder(folder)) return false; + return Boolean(folder.is_system) && !String(folder.external_ref || "").trim(); +} + +export function isUmeSyncedSubRegion(folder: TopologyTreeFolderItem | null | undefined): boolean { + if (!folder || isUmeStructuralFolder(folder)) return false; + return Boolean(String(folder.external_ref || "").trim()); +} + +export function worldVisualLodFromZoom(zoom: number): "dot" | "pin" | "full" { + if (zoom < 0.04) return "dot"; + if (zoom < 0.12) return "pin"; + return "full"; +} + +export 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 }; +} + +export 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; +} + +export 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; +} + +export function folderNeCount(folder: TopologyTreeFolderItem | null | undefined): number { + if (!folder) return 0; + return Math.max(0, Math.floor(Number(folder.ne_count) || 0)); +} + +export function formatNeCount(count: number): string { + const n = Math.max(0, Math.floor(Number(count) || 0)); + return `${n}N`; +} + +export 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; +} + +export 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; +} diff --git a/web/src/pages/topology/vendorTone.ts b/web/src/pages/topology/vendorTone.ts new file mode 100644 index 0000000..694cf15 --- /dev/null +++ b/web/src/pages/topology/vendorTone.ts @@ -0,0 +1,35 @@ +import type { NeNodeData } from "./TopologyReactFlowView"; +import { VENDOR_TONE_KEYS, type VendorColors, type VendorToneKey } from "./displayPrefs"; + +export type { VendorToneKey, VendorColors }; + +export function nodeIconTone( + vendor: string, + managedNeId: string, + umeNeId = "", +): VendorToneKey { + const hasManaged = Boolean(String(managedNeId || "").trim()); + const hasUme = Boolean(String(umeNeId || "").trim()); + if (!hasManaged && !hasUme) return "gray"; + const v = String(vendor || "").trim().toLowerCase(); + if (!v || v === "other" || v === "unknown" || v === "generic") { + return hasUme ? "zte" : "gray"; + } + if (v.includes("cisco")) return "cisco"; + if (v.includes("huawei")) return "huawei"; + if (v.includes("zte")) return "zte"; + if (v.includes("juniper")) return "juniper"; + if (v.includes("nokia") || v.includes("alcatel")) return "nokia"; + if (v.includes("ericsson")) return "ericsson"; + if (v.includes("h3c") || v.includes("comware")) return "h3c"; + if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie"; + if (v.includes("mikrotik")) return "mikrotik"; + return hasUme ? "zte" : "gray"; +} + +export function vendorColorForNode(data: NeNodeData, colors: VendorColors): string { + if (data.kind === "region") return "#38bdf8"; + return colors[nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id)] || colors.gray; +} + +export { VENDOR_TONE_KEYS }; diff --git a/web/src/pages/topology/worldGraph.ts b/web/src/pages/topology/worldGraph.ts new file mode 100644 index 0000000..8ec3857 --- /dev/null +++ b/web/src/pages/topology/worldGraph.ts @@ -0,0 +1,73 @@ +import type { + TopologyViewEdgeItem, + TopologyViewGraph, + TopologyViewNodeItem, +} from "../../types"; +import { WORLD_FLAT_ACCUM_CAP } from "./constants"; + +/** Merge newly fetched flat LOD into the existing cache so visited areas stay. */ +export 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; + } + 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, + }; +}