Added the sonner library for toast notifications across various components, improving user feedback for actions such as saving settings, syncing rules, and handling errors. Updated the layout to include a Toaster component for consistent notification display. Refactored alert messages in the backups, gre, and filters pages to utilize the new notification system, enhancing overall user experience.
1510 lines
73 KiB
TypeScript
1510 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, 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>
|
||
)
|
||
}
|