Files
MikrotikManager/app/(main)/ospf/page.tsx
T
DenozordecandCursor 5f31bb47fb chore: synchronize pending app/backend updates and repository hygiene
Includes current frontend and backend work in progress and removes generated artifacts from tracking to keep the repository clean for дальнейшая разработка.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-07 12:29:04 +07:00

1519 lines
74 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 { useMemo, useState, useEffect } from "react"
import { PageHeader } from "@/components/page-header"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import {
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
CheckIcon, NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { Flag } from "@/components/flag"
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
type?: string
useBfd?: boolean
helloInterval?: number
deadInterval?: number
}
interface OspfNeighbor {
id: string
localRouter: string
localLabel: string
localIface: string
remoteRouter: string
remoteLabel: string
remoteRouterId: string
area: string
state: "Full" | "2-Way" | "ExStart" | "Down"
cost: number
uptime: string
priority: number
}
interface OspfRoute {
id: string
destination: string
type: "O" | "O IA" | "O E1" | "O E2"
cost: number
nextHop: string
via: string
serverId: string
serverLabel: string
area: string
}
interface BfdSession {
id: string
serverId: string
serverLabel: string
localAddr: string
remoteAddr: string
state: "Up" | "Down" | "Init" | "AdminDown"
interval: number // txInterval ms
multiplier: number
iface: string
uptime: string | null
multihop: boolean
rxInterval: number // ms
holdTime: number // ms
packetsRx: number
packetsTx: number
stateChanges: number
}
// ─── backend types ────────────────────────────────────────────────────────────
interface BackendNeighbor {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
address: string; routerId: string; instance: string; area: string; areaId: string
interface: string; state: string; uptime: string | null; stateChanges: number; priority: number
}
interface BackendInterface {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
instance: string; area: string; areaId: string; interface: string; cost: number
type: string; disabled: boolean; inactive: boolean; priority: number
helloInterval: number; deadInterval: number; useBfd: boolean
}
interface BackendInstance {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
name: string; routerId: string; version: number; disabled: boolean; inactive: boolean; redistribute: string
}
interface BackendBfdSession {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
localAddr: string; remoteAddr: string; interface: string; state: string; uptime: string | null
multihop: boolean; multiplier: number; txInterval: number; rxInterval: number; holdTime: number
packetsRx: number; packetsTx: number; stateChanges: number
}
interface BackendOspfAll {
neighbors: BackendNeighbor[]
interfaces: BackendInterface[]
instances: BackendInstance[]
bfdSessions: BackendBfdSession[]
}
function isRefInterfaceName(name: string): boolean {
return /^\(ref\s+\*.+\)$/.test(name.trim())
}
interface BackendOspfOptimizeResponse {
serverId: number
serverName: string
pingWeight: number
optimizedCount: number
applied: Array<{ interface: string; from: number; to: number }>
interfaces: Array<{
id: string
interface: string
currentCost: number
optimalCost: number
score: number
pingMs: number
dlMbps: number
ulMbps: number
}>
}
// ─── backend → frontend mappers ───────────────────────────────────────────────
function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map<string, number>): OspfNeighbor {
const validStates = ["Full", "2-Way", "ExStart", "Down"]
const state = validStates.includes(b.state) ? b.state as OspfNeighbor["state"] : "Down"
const costKey = `${b.serverId}::${b.interface}`
return {
id: `${b.serverId}-${b.id}`,
localRouter: String(b.serverId),
localLabel: b.serverName,
localIface: b.interface,
remoteRouter: b.routerId,
remoteLabel: b.routerId, // no server name for remote peers
remoteRouterId: b.routerId,
area: b.areaId !== b.area ? `${b.area} (${b.areaId})` : b.area,
state,
cost: ifaceMap.get(costKey) ?? 10,
uptime: b.uptime ?? "—",
priority: b.priority,
}
}
function backendToItem(b: BackendInterface): OspfItem {
return {
key: `${b.serverId}-${b.id}`,
routerKey: String(b.serverId),
routerLabel: b.serverName,
serverId: String(b.serverId),
interfaceName: b.interface,
area: b.areaId,
cost: b.cost,
active: !b.disabled && !b.inactive,
type: b.type,
useBfd: b.useBfd,
helloInterval: b.helloInterval,
deadInterval: b.deadInterval,
}
}
function backendToBfdSession(b: BackendBfdSession): BfdSession {
const validStates = ["Up", "Down", "Init", "AdminDown"]
const state = validStates.includes(b.state) ? b.state as BfdSession["state"] : "Down"
return {
id: `${b.serverId}-${b.id}`,
serverId: String(b.serverId),
serverLabel: b.serverName,
localAddr: b.localAddr,
remoteAddr: b.remoteAddr,
state,
interval: b.txInterval,
multiplier: b.multiplier,
iface: b.interface,
uptime: b.uptime,
multihop: b.multihop,
rxInterval: b.rxInterval,
holdTime: b.holdTime,
packetsRx: b.packetsRx,
packetsTx: b.packetsTx,
stateChanges: b.stateChanges,
}
}
// ─── mock data ────────────────────────────────────────────────────────────────
const COST_STEP = 10
const OPTIMIZER_PROB: Record<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 = {
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> = {
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 routeTypeClass(type: OspfRoute["type"]) {
if (type === "O") return "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25"
if (type === "O IA") return "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25"
if (type === "O E1") return "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/25"
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25"
}
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
return (
<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)
if (!involved.length) return "none"
if (involved.some(e => e.state === "Full")) return "Full"
if (involved.some(e => e.state === "2-Way")) return "2-Way"
return "Down"
}
return (
<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
let panelState: keyof typeof NODE_THEME = "none"
if (adjacencies.some(a => a.state === "Full")) panelState = "Full"
else if (adjacencies.some(a => a.state === "2-Way")) panelState = "2-Way"
else if (adjacencies.length) panelState = "Down"
const theme = NODE_THEME[panelState]
const siteFill =
node.site === "MSK" ? "#7c3aed" : node.site === "SPB" ? "#0369a1" :
node.site === "FRA" ? "#b45309" : "#065f46"
return (
<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,
}: {
items: OspfItem[]
isLive: boolean
filterServerId: string
backendUrl: string
onLiveDataRefresh: () => void
}) {
const [items, setItems] = useState<OspfItem[]>(initialItems)
const [dragging, setDragging] = useState<string | null>(null)
const [dragOver, setDragOver] = useState<string | null>(null)
const [toast, setToast] = useState<string | null>(null)
const [optimizing, setOptimizing] = useState(false)
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
// Sync with live data when it changes
useEffect(() => {
queueMicrotask(() => setItems(initialItems))
}, [initialItems])
function showToast(msg: string) { setToast(msg); setTimeout(() => setToast(null), 2500) }
const grouped = useMemo(() => {
const byRouter: Record<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 && filterServerId !== "all"
const uniqueLiveFallbackOpt = useMemo(() => {
const out: Record<string, number> = {}
const byRouter: Record<string, OspfItem[]> = {}
items.forEach((item) => {
if (!byRouter[item.routerKey]) byRouter[item.routerKey] = []
byRouter[item.routerKey].push(item)
})
Object.values(byRouter).forEach((routerItems) => {
routerItems
.slice()
.sort((a, b) => a.cost - b.cost || a.interfaceName.localeCompare(b.interfaceName))
.forEach((item, idx) => {
out[item.key] = (idx + 1) * COST_STEP
})
})
return out
}, [items])
async function handleLiveOptimize() {
if (!canOptimizeLive) {
showToast("Выберите конкретный сервер для оптимизации OSPF")
return
}
const ra = readStoredRouteOptimizerSettings()
setOptimizing(true)
try {
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pingWeight: ra.pingWeight }),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json() as BackendOspfOptimizeResponse
const byKey: Record<string, number> = {}
data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
})
setLiveOptimalCost(byKey)
showToast(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`)
onLiveDataRefresh()
} catch (err) {
showToast(`Ошибка оптимизации OSPF: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setOptimizing(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
}))
showToast("Costs оптимизированы по рекомендациям оптимизатора")
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => setItems(initialItems)}>
<RefreshCwIcon className="size-4" />Загрузить
</Button>
{needsOptimize && (
<Button variant="outline" size="sm" onClick={handleOptimize}>
<WandSparklesIcon className="size-4" />Оптимизировать
</Button>
)}
{canOptimizeLive && (
<Button variant="outline" size="sm" onClick={handleLiveOptimize} disabled={optimizing}>
<WandSparklesIcon className={cn("size-4", optimizing && "animate-spin")} />
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
</Button>
)}
{!isLive && (
<Button size="sm" onClick={() => showToast("OSPF Interface Templates применены")}>
<SaveIcon className="size-4" />Сохранить
</Button>
)}
</div>
{toast && (
<div className="flex items-center gap-2 rounded-lg border border-current/20 px-4 py-2.5 text-sm"
style={{ background: "var(--status-online-bg)", color: "var(--status-online-fg)" }}>
<CheckIcon className="size-4 shrink-0" />{toast}
</div>
)}
{grouped.length === 0 && (
<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 (
<Card key={router.routerKey} className="overflow-hidden gap-0 py-0">
<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 = liveOptimalCost[item.key] ?? uniqueLiveFallbackOpt[item.key] ?? item.cost
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.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.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>
<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>
))}
</Card>
)
})}
</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,
}: {
neighbors: OspfNeighbor[]
items: OspfItem[]
graphNodes: GraphNode[]
graphEdges: GraphEdge[]
routerIds: Record<string, string>
}) {
const [highlightId, setHighlightId] = useState<string | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [showDots, setShowDots] = useState(true)
const fullCount = neighbors.filter(n => n.state === "Full").length
return (
<div className="flex flex-col gap-5">
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Всего соседей", value: neighbors.length, color: "" },
{ label: "Full", value: fullCount, color: "text-[var(--status-online-fg)]" },
{ label: "Не Full", value: neighbors.length - fullCount, color: neighbors.length - fullCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums", s.color)}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
{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>
)}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
{["Роутер", "Интерфейс", "Сосед (Router ID)", "Область", "Состояние", "Cost", "Uptime", "Prio"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{neighbors.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-muted-foreground">
Нет OSPF-соседей
</td>
</tr>
) : neighbors.map(n => {
const isHighlighted = selectedId === n.localRouter || selectedId === n.remoteRouter
return (
<tr key={n.id}
onMouseEnter={() => setHighlightId(n.localRouter)}
onMouseLeave={() => setHighlightId(null)}
onClick={() => setSelectedId(prev => prev === n.localRouter ? null : n.localRouter)}
className={cn(
"transition-colors cursor-pointer",
isHighlighted ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30",
)}>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{n.localLabel}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground whitespace-nowrap">{n.localIface}</td>
<td className="px-3 py-2.5">
<div className="flex flex-col">
<span className="font-mono">{n.remoteLabel !== n.remoteRouterId ? n.remoteLabel : n.remoteRouterId}</span>
{n.remoteLabel !== n.remoteRouterId && (
<span className="text-[10px] font-mono text-muted-foreground">{n.remoteRouterId}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{n.area}</td>
<td className="px-3 py-2.5">
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", stateClass(n.state))}>
{n.state}
</span>
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{n.cost}</td>
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums">{n.uptime}</td>
<td className="px-3 py-2.5 text-center font-mono">{n.priority}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</Card>
</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.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>
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
{["Назначение", "Тип", "Cost", "Следующий хоп", "Интерфейс", "Роутер", "Область"].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{routes.map(r => (
<tr key={r.id} className="hover:bg-muted/30 transition-colors">
<td className="px-3 py-2.5 font-mono">{r.destination}</td>
<td className="px-3 py-2.5">
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", routeTypeClass(r.type))}>
{r.type}
</span>
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{r.cost}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.nextHop}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.via}</td>
<td className="px-3 py-2.5 font-mono">{r.serverLabel}</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground">{r.area}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<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 }: { sessions: BfdSession[] }) {
const upCount = sessions.filter(b => b.state === "Up").length
const downCount = sessions.filter(b => b.state === "Down" || b.state === "AdminDown").length
const initCount = sessions.filter(b => b.state === "Init").length
const totalPkts = sessions.reduce((s, b) => s + b.packetsRx, 0)
function fmtMs(ms: number) {
if (!ms) return "—"
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
return (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: "Сессий BFD", value: sessions.length, color: "" },
{ label: "Up", value: upCount, color: "text-[var(--status-online-fg)]" },
{ label: "Down / Admin", value: downCount, color: downCount > 0 ? "text-[var(--status-offline-fg)]" : "text-muted-foreground" },
{ label: "Init / другие", value: initCount, color: initCount > 0 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground" },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className={cn("text-2xl font-semibold tabular-nums", s.color)}>{s.value}</p>
</CardContent>
</Card>
))}
</div>
{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 && (
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b bg-muted/40">
{[
"Роутер", "Интерфейс", "Локальный", "Удалённый",
"Состояние", "Uptime", "Tx / Rx", "Hold", "Mult",
"Пакеты Rx", "Пакеты Tx", "Переходы",
].map(h => (
<th key={h} className="text-left px-3 py-2.5 font-medium text-muted-foreground whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{sessions.map(b => (
<tr key={b.id} className="hover:bg-muted/30 transition-colors">
<td className="px-3 py-2.5 font-mono whitespace-nowrap">
<div className="flex flex-col gap-0.5">
<span>{b.serverLabel}</span>
{b.multihop && (
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">multihop</Chip>
)}
</div>
</td>
<td className="px-3 py-2.5 font-mono text-muted-foreground whitespace-nowrap">{b.iface || "—"}</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{b.localAddr}</td>
<td className="px-3 py-2.5 font-mono whitespace-nowrap">{b.remoteAddr}</td>
<td className="px-3 py-2.5">
<span className={cn("inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium", stateClass(b.state))}>
{b.state}
</span>
</td>
<td className="px-3 py-2.5 text-muted-foreground whitespace-nowrap tabular-nums">
{b.uptime ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground whitespace-nowrap">
{fmtMs(b.interval)} / {fmtMs(b.rxInterval)}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-muted-foreground whitespace-nowrap">
{fmtMs(b.holdTime)}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">{b.multiplier}</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right text-muted-foreground">
{b.packetsRx.toLocaleString()}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-right text-muted-foreground">
{b.packetsTx.toLocaleString()}
</td>
<td className="px-3 py-2.5 font-mono tabular-nums text-center">
<span className={cn(b.stateChanges > 3 ? "text-[var(--status-degraded-fg)]" : "text-muted-foreground")}>
{b.stateChanges}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{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 — интервалы отправки и приёма контрольных пакетов.
{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 [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
const [filterServerId, setFilterServerId] = useState<string>("all")
const { mode, backendUrl, backendStatus } = useDataSource()
const isLive = mode === "live" && backendStatus === true
const [liveData, setLiveData] = useState<BackendOspfAll | null>(null)
const [loading, setLoading] = useState(false)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [fetchTick, setFetchTick] = useState(0)
// Reset server filter when switching live ↔ mock
useEffect(() => {
queueMicrotask(() => setFilterServerId("all"))
}, [isLive])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => setLiveData(null))
return
}
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/ospf/all`)
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
.then(data => {
if (cancelled) return
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
})
.catch((err: unknown) => {
if (cancelled) return
setLiveError(err instanceof Error ? err.message : String(err)); setLoading(false)
})
})
return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick])
// Derive frontend types from backend data or use mocks
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions } = useMemo(() => {
if (isLive && liveData) {
// Build interface→cost map for neighbor cost lookup
const ifaceMap = new Map<string, number>()
for (const iface of liveData.interfaces) {
ifaceMap.set(`${iface.serverId}::${iface.interface}`, iface.cost)
}
const items = liveData.interfaces
.map(backendToItem)
.filter((item) => !isRefInterfaceName(item.interfaceName))
const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap))
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
// 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 }
}
return {
items: MOCK_ITEMS,
neighbors: MOCK_NEIGHBORS,
graphNodes: MOCK_GRAPH_NODES,
graphEdges: MOCK_GRAPH_EDGES,
routerIds: MOCK_ROUTER_IDS,
bfdSessions: MOCK_BFD,
}
}, [isLive, liveData])
// ── server list for chips bar ────────────────────────────────────────────────
const ospfServers = useMemo((): OspfServerInfo[] => {
if (isLive && liveData) {
const seen = new Map<string, OspfServerInfo>()
for (const inst of liveData.instances) {
const id = String(inst.serverId)
if (!seen.has(id))
seen.set(id, { id, label: inst.serverName, site: inst.serverSite, country: inst.serverCountry })
}
// Fallback: pick up servers that appear in neighbors/interfaces but not in instances
for (const n of liveData.neighbors) {
const id = String(n.serverId)
if (!seen.has(id))
seen.set(id, { id, label: n.serverName, site: n.serverSite, country: n.serverCountry })
}
return [...seen.values()]
}
// Mock: derive from MOCK_GRAPH_NODES that actually have interfaces
const activeIds = new Set(MOCK_ITEMS.map(i => i.routerKey))
const siteCountry: Record<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])
// 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" ? items : items.filter(i => i.routerKey === filterServerId)
const displayNeighbors = filterServerId === "all" ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const displayBfdSessions = filterServerId === "all" ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
// Graph always shows full topology (highlight is handled by node click inside tab)
// KPIs reflect the current filter
const totalRouters = new Set(displayItems.map(i => i.routerKey)).size
const totalInterfaces = displayItems.length
const totalAreas = new Set(displayItems.map(i => i.area)).size
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "OSPF" }]}
actions={
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
}
/>
<div className="border-b bg-background shrink-0">
<div className="flex items-center px-6">
{TABS.map(t => (
<button key={t.id} onClick={() => 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>
{/* ── server filter chips (same pattern as Filters page) ─────────────── */}
{ospfServers.length > 0 && (
<div className="border-b bg-muted/20 px-6 py-2.5 flex items-center gap-2 flex-wrap shrink-0">
{/* "All" chip */}
<button
onClick={() => setFilterServerId("all")}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
filterServerId === "all"
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
Все серверы
<span className={cn(
"tabular-nums font-semibold",
filterServerId === "all" ? "" : "text-foreground/60",
)}>{items.length}</span>
</button>
<div className="w-px h-4 bg-border shrink-0" />
{ospfServers.map(s => {
const counts = serverCounts[s.id]
const active = filterServerId === s.id
return (
<button key={s.id} onClick={() => setFilterServerId(s.id)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-all",
active
? "bg-foreground text-background border-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
)}>
{s.country ? <Flag code={s.country} size={12} /> : null}
{s.site && (
<span className={cn(
"inline-block px-1 py-0 rounded text-[9px] font-bold leading-4",
active
? "bg-white/20"
: "bg-muted-foreground/15 text-foreground/70",
)}>{s.site}</span>
)}
<span className="font-mono">{s.label.replace(/^mt-/, "")}</span>
{counts && (
<span className={cn(
"tabular-nums text-[10px]",
active ? "opacity-80" : "text-foreground/50",
)}>
{counts.neighbors}n · {counts.ifaces}i
{counts.bfd > 0 && ` · ${counts.bfd}b`}
</span>
)}
</button>
)
})}
</div>
)}
<div className="flex-1 overflow-y-auto p-6">
<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 && !loading && fetchedAt && !liveError && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-500" />
Живые данные · обновлено {fetchedAt.toLocaleTimeString("ru")}
</span>
<button onClick={() => setFetchTick(t => t + 1)}
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors">
<RefreshCwIcon className="size-3" />Обновить
</button>
</div>
)}
{isLive && liveError && (
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/8 px-3 py-2">
<span className="size-1.5 rounded-full bg-amber-500 shrink-0" />
<p className="text-xs text-amber-600 dark:text-amber-400">Ошибка загрузки: {liveError}</p>
</div>
)}
{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>
)}
{/* KPI strip */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Роутеров", value: totalRouters },
{ label: "Интерфейсов", value: totalInterfaces },
{ label: "Зон (Area)", value: totalAreas },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">{s.label}</p>
<p className="text-2xl font-semibold tabular-nums">{s.value}</p>
</CardContent>
</Card>
))}
</div>
{activeTab === "interfaces" && (
<InterfacesTab
items={displayItems}
isLive={isLive}
filterServerId={filterServerId}
backendUrl={backendUrl}
onLiveDataRefresh={() => setFetchTick(t => t + 1)}
/>
)}
{activeTab === "neighbors" && (
<NeighborsTab
neighbors={displayNeighbors}
items={displayItems}
graphNodes={graphNodes}
graphEdges={graphEdges}
routerIds={routerIds}
/>
)}
{activeTab === "routes" && <RoutesTab routes={isLive ? [] : MOCK_ROUTES} />}
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
</div>
</div>
</div>
)
}