import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, } from "react"; import { useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ReactFlow, Background, BackgroundVariant, Controls, ControlButton, MiniMap, addEdge, useEdgesState, useNodesState, Handle, Position, ConnectionMode, SelectionMode, type Connection, type Edge, type Node, type NodeProps, type ReactFlowInstance, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { addTopologyViewNodes, createFabricManualEdge, createTopologyFolder, createTopologyView, deleteTopologyFolder, deleteTopologyMap, fetchLldpDiscoverJob, fetchManagedNe, fetchManagedNeById, fetchTopologyGraph, fetchTopologyTree, fetchUmeNe, patchTopologyEdgeStyle, patchTopologyPositions, projectTopologyNeighbors, removeTopologyViewNodes, searchFabricNodes, startLldpDiscover, updateTopologyFolder, updateTopologyMap, } 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, TopologyEdgeItem, TopologyNodeItem, TopologyTreeFolderItem, TopologyTreeViewItem, TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem, TopologyViewKind, TopologyViewRole, } from "../types"; import { alignNodes, layoutGraph, type LayoutKind } from "./topology/layoutGraph"; import { ParallelEdge } from "./topology/ParallelEdge"; import { buildLinkDisplayEdges, formatPortPairLabel, isAggregateEdgeId, physicalIdsForDisplayEdge, type LinkEdgeData, type LinkMember, } from "./topology/linkDisplay"; import { behaviorForMode, toolModeFromKey, type ToolMode } from "./topology/toolMode"; const LAST_LEAF_KEY = "netx.topology.lastLeafViewId"; const TREE_EXPAND_KEY = "netx.topology.treeExpanded"; function findViewInRegion( regions: TopologyTreeFolderItem[], viewId: string, ): { region: TopologyTreeFolderItem; view: TopologyTreeViewItem } | null { for (const region of regions) { const view = (region.views || []).find((v) => v.id === viewId); if (view) return { region, view }; } return null; } function regionDisplayName(region: TopologyTreeFolderItem | null | undefined): string { if (!region) return ""; return region.name || ""; } function formatUpdatedAt(value?: string | null): string { if (!value) return ""; try { return new Date(value).toLocaleString(); } catch { return String(value); } } const SNAP_GRID: [number, number] = [16, 16]; /** * onlyRenderVisibleElements leaves off-screen nodes unmeasured; default fitView * skips those. includeHiddenNodes uses declared width/height so fit still works. */ const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const; const FIT_VIEW_OPTS_FS = { padding: 0.15, includeHiddenNodes: true } as const; const UNDO_MAX = 40; const PALETTE_DND = "application/x-netx-topo-palette"; function FullscreenIcon({ exit }: { exit?: boolean }) { if (exit) { return ( ); } return ( ); } /** ASCII-safe separators ? avoid Unicode middots that corrupt on some editors. */ const SEP = " / "; /** Left-panel collapse / expand control (simple chevron). */ function SidebarFoldIcon({ expand }: { expand?: boolean }) { return ( ); } function PencilIcon() { return ( ); } function PlusIcon() { return ( ); } function CloseIcon() { return ( ); } function RegionGlyph({ size = 16 }: { size?: number }) { return ( ); } function LayerGlyph({ role, size = 16 }: { role?: string; size?: number }) { const r = String(role || "core").toLowerCase(); if (r === "aggregation") { return ( ); } if (r === "access") { return ( ); } return ( ); } type NeNodeData = { label: string; managed_ne_id: string; ume_ne_id: string; ne_ip: string; vendor: string; connect_status: string; /** Canvas navigation node (not a fabric NE). */ kind?: "ne" | "region" | "layer"; folder_id?: string; view_id?: string; role?: TopologyViewRole | string; subtitle?: string; node_count?: number; }; type HistorySnap = { nodes: Node[]; edges: Edge[] }; type PaletteSource = "managed" | "ume"; type PaletteItem = { key: string; source: PaletteSource; managed_ne_id: string; ume_ne_id: string; name: string; ip: string; vendor: string; meta: string; connect_status: string; }; type TopoDisplayOpts = { hideIp: boolean; hideVendor: boolean; hidePorts: boolean; connectMode: boolean; }; type CtxMenu = | { kind: "node"; id: string; x: number; y: number } | { kind: "edge"; id: string; x: number; y: number } | { kind: "selection"; x: number; y: number }; const TopoDisplayContext = createContext({ hideIp: true, hideVendor: true, hidePorts: true, connectMode: false, }); 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)}`; } /** * Icon tone for /topo/ne-router.png (base = blue). * Unmanaged (no managed/ume) and unknown vendors → gray. * UME-linked NEs default to ZTE when vendor is empty/unknown. */ function nodeIconTone(vendor: string, managedNeId: string, umeNeId = ""): string { const hasManaged = Boolean(String(managedNeId || "").trim()); const hasUme = Boolean(String(umeNeId || "").trim()); if (!hasManaged && !hasUme) return "gray"; const v = String(vendor || "").trim().toLowerCase(); if (!v || v === "other" || v === "unknown" || v === "generic") { return hasUme ? "zte" : "gray"; } if (v.includes("cisco")) return "cisco"; if (v.includes("huawei")) return "huawei"; if (v.includes("zte")) return "zte"; if (v.includes("juniper")) return "juniper"; if (v.includes("nokia") || v.includes("alcatel")) return "nokia"; if (v.includes("ericsson")) return "ericsson"; if (v.includes("h3c") || v.includes("comware")) return "h3c"; if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie"; if (v.includes("mikrotik")) return "mikrotik"; return hasUme ? "zte" : "gray"; } function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" { if (!r.ok) return "fail"; const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0); if (r.parser_stub || unmatchedCount > 0) return "warn"; return "ok"; } function normalizeSearchText(s: string): string { return String(s || "") .toLowerCase() .replace(/[\s_\-./]+/g, ""); } /** Case-insensitive substring + loose subsequence (e.g. r1 ? router-1). */ function fuzzyIncludes(haystack: string, needle: string): boolean { const h = normalizeSearchText(haystack); const n = normalizeSearchText(needle); if (!n) return true; if (!h) return false; if (h.includes(n)) return true; let i = 0; for (const ch of h) { if (ch === n[i]) i += 1; if (i >= n.length) return true; } return false; } function nodeMatchesQuery(n: Node, query: string): boolean { const tokens = String(query || "") .trim() .toLowerCase() .split(/\s+/) .filter(Boolean); if (!tokens.length) return false; const bits = [n.data.label, n.data.ne_ip, n.data.vendor, n.data.managed_ne_id, n.data.ume_ne_id]; return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok))); } /** * Keep the PNG’s white texture/highlights; tint brand color via mix-blend-mode. * Color comes from --topo-icon-color (vendor palette in Display settings). */ function RouterIcon() { return (