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

1426 lines
71 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 { normalizeOspfState, normalizeBfdState, ospfNodeState, type OspfNeighborState, type BfdState } from "@/lib/ospf-state"
import { useMemo, useState, useEffect, useRef } from "react"
import { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
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, routeTypeClass } 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 ────────────────────────────────────────────────────────────────────
type OspfTab = "interfaces" | "neighbors" | "routes" | "bfd"
interface OspfServerInfo {
id: string // String(serverId) in live mode, routerKey in mock
label: string // serverName / routerLabel
site: string // "MSK", "SPB", …
country: string // ISO alpha-2 or ""
}
interface OspfItem {
key: string
routerKey: string
routerLabel: string
serverId: string
interfaceName: string
area: string
cost: number
active: boolean
dataUnknown?: boolean
type?: string
useBfd?: boolean
helloInterval?: number
deadInterval?: number
}
interface OspfNeighbor {
id: string
localRouter: string
localLabel: string
localIface: string
remoteRouter: string
remoteLabel: string
remoteRouterId: string
area: string
state: OspfNeighborState
cost: number
uptime: string
priority: number
dataUnknown?: boolean
}
interface OspfRoute {
id: string
destination: string
type: "O" | "O IA" | "O E1" | "O E2"
cost: number
nextHop: string
via: string
serverId: string
serverLabel: string
area: string
dataUnknown?: boolean
}
interface BfdSession {
id: string
serverId: string
serverLabel: string
localAddr: string
remoteAddr: string
state: BfdState
interval: number // txInterval ms
multiplier: number
iface: string
uptime: string | null
multihop: boolean
rxInterval: number // ms
holdTime: number // ms
packetsRx: number
packetsTx: number
stateChanges: number
dataUnknown?: boolean
}
// ─── backend types ────────────────────────────────────────────────────────────
interface BackendNeighbor {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
address: string; routerId: string; instance: string; area: string; areaId: string
interface: string; state: string; uptime: string | null; stateChanges: number; priority: number
}
interface BackendInterface {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
instance: string; area: string; areaId: string; interface: string; cost: number
type: string; disabled: boolean; inactive: boolean; priority: number
helloInterval: number; deadInterval: number; useBfd: boolean
}
interface BackendBfdSession {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
localAddr: string; remoteAddr: string; interface: string; state: string; uptime: string | null
multihop: boolean; multiplier: number; txInterval: number; rxInterval: number; holdTime: number
packetsRx: number; packetsTx: number; stateChanges: number
}
interface BackendOspfRoute {
id: string; serverId: number; serverName: string; serverSite: string
destination: string; type: OspfRoute["type"]; cost: number; nextHop: string; via: string; area: string
}
function isRefInterfaceName(name: string): boolean {
return /^\(ref\s+\*.+\)$/.test(name.trim())
}
// ─── backend → frontend mappers ───────────────────────────────────────────────
function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map<string, number>): OspfNeighbor {
const state = normalizeOspfState(b.state)
const costKey = `${b.serverId}::${b.interface}`
return {
id: `${b.serverId}-${b.id}`,
localRouter: String(b.serverId),
localLabel: b.serverName,
localIface: b.interface,
remoteRouter: b.routerId,
remoteLabel: b.routerId, // no server name for remote peers
remoteRouterId: b.routerId,
area: b.areaId !== b.area ? `${b.area} (${b.areaId})` : b.area,
state,
cost: ifaceMap.get(costKey) ?? 10,
uptime: b.uptime ?? "—",
priority: b.priority,
}
}
function backendToItem(b: BackendInterface): OspfItem {
return {
key: `${b.serverId}-${b.id}`,
routerKey: String(b.serverId),
routerLabel: b.serverName,
serverId: String(b.serverId),
interfaceName: b.interface,
area: b.areaId,
cost: b.cost,
active: !b.disabled && !b.inactive,
type: b.type,
useBfd: b.useBfd,
helloInterval: b.helloInterval,
deadInterval: b.deadInterval,
}
}
function backendToBfdSession(b: BackendBfdSession): BfdSession {
const 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"]
const type = allowed.includes(b.type) ? b.type : "O"
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: "#2d1e06", stroke: "#fbbf24", glow: "transparent" },
Unknown: { fill: "#111827", stroke: "#9ca3af", glow: "transparent" },
Full: { fill: "#0f2d1f", stroke: "#4ade80", glow: "rgba(74,222,128,0.18)" },
"2-Way":{ fill: "#2d1e06", stroke: "#fbbf24", glow: "rgba(251,191,36,0.14)" },
Down: { fill: "#2d0f0f", stroke: "#f87171", glow: "transparent" },
none: { fill: "#111827", stroke: "#374151", glow: "transparent" },
}
const EDGE_COLOR: Record<string, string> = {
Attempt: "#fbbf24", Init: "#fbbf24", ExStart: "#fbbf24", Exchange: "#fbbf24", Loading: "#fbbf24", Unknown: "#9ca3af",
Full: "#4ade80",
"2-Way":"#fbbf24",
Down: "#f87171",
}
interface GraphNode { id: string; label: string; short: string; site: string; x: number; y: number }
interface GraphEdge { from: string; to: string; state: OspfNeighbor["state"]; cost: number }
// Mock topology (static layout for mock mode)
const MOCK_GRAPH_NODES: GraphNode[] = [
{ id: "srv1", label: "mt-msk-core-01", short: "CORE-01", site: "MSK", x: 450, y: 180 },
{ id: "srv2", label: "mt-spb-edge-01", short: "EDGE-01", site: "SPB", x: 730, y: 80 },
{ id: "srv3", label: "mt-fra-edge-01", short: "EDGE-01", site: "FRA", x: 730, y: 290 },
{ id: "srv4", label: "mt-ams-edge-01", short: "EDGE-01", site: "AMS", x: 600, y: 180 },
{ id: "srv7", label: "mt-msk-lab-01", short: "LAB-01", site: "MSK", x: 170, y: 80 },
{ id: "srv6", label: "mt-ams-test-01", short: "TEST-01", site: "AMS", x: 170, y: 290 },
]
const MOCK_GRAPH_EDGES: GraphEdge[] = [
{ from: "srv1", to: "srv2", state: "Full", cost: 10 },
{ from: "srv1", to: "srv3", state: "Full", cost: 20 },
{ from: "srv1", to: "srv4", state: "2-Way", cost: 30 },
{ from: "srv1", to: "srv7", state: "Full", cost: 10 },
{ from: "srv7", to: "srv2", state: "Full", cost: 20 },
{ from: "srv6", to: "srv1", state: "Full", cost: 10 },
{ from: "srv6", to: "srv3", state: "Full", cost: 20 },
]
const MOCK_ROUTER_IDS: Record<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 = `remote-${n.remoteRouterId}`
if (!localMap.has(n.remoteRouter) && !remoteMap.has(remId)) {
remoteMap.set(remId, { label: n.remoteRouterId })
}
}
const allIds = [...localMap.keys(), ...remoteMap.keys()]
const total = allIds.length
const cx = TOPO_W / 2, cy = TOPO_H / 2
const radius = Math.min(TOPO_W, TOPO_H) * 0.38
const nodes: GraphNode[] = allIds.map((id, i) => {
const angle = (2 * Math.PI * i / total) - Math.PI / 2
const x = cx + radius * Math.cos(angle)
const y = cy + radius * Math.sin(angle)
const label = localMap.get(id)?.label ?? remoteMap.get(id)?.label ?? id
const short = label.replace(/^mt-/, "").toUpperCase().slice(0, 8)
return { id, label, short, site: "—", x, y }
})
const edges: GraphEdge[] = neighbors.map(n => ({
from: n.localRouter,
to: localMap.has(n.remoteRouter) ? n.remoteRouter : `remote-${n.remoteRouterId}`,
state: n.state,
cost: n.cost,
}))
return { nodes, edges }
}
// ─── helpers ──────────────────────────────────────────────────────────────────
function moveInArray<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 stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) {
if (state === "Full" || state === "Up")
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
if (state === "2-Way" || state === "Init")
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
}
function 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 {
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, highlightId, selectedId, showDots, onNodeClick }: TopologyGraphProps) {
const nodeMap = Object.fromEntries(graphNodes.map(n => [n.id, n]))
function nodeState(id: string): keyof typeof NODE_THEME {
const involved = graphEdges.filter(e => e.from === id || e.to === id)
return ospfNodeState(involved.map(edge => edge.state))
}
return (
<svg
viewBox={`0 0 ${TOPO_W} ${TOPO_H}`}
style={{ width: "100%", 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="#060d1a" />
<rect width={TOPO_W} height={TOPO_H} fill="url(#ospf-dots)" />
{graphEdges.map((e, i) => {
const a = nodeMap[e.from]
const b = nodeMap[e.to]
if (!a || !b) return null
const color = EDGE_COLOR[e.state] ?? "#64748b"
const isDashed = e.state !== "Full"
const active = highlightId === e.from || highlightId === e.to
|| selectedId === e.from || selectedId === e.to
const opacity = active ? 1 : 0.38
const strokeW = active ? 2.4 : 1.4
const mx = (a.x + b.x) / 2
const my = (a.y + b.y) / 2
return (
<g key={i} style={{ transition: "opacity 0.2s" }} opacity={opacity}>
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y}
stroke={color} strokeWidth={strokeW}
strokeDasharray={isDashed ? "7 4" : "none"} />
{showDots && 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="-15" y="-10" width="30" height="18" rx="4"
fill="#060d1a" stroke={color} strokeWidth="0.8" opacity="0.95" />
<text textAnchor="middle" y="4" fontSize="8.5" fontWeight="700"
fill={color} fontFamily="ui-monospace,monospace">{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 key={node.id} transform={`translate(${node.x},${node.y})`}
style={{ cursor: "pointer", transition: "opacity 0.2s" }}
opacity={dimmed ? 0.25 : 1}
onClick={e => { e.stopPropagation(); onNodeClick(isSel ? null : node.id) }}>
{state === "Full" && !dimmed && (
<circle r={NODE_R + 14} fill={theme.glow} opacity="0.7">
<animate attributeName="r" values={`${NODE_R+9};${NODE_R+17};${NODE_R+9}`} dur="3s" repeatCount="indefinite" />
<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="rgba(255,255,255,0.65)" strokeWidth="2" />}
{isHov && !isSel && <circle r={NODE_R+10} fill="none" stroke="rgba(255,255,255,0.38)" strokeWidth="1.5" strokeDasharray="4 3" />}
<circle r={NODE_R} fill={theme.fill}
stroke={isSel ? "#fff" : 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" ? "#7c3aed" : node.site === "SPB" ? "#0369a1" :
node.site === "FRA" ? "#b45309" : "#065f46"} />
<text textAnchor="middle" y="4" fontSize="6" fontWeight="700" fill="#fff" fontFamily="system-ui,sans-serif">
{node.site.slice(0,3)}
</text>
</g>
)}
<text textAnchor="middle" y="-5" fontSize="11" fontWeight="700" fill="#f1f5f9" fontFamily="ui-monospace,monospace">
{node.site !== "—" ? node.site : "?"}
</text>
<text textAnchor="middle" y="10" fontSize="7.5" fill={isSel ? "#fff" : theme.stroke}
fontFamily="ui-monospace,monospace" fontWeight="500">{node.short}</text>
<text textAnchor="middle" y={NODE_R+18} fontSize="8" fill="#94a3b8" fontFamily="ui-monospace,monospace">
{node.label.replace(/^mt-/, "")}
</text>
</g>
)
})}
{/* legend */}
<g transform="translate(14, 14)">
<rect width="152" height="118" rx="8" fill="rgba(6,13,26,0.90)" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
<text x="10" y="22" fontSize="7.5" fontWeight="700" fill="#64748b" fontFamily="system-ui" letterSpacing="0.1em">ЛЕГЕНДА OSPF</text>
{([
{ state: "Full", label: "Full adjacency" },
{ state: "2-Way", label: "2-Way partial" },
{ state: "Down", label: "Down" },
] as const).map(({ state, label }, i) => {
const c = EDGE_COLOR[state] ?? "#64748b"
return (
<g key={state} transform={`translate(10, ${34 + i * 20})`}>
<circle cx="5" cy="0" r="4.5" fill={c} opacity="0.85" />
<line x1="16" y1="0" x2="44" y2="0" stroke={c}
strokeWidth={state === "Full" ? 2 : 1.2}
strokeDasharray={state !== "Full" ? "5 3" : "none"} opacity="0.8" />
<text x="52" y="4" fontSize="8" fill="#cbd5e1" fontFamily="system-ui">{label}</text>
</g>
)
})}
<line x1="10" y1="100" x2="142" y2="100" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
<text x="10" y="113" fontSize="7" fill="#475569" fontFamily="ui-monospace,monospace">
клик по узлу — подробности · цифра = cost
</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] ?? "—"
const adjacencies = neighbors.filter(n => n.localRouter === nodeId || n.remoteRouter === nodeId)
.map(n => {
if (n.localRouter === nodeId)
return { iface: n.localIface, peerLabel: n.remoteLabel, peerId: n.remoteRouterId, state: n.state, cost: n.cost, uptime: n.uptime }
return { iface: n.localIface, peerLabel: n.localLabel, peerId: routerId, state: n.state, cost: n.cost, uptime: n.uptime }
})
const ifaces = items.filter(i => i.routerKey === nodeId)
const fullCnt = adjacencies.filter(a => a.state === "Full").length
const partCnt = adjacencies.filter(a => a.state === "2-Way").length
// derive state from edges
const panelState = ospfNodeState(adjacencies.map(edge => edge.state))
const theme = NODE_THEME[panelState]
const siteFill =
node.site === "MSK" ? "#7c3aed" : node.site === "SPB" ? "#0369a1" :
node.site === "FRA" ? "#b45309" : "#065f46"
return (
<div className="w-72 shrink-0 flex flex-col overflow-y-auto border-l border-white/10"
style={{ background: "#060d1a", maxHeight: TOPO_H }}>
<div className="flex items-start gap-2 px-4 py-3 border-b border-white/10 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 }}>
{node.site !== "—" ? node.site.slice(0,3) : "?"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-mono font-semibold text-[#f1f5f9] truncate leading-tight">{node.label}</p>
<p className="text-[10px] font-mono text-[#64748b] mt-0.5">Router-ID: {routerId}</p>
</div>
<button onClick={onClose} className="text-white/25 hover:text-white/70 transition-colors mt-0.5 shrink-0">
<XIcon className="size-3.5" />
</button>
</div>
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-white/10 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}</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-white/10">
<p className="text-[10px] font-semibold text-[#475569] uppercase tracking-wider mb-2">Соседи ({adjacencies.length})</p>
{adjacencies.length === 0 && <p className="text-[11px] text-[#475569]">Нет активных adjacency</p>}
<div className="flex flex-col gap-0 divide-y divide-white/[0.04]">
{adjacencies.map((adj, i) => {
const ec = EDGE_COLOR[adj.state] ?? "#64748b"
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-[#f1f5f9] truncate leading-tight">{adj.peerLabel}</p>
<p className="text-[10px] font-mono text-[#64748b] mt-0.5">{adj.iface} · cost {adj.cost}</p>
<p className="text-[10px] text-[#475569] 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>
<div className="px-4 py-3">
<p className="text-[10px] font-semibold text-[#475569] uppercase tracking-wider mb-2">
Интерфейсы OSPF{ifaces.length > 0 && <span className="ml-1 text-[#374151]">({ifaces.length})</span>}
</p>
{ifaces.length === 0 && <p className="text-[11px] text-[#475569]">Нет данных</p>}
<div className="flex flex-col gap-0 divide-y divide-white/[0.04]">
{ifaces.map(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-[#374151]")} />
<code className="text-[11px] font-mono flex-1 min-w-0 truncate" style={{ color: "#94a3b8" }}>{iface.interfaceName}</code>
<span className="text-[10px] font-mono text-[#475569] shrink-0">
{iface.area === "0.0.0.0" ? "backbone" : `area ${iface.area}`}
</span>
<span className="text-[10px] font-mono shrink-0" style={{ color: "#0ea5e9" }}>cost {iface.cost}</span>
</div>
))}
</div>
</div>
</div>
)
}
// ─── interfaces tab ───────────────────────────────────────────────────────────
function InterfacesTab({
items: initialItems,
isLive,
filterServerId,
backendUrl,
onLiveDataRefresh,
dataReady,
dataIncomplete,
onOperationBusy,
onOperationConfirmed,
}: {
items: OspfItem[]
isLive: boolean
filterServerId: string
backendUrl: string
onLiveDataRefresh: () => void
dataReady: boolean
dataIncomplete: 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 - b.cost) }))
.sort((a, b) => a.area.localeCompare(b.area)),
}))
}, [items])
// Optimizer hints only work meaningfully for mock data
const hints = useMemo(() => {
if (isLive) return {}
const out: Record<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:servers: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 }),
},
)
if (!mounted.current) return
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
})
setLiveOptimalCost(byKey)
onOperationConfirmed(data.serverId)
toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`)
onLiveDataRefresh()
} catch (err) {
if (!mounted.current) return
setOperationError(`API не подтвердил завершение оптимизации: ${err instanceof Error ? err.message : String(err)}. Часть costs могла измениться; выполнен повторный опрос перед новой попыткой.`)
onLiveDataRefresh()
} 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 - b.cost)
const fromIdx = areaItems.findIndex(i => i.key === dragging)
const toIdx = areaItems.findIndex(i => i.key === targetKey)
if (fromIdx < 0 || toIdx < 0) return prev
const reordered = moveInArray(areaItems, fromIdx, toIdx).map((item, idx) => ({ ...item, cost: (idx + 1) * COST_STEP }))
return [...prev.filter(i => !(i.routerKey === routerKey && i.area === area)), ...reordered]
})
setDragging(null); setDragOver(null)
}
function handleOptimize() {
setItems(prev => prev.map(item => {
const h = hints[item.key]
return h && item.cost !== h.optimalCost ? { ...item, cost: h.optimalCost } : item
}))
toast.success("Локальный предпросмотр costs обновлён")
}
return (
<div className="flex flex-col gap-4">
{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:servers:write")}>
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin")} />
{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">
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 items-center gap-3 px-4 py-2.5 select-none transition-colors",
!isLive && "cursor-grab",
dragging === item.key && "opacity-40",
dragOver === item.key && dragging !== item.key && "bg-primary/5",
dragging !== item.key && dragOver !== item.key && "hover:bg-muted/40",
)}>
<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 && <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,
}: {
neighbors: OspfNeighbor[]
items: OspfItem[]
graphNodes: GraphNode[]
graphEdges: GraphEdge[]
routerIds: Record<string, string>
dataIncomplete: boolean
graphUnknown: boolean
}) {
const [highlightId, setHighlightId] = useState<string | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
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
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",
},
]}
/>
{graphUnknown && <p role="status" className="text-sm text-muted-foreground">Топология OSPF неизвестна до успешного опроса всех устройств.</p>}
{!graphUnknown && graphNodes.length > 0 && (
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
<div className="flex-1 relative" style={{ background: "#060d1a", minWidth: 0 }}>
<TopologyGraph
graphNodes={graphNodes}
graphEdges={graphEdges}
routerIds={routerIds}
neighbors={neighbors}
highlightId={highlightId}
selectedId={selectedId}
showDots={showDots}
onNodeClick={setSelectedId}
/>
<button
onClick={() => setShowDots(v => !v)}
className={cn(
"absolute top-3 right-3 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-[11px] font-medium transition-colors backdrop-blur-sm",
showDots
? "bg-black/60 border-white/15 text-white/70 hover:text-white"
: "bg-black/60 border-white/10 text-white/30 hover:text-white/60",
)}>
<span className={cn("size-1.5 rounded-full", showDots ? "bg-[var(--status-online)]" : "bg-white/20")} />
Анимация
</button>
{!selectedId && (
<div className="absolute bottom-3 right-3 text-[10px] font-mono text-white/20 pointer-events-none">
кликните по узлу для деталей
</div>
)}
</div>
{selectedId && (
<NodeDetailPanel
nodeId={selectedId}
graphNodes={graphNodes}
routerIds={routerIds}
neighbors={neighbors}
items={items}
onClose={() => setSelectedId(null)}
/>
)}
</div>
)}
<DataPageCard>
<OspfNeighborsDataGrid
neighbors={neighbors}
selectedRouterId={selectedId}
onSelect={setSelectedId}
onHighlight={setHighlightId}
/>
</DataPageCard>
</div>
)
}
// ─── routes tab (mock) ────────────────────────────────────────────────────────
function RoutesTab({ routes }: { routes: OspfRoute[] }) {
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]) => (
<span key={type} className={cn("inline-flex items-center rounded border px-2 py-0.5 text-[11px] font-medium", routeTypeClass(type as OspfRoute["type"]))}>
{type}: {count}
</span>
))}
</div>
<DataPageCard>
<OspfRoutesDataGrid routes={routes} />
</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">
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-medium", routeTypeClass(type))}>{type}</span>
{desc}
</span>
))}
</div>
</div>
)
}
// ─── BFD tab ──────────────────────────────────────────────────────────────────
function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataIncomplete: 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.reduce((s, b) => s + b.packetsRx, 0)
function fmtMs(ms: number) {
if (!ms) return "—"
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
return (
<div className="flex flex-col gap-4">
<KpiStatGrid
aria-label="Сводка BFD"
items={[
{
id: "sessions",
label: "Сессий BFD",
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: "Down / Admin",
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">
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 таймеров.
Tx&nbsp;/&nbsp;Rx — интервалы отправки и приёма контрольных пакетов.
{!dataIncomplete && totalPkts > 0 && <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() {
const { mode, backendUrl } = useDataSource()
return <OspfPageContent key={JSON.stringify([mode, backendUrl])} />
}
function OspfPageContent() {
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
const [filterServerId, setFilterServerId] = useState<string>(ALL_SERVERS_ID)
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 operationBusy = useRef(false)
const [writeBusy, setWriteBusy] = useState(false)
function refresh() { if (operationBusy.current) return; setLoading(true); setFetchTick(t => t + 1) }
// Reset server filter when switching live ↔ mock
useEffect(() => {
queueMicrotask(() => setFilterServerId("all"))
}, [isLive])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => setLiveData(null))
return
}
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setLoading(true)
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 = isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId))))
const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write"))
// 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 = new Map<string, number>()
for (const iface of liveData.interfaces) {
ifaceMap.set(`${iface.serverId}::${iface.interface}`, iface.cost)
}
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), 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) }))
// Build routerIds from instances
const routerIds: Record<string, string> = {}
for (const inst of liveData.instances) {
if (!routerIds[String(inst.serverId)]) {
routerIds[String(inst.serverId)] = inst.routerId
}
}
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, 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, 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 displayItems = filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const displayRoutes = filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId)
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) setFilterServerId(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")} />
Обновить
</Button>
</>
}
/>
}
banner={
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} aria-pressed={activeTab === t.id} disabled={writeBusy} onClick={() => { if (!operationBusy.current) setActiveTab(t.id) }}
className={cn(
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -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">
{/* data source banner */}
{isLive && loading && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<RefreshCwIcon className="size-3.5 animate-spin" />Загрузка 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>}
{incomplete && (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>
)}
<KpiStatGrid
aria-label="Сводка OSPF"
items={[
{
id: "routers",
label: "Роутеров",
value: incomplete ? "—" : totalRouters,
hint: incomplete ? "неизвестно" : undefined,
icon: <RouterIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ifaces",
label: "Интерфейсов",
value: incomplete ? "—" : totalInterfaces,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "areas",
label: "Зон (Area)",
value: incomplete ? "—" : totalAreas,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{activeTab === "interfaces" && (!incomplete || displayItems.length > 0) && (
<InterfacesTab key={filterServerId}
items={displayItems}
isLive={isLive}
filterServerId={filterServerId}
backendUrl={backendUrl}
onLiveDataRefresh={() => { setLoading(true); setFetchTick(t => t + 1) }}
dataReady={dataReady}
dataIncomplete={incomplete}
onOperationConfirmed={serverId => setWriteReceipt({ serverId, at: new Date().toISOString() })}
onOperationBusy={busy => { operationBusy.current = busy; setWriteBusy(busy) }}
/>
)}
{activeTab === "neighbors" && (!incomplete || displayNeighbors.length > 0) && (
<NeighborsTab
neighbors={displayNeighbors}
items={displayItems}
graphNodes={graphNodes}
graphEdges={graphEdges}
routerIds={routerIds}
dataIncomplete={incomplete}
graphUnknown={isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)}
/>
)}
{activeTab === "routes" && (!incomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} />}
{activeTab === "bfd" && (!incomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={incomplete} />}
</div>
</ServerRailLayout>
)
}