Files
MikrotikManager/app/(main)/ospf/page.tsx
T

1442 lines
81 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, number | null>, serverByRouterId: Record<string, string>, labels: Record<string, string>): 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<string, number> = {
"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<string, string> = {
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<string, string> = {
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<string, { label: string }>()
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<string, { label: string }>()
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<T>(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 (
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium border",
color ?? "bg-muted text-muted-foreground border-border",
)}>
{children}
</span>
)
}
// ─── topology SVG ─────────────────────────────────────────────────────────────
interface TopologyGraphProps {
nodeStates?: ReadonlyMap<string, ReturnType<typeof ospfNodeState>>
graphNodes: GraphNode[]
graphEdges: GraphEdge[]
routerIds: Record<string, string>
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<SVGSVGElement>(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<string, GraphEdge[]>()
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 (
<svg ref={svgRef} role="group" aria-label="Топология OSPF"
viewBox={`0 0 ${TOPO_W} ${TOPO_H}`}
style={{ width: "100%", minWidth: 680, height: TOPO_H, display: "block", userSelect: "none", cursor: "default" }}
onClick={() => onNodeClick(null)}
>
<defs>
<pattern id="ospf-dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.8" fill="rgba(148,163,184,0.08)" />
</pattern>
</defs>
<rect width={TOPO_W} height={TOPO_H} fill="var(--background)" />
<rect width={TOPO_W} height={TOPO_H} fill="url(#ospf-dots)" />
{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 (
<g key={i} role={grouped ? "img" : undefined} data-ospf-links={links.length} aria-label={grouped ? `Параллельные связи: ${links.length}. ${links.map(link => `${link.state}, cost ${link.cost ?? "—"}`).join("; ")}` : undefined} style={{ transition: showDots ? "opacity 0.2s" : "none" }} opacity={opacity}>
{grouped && <title>{links.map(link => `${link.state}, cost ${link.cost ?? "—"}`).join("; ")}</title>}
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y}
stroke={color} strokeWidth={strokeW}
strokeDasharray={isDashed ? "7 4" : "none"} />
{showDots && !mixed && e.state === "Full" && (
<circle r="3.5" fill={color} opacity="0.9">
<animateMotion dur={`${2.2 + (i % 5) * 0.38}s`} repeatCount="indefinite"
path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
</circle>
)}
<g transform={`translate(${mx},${my})`}>
<rect x={-badgeWidth / 2} y="-10" width={badgeWidth} height="18" rx="4"
fill="var(--background)" stroke={color} strokeWidth="0.8" opacity="0.95" />
<text textAnchor="middle" y="4" fontSize="12" fontWeight="700"
fill={color} fontFamily="ui-monospace,monospace">{grouped ? `${links.length} ${links.length < 5 ? "связи" : "связей"}` : e.cost ?? "—"}</text>
</g>
</g>
)
})}
{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 (
<g role="button" tabIndex={0} aria-label={`Открыть узел ${node.label}`} aria-expanded={isSel} className="focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring" data-ospf-node={node.id} data-ospf-state={state} key={node.id} transform={`translate(${node.x},${node.y})`}
style={{ cursor: "pointer", transition: showDots ? "opacity 0.2s" : "none" }}
opacity={dimmed ? 0.25 : 1}
onClick={e => { 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 && (
<circle r={NODE_R + 14} fill={theme.glow} opacity="0.7">
<animate attributeName="opacity" values="0.8;0.18;0.8" dur="3s" repeatCount="indefinite" />
</circle>
)}
{state === "2-Way" && !dimmed && <circle r={NODE_R + 10} fill={theme.glow} />}
{isSel && <circle r={NODE_R+11} fill="none" stroke="var(--ring)" strokeWidth="2" />}
{isHov && !isSel && <circle r={NODE_R+10} fill="none" stroke="var(--ring)" strokeWidth="1.5" strokeDasharray="4 3" />}
<circle r={NODE_R} fill={theme.fill}
stroke={isSel ? "var(--foreground)" : theme.stroke} strokeWidth={isSel ? 2.8 : 1.8} />
{node.site !== "—" && (
<g transform={`translate(${NODE_R-10},${-(NODE_R-10)})`}>
<circle r="10" fill={
node.site === "MSK" ? "var(--primary)" : node.site === "SPB" ? "var(--info)" :
node.site === "FRA" ? "var(--warning)" : "var(--success)"} />
<text textAnchor="middle" y="4" fontSize="6" fontWeight="700" fill="var(--foreground)" fontFamily="system-ui,sans-serif">
{node.site.slice(0,3)}
</text>
</g>
)}
<text textAnchor="middle" y="-5" fontSize="11" fontWeight="700" fill="var(--foreground)" fontFamily="ui-monospace,monospace">
{node.site !== "—" ? node.site : "?"}
</text>
<text textAnchor="middle" y="10" fontSize="12" fill={isSel ? "var(--foreground)" : theme.stroke}
fontFamily="ui-monospace,monospace" fontWeight="500">{node.short}</text>
<text textAnchor="middle" y={NODE_R+20} fontSize="14" fill="var(--muted-foreground)" fontFamily="ui-monospace,monospace">
{node.label.replace(/^mt-/, "")}
</text>
</g>
)
})}
</svg>
)
}
// ─── node detail panel ────────────────────────────────────────────────────────
function NodeDetailPanel({
nodeId, graphNodes, routerIds, neighbors, items, onClose,
}: {
nodeId: string; graphNodes: GraphNode[]; routerIds: Record<string, string>
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 (
<div role="region" aria-label="Детали узла OSPF" className="w-full min-h-0 flex flex-col border-t border-border lg:w-72 lg:shrink-0 lg:border-t-0 lg:border-l lg:overflow-y-auto"
style={{ background: "var(--background)" }}
onKeyDown={event => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose() } }}>
<div className="flex items-start gap-2 px-4 py-3 border-b border-border shrink-0">
<div className="size-8 rounded-full flex items-center justify-center shrink-0 mt-0.5 text-[10px] font-bold text-white"
style={{ background: siteFill, color: node.site === "—" ? "var(--muted-foreground)" : "var(--primary-foreground)" }}>
{node.site !== "—" ? node.site.slice(0,3) : "?"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-mono font-semibold text-foreground truncate leading-tight">{node.label}</p>
<p className="text-[10px] font-mono text-muted-foreground mt-0.5">Router-ID: {routerId}</p>
</div>
<button type="button" aria-label="Закрыть" onClick={onClose} className="flex min-h-11 min-w-11 items-center justify-center text-muted-foreground hover:text-foreground transition-colors shrink-0">
<XIcon className="size-3.5" />
</button>
</div>
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="size-2 rounded-full shrink-0" style={{ background: theme.stroke }} />
<span className="text-xs font-mono font-semibold" style={{ color: theme.stroke }}>{panelState === "Pending" ? "Согласование" : panelState === "Unknown" ? "Неизвестно" : panelState}</span>
<div className="flex gap-1.5 ml-auto">
{fullCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>Full ×{fullCnt}</span>}
{partCnt > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded-full border border-current/25 font-medium"
style={{ background: "var(--status-degraded-bg)", color: "var(--status-degraded-fg)" }}>2-Way ×{partCnt}</span>}
</div>
</div>
<div className="px-4 py-3 border-b border-border">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Соседи ({adjacencies.length})</p>
{adjacencies.length === 0 && <p className="text-[11px] text-muted-foreground">Нет активных adjacency</p>}
<OspfDetailList key={`neighbors-${nodeId}`} label="Соседи узла OSPF" items={adjacencies} getSearchValue={adj => `${adj.peerLabel} ${adj.peerId} ${adj.iface} ${adj.state}`} renderItem={(adj, i) => {
const ec = EDGE_COLOR[adj.state] ?? "var(--muted-foreground)"
return (
<div key={i} className="flex items-start gap-2.5 py-2.5">
<span className="size-1.5 rounded-full shrink-0 mt-1.5" style={{ background: ec }} />
<div className="flex-1 min-w-0">
<p className="text-xs font-mono font-medium text-foreground truncate leading-tight">{adj.peerLabel}</p>
<p className="text-[10px] font-mono text-muted-foreground mt-0.5">{adj.iface} · cost {adj.cost ?? "—"}</p>
<p className="text-[10px] text-muted-foreground mt-0.5 tabular-nums">{adj.uptime}</p>
</div>
<span className="text-[10px] font-mono font-semibold shrink-0 mt-0.5" style={{ color: ec }}>{adj.state}</span>
</div>
)
}} />
</div>
<div className="px-4 py-3">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Интерфейсы OSPF{ifaces.length > 0 && <span className="ml-1 text-muted-foreground">({ifaces.length})</span>}
</p>
{ifaces.length === 0 && <p className="text-[11px] text-muted-foreground">Нет данных</p>}
<OspfDetailList key={`interfaces-${nodeId}`} label="Интерфейсы узла OSPF" items={ifaces} getSearchValue={iface => `${iface.interfaceName} ${iface.area} ${iface.cost ?? ""}`} renderItem={iface => (
<div key={iface.key} className="flex items-center gap-2 py-2">
<span className={cn("size-1.5 rounded-full shrink-0", iface.active ? "bg-emerald-500" : "bg-muted-foreground/40")} />
<code className="text-[11px] font-mono flex-1 min-w-0 truncate" style={{ color: "var(--muted-foreground)" }}>{iface.interfaceName}</code>
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
{iface.area === "0.0.0.0" ? "backbone" : `area ${iface.area}`}
</span>
<span className="text-[10px] font-mono shrink-0" style={{ color: "var(--info)" }}>cost {iface.cost ?? "—"}</span>
</div>
)} />
</div>
</div>
)
}
// ─── 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<OspfItem[]>(initialItems)
const [dragging, setDragging] = useState<string | null>(null)
const [dragOver, setDragOver] = useState<string | null>(null)
const [optimizing, setOptimizing] = useState(false)
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
const [operationError, setOperationError] = useState<string | null>(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<string, { routerKey: string; routerLabel: string; areas: Record<string, OspfItem[]> }> = {}
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<string, { optimalCost: number; prob: number }> = {}
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<unknown>(
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<string, number> = {}
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 (
<div className="flex flex-col gap-4">
{isLive && filterServerId !== ALL_SERVERS_ID && <p className="text-xs text-muted-foreground">Оптимизация применяется ко всем интерфейсам выбранного устройства.</p>}
{isLive && filterServerId !== ALL_SERVERS_ID && !dataReady && <p role="status" className="text-xs text-muted-foreground">Оптимизация недоступна до полного успешного опроса и подтверждения costs/состояний интерфейсов с правом записи.</p>}
{operationError && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Оптимизация OSPF не подтверждена</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" disabled={optimizing} onClick={() => setItems(initialItems)}>
<RefreshCwIcon className="size-4" />{isLive ? "Последний снимок" : "Сбросить предпросмотр"}
</Button>
{needsOptimize && (
<Button variant="outline" size="sm" onClick={handleOptimize}>
<WandSparklesIcon className="size-4" />Оптимизировать
</Button>
)}
{isLive && filterServerId !== ALL_SERVERS_ID && (
<Button variant="outline" size="sm" onClick={handleLiveOptimize} disabled={optimizing || !dataReady || !can("mm:network:write")}>
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin motion-reduce:animate-none")} />
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
</Button>
)}
</div>
{grouped.length === 0 && !dataIncomplete && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
{hasSearch ? "Нет интерфейсов по текущему поиску" : "OSPF интерфейсы не настроены ни на одном сервере"}
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{grouped.map(router => {
const totalIfaces = router.areas.reduce((s, a) => s + a.items.length, 0)
return (
<Frame key={router.routerKey} dense className="w-full overflow-hidden">
<FramePanel className="p-0 overflow-hidden">
<div className="flex items-center gap-3 px-4 py-3 border-b">
<NetworkIcon className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-none">{router.routerLabel}</p>
<p className="text-[11px] font-mono text-muted-foreground mt-0.5">{router.routerKey}</p>
</div>
<span className="text-[11px] text-muted-foreground">{totalIfaces} iface · {router.areas.length} area</span>
</div>
{router.areas.map((ag, aIdx) => (
<div key={ag.area}>
{aIdx > 0 && <Separator />}
<div className="flex items-center gap-2 px-4 py-1.5 bg-muted/30">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Area</span>
<code className="text-[11px] font-mono">{ag.area}</code>
{ag.area === "0.0.0.0" && <span className="text-[10px] text-muted-foreground/60">Backbone</span>}
</div>
<div className="divide-y divide-border/60">
{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 (
<div key={item.key}
draggable={!isLive}
onDragStart={() => !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",
)}>
<GripVerticalIcon className={cn("size-3.5 shrink-0", isLive ? "text-muted-foreground/10" : "text-muted-foreground/30")} />
<span className={cn("inline-block size-1.5 rounded-full shrink-0", !item.dataUnknown && item.active ? "bg-[var(--status-online)]" : "bg-muted-foreground/40")} />
<code className="text-xs font-mono flex-1 min-w-0 truncate">{item.interfaceName}</code>
{(item.dataUnknown || item.active === null) && <span className="text-xs text-muted-foreground">Неизвестно</span>}
{item.type && item.type !== "broadcast" && (
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">{item.type}</Chip>
)}
{item.useBfd && (
<Chip color="bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20">BFD</Chip>
)}
{hint && hint.prob > 0 && !isLive && (
<Chip color={matches
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
}>opt {hint.optimalCost}</Chip>
)}
<Chip color="bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20">cur {item.cost ?? "—"}</Chip>
{liveOptimal !== undefined && !item.dataUnknown && <Chip color={costDiffers
? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
}>opt {liveOptimal}</Chip>}
</div>
)
})}
</div>
</div>
))}
</FramePanel>
</Frame>
)
})}
</div>
{!isLive && (
<div className="flex items-center gap-4 flex-wrap px-1">
<span className="text-xs text-muted-foreground">Легенда:</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Chip color="bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">opt 10</Chip>
совпадает с оптимальным
</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Chip color="bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">opt 20</Chip>
рекомендован другой cost
</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<GripVerticalIcon className="size-3.5 text-muted-foreground" />перетащите для изменения приоритета
</span>
</div>
)}
</div>
)
}
// ─── neighbors tab ────────────────────────────────────────────────────────────
function NeighborsTab({
neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown, selectedId, onSelect, hasSearch,
}: {
neighbors: OspfNeighbor[]
items: OspfItem[]
graphNodes: GraphNode[]
graphEdges: GraphEdge[]
routerIds: Record<string, string>
dataIncomplete: boolean
graphUnknown: boolean
selectedId: string | null
onSelect: (id: string | null) => void
hasSearch: boolean
}) {
const [highlightId, setHighlightId] = useState<string | null>(null)
const reducedMotion = usePrefersReducedMotion()
function closeNode() {
const previous = selectedId; onSelect(null)
if (previous) requestAnimationFrame(() => (document.querySelector<SVGGElement>(`[data-ospf-node="${CSS.escape(previous)}"]`) ?? document.querySelector<HTMLInputElement>('[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 (
<div className="flex flex-col gap-5">
<KpiStatGrid
gridClassName="grid-cols-1 sm:grid-cols-3"
aria-label="Сводка соседей OSPF"
items={[
{
id: "neighbors",
label: "Соседи",
value: dataIncomplete ? "—" : neighbors.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "full",
label: "Full",
value: dataIncomplete ? "—" : fullCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-full",
label: "Не Full",
value: dataIncomplete ? "—" : neighbors.length - fullCount,
icon: <AlertCircleIcon className="size-4" />,
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) && <Alert variant="warning"><AlertTitle>Узел OSPF недоступен</AlertTitle><AlertDescription>Узел из ссылки отсутствует в текущем срезе.<Button type="button" size="sm" variant="outline" onClick={() => onSelect(null)}>Закрыть узел OSPF</Button></AlertDescription></Alert>}
{graphUnknown && <p role="status" className="text-sm text-muted-foreground">Топология OSPF неизвестна до успешного опроса всех устройств.</p>}
{!graphUnknown && graphNodes.length > 0 && (
<OspfTopologyScope nodes={graphNodes} edges={graphEdges} selectedId={selectedId} onSelect={onSelect} width={TOPO_W} height={TOPO_H}>
{view => <div className="flex min-w-0 flex-col rounded-xl overflow-hidden border border-border lg:h-[360px] lg:flex-row">
<div className="relative min-w-0 overflow-x-auto lg:flex-1" style={{ background: "var(--background)", minWidth: 0 }}>
<TopologyGraph
graphNodes={view.nodes}
graphEdges={view.edges}
nodeStates={view.nodeStates}
routerIds={routerIds}
neighbors={neighbors}
highlightId={highlightId}
selectedId={selectedId}
showDots={showDots && !reducedMotion}
onNodeClick={onSelect}
/>
<button type="button" aria-pressed={showDots && !reducedMotion} disabled={reducedMotion} title={reducedMotion ? "Анимация отключена настройками устройства" : undefined}
onClick={() => 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",
)}>
<span className={cn("size-1.5 rounded-full", showDots && !reducedMotion ? "bg-[var(--status-online)]" : "bg-muted-foreground/40")} />
Анимация
</button>
{!selectedId && (
<div className="absolute bottom-3 right-3 text-[10px] font-mono text-muted-foreground pointer-events-none">
кликните по узлу для деталей
</div>
)}
</div>
{selectedId && (
<NodeDetailPanel
nodeId={selectedId}
graphNodes={graphNodes}
routerIds={routerIds}
neighbors={neighbors}
items={items}
onClose={closeNode}
/>
)}
</div>}
</OspfTopologyScope>
)}
{!graphUnknown && graphNodes.length > 0 && <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground" aria-label="Легенда OSPF">
{(["Full", "2-Way", "Pending", "Down", "Unknown"] as const).map(state => <span key={state} className="inline-flex items-center gap-1.5"><span aria-hidden="true" className="size-2 rounded-full" style={{ background: EDGE_COLOR[state] }} />{state === "Pending" ? "Согласование" : state === "Unknown" ? "Неизвестно" : state}</span>)}
<span>Цифра на связи — cost. Узел открывается нажатием или Enter.</span>
<span className="lg:hidden">Схему можно прокручивать по горизонтали.</span>
</div>}
<DataPageCard>
<OspfNeighborsDataGrid
neighbors={neighbors}
emptyTitle={hasSearch ? "Нет соседей по текущему поиску" : undefined}
selectedRouterId={selectedId}
onSelect={onSelect}
onHighlight={setHighlightId}
/>
</DataPageCard>
</div>
)
}
// ─── routes tab (mock) ────────────────────────────────────────────────────────
function RoutesTab({ routes, hasSearch }: { routes: OspfRoute[]; hasSearch: boolean }) {
const typeCount = Object.entries(
routes.reduce<Record<string, number>>((acc, r) => { acc[r.type] = (acc[r.type] ?? 0) + 1; return acc }, {})
)
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm font-medium">{routes.some(r => r.dataUnknown) ? "Последний снимок: " : ""}{routes.length} маршрутов</span>
{typeCount.map(([type, count]) => (
<OspfRouteTypeBadge key={type} type={type as OspfRoute["type"]} count={count} />
))}
</div>
<DataPageCard>
<OspfRoutesDataGrid routes={routes} emptyTitle={hasSearch ? "Нет маршрутов по текущему поиску" : undefined} />
</DataPageCard>
<div className="flex items-center gap-5 flex-wrap px-1">
<span className="text-xs text-muted-foreground">Типы:</span>
{([
{ 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 }) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<OspfRouteTypeBadge type={type} />
{desc}
</span>
))}
</div>
</div>
)
}
// ─── 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 (
<div className="flex flex-col gap-4">
<KpiStatGrid
gridClassName="grid-cols-2 md:grid-cols-4"
aria-label="Сводка BFD"
items={[
{
id: "sessions",
label: "Сессии",
value: dataIncomplete ? "—" : sessions.length,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Up",
value: dataIncomplete ? "—" : upCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "down",
label: <span title="Down / AdminDown">Down/Adm</span>,
value: dataIncomplete ? "—" : downCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
variant: downCount > 0 ? "destructive" : "default",
},
{
id: "init",
label: "Init",
value: dataIncomplete ? "—" : initCount,
icon: <LayersIcon className="size-4" />,
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
variant: initCount > 0 ? "warning" : "default",
},
]}
/>
{sessions.length === 0 && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
{hasSearch ? "Нет BFD-сессий по текущему поиску" : "BFD-сессий не обнаружено ни на одном сервере"}
</div>
)}
{sessions.length > 0 && (
<DataPageCard>
<OspfBfdDataGrid sessions={sessions} />
</DataPageCard>
)}
{sessions.length > 0 && (
<div className="flex items-center gap-4 flex-wrap px-1">
<p className="text-xs text-muted-foreground">
BFD обнаруживает сбои быстрее OSPF Hello/Dead таймеров.
{!dataIncomplete && totalPkts === null && <span> Общее число полученных пакетов неизвестно.</span>}
TX факт — наблюдаемый интервал отправки; RX минимум — требуемый минимальный интервал.
{!dataIncomplete && totalPkts !== null && <span className="ml-2 tabular-nums">Всего получено: {totalPkts.toLocaleString()} пакетов.</span>}
</p>
</div>
)}
</div>
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
{ id: "interfaces", label: "Интерфейсы", icon: <NetworkIcon className="size-3.5" /> },
{ id: "neighbors", label: "Соседи", icon: <ActivityIcon className="size-3.5" /> },
{ id: "routes", label: "Маршруты", icon: <RouteIcon className="size-3.5" /> },
{ id: "bfd", label: "BFD", icon: <ShieldIcon className="size-3.5" /> },
]
export default function OspfPage() {
return <Suspense fallback={<p role="status">Загрузка контекста OSPF…</p>}><OspfRoute /></Suspense>
}
function OspfRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const state = readOspfUrl(new URLSearchParams(params.toString()))
function navigate(values: Record<string, string | null>, 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 <OspfPageContent key={JSON.stringify([mode, backendUrl])} filterServerId={state.server} activeTab={state.tab} contextError={state.error} search={state.search} selectedNodeId={state.node} onSearch={value => 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<OspfCatalog | null>(null)
const [liveServers, setLiveServers] = useState<ReturnType<typeof ospfServersSchema.parse>>([])
const [loading, setLoading] = useState(isLive)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [fetchTick, setFetchTick] = useState(0)
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
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<unknown>(backendUrl, "/api/ospf/catalog"), requestJson<unknown>(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<string, string> = { 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<string, { ifaces: number; neighbors: number; bfd: number }> = {}
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<ServerTileItem[]>(() => (
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 (
<ServerRailLayout
preserveSelection
items={ospfRailItems}
selectedId={filterServerId}
onSelect={id => { if (!operationBusy.current) onSelectServer(id) }}
showAll
allCount={ospfServers.length}
loading={isLive && loading && ospfServers.length === 0}
header={
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" disabled={!isLive || loading || writeBusy} onClick={refresh}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin motion-reduce:animate-none")} />
Обновить
</Button>
</>
}
/>
}
banner={
<div className="border-b bg-background shrink-0">
<div className="flex min-w-0 items-center overflow-x-auto px-4 md:px-6">
{TABS.map(t => (
<button type="button" key={t.id} aria-pressed={activeTab === t.id} disabled={writeBusy} onClick={() => { 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}
</button>
))}
</div>
</div>
}
>
<div className="flex flex-col gap-5">
{unavailable && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Контекст OSPF недоступен</AlertTitle><AlertDescription>{contextError ?? `Сервер ${filterServerId} отсутствует среди доступных устройств.`}<Button type="button" variant="outline" size="sm" disabled={writeBusy} onClick={onResetContext}>Сбросить контекст OSPF</Button></AlertDescription></Alert>}
{bfdConflicts.length > 0 && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>OSPF Full при недоступном BFD</AlertTitle><AlertDescription>Последний опрос: {bfdConflicts.map(n => `${n.localLabel} · ${n.localIface}`).join("; ")} — OSPF Full, BFD не Up. Проверьте канал: Full не подтверждает работу BFD.<Button type="button" variant="outline" size="sm" disabled={writeBusy} onClick={() => { if (!operationBusy.current) onInspectBfd() }}>Проверить BFD</Button></AlertDescription></Alert>}
<DataPageToolbar search={search} onSearchChange={onSearch} searchPlaceholder="Поиск в текущем разделе OSPF…" countLabel={viewIncomplete ? "Данные неизвестны" : `${visibleCount} из ${scopeCount}`} actions={search ? <Button type="button" variant="ghost" size="sm" onClick={() => onSearch("")}>Сбросить поиск</Button> : undefined} />
{/* data source banner */}
{isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCwIcon className="size-3.5 animate-spin motion-reduce:animate-none" />Загрузка OSPF данных…
</div>
)}
{isLive && fetchedAt && <div role="group" aria-label="Свежесть OSPF" className="text-xs text-muted-foreground">
<p>Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>. Время получения браузером, не измерения RouterOS.</p>
{liveServers.filter(s => filterServerId === ALL_SERVERS_ID || String(s.id) === filterServerId).map(s => <p key={s.id}>{s.name}: {readReceipts[s.id] ? <time data-ospf-server={s.id} dateTime={readReceipts[s.id]}>{new Date(readReceipts[s.id]).toLocaleTimeString("ru")}</time> : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}</p>)}
{writeReceipt && (filterServerId === ALL_SERVERS_ID || String(writeReceipt.serverId) === filterServerId) && <p data-ospf-time="write">API подтвердил оптимизацию устройства {writeReceipt.serverId}: <time dateTime={writeReceipt.at}>{new Date(writeReceipt.at).toLocaleTimeString("ru")}</time></p>}
</div>}
{isLive && (liveError || Boolean(liveData?.failures.length)) && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>{liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}</AlertTitle><AlertDescription>{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}<Button variant="outline" size="sm" disabled={loading || writeBusy} onClick={refresh} aria-label="Повторить загрузку OSPF">Повторить</Button></AlertDescription></Alert>}
{viewIncomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) && <p role="status">Данные выбранного раздела OSPF пока неизвестны.</p>}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
</span>
)}
{(activeTab === "interfaces" || unavailable || isLive && !liveData) && <KpiStatGrid
gridClassName="grid-cols-2 md:grid-cols-3"
aria-label="Сводка OSPF"
items={[
{
id: "routers",
label: "Роутеры",
value: viewIncomplete || unavailable ? "—" : totalRouters,
hint: viewIncomplete || unavailable ? "неизвестно" : undefined,
icon: <RouterIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ifaces",
label: <span title="Интерфейсы">Интерф.</span>,
value: viewIncomplete ? "—" : totalInterfaces,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "areas",
label: "Зоны",
value: viewIncomplete ? "—" : totalAreas,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>}
{activeTab === "interfaces" && (!viewIncomplete || displayItems.length > 0) && (
<InterfacesTab key={filterServerId}
items={displayItems}
isLive={isLive}
filterServerId={filterServerId}
backendUrl={backendUrl}
dataReady={dataReady}
hasSearch={Boolean(query)}
dataIncomplete={viewIncomplete}
onOperationConfirmed={serverId => { 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) && (
<NeighborsTab
neighbors={displayNeighbors}
items={displayItems}
graphNodes={displayGraph.nodes}
graphEdges={displayGraph.edges}
selectedId={selectedNodeId}
onSelect={onSelectNode}
hasSearch={Boolean(query)}
routerIds={routerIds}
dataIncomplete={viewIncomplete}
graphUnknown={unavailable || isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)}
/>
)}
{activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} hasSearch={Boolean(query)} />}
{activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={viewIncomplete} hasSearch={Boolean(query)} />}
</div>
</ServerRailLayout>
)
}