Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 1m39s
Docker images / updater-image (push) Successful in 37s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 6s
1521 lines
73 KiB
TypeScript
1521 lines
73 KiB
TypeScript
"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 { toast } from "sonner"
|
||
import {
|
||
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
|
||
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 [optimizing, setOptimizing] = useState(false)
|
||
const [liveOptimalCost, setLiveOptimalCost] = useState<Record<string, number>>({})
|
||
|
||
// 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 && 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) {
|
||
toast.info("Выберите конкретный сервер для оптимизации 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)
|
||
toast.success(`OSPF оптимизация применена: ${data.optimizedCount} интерфейсов`)
|
||
onLiveDataRefresh()
|
||
} catch (err) {
|
||
toast.error(`Ошибка оптимизации 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
|
||
}))
|
||
toast.success("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={() => toast.success("OSPF Interface Templates применены")}>
|
||
<SaveIcon className="size-4" />Сохранить
|
||
</Button>
|
||
)}
|
||
</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 / 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 } = useDataSource()
|
||
const isLive = mode === "live"
|
||
|
||
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 }
|
||
}
|
||
if (isLive) {
|
||
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()]
|
||
}
|
||
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])
|
||
|
||
// 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>
|
||
)
|
||
}
|