"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} }
setItems(initialItems)}>
{isLive ? "Последний снимок" : "Сбросить предпросмотр"}
{needsOptimize && (
Оптимизировать
)}
{isLive && filterServerId !== ALL_SERVERS_ID && (
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
)}
{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 недоступен Узел из ссылки отсутствует в текущем срезе. onSelect(null)}>Закрыть узел OSPF }
{graphUnknown &&
Топология OSPF неизвестна до успешного опроса всех устройств.
}
{!graphUnknown && graphNodes.length > 0 && (
{view =>
setShowDots(v => !v)}
className={cn(
"absolute top-3 right-3 flex min-h-11 items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-[11px] font-medium transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring disabled:opacity-50",
showDots
? "bg-background border-border text-foreground hover:bg-muted"
: "bg-background border-border text-muted-foreground hover:bg-muted",
)}>
Анимация
{!selectedId && (
кликните по узлу для деталей
)}
{selectedId && (
)}
}
)}
{!graphUnknown && graphNodes.length > 0 &&
{(["Full", "2-Way", "Pending", "Down", "Unknown"] as const).map(state => {state === "Pending" ? "Согласование" : state === "Unknown" ? "Неизвестно" : 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 => (
{ if (!operationBusy.current) onSelectTab(t.id) }}
className={cn(
"flex shrink-0 items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring -mb-px",
activeTab === t.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
)}>
{t.icon}{t.label}
))}
}
>
{unavailable &&
Контекст OSPF недоступен {contextError ?? `Сервер ${filterServerId} отсутствует среди доступных устройств.`}Сбросить контекст OSPF }
{bfdConflicts.length > 0 &&
OSPF Full при недоступном BFD Последний опрос: {bfdConflicts.map(n => `${n.localLabel} · ${n.localIface}`).join("; ")} — OSPF Full, BFD не Up. Проверьте канал: Full не подтверждает работу BFD. { if (!operationBusy.current) onInspectBfd() }}>Проверить BFD }
onSearch("")}>Сбросить поиск : undefined} />
{/* data source banner */}
{isLive && loading && (
Загрузка OSPF данных…
)}
{isLive && fetchedAt &&
Ответ каталога получен {fetchedAt.toLocaleTimeString("ru")} . Время получения браузером, не измерения RouterOS.
{liveServers.filter(s => filterServerId === ALL_SERVERS_ID || String(s.id) === filterServerId).map(s =>
{s.name}: {readReceipts[s.id] ? {new Date(readReceipts[s.id]).toLocaleTimeString("ru")} : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}
)}
{writeReceipt && (filterServerId === ALL_SERVERS_ID || String(writeReceipt.serverId) === filterServerId) &&
API подтвердил оптимизацию устройства {writeReceipt.serverId}: {new Date(writeReceipt.at).toLocaleTimeString("ru")}
}
}
{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) && }
)
}