"use client" import { OspfDetailList } from "@/components/ospf-detail-list" import { OspfTopologyScope } from "@/components/ospf-topology-scope" import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion" import { deriveOspfRouterIdentity } from "@/lib/ospf-router-identity" import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost" import { normalizeOspfState, normalizeBfdState, ospfNodeState, ospfBfdConflicts, type OspfNeighborState, type BfdState } from "@/lib/ospf-state" import { useMemo, useState, useEffect, useRef, Suspense } from "react" import { useSearchParams } from "next/navigation" import { readOspfUrl, patchOspfUrl, type OspfTab } from "@/lib/ospf-url-state" import { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { DataPageToolbar } from "@/components/data-page-toolbar" import { PageHeader } from "@/components/page-header" import { DataPageCard } from "@/components/data-page-card" import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid" import { OspfRoutesDataGrid, OspfRouteTypeBadge } from "@/components/data-grids/ospf-routes-data-grid" import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { can } from "@/lib/auth" import { toast } from "sonner" import { RefreshCwIcon, WandSparklesIcon, GripVerticalIcon, NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon, LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon, } from "lucide-react" import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data" // ─── types ──────────────────────────────────────────────────────────────────── interface OspfServerInfo { id: string // String(serverId) in live mode, routerKey in mock label: string // serverName / routerLabel site: string // "MSK", "SPB", … country: string // ISO alpha-2 or "" } interface OspfItem { key: string routerKey: string routerLabel: string serverId: string interfaceName: string area: string cost: number | null active: boolean | null dataUnknown?: boolean type?: string | null useBfd?: boolean | null helloInterval?: number | null deadInterval?: number | null } interface OspfNeighbor { neighborAddress?: string id: string localRouter: string localLabel: string localIface: string remoteRouter: string remoteLabel: string remoteRouterId: string area: string state: OspfNeighborState cost: number | null uptime: string priority: number | null dataUnknown?: boolean } interface OspfRoute { id: string destination: string type: "O" | "O IA" | "O E1" | "O E2" | "Unknown" cost: number | null nextHop: string via: string serverId: string serverLabel: string area: string dataUnknown?: boolean } interface BfdSession { id: string serverId: string serverLabel: string localAddr: string remoteAddr: string state: BfdState interval: number | null // txInterval ms multiplier: number | null iface: string uptime: string | null multihop: boolean | null rxInterval: number | null // ms holdTime: number | null // ms packetsRx: number | null packetsTx: number | null stateChanges: number | null dataUnknown?: boolean } // ─── backend types ──────────────────────────────────────────────────────────── type BackendNeighbor = OspfCatalog["neighbors"][number] type BackendInterface = OspfCatalog["interfaces"][number] type BackendBfdSession = OspfCatalog["bfdSessions"][number] type BackendOspfRoute = OspfCatalog["routes"][number] function isRefInterfaceName(name: string): boolean { return /^\(ref\s+\*.+\)$/.test(name.trim()) } // ─── backend → frontend mappers ─────────────────────────────────────────────── function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map, serverByRouterId: Record, labels: Record): OspfNeighbor { const state = normalizeOspfState(b.state) const costKey = ospfCostKey(b.serverId, b.interface, b.areaId) return { id: `${b.serverId}-${b.id}`, localRouter: String(b.serverId), localLabel: b.serverName, localIface: b.interface, remoteRouter: serverByRouterId[b.routerId] ?? b.routerId, remoteLabel: labels[serverByRouterId[b.routerId]] ?? b.routerId, remoteRouterId: b.routerId, neighborAddress: b.address, area: b.areaId !== b.area ? `${b.area} (${b.areaId})` : b.area, state, cost: ifaceMap.get(costKey) ?? null, uptime: b.uptime ?? "—", priority: b.priority, } } function backendToItem(b: BackendInterface): OspfItem { return { key: `${b.serverId}-${b.id}`, routerKey: String(b.serverId), routerLabel: b.serverName, serverId: String(b.serverId), interfaceName: b.interface, area: b.areaId, cost: b.cost, active: b.disabled === true || b.inactive === true ? false : b.disabled === false && b.inactive === false ? true : null, type: b.type, useBfd: b.useBfd, helloInterval: b.helloInterval, deadInterval: b.deadInterval, } } function backendToBfdSession(b: BackendBfdSession): BfdSession { const state = normalizeBfdState(b.state) return { id: `${b.serverId}-${b.id}`, serverId: String(b.serverId), serverLabel: b.serverName, localAddr: b.localAddr, remoteAddr: b.remoteAddr, state, interval: b.txInterval, multiplier: b.multiplier, iface: b.interface, uptime: b.uptime, multihop: b.multihop, rxInterval: b.rxInterval, holdTime: b.holdTime, packetsRx: b.packetsRx, packetsTx: b.packetsTx, stateChanges: b.stateChanges, } } function backendToRoute(b: BackendOspfRoute): OspfRoute { const allowed: OspfRoute["type"][] = ["O", "O IA", "O E1", "O E2", "Unknown"] const type = allowed.includes(b.type) ? b.type : "Unknown" return { id: `${b.serverId}-${b.id}`, destination: b.destination, type, cost: b.cost, nextHop: b.nextHop, via: b.via, serverId: String(b.serverId), serverLabel: b.serverName, area: b.area || "—", } } // ─── mock data ──────────────────────────────────────────────────────────────── const COST_STEP = 10 const OPTIMIZER_PROB: Record = { "srv1::GRE-MSK-SPB": 68, "srv1::GRE-MSK-FRA": 18, "srv1::GRE-MSK-AMS": 10, "srv1::GRE-MSK-SGP": 4, "srv1::ETHER1-WAN": 100, "srv1::ETHER2-LAN": 100, "srv7::GRE-LAB-SPB": 41, "srv7::GRE-LAB-CORE": 59, "srv7::ETHER1-UPLINK":100, "srv6::GRE-AMS-CORE": 68, "srv6::GRE-AMS-FRA": 32, } const MOCK_ITEMS: OspfItem[] = [ { key: "srv1-bb-gre-msk-spb", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-spb", area: "0.0.0.0", cost: 10, active: true }, { key: "srv1-bb-gre-msk-fra", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-fra", area: "0.0.0.0", cost: 20, active: true }, { key: "srv1-bb-gre-msk-ams", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-ams", area: "0.0.0.0", cost: 30, active: true }, { key: "srv1-bb-gre-msk-sgp", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-sgp", area: "0.0.0.0", cost: 40, active: false }, { key: "srv1-a1-ether1", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "ether1-wan", area: "0.0.0.1", cost: 10, active: true }, { key: "srv1-a1-ether2", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "ether2-lan", area: "0.0.0.1", cost: 20, active: true }, { key: "srv7-bb-gre-lab-core", routerKey: "srv7", routerLabel: "mt-msk-lab-01", serverId: "srv7", interfaceName: "gre-lab-core", area: "0.0.0.0", cost: 10, active: true }, { key: "srv7-bb-gre-lab-spb", routerKey: "srv7", routerLabel: "mt-msk-lab-01", serverId: "srv7", interfaceName: "gre-lab-spb", area: "0.0.0.0", cost: 20, active: true }, { key: "srv7-bb-ether1", routerKey: "srv7", routerLabel: "mt-msk-lab-01", serverId: "srv7", interfaceName: "ether1-uplink", area: "0.0.0.0", cost: 30, active: true }, { key: "srv6-bb-gre-ams-core", routerKey: "srv6", routerLabel: "mt-ams-test-01", serverId: "srv6", interfaceName: "gre-ams-core", area: "0.0.0.0", cost: 10, active: true }, { key: "srv6-bb-gre-ams-fra", routerKey: "srv6", routerLabel: "mt-ams-test-01", serverId: "srv6", interfaceName: "gre-ams-fra", area: "0.0.0.0", cost: 20, active: true }, ] const MOCK_NEIGHBORS: OspfNeighbor[] = [ { id: "n1", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-msk-spb", remoteRouter: "srv2", remoteLabel: "mt-spb-edge-01", remoteRouterId: "10.0.0.2", area: "0.0.0.0", state: "Full", cost: 10, uptime: "14d 6h 22m", priority: 1 }, { id: "n2", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-msk-fra", remoteRouter: "srv3", remoteLabel: "mt-fra-edge-01", remoteRouterId: "10.0.0.3", area: "0.0.0.0", state: "Full", cost: 20, uptime: "12d 3h 11m", priority: 1 }, { id: "n3", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-msk-ams", remoteRouter: "srv4", remoteLabel: "mt-ams-edge-01", remoteRouterId: "10.0.0.4", area: "0.0.0.0", state: "2-Way", cost: 30, uptime: "2d 1h 4m", priority: 0 }, { id: "n4", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-lab-core", remoteRouter: "srv7", remoteLabel: "mt-msk-lab-01", remoteRouterId: "10.0.0.7", area: "0.0.0.0", state: "Full", cost: 10, uptime: "8d 14h 5m", priority: 1 }, { id: "n5", localRouter: "srv7", localLabel: "mt-msk-lab-01", localIface: "gre-lab-spb", remoteRouter: "srv2", remoteLabel: "mt-spb-edge-01", remoteRouterId: "10.0.0.2", area: "0.0.0.0", state: "Full", cost: 20, uptime: "8d 14h 4m", priority: 1 }, { id: "n6", localRouter: "srv6", localLabel: "mt-ams-test-01", localIface: "gre-ams-core", remoteRouter: "srv1", remoteLabel: "mt-msk-core-01", remoteRouterId: "10.0.0.1", area: "0.0.0.0", state: "Full", cost: 10, uptime: "5d 9h 17m", priority: 1 }, { id: "n7", localRouter: "srv6", localLabel: "mt-ams-test-01", localIface: "gre-ams-fra", remoteRouter: "srv3", remoteLabel: "mt-fra-edge-01", remoteRouterId: "10.0.0.3", area: "0.0.0.0", state: "Full", cost: 20, uptime: "5d 9h 12m", priority: 1 }, ] const MOCK_ROUTES: OspfRoute[] = [ { id: "r1", destination: "10.0.0.0/8", type: "O", cost: 10, nextHop: "10.200.0.1", via: "gre-msk-spb", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.0" }, { id: "r2", destination: "172.16.0.0/12", type: "O", cost: 20, nextHop: "10.200.1.1", via: "gre-msk-fra", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.0" }, { id: "r3", destination: "192.168.1.0/24", type: "O IA", cost: 30, nextHop: "10.200.2.1", via: "gre-msk-ams", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.1" }, { id: "r4", destination: "10.10.0.0/16", type: "O E2", cost: 20, nextHop: "10.200.0.1", via: "gre-msk-spb", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.0" }, { id: "r5", destination: "10.50.0.0/16", type: "O IA", cost: 20, nextHop: "10.100.0.1", via: "gre-lab-spb", serverId: "srv7", serverLabel: "mt-msk-lab-01", area: "0.0.0.0" }, { id: "r6", destination: "192.168.100.0/24", type: "O", cost: 10, nextHop: "10.100.0.1", via: "gre-lab-core", serverId: "srv7", serverLabel: "mt-msk-lab-01", area: "0.0.0.0" }, { id: "r7", destination: "192.168.200.0/24", type: "O", cost: 10, nextHop: "10.150.0.1", via: "gre-ams-core", serverId: "srv6", serverLabel: "mt-ams-test-01", area: "0.0.0.0" }, { id: "r8", destination: "10.80.0.0/16", type: "O E1", cost: 30, nextHop: "10.150.1.1", via: "gre-ams-fra", serverId: "srv6", serverLabel: "mt-ams-test-01", area: "0.0.0.0" }, ] const MOCK_BFD: BfdSession[] = [ { id: "b1", serverId: "srv1", serverLabel: "mt-msk-core-01", localAddr: "10.200.0.0", remoteAddr: "10.200.0.1", state: "Up", interval: 200, multiplier: 3, iface: "gre-msk-spb", uptime: "14d 6h 22m", multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 14400, packetsTx: 14400, stateChanges: 1 }, { id: "b2", serverId: "srv1", serverLabel: "mt-msk-core-01", localAddr: "10.200.1.0", remoteAddr: "10.200.1.1", state: "Up", interval: 200, multiplier: 3, iface: "gre-msk-fra", uptime: "12d 3h 11m", multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 12520, packetsTx: 12519, stateChanges: 2 }, { id: "b3", serverId: "srv1", serverLabel: "mt-msk-core-01", localAddr: "10.200.2.0", remoteAddr: "10.200.2.1", state: "Down", interval: 200, multiplier: 3, iface: "gre-msk-ams", uptime: null, multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 0, packetsTx: 0, stateChanges: 5 }, { id: "b4", serverId: "srv7", serverLabel: "mt-msk-lab-01", localAddr: "10.100.0.0", remoteAddr: "10.100.0.1", state: "Up", interval: 200, multiplier: 5, iface: "gre-lab-core", uptime: "8d 14h 5m", multihop: false, rxInterval: 200, holdTime: 1000, packetsRx: 9200, packetsTx: 9200, stateChanges: 1 }, { id: "b5", serverId: "srv6", serverLabel: "mt-ams-test-01", localAddr: "10.150.0.0", remoteAddr: "10.150.0.1", state: "Up", interval: 200, multiplier: 3, iface: "gre-ams-core", uptime: "5d 9h 17m", multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 5700, packetsTx: 5700, stateChanges: 1 }, { id: "b6", serverId: "srv6", serverLabel: "mt-ams-test-01", localAddr: "10.150.1.0", remoteAddr: "10.150.1.1", state: "Init", interval: 200, multiplier: 3, iface: "gre-ams-fra", uptime: null, multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 0, packetsTx: 3, stateChanges: 3 }, ] // ─── topology constants ──────────────────────────────────────────────────────── const TOPO_W = 900 const TOPO_H = 360 const NODE_R = 34 const NODE_THEME = { Pending: { fill: "var(--status-degraded-bg)", stroke: "var(--status-degraded-fg)", glow: "transparent" }, Unknown: { fill: "var(--muted)", stroke: "var(--muted-foreground)", glow: "transparent" }, Full: { fill: "var(--status-online-bg)", stroke: "var(--status-online-fg)", glow: "var(--status-online-bg)" }, "2-Way":{ fill: "var(--status-degraded-bg)", stroke: "var(--status-degraded-fg)", glow: "var(--status-degraded-bg)" }, Down: { fill: "var(--status-offline-bg)", stroke: "var(--status-offline-fg)", glow: "transparent" }, none: { fill: "var(--muted)", stroke: "var(--muted-foreground)", glow: "transparent" }, } const EDGE_COLOR: Record = { Pending: "var(--status-degraded-fg)", Attempt: "var(--status-degraded-fg)", Init: "var(--status-degraded-fg)", ExStart: "var(--status-degraded-fg)", Exchange: "var(--status-degraded-fg)", Loading: "var(--status-degraded-fg)", Unknown: "var(--muted-foreground)", Full: "var(--status-online-fg)", "2-Way":"var(--status-degraded-fg)", Down: "var(--status-offline-fg)", } interface GraphNode { id: string; label: string; short: string; site: string; x: number; y: number } interface GraphEdge { from: string; to: string; state: OspfNeighbor["state"]; cost: number | null } // Mock topology (static layout for mock mode) const MOCK_GRAPH_NODES: GraphNode[] = [ { id: "srv1", label: "mt-msk-core-01", short: "CORE-01", site: "MSK", x: 450, y: 180 }, { id: "srv2", label: "mt-spb-edge-01", short: "EDGE-01", site: "SPB", x: 730, y: 80 }, { id: "srv3", label: "mt-fra-edge-01", short: "EDGE-01", site: "FRA", x: 730, y: 290 }, { id: "srv4", label: "mt-ams-edge-01", short: "EDGE-01", site: "AMS", x: 600, y: 180 }, { id: "srv7", label: "mt-msk-lab-01", short: "LAB-01", site: "MSK", x: 170, y: 80 }, { id: "srv6", label: "mt-ams-test-01", short: "TEST-01", site: "AMS", x: 170, y: 290 }, ] const MOCK_GRAPH_EDGES: GraphEdge[] = [ { from: "srv1", to: "srv2", state: "Full", cost: 10 }, { from: "srv1", to: "srv3", state: "Full", cost: 20 }, { from: "srv1", to: "srv4", state: "2-Way", cost: 30 }, { from: "srv1", to: "srv7", state: "Full", cost: 10 }, { from: "srv7", to: "srv2", state: "Full", cost: 20 }, { from: "srv6", to: "srv1", state: "Full", cost: 10 }, { from: "srv6", to: "srv3", state: "Full", cost: 20 }, ] const MOCK_ROUTER_IDS: Record = { srv1: "10.0.0.1", srv2: "10.0.0.2", srv3: "10.0.0.3", srv4: "10.0.0.4", srv6: "10.0.0.6", srv7: "10.0.0.7", } /** Build graph nodes and edges from live data */ function buildLiveGraph( neighbors: OspfNeighbor[], ): { nodes: GraphNode[]; edges: GraphEdge[] } { // Collect unique local routers const localMap = new Map() for (const n of neighbors) { if (!localMap.has(n.localRouter)) localMap.set(n.localRouter, { label: n.localLabel }) } // Collect unique remote peers (that are NOT already local) const remoteMap = new Map() for (const n of neighbors) { const remId = n.remoteRouter !== n.remoteRouterId ? n.remoteRouter : `remote-${n.remoteRouterId}` if (!localMap.has(n.remoteRouter) && !remoteMap.has(remId)) { remoteMap.set(remId, { label: n.remoteLabel }) } } const allIds = [...localMap.keys(), ...remoteMap.keys()] const total = allIds.length const cx = TOPO_W / 2, cy = TOPO_H / 2 const radius = Math.min(TOPO_W, TOPO_H) * 0.38 const nodes: GraphNode[] = allIds.map((id, i) => { const angle = (2 * Math.PI * i / total) - Math.PI / 2 const x = cx + radius * Math.cos(angle) const y = cy + radius * Math.sin(angle) const label = localMap.get(id)?.label ?? remoteMap.get(id)?.label ?? id const short = label.replace(/^mt-/, "").toUpperCase().slice(0, 8) return { id, label, short, site: "—", x, y } }) const edges: GraphEdge[] = neighbors.map(n => ({ from: n.localRouter, to: n.remoteRouter !== n.remoteRouterId || localMap.has(n.remoteRouter) ? n.remoteRouter : `remote-${n.remoteRouterId}`, state: n.state, cost: n.cost, })) return { nodes, edges } } // ─── helpers ────────────────────────────────────────────────────────────────── function moveInArray(arr: T[], from: number, to: number): T[] { if (from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr const copy = [...arr] const [moved] = copy.splice(from, 1) copy.splice(to, 0, moved) return copy } function Chip({ children, color }: { children: React.ReactNode; color?: string }) { return ( {children} ) } // ─── topology SVG ───────────────────────────────────────────────────────────── interface TopologyGraphProps { nodeStates?: ReadonlyMap> graphNodes: GraphNode[] graphEdges: GraphEdge[] routerIds: Record neighbors: OspfNeighbor[] highlightId: string | null selectedId: string | null showDots: boolean onNodeClick: (id: string | null) => void } function TopologyGraph({ graphNodes, graphEdges, nodeStates, highlightId, selectedId, showDots, onNodeClick }: TopologyGraphProps) { const svgRef = useRef(null) useEffect(() => { const svg = svgRef.current, viewport = svg?.parentElement if (!svg || !viewport) return const center = () => { const node = graphNodes.find(node => node.id === selectedId) const point = svg.createSVGPoint(); point.x = node?.x ?? TOPO_W / 2 const matrix = svg.getScreenCTM() if (matrix) viewport.scrollLeft += point.matrixTransform(matrix).x - viewport.getBoundingClientRect().left - viewport.clientWidth / 2 } center() const observer = new ResizeObserver(center); observer.observe(viewport) return () => observer.disconnect() }, [graphNodes, selectedId]) const nodeMap = Object.fromEntries(graphNodes.map(n => [n.id, n])) const visualLinks = useMemo(() => { const groups = new Map() for (const edge of graphEdges) { const key = JSON.stringify([edge.from, edge.to].sort()) const links = groups.get(key) ?? []; links.push(edge); groups.set(key, links) } return [...groups.values()] }, [graphEdges]) function nodeState(id: string): keyof typeof NODE_THEME { if (nodeStates?.has(id)) return nodeStates.get(id)! const involved = graphEdges.filter(e => e.from === id || e.to === id) return ospfNodeState(involved.map(edge => edge.state)) } return ( onNodeClick(null)} > {visualLinks.map((links, i) => { const e = links[0] const mixed = links.some(link => link.state !== e.state) const grouped = links.length > 1 const badgeWidth = grouped ? 90 : 30 const a = nodeMap[e.from] const b = nodeMap[e.to] if (!a || !b) return null const color = mixed ? "var(--muted-foreground)" : EDGE_COLOR[e.state] ?? "var(--muted-foreground)" const isDashed = mixed || e.state !== "Full" const active = highlightId === e.from || highlightId === e.to || selectedId === e.from || selectedId === e.to const opacity = active || grouped ? 1 : 0.38 const strokeW = active ? 2.4 : 1.4 const mx = (a.x + b.x) / 2 const my = (a.y + b.y) / 2 return ( `${link.state}, cost ${link.cost ?? "—"}`).join("; ")}` : undefined} style={{ transition: showDots ? "opacity 0.2s" : "none" }} opacity={opacity}> {grouped && {links.map(link => `${link.state}, cost ${link.cost ?? "—"}`).join("; ")}} {showDots && !mixed && e.state === "Full" && ( )} {grouped ? `${links.length} ${links.length < 5 ? "связи" : "связей"}` : e.cost ?? "—"} ) })} {graphNodes.map(node => { const state = nodeState(node.id) const theme = NODE_THEME[state] const isHov = highlightId === node.id const isSel = selectedId === node.id const dimmed = (selectedId !== null || highlightId !== null) && !isSel && !isHov && !graphEdges.some(e => (e.from === selectedId && e.to === node.id) || (e.to === selectedId && e.from === node.id) || (e.from === highlightId && e.to === node.id) || (e.to === highlightId && e.from === node.id)) return ( { e.stopPropagation(); onNodeClick(isSel ? null : node.id) }} onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); onNodeClick(isSel ? null : node.id) } if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onNodeClick(null) } }}> {showDots && state === "Full" && !dimmed && ( )} {state === "2-Way" && !dimmed && } {isSel && } {isHov && !isSel && } {node.site !== "—" && ( {node.site.slice(0,3)} )} {node.site !== "—" ? node.site : "?"} {node.short} {node.label.replace(/^mt-/, "")} ) })} ) } // ─── node detail panel ──────────────────────────────────────────────────────── function NodeDetailPanel({ nodeId, graphNodes, routerIds, neighbors, items, onClose, }: { nodeId: string; graphNodes: GraphNode[]; routerIds: Record neighbors: OspfNeighbor[]; items: OspfItem[]; onClose: () => void }) { const node = graphNodes.find(n => n.id === nodeId) if (!node) return null const routerId = routerIds[nodeId] ?? (nodeId.startsWith("remote-") ? nodeId.slice(7) : "—") const adjacencies = neighbors.filter(n => n.localRouter === nodeId || n.remoteRouter === nodeId || nodeId === `remote-${n.remoteRouterId}`) .map(n => { if (n.localRouter === nodeId) return { iface: n.localIface, peerLabel: n.remoteLabel, peerId: n.remoteRouterId, state: n.state, cost: n.cost, uptime: n.uptime } return { iface: n.localIface, peerLabel: n.localLabel, peerId: routerIds[n.localRouter] ?? "—", state: n.state, cost: n.cost, uptime: n.uptime } }) const ifaces = items.filter(i => i.routerKey === nodeId) const fullCnt = adjacencies.filter(a => a.state === "Full").length const partCnt = adjacencies.filter(a => a.state === "2-Way").length // derive state from edges const panelState = ospfNodeState(adjacencies.map(edge => edge.state)) const theme = NODE_THEME[panelState] const siteFill = node.site === "—" ? "var(--muted)" : node.site === "MSK" ? "var(--primary)" : node.site === "SPB" ? "var(--info)" : node.site === "FRA" ? "var(--warning)" : "var(--success)" return (
{ if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose() } }}>
{node.site !== "—" ? node.site.slice(0,3) : "?"}

{node.label}

Router-ID: {routerId}

{panelState === "Pending" ? "Согласование" : panelState === "Unknown" ? "Неизвестно" : panelState}
{fullCnt > 0 && Full ×{fullCnt}} {partCnt > 0 && 2-Way ×{partCnt}}

Соседи ({adjacencies.length})

{adjacencies.length === 0 &&

Нет активных adjacency

} `${adj.peerLabel} ${adj.peerId} ${adj.iface} ${adj.state}`} renderItem={(adj, i) => { const ec = EDGE_COLOR[adj.state] ?? "var(--muted-foreground)" return (

{adj.peerLabel}

{adj.iface} · cost {adj.cost ?? "—"}

{adj.uptime}

{adj.state}
) }} />

Интерфейсы OSPF{ifaces.length > 0 && ({ifaces.length})}

{ifaces.length === 0 &&

Нет данных

} `${iface.interfaceName} ${iface.area} ${iface.cost ?? ""}`} renderItem={iface => (
{iface.interfaceName} {iface.area === "0.0.0.0" ? "backbone" : `area ${iface.area}`} cost {iface.cost ?? "—"}
)} />
) } // ─── interfaces tab ─────────────────────────────────────────────────────────── function InterfacesTab({ items: initialItems, isLive, filterServerId, backendUrl, dataReady, dataIncomplete, hasSearch, onOperationBusy, onOperationConfirmed, }: { items: OspfItem[] isLive: boolean filterServerId: string backendUrl: string dataReady: boolean dataIncomplete: boolean hasSearch: boolean onOperationConfirmed: (id: number) => void onOperationBusy: (busy: boolean) => void }) { const [items, setItems] = useState(initialItems) const [dragging, setDragging] = useState(null) const [dragOver, setDragOver] = useState(null) const [optimizing, setOptimizing] = useState(false) const [liveOptimalCost, setLiveOptimalCost] = useState>({}) const [operationError, setOperationError] = useState(null) const operationLock = useRef(false) const mounted = useRef(true) useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, []) // Sync with live data when it changes useEffect(() => { queueMicrotask(() => setItems(initialItems)) }, [initialItems]) const grouped = useMemo(() => { const byRouter: Record }> = {} items.forEach(item => { if (!byRouter[item.routerKey]) byRouter[item.routerKey] = { routerKey: item.routerKey, routerLabel: item.routerLabel, areas: {} } if (!byRouter[item.routerKey].areas[item.area]) byRouter[item.routerKey].areas[item.area] = [] byRouter[item.routerKey].areas[item.area].push(item) }) return Object.values(byRouter).map(r => ({ ...r, areas: Object.entries(r.areas) .map(([area, areaItems]) => ({ area, items: [...areaItems].sort((a, b) => (a.cost ?? Infinity) - (b.cost ?? Infinity)) })) .sort((a, b) => a.area.localeCompare(b.area)), })) }, [items]) // Optimizer hints only work meaningfully for mock data const hints = useMemo(() => { if (isLive) return {} const out: Record = {} grouped.forEach(router => { const ranked = router.areas .flatMap(ag => ag.items) .map(item => ({ item, prob: OPTIMIZER_PROB[`${router.routerKey}::${item.interfaceName.toUpperCase()}`] ?? 0 })) .sort((a, b) => b.prob - a.prob || a.item.interfaceName.localeCompare(b.item.interfaceName)) // В рамках одного роутера выдаём строго уникальные optimal cost. ranked.forEach(({ item, prob }, idx) => { out[item.key] = { optimalCost: (idx + 1) * COST_STEP, prob } }) }) return out }, [grouped, isLive]) const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost) const canOptimizeLive = isLive && dataReady && filterServerId !== ALL_SERVERS_ID async function handleLiveOptimize() { if (!mounted.current || operationLock.current || !canOptimizeLive || !can("mm:network:write")) { toast.info("Выберите конкретный сервер для оптимизации OSPF") return } const ra = readStoredRouteOptimizerSettings() operationLock.current = true onOperationBusy(true) setOperationError(null) setOptimizing(true) try { const response = await requestJson( backendUrl, `/api/servers/${filterServerId}/ospf/optimize`, { method: "POST", body: JSON.stringify({ pingWeight: ra.pingWeight }), }, ) const data = ospfOptimizeSchema.parse(response) if (String(data.serverId) !== filterServerId) throw new Error("API вернул другой контекст OSPF") const byKey: Record = {} data.interfaces.forEach((row) => { byKey[`${data.serverId}-${row.id}`] = row.optimalCost }) onOperationConfirmed(data.serverId) if (!mounted.current) return setLiveOptimalCost(byKey) toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`) } catch (err) { if (!mounted.current) return setOperationError(`API не подтвердил завершение оптимизации: ${err instanceof Error ? err.message : String(err)}. Часть costs могла измениться; выполнен повторный опрос перед новой попыткой.`) } finally { operationLock.current = false if (mounted.current) setOptimizing(false) onOperationBusy(false) } } function onDrop(routerKey: string, area: string, targetKey: string) { if (!dragging || dragging === targetKey) { setDragging(null); setDragOver(null); return } setItems(prev => { const areaItems = prev.filter(i => i.routerKey === routerKey && i.area === area).sort((a, b) => (a.cost ?? Infinity) - (b.cost ?? Infinity)) const fromIdx = areaItems.findIndex(i => i.key === dragging) const toIdx = areaItems.findIndex(i => i.key === targetKey) if (fromIdx < 0 || toIdx < 0) return prev const reordered = moveInArray(areaItems, fromIdx, toIdx).map((item, idx) => ({ ...item, cost: (idx + 1) * COST_STEP })) return [...prev.filter(i => !(i.routerKey === routerKey && i.area === area)), ...reordered] }) setDragging(null); setDragOver(null) } function handleOptimize() { setItems(prev => prev.map(item => { const h = hints[item.key] return h && item.cost !== h.optimalCost ? { ...item, cost: h.optimalCost } : item })) toast.success("Локальный предпросмотр costs обновлён") } return (
{isLive && filterServerId !== ALL_SERVERS_ID &&

Оптимизация применяется ко всем интерфейсам выбранного устройства.

} {isLive && filterServerId !== ALL_SERVERS_ID && !dataReady &&

Оптимизация недоступна до полного успешного опроса и подтверждения costs/состояний интерфейсов с правом записи.

} {operationError && Оптимизация OSPF не подтверждена{operationError}}
{needsOptimize && ( )} {isLive && filterServerId !== ALL_SERVERS_ID && ( )}
{grouped.length === 0 && !dataIncomplete && (
{hasSearch ? "Нет интерфейсов по текущему поиску" : "OSPF интерфейсы не настроены ни на одном сервере"}
)}
{grouped.map(router => { const totalIfaces = router.areas.reduce((s, a) => s + a.items.length, 0) return (

{router.routerLabel}

{router.routerKey}

{totalIfaces} iface · {router.areas.length} area
{router.areas.map((ag, aIdx) => (
{aIdx > 0 && }
Area {ag.area} {ag.area === "0.0.0.0" && Backbone}
{ag.items.map(item => { const hint = hints[item.key] const matches = hint && hint.optimalCost === item.cost const liveOptimal = isLive ? liveOptimalCost[item.key] : hint?.optimalCost const costDiffers = liveOptimal !== item.cost return (
!isLive && setDragging(item.key)} onDragEnd={() => { setDragging(null); setDragOver(null) }} onDragOver={e => { e.preventDefault(); setDragOver(item.key) }} onDrop={() => !isLive && onDrop(router.routerKey, ag.area, item.key)} className={cn( "flex flex-wrap items-center gap-3 px-4 py-2.5 select-none transition-colors sm:flex-nowrap", !isLive && "cursor-grab", dragging === item.key && "opacity-40", dragOver === item.key && dragging !== item.key && "bg-primary/5", dragging !== item.key && dragOver !== item.key && "hover:bg-muted/40", )}> {item.interfaceName} {(item.dataUnknown || item.active === null) && Неизвестно} {item.type && item.type !== "broadcast" && ( {item.type} )} {item.useBfd && ( BFD )} {hint && hint.prob > 0 && !isLive && ( opt {hint.optimalCost} )} cur {item.cost ?? "—"} {liveOptimal !== undefined && !item.dataUnknown && opt {liveOptimal}}
) })}
))}
) })}
{!isLive && (
Легенда: opt 10 совпадает с оптимальным opt 20 рекомендован другой cost перетащите для изменения приоритета
)}
) } // ─── neighbors tab ──────────────────────────────────────────────────────────── function NeighborsTab({ neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown, selectedId, onSelect, hasSearch, }: { neighbors: OspfNeighbor[] items: OspfItem[] graphNodes: GraphNode[] graphEdges: GraphEdge[] routerIds: Record dataIncomplete: boolean graphUnknown: boolean selectedId: string | null onSelect: (id: string | null) => void hasSearch: boolean }) { const [highlightId, setHighlightId] = useState(null) const reducedMotion = usePrefersReducedMotion() function closeNode() { const previous = selectedId; onSelect(null) if (previous) requestAnimationFrame(() => (document.querySelector(`[data-ospf-node="${CSS.escape(previous)}"]`) ?? document.querySelector('[aria-label="Найти узел топологии"]'))?.focus()) } const [showDots, setShowDots] = useState(true) dataIncomplete = dataIncomplete || neighbors.some(n => n.state === "Unknown") const fullCount = neighbors.filter(n => n.state === "Full").length return (
, iconClassName: "text-muted-foreground", }, { id: "full", label: "Full", value: dataIncomplete ? "—" : fullCount, icon: , iconClassName: "text-success", }, { id: "not-full", label: "Не Full", value: dataIncomplete ? "—" : neighbors.length - fullCount, icon: , iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground", variant: neighbors.length - fullCount > 0 ? "warning" : "default", }, ]} /> {selectedId && !graphUnknown && !graphNodes.some(node => node.id === selectedId) && Узел OSPF недоступенУзел из ссылки отсутствует в текущем срезе.} {graphUnknown &&

Топология OSPF неизвестна до успешного опроса всех устройств.

} {!graphUnknown && graphNodes.length > 0 && ( {view =>
{!selectedId && (
кликните по узлу для деталей
)}
{selectedId && ( )}
}
)} {!graphUnknown && graphNodes.length > 0 &&
{(["Full", "2-Way", "Pending", "Down", "Unknown"] as const).map(state => )} Цифра на связи — cost. Узел открывается нажатием или Enter. Схему можно прокручивать по горизонтали.
}
) } // ─── routes tab (mock) ──────────────────────────────────────────────────────── function RoutesTab({ routes, hasSearch }: { routes: OspfRoute[]; hasSearch: boolean }) { const typeCount = Object.entries( routes.reduce>((acc, r) => { acc[r.type] = (acc[r.type] ?? 0) + 1; return acc }, {}) ) return (
{routes.some(r => r.dataUnknown) ? "Последний снимок: " : ""}{routes.length} маршрутов {typeCount.map(([type, count]) => ( ))}
Типы: {([ { type: "O", desc: "Internal (intra-area)" }, { type: "O IA", desc: "Inter-Area" }, { type: "O E1", desc: "External Type 1" }, { type: "O E2", desc: "External Type 2" }, ] as const).map(({ type, desc }) => ( {desc} ))}
) } // ─── BFD tab ────────────────────────────────────────────────────────────────── function BfdTab({ sessions, dataIncomplete, hasSearch }: { sessions: BfdSession[]; dataIncomplete: boolean; hasSearch: boolean }) { dataIncomplete = dataIncomplete || sessions.some(b => b.state === "Unknown") const upCount = sessions.filter(b => b.state === "Up").length const downCount = sessions.filter(b => b.state === "Down" || b.state === "AdminDown").length const initCount = sessions.filter(b => b.state === "Init").length const totalPkts = sessions.some(b => b.packetsRx === null) ? null : sessions.reduce((sum, b) => sum + (b.packetsRx ?? 0), 0) return (
, iconClassName: "text-muted-foreground", }, { id: "up", label: "Up", value: dataIncomplete ? "—" : upCount, icon: , iconClassName: "text-success", }, { id: "down", label: Down/Adm, value: dataIncomplete ? "—" : downCount, icon: , iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground", variant: downCount > 0 ? "destructive" : "default", }, { id: "init", label: "Init", value: dataIncomplete ? "—" : initCount, icon: , iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground", variant: initCount > 0 ? "warning" : "default", }, ]} /> {sessions.length === 0 && (
{hasSearch ? "Нет BFD-сессий по текущему поиску" : "BFD-сессий не обнаружено ни на одном сервере"}
)} {sessions.length > 0 && ( )} {sessions.length > 0 && (

BFD обнаруживает сбои быстрее OSPF Hello/Dead таймеров. {!dataIncomplete && totalPkts === null && Общее число полученных пакетов неизвестно.} TX факт — наблюдаемый интервал отправки; RX минимум — требуемый минимальный интервал. {!dataIncomplete && totalPkts !== null && Всего получено: {totalPkts.toLocaleString()} пакетов.}

)}
) } // ─── page ───────────────────────────────────────────────────────────────────── const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [ { id: "interfaces", label: "Интерфейсы", icon: }, { id: "neighbors", label: "Соседи", icon: }, { id: "routes", label: "Маршруты", icon: }, { id: "bfd", label: "BFD", icon: }, ] export default function OspfPage() { return Загрузка контекста OSPF…

}>
} function OspfRoute() { const { mode, backendUrl } = useDataSource() const params = useSearchParams() const state = readOspfUrl(new URLSearchParams(params.toString())) function navigate(values: Record, replace = false) { const href = patchOspfUrl(new URLSearchParams(window.location.search), values) if (href !== window.location.pathname + window.location.search) (replace ? window.history.replaceState : window.history.pushState).call(window.history, null, "", href) } return navigate({ q: value }, true)} onSelectNode={node => navigate({ node })} onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, node: null })} onSelectTab={tab => navigate({ tab: tab === "interfaces" ? null : tab })} onInspectBfd={() => navigate({ tab: "bfd", q: null })} onResetContext={() => navigate({ server: null, tab: null, q: null, node: null })} /> } function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filterServerId, activeTab, contextError, onSelectServer, onSelectTab, onInspectBfd, onResetContext }: { search: string; selectedNodeId: string | null; onSearch: (value: string) => void; onSelectNode: (id: string | null) => void; filterServerId: string; activeTab: OspfTab; contextError: string | null; onSelectServer: (id: string) => void; onSelectTab: (tab: OspfTab) => void; onInspectBfd: () => void; onResetContext: () => void }) { const { mode, backendUrl } = useDataSource() const isLive = mode === "live" const [liveData, setLiveData] = useState(null) const [liveServers, setLiveServers] = useState>([]) const [loading, setLoading] = useState(isLive) const [fetchedAt, setFetchedAt] = useState(null) const [liveError, setLiveError] = useState(null) const [fetchTick, setFetchTick] = useState(0) const [readReceipts, setReadReceipts] = useState>({}) const [writeReceipt, setWriteReceipt] = useState<{ serverId: number; at: string } | null>(null) const pageMounted = useRef(true) useEffect(() => { pageMounted.current = true; return () => { pageMounted.current = false } }, []) const operationBusy = useRef(false) const [writeBusy, setWriteBusy] = useState(false) function refresh() { if (operationBusy.current) return; setLoading(true); setFetchTick(t => t + 1) } useEffect(() => { if (!isLive) { queueMicrotask(() => setLiveData(null)) return } let cancelled = false queueMicrotask(() => { if (cancelled) return setLoading(true) void Promise.all([requestJson(backendUrl, "/api/ospf/catalog"), requestJson(backendUrl, "/api/servers")]) .then(([response, servers]) => { if (cancelled) return const data = ospfCatalogSchema.parse(response) const inventory = ospfServersSchema.parse(servers) const enabled = new Set(inventory.filter(s => s.enabled).map(s => s.id)) const sections = ["neighbors", "interfaces", "instances", "bfdSessions", "routes"] as const if (sections.some(section => data[section].some(row => !enabled.has(row.serverId))) || data.failures.some(f => !enabled.has(f.serverId))) throw new Error("Чужой контекст OSPF-каталога") const failed = new Set(data.failures.map(f => f.serverId)) setLiveData(previous => ({ ...data, ...Object.fromEntries(sections.map(section => [section, [...data[section], ...(previous?.[section] ?? []).filter(row => enabled.has(row.serverId) && failed.has(row.serverId))]])) })) setLiveServers(inventory.filter(s => s.enabled)) const at = new Date() setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(id)).map(id => [String(id), at.toISOString()])) })) setLiveError(null); setFetchedAt(at); setLoading(false) }) .catch((err: unknown) => { if (cancelled) return setLiveError(err instanceof Error ? err.message : String(err)); setLoading(false) }) }) return () => { cancelled = true } }, [isLive, backendUrl, fetchTick]) const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData]) const incomplete = Boolean(contextError) || isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId)))) const dataReady = !contextError && (!isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:network:write") && liveData.interfaces.filter(row => String(row.serverId) === filterServerId).every(row => row.cost !== null && row.cost >= 1 && row.disabled !== null && row.inactive !== null))) // Derive frontend types from backend data or use mocks const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => { if (isLive && liveData) { // Build interface→cost map for neighbor cost lookup const ifaceMap = buildOspfCostIndex(liveData.interfaces) const { routerIds, serverByRouterId } = deriveOspfRouterIdentity(liveData.instances) const labels = Object.fromEntries(liveServers.map(server => [String(server.id), server.name])) const items = liveData.interfaces .map(b => ({ ...backendToItem(b), dataUnknown: unknownIds.has(b.serverId) })) .filter((item) => !isRefInterfaceName(item.interfaceName)) const neighbors = liveData.neighbors.map(b => ({ ...backendToNeighbor(b, ifaceMap, serverByRouterId, labels), dataUnknown: unknownIds.has(b.serverId) })) const bfdSessions = liveData.bfdSessions.map(b => ({ ...backendToBfdSession(b), dataUnknown: unknownIds.has(b.serverId) })) const routes = liveData.routes.map(b => ({ ...backendToRoute(b), dataUnknown: unknownIds.has(b.serverId) })) const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors) return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } } if (isLive) { return { items: [], neighbors: [], graphNodes: [], graphEdges: [], routerIds: {}, bfdSessions: [], routes: [], } } return { items: MOCK_ITEMS, neighbors: MOCK_NEIGHBORS, graphNodes: MOCK_GRAPH_NODES, graphEdges: MOCK_GRAPH_EDGES, routerIds: MOCK_ROUTER_IDS, bfdSessions: MOCK_BFD, routes: MOCK_ROUTES, } }, [isLive, liveData, liveServers, unknownIds]) // ── server list for chips bar ──────────────────────────────────────────────── const ospfServers = useMemo((): OspfServerInfo[] => { if (isLive && liveData) { return liveServers.map(s => ({ id: String(s.id), label: s.name, site: s.site ?? "", country: s.country })) } if (isLive) return [] // Mock: derive from MOCK_GRAPH_NODES that actually have interfaces const activeIds = new Set(MOCK_ITEMS.map(i => i.routerKey)) const siteCountry: Record = { MSK: "RU", SPB: "RU", FRA: "DE", AMS: "NL", SGP: "SG" } return MOCK_GRAPH_NODES .filter(n => activeIds.has(n.id)) .map(n => ({ id: n.id, label: n.label, site: n.site, country: siteCountry[n.site] ?? "" })) }, [isLive, liveData, liveServers]) // Per-server counts for chips (all data, before filtering) const serverCounts = useMemo(() => { const out: Record = {} const ensure = (id: string) => { if (!out[id]) out[id] = { ifaces: 0, neighbors: 0, bfd: 0 } } for (const item of items) { ensure(item.routerKey); out[item.routerKey].ifaces++ } for (const n of neighbors) { ensure(n.localRouter); out[n.localRouter].neighbors++ } for (const b of bfdSessions) { ensure(b.serverId); out[b.serverId].bfd++ } return out }, [items, neighbors, bfdSessions]) // ── filtered display data ───────────────────────────────────────────────────── const missingServer = filterServerId !== ALL_SERVERS_ID && (!isLive || Boolean(liveData)) && !ospfServers.some(server => server.id === filterServerId) const unavailable = Boolean(contextError) || missingServer const viewIncomplete = incomplete || unavailable const scopedItems = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId) const scopedNeighbors = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) const scopedBfdSessions = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) const scopedRoutes = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId) const bfdConflicts = ospfBfdConflicts(scopedNeighbors, scopedItems, scopedBfdSessions) const query = search.trim().toLocaleLowerCase("ru") const matches = (...fields: (string | number | null | undefined)[]) => !query || fields.some(field => String(field ?? "").toLocaleLowerCase("ru").includes(query)) const displayItems = scopedItems.filter(i => matches(i.routerLabel, i.interfaceName, i.area, i.type)) const displayNeighbors = scopedNeighbors.filter(n => matches(n.localLabel, n.localIface, n.remoteRouterId, n.area, n.state)) const displayBfdSessions = scopedBfdSessions.filter(b => matches(b.serverLabel, b.iface, b.localAddr, b.remoteAddr, b.state)) const displayRoutes = scopedRoutes.filter(r => matches(r.serverLabel, r.destination, r.nextHop, r.via, r.area, r.type)) const displayGraph = isLive || filterServerId !== ALL_SERVERS_ID || query ? buildLiveGraph(displayNeighbors) : { nodes: graphNodes, edges: graphEdges } const visibleCount = activeTab === "interfaces" ? displayItems.length : activeTab === "neighbors" ? displayNeighbors.length : activeTab === "bfd" ? displayBfdSessions.length : displayRoutes.length const scopeCount = activeTab === "interfaces" ? scopedItems.length : activeTab === "neighbors" ? scopedNeighbors.length : activeTab === "bfd" ? scopedBfdSessions.length : scopedRoutes.length const ospfRailItems = useMemo(() => ( ospfServers.map((s) => { const counts = serverCounts[s.id] const host = s.label.replace(/^mt-/, "") return { id: s.id, selectable: !writeBusy, name: host, host, site: s.site, country: s.country || undefined, meta: isLive && unknownIds.has(Number(s.id)) ? "Данные OSPF неизвестны" : counts ? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}` : undefined, } }) ), [ospfServers, serverCounts, isLive, unknownIds, writeBusy]) // Graph always shows full topology (highlight is handled by node click inside tab) // KPIs reflect the current filter const totalRouters = new Set(displayItems.map(i => i.routerKey)).size const totalInterfaces = displayItems.length const totalAreas = new Set(displayItems.map(i => i.area)).size return ( { if (!operationBusy.current) onSelectServer(id) }} showAll allCount={ospfServers.length} loading={isLive && loading && ospfServers.length === 0} header={ } /> } banner={
{TABS.map(t => ( ))}
} >
{unavailable && Контекст OSPF недоступен{contextError ?? `Сервер ${filterServerId} отсутствует среди доступных устройств.`}} {bfdConflicts.length > 0 && OSPF Full при недоступном BFDПоследний опрос: {bfdConflicts.map(n => `${n.localLabel} · ${n.localIface}`).join("; ")} — OSPF Full, BFD не Up. Проверьте канал: Full не подтверждает работу BFD.} onSearch("")}>Сбросить поиск : undefined} /> {/* data source banner */} {isLive && loading && (
Загрузка OSPF данных…
)} {isLive && fetchedAt &&

Ответ каталога получен . Время получения браузером, не измерения RouterOS.

{liveServers.filter(s => filterServerId === ALL_SERVERS_ID || String(s.id) === filterServerId).map(s =>

{s.name}: {readReceipts[s.id] ? : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}

)} {writeReceipt && (filterServerId === ALL_SERVERS_ID || String(writeReceipt.serverId) === filterServerId) &&

API подтвердил оптимизацию устройства {writeReceipt.serverId}:

}
} {isLive && (liveError || Boolean(liveData?.failures.length)) && {liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}} {viewIncomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) &&

Данные выбранного раздела OSPF пока неизвестны.

} {mode === "mock" && ( Моковые данные )} {(activeTab === "interfaces" || unavailable || isLive && !liveData) && , iconClassName: "text-muted-foreground", }, { id: "ifaces", label: Интерф., value: viewIncomplete ? "—" : totalInterfaces, icon: , iconClassName: "text-info", }, { id: "areas", label: "Зоны", value: viewIncomplete ? "—" : totalAreas, icon: , iconClassName: "text-primary", }, ]} />} {activeTab === "interfaces" && (!viewIncomplete || displayItems.length > 0) && ( { if (pageMounted.current) setWriteReceipt({ serverId, at: new Date().toISOString() }) }} onOperationBusy={busy => { if (!pageMounted.current) return operationBusy.current = busy; setWriteBusy(busy) if (!busy) { setLoading(true); setFetchTick(t => t + 1) } }} /> )} {activeTab === "neighbors" && (!viewIncomplete || displayNeighbors.length > 0) && ( 0)} /> )} {activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && } {activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && }
) }