"use client" import { useMemo, useState, useEffect } from "react" import { PageHeader } from "@/components/page-header" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon, CheckIcon, NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon, } from "lucide-react" import { cn } from "@/lib/utils" import { useDataSource } from "@/lib/data-source" import { Flag } from "@/components/flag" // ─── types ──────────────────────────────────────────────────────────────────── type OspfTab = "interfaces" | "neighbors" | "routes" | "bfd" 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 active: boolean type?: string useBfd?: boolean helloInterval?: number deadInterval?: number } interface OspfNeighbor { id: string localRouter: string localLabel: string localIface: string remoteRouter: string remoteLabel: string remoteRouterId: string area: string state: "Full" | "2-Way" | "ExStart" | "Down" cost: number uptime: string priority: number } interface OspfRoute { id: string destination: string type: "O" | "O IA" | "O E1" | "O E2" cost: number nextHop: string via: string serverId: string serverLabel: string area: string } interface BfdSession { id: string serverId: string serverLabel: string localAddr: string remoteAddr: string state: "Up" | "Down" | "Init" | "AdminDown" interval: number // txInterval ms multiplier: number iface: string uptime: string | null multihop: boolean rxInterval: number // ms holdTime: number // ms packetsRx: number packetsTx: number stateChanges: number } // ─── backend types ──────────────────────────────────────────────────────────── interface BackendNeighbor { id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string address: string; routerId: string; instance: string; area: string; areaId: string interface: string; state: string; uptime: string | null; stateChanges: number; priority: number } interface BackendInterface { id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string instance: string; area: string; areaId: string; interface: string; cost: number type: string; disabled: boolean; inactive: boolean; priority: number helloInterval: number; deadInterval: number; useBfd: boolean } interface BackendInstance { id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string name: string; routerId: string; version: number; disabled: boolean; inactive: boolean; redistribute: string } interface BackendBfdSession { id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string localAddr: string; remoteAddr: string; interface: string; state: string; uptime: string | null multihop: boolean; multiplier: number; txInterval: number; rxInterval: number; holdTime: number packetsRx: number; packetsTx: number; stateChanges: number } interface BackendOspfAll { neighbors: BackendNeighbor[] interfaces: BackendInterface[] instances: BackendInstance[] bfdSessions: BackendBfdSession[] } // ─── backend → frontend mappers ─────────────────────────────────────────────── function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map): OspfNeighbor { const validStates = ["Full", "2-Way", "ExStart", "Down"] const state = validStates.includes(b.state) ? b.state as OspfNeighbor["state"] : "Down" const costKey = `${b.serverId}::${b.interface}` return { id: `${b.serverId}-${b.id}`, localRouter: String(b.serverId), localLabel: b.serverName, localIface: b.interface, remoteRouter: b.routerId, remoteLabel: b.routerId, // no server name for remote peers remoteRouterId: b.routerId, area: b.areaId !== b.area ? `${b.area} (${b.areaId})` : b.area, state, cost: ifaceMap.get(costKey) ?? 10, 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 && !b.inactive, type: b.type, useBfd: b.useBfd, helloInterval: b.helloInterval, deadInterval: b.deadInterval, } } function backendToBfdSession(b: BackendBfdSession): BfdSession { const validStates = ["Up", "Down", "Init", "AdminDown"] const state = validStates.includes(b.state) ? b.state as BfdSession["state"] : "Down" 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, } } // ─── 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 = { Full: { fill: "#0f2d1f", stroke: "#4ade80", glow: "rgba(74,222,128,0.18)" }, "2-Way":{ fill: "#2d1e06", stroke: "#fbbf24", glow: "rgba(251,191,36,0.14)" }, Down: { fill: "#2d0f0f", stroke: "#f87171", glow: "transparent" }, none: { fill: "#111827", stroke: "#374151", glow: "transparent" }, } const EDGE_COLOR: Record = { Full: "#4ade80", "2-Way":"#fbbf24", Down: "#f87171", } 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 } // 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 = `remote-${n.remoteRouterId}` if (!localMap.has(n.remoteRouter) && !remoteMap.has(remId)) { remoteMap.set(remId, { label: n.remoteRouterId }) } } 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: 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 stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) { if (state === "Full" || state === "Up") return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25" if (state === "2-Way" || state === "Init") return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25" return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25" } function routeTypeClass(type: OspfRoute["type"]) { if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25" if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25" if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25" return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25" } function Chip({ children, color }: { children: React.ReactNode; color?: string }) { return ( {children} ) } // ─── topology SVG ───────────────────────────────────────────────────────────── interface TopologyGraphProps { 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, highlightId, selectedId, showDots, onNodeClick }: TopologyGraphProps) { const nodeMap = Object.fromEntries(graphNodes.map(n => [n.id, n])) function nodeState(id: string): keyof typeof NODE_THEME { const involved = graphEdges.filter(e => e.from === id || e.to === id) if (!involved.length) return "none" if (involved.some(e => e.state === "Full")) return "Full" if (involved.some(e => e.state === "2-Way")) return "2-Way" return "Down" } return ( onNodeClick(null)} > {graphEdges.map((e, i) => { const a = nodeMap[e.from] const b = nodeMap[e.to] if (!a || !b) return null const color = EDGE_COLOR[e.state] ?? "#64748b" const isDashed = e.state !== "Full" const active = highlightId === e.from || highlightId === e.to || selectedId === e.from || selectedId === e.to const opacity = active ? 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 ( {showDots && e.state === "Full" && ( )} {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) }}> {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-/, "")} ) })} {/* legend */} ЛЕГЕНДА OSPF {([ { state: "Full", label: "Full adjacency" }, { state: "2-Way", label: "2-Way partial" }, { state: "Down", label: "Down" }, ] as const).map(({ state, label }, i) => { const c = EDGE_COLOR[state] ?? "#64748b" return ( {label} ) })} клик по узлу — подробности · цифра = cost ) } // ─── 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] ?? "—" const adjacencies = neighbors.filter(n => n.localRouter === nodeId || n.remoteRouter === nodeId) .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: routerId, 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 let panelState: keyof typeof NODE_THEME = "none" if (adjacencies.some(a => a.state === "Full")) panelState = "Full" else if (adjacencies.some(a => a.state === "2-Way")) panelState = "2-Way" else if (adjacencies.length) panelState = "Down" const theme = NODE_THEME[panelState] const siteFill = node.site === "MSK" ? "#7c3aed" : node.site === "SPB" ? "#0369a1" : node.site === "FRA" ? "#b45309" : "#065f46" return (
{node.site !== "—" ? node.site.slice(0,3) : "?"}

{node.label}

Router-ID: {routerId}

{panelState}
{fullCnt > 0 && Full ×{fullCnt}} {partCnt > 0 && 2-Way ×{partCnt}}

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

{adjacencies.length === 0 &&

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

}
{adjacencies.map((adj, i) => { const ec = EDGE_COLOR[adj.state] ?? "#64748b" return (

{adj.peerLabel}

{adj.iface} · cost {adj.cost}

{adj.uptime}

{adj.state}
) })}

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

{ifaces.length === 0 &&

Нет данных

}
{ifaces.map(iface => (
{iface.interfaceName} {iface.area === "0.0.0.0" ? "backbone" : `area ${iface.area}`} cost {iface.cost}
))}
) } // ─── interfaces tab ─────────────────────────────────────────────────────────── function InterfacesTab({ items: initialItems, isLive }: { items: OspfItem[]; isLive: boolean }) { const [items, setItems] = useState(initialItems) const [dragging, setDragging] = useState(null) const [dragOver, setDragOver] = useState(null) const [toast, setToast] = useState(null) // Sync with live data when it changes useEffect(() => { queueMicrotask(() => setItems(initialItems)) }, [initialItems]) function showToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 2500) } 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 - b.cost) })) .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 => { router.areas.forEach(ag => { const ranked = [...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)) 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) 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 - b.cost) 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 })) showToast("Costs оптимизированы по рекомендациям оптимизатора") } return (
{needsOptimize && ( )} {!isLive && ( )}
{toast && (
{toast}
)} {grouped.length === 0 && (
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 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 items-center gap-3 px-4 py-2.5 select-none transition-colors", !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.type && item.type !== "broadcast" && ( {item.type} )} {item.useBfd && ( BFD )} {hint && hint.prob > 0 && !isLive && ( opt {hint.optimalCost} )} cost {item.cost}
) })}
))}
) })}
{!isLive && (
Легенда: opt 10 совпадает с оптимальным opt 20 рекомендован другой cost перетащите для изменения приоритета
)}
) } // ─── neighbors tab ──────────────────────────────────────────────────────────── function NeighborsTab({ neighbors, items, graphNodes, graphEdges, routerIds, }: { neighbors: OspfNeighbor[] items: OspfItem[] graphNodes: GraphNode[] graphEdges: GraphEdge[] routerIds: Record }) { const [highlightId, setHighlightId] = useState(null) const [selectedId, setSelectedId] = useState(null) const [showDots, setShowDots] = useState(true) const fullCount = neighbors.filter(n => n.state === "Full").length return (
{[ { label: "Всего соседей", value: neighbors.length, color: "" }, { label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" }, { label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" }, ].map(s => (

{s.label}

{s.value}

))}
{graphNodes.length > 0 && (
{!selectedId && (
кликните по узлу для деталей
)}
{selectedId && ( setSelectedId(null)} /> )}
)}
{["Роутер", "Интерфейс", "Сосед (Router ID)", "Область", "Состояние", "Cost", "Uptime", "Prio"].map(h => ( ))} {neighbors.length === 0 ? ( ) : neighbors.map(n => { const isHighlighted = selectedId === n.localRouter || selectedId === n.remoteRouter return ( setHighlightId(n.localRouter)} onMouseLeave={() => setHighlightId(null)} onClick={() => setSelectedId(prev => prev === n.localRouter ? null : n.localRouter)} className={cn( "transition-colors cursor-pointer", isHighlighted ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30", )}> ) })}
{h}
Нет OSPF-соседей
{n.localLabel} {n.localIface}
{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId} {n.remoteLabel !== n.remoteRouterId && ( {n.remoteRouterId} )}
{n.area} {n.state} {n.cost} {n.uptime} {n.priority}
) } // ─── routes tab (mock) ──────────────────────────────────────────────────────── function RoutesTab({ routes }: { routes: OspfRoute[] }) { const typeCount = Object.entries( routes.reduce>((acc, r) => { acc[r.type] = (acc[r.type] ?? 0) + 1; return acc }, {}) ) return (
{routes.length} маршрутов {typeCount.map(([type, count]) => ( {type}: {count} ))}
{["Назначение", "Тип", "Cost", "Следующий хоп", "Интерфейс", "Роутер", "Область"].map(h => ( ))} {routes.map(r => ( ))}
{h}
{r.destination} {r.type} {r.cost} {r.nextHop} {r.via} {r.serverLabel} {r.area}
Типы: {([ { 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 }) => ( {type} {desc} ))}
) } // ─── BFD tab ────────────────────────────────────────────────────────────────── function BfdTab({ sessions }: { sessions: BfdSession[] }) { 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.reduce((s, b) => s + b.packetsRx, 0) function fmtMs(ms: number) { if (!ms) return "—" if (ms < 1000) return `${ms}ms` return `${(ms / 1000).toFixed(1)}s` } return (
{[ { label: "Сессий BFD", value: sessions.length, color: "" }, { label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" }, { label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" }, { label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" }, ].map(s => (

{s.label}

{s.value}

))}
{sessions.length === 0 && (
BFD-сессий не обнаружено ни на одном сервере
)} {sessions.length > 0 && (
{[ "Роутер", "Интерфейс", "Локальный", "Удалённый", "Состояние", "Uptime", "Tx / Rx", "Hold", "Mult", "Пакеты Rx", "Пакеты Tx", "Переходы", ].map(h => ( ))} {sessions.map(b => ( ))}
{h}
{b.serverLabel} {b.multihop && ( multihop )}
{b.iface || "—"} {b.localAddr} {b.remoteAddr} {b.state} {b.uptime ?? "—"} {fmtMs(b.interval)} / {fmtMs(b.rxInterval)} {fmtMs(b.holdTime)} {b.multiplier} {b.packetsRx.toLocaleString()} {b.packetsTx.toLocaleString()} 3 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground")}> {b.stateChanges}
)} {sessions.length > 0 && (

BFD обнаруживает сбои быстрее OSPF Hello/Dead таймеров. Tx / Rx — интервалы отправки и приёма контрольных пакетов. {totalPkts > 0 && Всего получено: {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() { const [activeTab, setActiveTab] = useState("interfaces") const [filterServerId, setFilterServerId] = useState("all") const { mode, backendUrl, backendStatus } = useDataSource() const isLive = mode === "live" && backendStatus === true const [liveData, setLiveData] = useState(null) const [loading, setLoading] = useState(false) const [fetchedAt, setFetchedAt] = useState(null) const [liveError, setLiveError] = useState(null) const [fetchTick, setFetchTick] = useState(0) // Reset server filter when switching live ↔ mock useEffect(() => { queueMicrotask(() => setFilterServerId("all")) }, [isLive]) useEffect(() => { if (!isLive) { queueMicrotask(() => setLiveData(null)) return } let cancelled = false queueMicrotask(() => { if (cancelled) return setLoading(true) setLiveError(null) fetch(`${backendUrl}/api/ospf/all`) .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise }) .then(data => { if (cancelled) return setLiveData(data); setFetchedAt(new Date()); setLoading(false) }) .catch((err: unknown) => { if (cancelled) return setLiveError(err instanceof Error ? err.message : String(err)); setLoading(false) }) }) return () => { cancelled = true } }, [isLive, backendUrl, fetchTick]) // Derive frontend types from backend data or use mocks const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } = useMemo(() => { if (isLive && liveData) { // Build interface→cost map for neighbor cost lookup const ifaceMap = new Map() for (const iface of liveData.interfaces) { ifaceMap.set(`${iface.serverId}::${iface.interface}`, iface.cost) } const items = liveData.interfaces.map(backendToItem) const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap)) const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession) // Build routerIds from instances const routerIds: Record = {} for (const inst of liveData.instances) { if (!routerIds[String(inst.serverId)]) { routerIds[String(inst.serverId)] = inst.routerId } } const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors) return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } } return { items: MOCK_ITEMS, neighbors: MOCK_NEIGHBORS, graphNodes: MOCK_GRAPH_NODES, graphEdges: MOCK_GRAPH_EDGES, routerIds: MOCK_ROUTER_IDS, bfdSessions: MOCK_BFD, } }, [isLive, liveData]) // ── server list for chips bar ──────────────────────────────────────────────── const ospfServers = useMemo((): OspfServerInfo[] => { if (isLive && liveData) { const seen = new Map() for (const inst of liveData.instances) { const id = String(inst.serverId) if (!seen.has(id)) seen.set(id, { id, label: inst.serverName, site: inst.serverSite, country: inst.serverCountry }) } // Fallback: pick up servers that appear in neighbors/interfaces but not in instances for (const n of liveData.neighbors) { const id = String(n.serverId) if (!seen.has(id)) seen.set(id, { id, label: n.serverName, site: n.serverSite, country: n.serverCountry }) } return [...seen.values()] } // 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]) // 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 displayItems = filterServerId === "all" ? items : items.filter(i => i.routerKey === filterServerId) const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId) const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId) // 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 (
setFetchTick(t => t + 1)}> Обновить } />
{TABS.map(t => ( ))}
{/* ── server filter chips (same pattern as Filters page) ─────────────── */} {ospfServers.length > 0 && (
{/* "All" chip */}
{ospfServers.map(s => { const counts = serverCounts[s.id] const active = filterServerId === s.id return ( ) })}
)}
{/* data source banner */} {isLive && loading && (
Загрузка OSPF данных…
)} {isLive && !loading && fetchedAt && !liveError && (
Живые данные · обновлено {fetchedAt.toLocaleTimeString("ru")}
)} {isLive && liveError && (

Ошибка загрузки: {liveError}

)} {mode === "mock" && ( Моковые данные )} {/* KPI strip */}
{[ { label: "Роутеров", value: totalRouters }, { label: "Интерфейсов", value: totalInterfaces }, { label: "Зон (Area)", value: totalAreas }, ].map(s => (

{s.label}

{s.value}

))}
{activeTab === "interfaces" && } {activeTab === "neighbors" && ( )} {activeTab === "routes" && } {activeTab === "bfd" && }
) }