"use client"
import { buildOspfCostIndex, ospfCostKey } from "@/lib/ospf-cost"
import { normalizeOspfState, normalizeBfdState, ospfNodeState, type OspfNeighborState, type BfdState } from "@/lib/ospf-state"
import { useMemo, useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams } from "next/navigation"
import { readOspfUrl, patchOspfUrl, type OspfTab } from "@/lib/ospf-url-state"
import { ospfCatalogSchema, ospfServersSchema, ospfOptimizeSchema, type OspfCatalog } from "@/lib/ospf-response"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
import { OspfRoutesDataGrid, routeTypeClass } from "@/components/data-grids/ospf-routes-data-grid"
import { OspfBfdDataGrid } from "@/components/data-grids/ospf-bfd-data-grid"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { can } from "@/lib/auth"
import { toast } from "sonner"
import {
RefreshCwIcon, WandSparklesIcon, GripVerticalIcon,
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
// ─── types ────────────────────────────────────────────────────────────────────
interface OspfServerInfo {
id: string // String(serverId) in live mode, routerKey in mock
label: string // serverName / routerLabel
site: string // "MSK", "SPB", …
country: string // ISO alpha-2 or ""
}
interface OspfItem {
key: string
routerKey: string
routerLabel: string
serverId: string
interfaceName: string
area: string
cost: number | null
active: boolean | null
dataUnknown?: boolean
type?: string | null
useBfd?: boolean | null
helloInterval?: number | null
deadInterval?: number | null
}
interface OspfNeighbor {
id: string
localRouter: string
localLabel: string
localIface: string
remoteRouter: string
remoteLabel: string
remoteRouterId: string
area: string
state: OspfNeighborState
cost: number | null
uptime: string
priority: number | null
dataUnknown?: boolean
}
interface OspfRoute {
id: string
destination: string
type: "O" | "O IA" | "O E1" | "O E2"
cost: number | null
nextHop: string
via: string
serverId: string
serverLabel: string
area: string
dataUnknown?: boolean
}
interface BfdSession {
id: string
serverId: string
serverLabel: string
localAddr: string
remoteAddr: string
state: BfdState
interval: number | null // txInterval ms
multiplier: number | null
iface: string
uptime: string | null
multihop: boolean
rxInterval: number | null // ms
holdTime: number | null // ms
packetsRx: number | null
packetsTx: number | null
stateChanges: number | null
dataUnknown?: boolean
}
// ─── backend types ────────────────────────────────────────────────────────────
type BackendNeighbor = OspfCatalog["neighbors"][number]
type BackendInterface = OspfCatalog["interfaces"][number]
type BackendBfdSession = OspfCatalog["bfdSessions"][number]
type BackendOspfRoute = OspfCatalog["routes"][number]
function isRefInterfaceName(name: string): boolean {
return /^\(ref\s+\*.+\)$/.test(name.trim())
}
// ─── backend → frontend mappers ───────────────────────────────────────────────
function backendToNeighbor(b: BackendNeighbor, ifaceMap: Map): OspfNeighbor {
const state = normalizeOspfState(b.state)
const costKey = ospfCostKey(b.serverId, b.interface, b.areaId)
return {
id: `${b.serverId}-${b.id}`,
localRouter: String(b.serverId),
localLabel: b.serverName,
localIface: b.interface,
remoteRouter: 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) ?? null,
uptime: b.uptime ?? "—",
priority: b.priority,
}
}
function backendToItem(b: BackendInterface): OspfItem {
return {
key: `${b.serverId}-${b.id}`,
routerKey: String(b.serverId),
routerLabel: b.serverName,
serverId: String(b.serverId),
interfaceName: b.interface,
area: b.areaId,
cost: b.cost,
active: b.disabled === true || b.inactive === true ? false : b.disabled === false && b.inactive === false ? true : null,
type: b.type,
useBfd: b.useBfd,
helloInterval: b.helloInterval,
deadInterval: b.deadInterval,
}
}
function backendToBfdSession(b: BackendBfdSession): BfdSession {
const state = normalizeBfdState(b.state)
return {
id: `${b.serverId}-${b.id}`,
serverId: String(b.serverId),
serverLabel: b.serverName,
localAddr: b.localAddr,
remoteAddr: b.remoteAddr,
state,
interval: b.txInterval,
multiplier: b.multiplier,
iface: b.interface,
uptime: b.uptime,
multihop: b.multihop,
rxInterval: b.rxInterval,
holdTime: b.holdTime,
packetsRx: b.packetsRx,
packetsTx: b.packetsTx,
stateChanges: b.stateChanges,
}
}
function backendToRoute(b: BackendOspfRoute): OspfRoute {
const allowed: OspfRoute["type"][] = ["O", "O IA", "O E1", "O E2"]
const type = allowed.includes(b.type) ? b.type : "O"
return {
id: `${b.serverId}-${b.id}`,
destination: b.destination,
type,
cost: b.cost,
nextHop: b.nextHop,
via: b.via,
serverId: String(b.serverId),
serverLabel: b.serverName,
area: b.area || "—",
}
}
// ─── mock data ────────────────────────────────────────────────────────────────
const COST_STEP = 10
const OPTIMIZER_PROB: Record = {
"srv1::GRE-MSK-SPB": 68,
"srv1::GRE-MSK-FRA": 18,
"srv1::GRE-MSK-AMS": 10,
"srv1::GRE-MSK-SGP": 4,
"srv1::ETHER1-WAN": 100,
"srv1::ETHER2-LAN": 100,
"srv7::GRE-LAB-SPB": 41,
"srv7::GRE-LAB-CORE": 59,
"srv7::ETHER1-UPLINK":100,
"srv6::GRE-AMS-CORE": 68,
"srv6::GRE-AMS-FRA": 32,
}
const MOCK_ITEMS: OspfItem[] = [
{ key: "srv1-bb-gre-msk-spb", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-spb", area: "0.0.0.0", cost: 10, active: true },
{ key: "srv1-bb-gre-msk-fra", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-fra", area: "0.0.0.0", cost: 20, active: true },
{ key: "srv1-bb-gre-msk-ams", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-ams", area: "0.0.0.0", cost: 30, active: true },
{ key: "srv1-bb-gre-msk-sgp", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "gre-msk-sgp", area: "0.0.0.0", cost: 40, active: false },
{ key: "srv1-a1-ether1", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "ether1-wan", area: "0.0.0.1", cost: 10, active: true },
{ key: "srv1-a1-ether2", routerKey: "srv1", routerLabel: "mt-msk-core-01", serverId: "srv1", interfaceName: "ether2-lan", area: "0.0.0.1", cost: 20, active: true },
{ key: "srv7-bb-gre-lab-core", routerKey: "srv7", routerLabel: "mt-msk-lab-01", serverId: "srv7", interfaceName: "gre-lab-core", area: "0.0.0.0", cost: 10, active: true },
{ key: "srv7-bb-gre-lab-spb", routerKey: "srv7", routerLabel: "mt-msk-lab-01", serverId: "srv7", interfaceName: "gre-lab-spb", area: "0.0.0.0", cost: 20, active: true },
{ key: "srv7-bb-ether1", routerKey: "srv7", routerLabel: "mt-msk-lab-01", serverId: "srv7", interfaceName: "ether1-uplink", area: "0.0.0.0", cost: 30, active: true },
{ key: "srv6-bb-gre-ams-core", routerKey: "srv6", routerLabel: "mt-ams-test-01", serverId: "srv6", interfaceName: "gre-ams-core", area: "0.0.0.0", cost: 10, active: true },
{ key: "srv6-bb-gre-ams-fra", routerKey: "srv6", routerLabel: "mt-ams-test-01", serverId: "srv6", interfaceName: "gre-ams-fra", area: "0.0.0.0", cost: 20, active: true },
]
const MOCK_NEIGHBORS: OspfNeighbor[] = [
{ id: "n1", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-msk-spb", remoteRouter: "srv2", remoteLabel: "mt-spb-edge-01", remoteRouterId: "10.0.0.2", area: "0.0.0.0", state: "Full", cost: 10, uptime: "14d 6h 22m", priority: 1 },
{ id: "n2", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-msk-fra", remoteRouter: "srv3", remoteLabel: "mt-fra-edge-01", remoteRouterId: "10.0.0.3", area: "0.0.0.0", state: "Full", cost: 20, uptime: "12d 3h 11m", priority: 1 },
{ id: "n3", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-msk-ams", remoteRouter: "srv4", remoteLabel: "mt-ams-edge-01", remoteRouterId: "10.0.0.4", area: "0.0.0.0", state: "2-Way", cost: 30, uptime: "2d 1h 4m", priority: 0 },
{ id: "n4", localRouter: "srv1", localLabel: "mt-msk-core-01", localIface: "gre-lab-core", remoteRouter: "srv7", remoteLabel: "mt-msk-lab-01", remoteRouterId: "10.0.0.7", area: "0.0.0.0", state: "Full", cost: 10, uptime: "8d 14h 5m", priority: 1 },
{ id: "n5", localRouter: "srv7", localLabel: "mt-msk-lab-01", localIface: "gre-lab-spb", remoteRouter: "srv2", remoteLabel: "mt-spb-edge-01", remoteRouterId: "10.0.0.2", area: "0.0.0.0", state: "Full", cost: 20, uptime: "8d 14h 4m", priority: 1 },
{ id: "n6", localRouter: "srv6", localLabel: "mt-ams-test-01", localIface: "gre-ams-core", remoteRouter: "srv1", remoteLabel: "mt-msk-core-01", remoteRouterId: "10.0.0.1", area: "0.0.0.0", state: "Full", cost: 10, uptime: "5d 9h 17m", priority: 1 },
{ id: "n7", localRouter: "srv6", localLabel: "mt-ams-test-01", localIface: "gre-ams-fra", remoteRouter: "srv3", remoteLabel: "mt-fra-edge-01", remoteRouterId: "10.0.0.3", area: "0.0.0.0", state: "Full", cost: 20, uptime: "5d 9h 12m", priority: 1 },
]
const MOCK_ROUTES: OspfRoute[] = [
{ id: "r1", destination: "10.0.0.0/8", type: "O", cost: 10, nextHop: "10.200.0.1", via: "gre-msk-spb", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.0" },
{ id: "r2", destination: "172.16.0.0/12", type: "O", cost: 20, nextHop: "10.200.1.1", via: "gre-msk-fra", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.0" },
{ id: "r3", destination: "192.168.1.0/24", type: "O IA", cost: 30, nextHop: "10.200.2.1", via: "gre-msk-ams", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.1" },
{ id: "r4", destination: "10.10.0.0/16", type: "O E2", cost: 20, nextHop: "10.200.0.1", via: "gre-msk-spb", serverId: "srv1", serverLabel: "mt-msk-core-01", area: "0.0.0.0" },
{ id: "r5", destination: "10.50.0.0/16", type: "O IA", cost: 20, nextHop: "10.100.0.1", via: "gre-lab-spb", serverId: "srv7", serverLabel: "mt-msk-lab-01", area: "0.0.0.0" },
{ id: "r6", destination: "192.168.100.0/24", type: "O", cost: 10, nextHop: "10.100.0.1", via: "gre-lab-core", serverId: "srv7", serverLabel: "mt-msk-lab-01", area: "0.0.0.0" },
{ id: "r7", destination: "192.168.200.0/24", type: "O", cost: 10, nextHop: "10.150.0.1", via: "gre-ams-core", serverId: "srv6", serverLabel: "mt-ams-test-01", area: "0.0.0.0" },
{ id: "r8", destination: "10.80.0.0/16", type: "O E1", cost: 30, nextHop: "10.150.1.1", via: "gre-ams-fra", serverId: "srv6", serverLabel: "mt-ams-test-01", area: "0.0.0.0" },
]
const MOCK_BFD: BfdSession[] = [
{ id: "b1", serverId: "srv1", serverLabel: "mt-msk-core-01", localAddr: "10.200.0.0", remoteAddr: "10.200.0.1", state: "Up", interval: 200, multiplier: 3, iface: "gre-msk-spb", uptime: "14d 6h 22m", multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 14400, packetsTx: 14400, stateChanges: 1 },
{ id: "b2", serverId: "srv1", serverLabel: "mt-msk-core-01", localAddr: "10.200.1.0", remoteAddr: "10.200.1.1", state: "Up", interval: 200, multiplier: 3, iface: "gre-msk-fra", uptime: "12d 3h 11m", multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 12520, packetsTx: 12519, stateChanges: 2 },
{ id: "b3", serverId: "srv1", serverLabel: "mt-msk-core-01", localAddr: "10.200.2.0", remoteAddr: "10.200.2.1", state: "Down", interval: 200, multiplier: 3, iface: "gre-msk-ams", uptime: null, multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 0, packetsTx: 0, stateChanges: 5 },
{ id: "b4", serverId: "srv7", serverLabel: "mt-msk-lab-01", localAddr: "10.100.0.0", remoteAddr: "10.100.0.1", state: "Up", interval: 200, multiplier: 5, iface: "gre-lab-core", uptime: "8d 14h 5m", multihop: false, rxInterval: 200, holdTime: 1000, packetsRx: 9200, packetsTx: 9200, stateChanges: 1 },
{ id: "b5", serverId: "srv6", serverLabel: "mt-ams-test-01", localAddr: "10.150.0.0", remoteAddr: "10.150.0.1", state: "Up", interval: 200, multiplier: 3, iface: "gre-ams-core", uptime: "5d 9h 17m", multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 5700, packetsTx: 5700, stateChanges: 1 },
{ id: "b6", serverId: "srv6", serverLabel: "mt-ams-test-01", localAddr: "10.150.1.0", remoteAddr: "10.150.1.1", state: "Init", interval: 200, multiplier: 3, iface: "gre-ams-fra", uptime: null, multihop: false, rxInterval: 200, holdTime: 600, packetsRx: 0, packetsTx: 3, stateChanges: 3 },
]
// ─── topology constants ────────────────────────────────────────────────────────
const TOPO_W = 900
const TOPO_H = 360
const NODE_R = 34
const NODE_THEME = {
Pending: { fill: "#2d1e06", stroke: "#fbbf24", glow: "transparent" },
Unknown: { fill: "#111827", stroke: "#9ca3af", glow: "transparent" },
Full: { fill: "#0f2d1f", stroke: "#4ade80", glow: "rgba(74,222,128,0.18)" },
"2-Way":{ fill: "#2d1e06", stroke: "#fbbf24", glow: "rgba(251,191,36,0.14)" },
Down: { fill: "#2d0f0f", stroke: "#f87171", glow: "transparent" },
none: { fill: "#111827", stroke: "#374151", glow: "transparent" },
}
const EDGE_COLOR: Record = {
Attempt: "#fbbf24", Init: "#fbbf24", ExStart: "#fbbf24", Exchange: "#fbbf24", Loading: "#fbbf24", Unknown: "#9ca3af",
Full: "#4ade80",
"2-Way":"#fbbf24",
Down: "#f87171",
}
interface GraphNode { id: string; label: string; short: string; site: string; x: number; y: number }
interface GraphEdge { from: string; to: string; state: OspfNeighbor["state"]; cost: number | null }
// Mock topology (static layout for mock mode)
const MOCK_GRAPH_NODES: GraphNode[] = [
{ id: "srv1", label: "mt-msk-core-01", short: "CORE-01", site: "MSK", x: 450, y: 180 },
{ id: "srv2", label: "mt-spb-edge-01", short: "EDGE-01", site: "SPB", x: 730, y: 80 },
{ id: "srv3", label: "mt-fra-edge-01", short: "EDGE-01", site: "FRA", x: 730, y: 290 },
{ id: "srv4", label: "mt-ams-edge-01", short: "EDGE-01", site: "AMS", x: 600, y: 180 },
{ id: "srv7", label: "mt-msk-lab-01", short: "LAB-01", site: "MSK", x: 170, y: 80 },
{ id: "srv6", label: "mt-ams-test-01", short: "TEST-01", site: "AMS", x: 170, y: 290 },
]
const MOCK_GRAPH_EDGES: GraphEdge[] = [
{ from: "srv1", to: "srv2", state: "Full", cost: 10 },
{ from: "srv1", to: "srv3", state: "Full", cost: 20 },
{ from: "srv1", to: "srv4", state: "2-Way", cost: 30 },
{ from: "srv1", to: "srv7", state: "Full", cost: 10 },
{ from: "srv7", to: "srv2", state: "Full", cost: 20 },
{ from: "srv6", to: "srv1", state: "Full", cost: 10 },
{ from: "srv6", to: "srv3", state: "Full", cost: 20 },
]
const MOCK_ROUTER_IDS: Record = {
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()
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()
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(arr: T[], from: number, to: number): T[] {
if (from < 0 || to < 0 || from >= arr.length || to >= arr.length) return arr
const copy = [...arr]
const [moved] = copy.splice(from, 1)
copy.splice(to, 0, moved)
return copy
}
function stateClass(state: OspfNeighbor["state"] | BfdSession["state"]) {
if (state === "Full" || state === "Up")
return "bg-[var(--status-online-bg)] text-[var(--status-online-fg)] border-current/25"
if (state === "2-Way" || state === "Init")
return "bg-[var(--status-degraded-bg)] text-[var(--status-degraded-fg)] border-current/25"
return "bg-[var(--status-offline-bg)] text-[var(--status-offline-fg)] border-current/25"
}
function Chip({ children, color }: { children: React.ReactNode; color?: string }) {
return (
{children}
)
}
// ─── topology SVG ─────────────────────────────────────────────────────────────
interface TopologyGraphProps {
graphNodes: GraphNode[]
graphEdges: GraphEdge[]
routerIds: Record
neighbors: OspfNeighbor[]
highlightId: string | null
selectedId: string | null
showDots: boolean
onNodeClick: (id: string | null) => void
}
function TopologyGraph({ graphNodes, graphEdges, highlightId, selectedId, showDots, onNodeClick }: TopologyGraphProps) {
const nodeMap = Object.fromEntries(graphNodes.map(n => [n.id, n]))
function nodeState(id: string): keyof typeof NODE_THEME {
const involved = graphEdges.filter(e => e.from === id || e.to === id)
return ospfNodeState(involved.map(edge => edge.state))
}
return (
)
}
// ─── node detail panel ────────────────────────────────────────────────────────
function NodeDetailPanel({
nodeId, graphNodes, routerIds, neighbors, items, onClose,
}: {
nodeId: string; graphNodes: GraphNode[]; routerIds: Record
neighbors: OspfNeighbor[]; items: OspfItem[]; onClose: () => void
}) {
const node = graphNodes.find(n => n.id === nodeId)
if (!node) return null
const routerId = routerIds[nodeId] ?? "—"
const adjacencies = neighbors.filter(n => n.localRouter === nodeId || n.remoteRouter === nodeId)
.map(n => {
if (n.localRouter === nodeId)
return { iface: n.localIface, peerLabel: n.remoteLabel, peerId: n.remoteRouterId, state: n.state, cost: n.cost, uptime: n.uptime }
return { iface: n.localIface, peerLabel: n.localLabel, peerId: routerId, state: n.state, cost: n.cost, uptime: n.uptime }
})
const ifaces = items.filter(i => i.routerKey === nodeId)
const fullCnt = adjacencies.filter(a => a.state === "Full").length
const partCnt = adjacencies.filter(a => a.state === "2-Way").length
// derive state from edges
const panelState = ospfNodeState(adjacencies.map(edge => edge.state))
const theme = NODE_THEME[panelState]
const siteFill =
node.site === "MSK" ? "#7c3aed" : node.site === "SPB" ? "#0369a1" :
node.site === "FRA" ? "#b45309" : "#065f46"
return (
{node.site !== "—" ? node.site.slice(0,3) : "?"}
{node.label}
Router-ID: {routerId}
{panelState}
{fullCnt > 0 && Full ×{fullCnt}}
{partCnt > 0 && 2-Way ×{partCnt}}
Соседи ({adjacencies.length})
{adjacencies.length === 0 &&
Нет активных adjacency
}
{adjacencies.map((adj, i) => {
const ec = EDGE_COLOR[adj.state] ?? "#64748b"
return (
{adj.peerLabel}
{adj.iface} · cost {adj.cost ?? "—"}
{adj.uptime}
{adj.state}
)
})}
Интерфейсы OSPF{ifaces.length > 0 && ({ifaces.length})}
{ifaces.length === 0 &&
Нет данных
}
{ifaces.map(iface => (
{iface.interfaceName}
{iface.area === "0.0.0.0" ? "backbone" : `area ${iface.area}`}
cost {iface.cost ?? "—"}
))}
)
}
// ─── interfaces tab ───────────────────────────────────────────────────────────
function InterfacesTab({
items: initialItems,
isLive,
filterServerId,
backendUrl,
dataReady,
dataIncomplete,
hasSearch,
onOperationBusy,
onOperationConfirmed,
}: {
items: OspfItem[]
isLive: boolean
filterServerId: string
backendUrl: string
dataReady: boolean
dataIncomplete: boolean
hasSearch: boolean
onOperationConfirmed: (id: number) => void
onOperationBusy: (busy: boolean) => void
}) {
const [items, setItems] = useState(initialItems)
const [dragging, setDragging] = useState(null)
const [dragOver, setDragOver] = useState(null)
const [optimizing, setOptimizing] = useState(false)
const [liveOptimalCost, setLiveOptimalCost] = useState>({})
const [operationError, setOperationError] = useState(null)
const operationLock = useRef(false)
const mounted = useRef(true)
useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, [])
// Sync with live data when it changes
useEffect(() => {
queueMicrotask(() => setItems(initialItems))
}, [initialItems])
const grouped = useMemo(() => {
const byRouter: Record }> = {}
items.forEach(item => {
if (!byRouter[item.routerKey])
byRouter[item.routerKey] = { routerKey: item.routerKey, routerLabel: item.routerLabel, areas: {} }
if (!byRouter[item.routerKey].areas[item.area])
byRouter[item.routerKey].areas[item.area] = []
byRouter[item.routerKey].areas[item.area].push(item)
})
return Object.values(byRouter).map(r => ({
...r,
areas: Object.entries(r.areas)
.map(([area, areaItems]) => ({ area, items: [...areaItems].sort((a, b) => (a.cost ?? Infinity) - (b.cost ?? Infinity)) }))
.sort((a, b) => a.area.localeCompare(b.area)),
}))
}, [items])
// Optimizer hints only work meaningfully for mock data
const hints = useMemo(() => {
if (isLive) return {}
const out: Record = {}
grouped.forEach(router => {
const ranked = router.areas
.flatMap(ag => ag.items)
.map(item => ({ item, prob: OPTIMIZER_PROB[`${router.routerKey}::${item.interfaceName.toUpperCase()}`] ?? 0 }))
.sort((a, b) => b.prob - a.prob || a.item.interfaceName.localeCompare(b.item.interfaceName))
// В рамках одного роутера выдаём строго уникальные optimal cost.
ranked.forEach(({ item, prob }, idx) => {
out[item.key] = { optimalCost: (idx + 1) * COST_STEP, prob }
})
})
return out
}, [grouped, isLive])
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
const canOptimizeLive = isLive && dataReady && filterServerId !== ALL_SERVERS_ID
async function handleLiveOptimize() {
if (!mounted.current || operationLock.current || !canOptimizeLive || !can("mm:servers:write")) {
toast.info("Выберите конкретный сервер для оптимизации OSPF")
return
}
const ra = readStoredRouteOptimizerSettings()
operationLock.current = true
onOperationBusy(true)
setOperationError(null)
setOptimizing(true)
try {
const response = await requestJson(
backendUrl,
`/api/servers/${filterServerId}/ospf/optimize`,
{
method: "POST",
body: JSON.stringify({ pingWeight: ra.pingWeight }),
},
)
const data = ospfOptimizeSchema.parse(response)
if (String(data.serverId) !== filterServerId) throw new Error("API вернул другой контекст OSPF")
const byKey: Record = {}
data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
})
onOperationConfirmed(data.serverId)
if (!mounted.current) return
setLiveOptimalCost(byKey)
toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`)
} catch (err) {
if (!mounted.current) return
setOperationError(`API не подтвердил завершение оптимизации: ${err instanceof Error ? err.message : String(err)}. Часть costs могла измениться; выполнен повторный опрос перед новой попыткой.`)
} finally {
operationLock.current = false
if (mounted.current) setOptimizing(false)
onOperationBusy(false)
}
}
function onDrop(routerKey: string, area: string, targetKey: string) {
if (!dragging || dragging === targetKey) { setDragging(null); setDragOver(null); return }
setItems(prev => {
const areaItems = prev.filter(i => i.routerKey === routerKey && i.area === area).sort((a, b) => (a.cost ?? Infinity) - (b.cost ?? Infinity))
const fromIdx = areaItems.findIndex(i => i.key === dragging)
const toIdx = areaItems.findIndex(i => i.key === targetKey)
if (fromIdx < 0 || toIdx < 0) return prev
const reordered = moveInArray(areaItems, fromIdx, toIdx).map((item, idx) => ({ ...item, cost: (idx + 1) * COST_STEP }))
return [...prev.filter(i => !(i.routerKey === routerKey && i.area === area)), ...reordered]
})
setDragging(null); setDragOver(null)
}
function handleOptimize() {
setItems(prev => prev.map(item => {
const h = hints[item.key]
return h && item.cost !== h.optimalCost ? { ...item, cost: h.optimalCost } : item
}))
toast.success("Локальный предпросмотр costs обновлён")
}
return (
{isLive && filterServerId !== ALL_SERVERS_ID &&
Оптимизация применяется ко всем интерфейсам выбранного устройства.
}
{isLive && filterServerId !== ALL_SERVERS_ID && !dataReady &&
Оптимизация недоступна до полного успешного опроса и подтверждения costs/состояний интерфейсов с правом записи.
}
{operationError &&
Оптимизация OSPF не подтверждена{operationError}}
{needsOptimize && (
)}
{isLive && filterServerId !== ALL_SERVERS_ID && (
)}
{grouped.length === 0 && !dataIncomplete && (
{hasSearch ? "Нет интерфейсов по текущему поиску" : "OSPF интерфейсы не настроены ни на одном сервере"}
)}
{grouped.map(router => {
const totalIfaces = router.areas.reduce((s, a) => s + a.items.length, 0)
return (
{router.routerLabel}
{router.routerKey}
{totalIfaces} iface · {router.areas.length} area
{router.areas.map((ag, aIdx) => (
{aIdx > 0 &&
}
Area
{ag.area}
{ag.area === "0.0.0.0" && Backbone}
{ag.items.map(item => {
const hint = hints[item.key]
const matches = hint && hint.optimalCost === item.cost
const liveOptimal = isLive ? liveOptimalCost[item.key] : hint?.optimalCost
const costDiffers = liveOptimal !== item.cost
return (
!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",
)}>
{item.interfaceName}
{(item.dataUnknown || item.active === null) && Неизвестно}
{item.type && item.type !== "broadcast" && (
{item.type}
)}
{item.useBfd && (
BFD
)}
{hint && hint.prob > 0 && !isLive && (
opt {hint.optimalCost}
)}
cur {item.cost ?? "—"}
{liveOptimal !== undefined && !item.dataUnknown && opt {liveOptimal}}
)
})}
))}
)
})}
{!isLive && (
Легенда:
opt 10
совпадает с оптимальным
opt 20
рекомендован другой cost
перетащите для изменения приоритета
)}
)
}
// ─── neighbors tab ────────────────────────────────────────────────────────────
function NeighborsTab({
neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown, selectedId, onSelect, hasSearch,
}: {
neighbors: OspfNeighbor[]
items: OspfItem[]
graphNodes: GraphNode[]
graphEdges: GraphEdge[]
routerIds: Record
dataIncomplete: boolean
graphUnknown: boolean
selectedId: string | null
onSelect: (id: string | null) => void
hasSearch: boolean
}) {
const [highlightId, setHighlightId] = useState(null)
const [showDots, setShowDots] = useState(true)
dataIncomplete = dataIncomplete || neighbors.some(n => n.state === "Unknown")
const fullCount = neighbors.filter(n => n.state === "Full").length
return (
,
iconClassName: "text-muted-foreground",
},
{
id: "full",
label: "Full",
value: dataIncomplete ? "—" : fullCount,
icon:
,
iconClassName: "text-success",
},
{
id: "not-full",
label: "Не Full",
value: dataIncomplete ? "—" : neighbors.length - fullCount,
icon:
,
iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground",
variant: neighbors.length - fullCount > 0 ? "warning" : "default",
},
]}
/>
{selectedId && !graphUnknown && !graphNodes.some(node => node.id === selectedId) &&
Узел OSPF недоступенУзел из ссылки отсутствует в текущем срезе.}
{graphUnknown &&
Топология OSPF неизвестна до успешного опроса всех устройств.
}
{!graphUnknown && graphNodes.length > 0 && (
{!selectedId && (
кликните по узлу для деталей
)}
{selectedId && (
onSelect(null)}
/>
)}
)}
)
}
// ─── routes tab (mock) ────────────────────────────────────────────────────────
function RoutesTab({ routes, hasSearch }: { routes: OspfRoute[]; hasSearch: boolean }) {
const typeCount = Object.entries(
routes.reduce>((acc, r) => { acc[r.type] = (acc[r.type] ?? 0) + 1; return acc }, {})
)
return (
{routes.some(r => r.dataUnknown) ? "Последний снимок: " : ""}{routes.length} маршрутов
{typeCount.map(([type, count]) => (
{type}: {count}
))}
Типы:
{([
{ 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 }) => (
{type}
{desc}
))}
)
}
// ─── BFD tab ──────────────────────────────────────────────────────────────────
function BfdTab({ sessions, dataIncomplete, hasSearch }: { sessions: BfdSession[]; dataIncomplete: boolean; hasSearch: boolean }) {
dataIncomplete = dataIncomplete || sessions.some(b => b.state === "Unknown")
const upCount = sessions.filter(b => b.state === "Up").length
const downCount = sessions.filter(b => b.state === "Down" || b.state === "AdminDown").length
const initCount = sessions.filter(b => b.state === "Init").length
const totalPkts = sessions.some(b => b.packetsRx === null) ? null : sessions.reduce((sum, b) => sum + (b.packetsRx ?? 0), 0)
return (
,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Up",
value: dataIncomplete ? "—" : upCount,
icon:
,
iconClassName: "text-success",
},
{
id: "down",
label: "Down / Admin",
value: dataIncomplete ? "—" : downCount,
icon:
,
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
variant: downCount > 0 ? "destructive" : "default",
},
{
id: "init",
label: "Init",
value: dataIncomplete ? "—" : initCount,
icon:
,
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
variant: initCount > 0 ? "warning" : "default",
},
]}
/>
{sessions.length === 0 && (
{hasSearch ? "Нет BFD-сессий по текущему поиску" : "BFD-сессий не обнаружено ни на одном сервере"}
)}
{sessions.length > 0 && (
)}
{sessions.length > 0 && (
BFD обнаруживает сбои быстрее OSPF Hello/Dead таймеров.
{!dataIncomplete && totalPkts === null && Общее число полученных пакетов неизвестно.}
TX факт — наблюдаемый интервал отправки; RX минимум — требуемый минимальный интервал.
{!dataIncomplete && totalPkts !== null && Всего получено: {totalPkts.toLocaleString()} пакетов.}
)}
)
}
// ─── page ─────────────────────────────────────────────────────────────────────
const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
{ id: "interfaces", label: "Интерфейсы", icon: },
{ id: "neighbors", label: "Соседи", icon: },
{ id: "routes", label: "Маршруты", icon: },
{ id: "bfd", label: "BFD", icon: },
]
export default function OspfPage() {
return Загрузка контекста OSPF…
}>
}
function OspfRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const state = readOspfUrl(new URLSearchParams(params.toString()))
function navigate(values: Record, replace = false) {
const href = patchOspfUrl(new URLSearchParams(window.location.search), values)
if (href !== window.location.pathname + window.location.search) (replace ? window.history.replaceState : window.history.pushState).call(window.history, null, "", href)
}
return navigate({ q: value }, true)} onSelectNode={node => navigate({ node })} onSelectServer={id => navigate({ server: id === ALL_SERVERS_ID ? null : id, node: null })} onSelectTab={tab => navigate({ tab: tab === "interfaces" ? null : tab })} onResetContext={() => navigate({ server: null, tab: null, q: null, node: null })} />
}
function OspfPageContent({ search, selectedNodeId, onSearch, onSelectNode, filterServerId, activeTab, contextError, onSelectServer, onSelectTab, onResetContext }: { search: string; selectedNodeId: string | null; onSearch: (value: string) => void; onSelectNode: (id: string | null) => void; filterServerId: string; activeTab: OspfTab; contextError: string | null; onSelectServer: (id: string) => void; onSelectTab: (tab: OspfTab) => void; onResetContext: () => void }) {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveData, setLiveData] = useState(null)
const [liveServers, setLiveServers] = useState>([])
const [loading, setLoading] = useState(isLive)
const [fetchedAt, setFetchedAt] = useState(null)
const [liveError, setLiveError] = useState(null)
const [fetchTick, setFetchTick] = useState(0)
const [readReceipts, setReadReceipts] = useState>({})
const [writeReceipt, setWriteReceipt] = useState<{ serverId: number; at: string } | null>(null)
const pageMounted = useRef(true)
useEffect(() => { pageMounted.current = true; return () => { pageMounted.current = false } }, [])
const operationBusy = useRef(false)
const [writeBusy, setWriteBusy] = useState(false)
function refresh() { if (operationBusy.current) return; setLoading(true); setFetchTick(t => t + 1) }
useEffect(() => {
if (!isLive) {
queueMicrotask(() => setLiveData(null))
return
}
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setLoading(true)
void Promise.all([requestJson(backendUrl, "/api/ospf/catalog"), requestJson(backendUrl, "/api/servers")])
.then(([response, servers]) => {
if (cancelled) return
const data = ospfCatalogSchema.parse(response)
const inventory = ospfServersSchema.parse(servers)
const enabled = new Set(inventory.filter(s => s.enabled).map(s => s.id))
const sections = ["neighbors", "interfaces", "instances", "bfdSessions", "routes"] as const
if (sections.some(section => data[section].some(row => !enabled.has(row.serverId))) || data.failures.some(f => !enabled.has(f.serverId))) throw new Error("Чужой контекст OSPF-каталога")
const failed = new Set(data.failures.map(f => f.serverId))
setLiveData(previous => ({ ...data, ...Object.fromEntries(sections.map(section => [section, [...data[section], ...(previous?.[section] ?? []).filter(row => enabled.has(row.serverId) && failed.has(row.serverId))]])) }))
setLiveServers(inventory.filter(s => s.enabled))
const at = new Date()
setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(id)).map(id => [String(id), at.toISOString()])) }))
setLiveError(null); setFetchedAt(at); setLoading(false)
})
.catch((err: unknown) => {
if (cancelled) return
setLiveError(err instanceof Error ? err.message : String(err)); setLoading(false)
})
})
return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick])
const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData])
const incomplete = Boolean(contextError) || isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId))))
const dataReady = !contextError && (!isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write") && liveData.interfaces.filter(row => String(row.serverId) === filterServerId).every(row => row.cost !== null && row.cost >= 1 && row.disabled !== null && row.inactive !== null)))
// Derive frontend types from backend data or use mocks
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
if (isLive && liveData) {
// Build interface→cost map for neighbor cost lookup
const ifaceMap = buildOspfCostIndex(liveData.interfaces)
const items = liveData.interfaces
.map(b => ({ ...backendToItem(b), dataUnknown: unknownIds.has(b.serverId) }))
.filter((item) => !isRefInterfaceName(item.interfaceName))
const neighbors = liveData.neighbors.map(b => ({ ...backendToNeighbor(b, ifaceMap), dataUnknown: unknownIds.has(b.serverId) }))
const bfdSessions = liveData.bfdSessions.map(b => ({ ...backendToBfdSession(b), dataUnknown: unknownIds.has(b.serverId) }))
const routes = liveData.routes.map(b => ({ ...backendToRoute(b), dataUnknown: unknownIds.has(b.serverId) }))
// Build routerIds from instances
const routerIds: Record = {}
for (const inst of liveData.instances) {
if (inst.routerId && !routerIds[String(inst.serverId)]) {
routerIds[String(inst.serverId)] = inst.routerId
}
}
const { nodes: graphNodes, edges: graphEdges } = buildLiveGraph(neighbors)
return { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes }
}
if (isLive) {
return {
items: [],
neighbors: [],
graphNodes: [],
graphEdges: [],
routerIds: {},
bfdSessions: [],
routes: [],
}
}
return {
items: MOCK_ITEMS,
neighbors: MOCK_NEIGHBORS,
graphNodes: MOCK_GRAPH_NODES,
graphEdges: MOCK_GRAPH_EDGES,
routerIds: MOCK_ROUTER_IDS,
bfdSessions: MOCK_BFD,
routes: MOCK_ROUTES,
}
}, [isLive, liveData, unknownIds])
// ── server list for chips bar ────────────────────────────────────────────────
const ospfServers = useMemo((): OspfServerInfo[] => {
if (isLive && liveData) {
return liveServers.map(s => ({ id: String(s.id), label: s.name, site: s.site ?? "", country: s.country }))
}
if (isLive) return []
// Mock: derive from MOCK_GRAPH_NODES that actually have interfaces
const activeIds = new Set(MOCK_ITEMS.map(i => i.routerKey))
const siteCountry: Record = { MSK: "RU", SPB: "RU", FRA: "DE", AMS: "NL", SGP: "SG" }
return MOCK_GRAPH_NODES
.filter(n => activeIds.has(n.id))
.map(n => ({ id: n.id, label: n.label, site: n.site, country: siteCountry[n.site] ?? "" }))
}, [isLive, liveData, liveServers])
// Per-server counts for chips (all data, before filtering)
const serverCounts = useMemo(() => {
const out: Record = {}
const ensure = (id: string) => { if (!out[id]) out[id] = { ifaces: 0, neighbors: 0, bfd: 0 } }
for (const item of items) { ensure(item.routerKey); out[item.routerKey].ifaces++ }
for (const n of neighbors) { ensure(n.localRouter); out[n.localRouter].neighbors++ }
for (const b of bfdSessions) { ensure(b.serverId); out[b.serverId].bfd++ }
return out
}, [items, neighbors, bfdSessions])
// ── filtered display data ─────────────────────────────────────────────────────
const missingServer = filterServerId !== ALL_SERVERS_ID && (!isLive || Boolean(liveData)) && !ospfServers.some(server => server.id === filterServerId)
const unavailable = Boolean(contextError) || missingServer
const viewIncomplete = incomplete || unavailable
const scopedItems = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? items : items.filter(i => i.routerKey === filterServerId)
const scopedNeighbors = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? neighbors : neighbors.filter(n => n.localRouter === filterServerId)
const scopedBfdSessions = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? bfdSessions : bfdSessions.filter(b => b.serverId === filterServerId)
const scopedRoutes = unavailable ? [] : filterServerId === ALL_SERVERS_ID ? routes : routes.filter(r => r.serverId === filterServerId)
const query = search.trim().toLocaleLowerCase("ru")
const matches = (...fields: (string | number | null | undefined)[]) => !query || fields.some(field => String(field ?? "").toLocaleLowerCase("ru").includes(query))
const displayItems = scopedItems.filter(i => matches(i.routerLabel, i.interfaceName, i.area, i.type))
const displayNeighbors = scopedNeighbors.filter(n => matches(n.localLabel, n.localIface, n.remoteRouterId, n.area, n.state))
const displayBfdSessions = scopedBfdSessions.filter(b => matches(b.serverLabel, b.iface, b.localAddr, b.remoteAddr, b.state))
const displayRoutes = scopedRoutes.filter(r => matches(r.serverLabel, r.destination, r.nextHop, r.via, r.area, r.type))
const displayGraph = isLive || filterServerId !== ALL_SERVERS_ID || query ? buildLiveGraph(displayNeighbors) : { nodes: graphNodes, edges: graphEdges }
const visibleCount = activeTab === "interfaces" ? displayItems.length : activeTab === "neighbors" ? displayNeighbors.length : activeTab === "bfd" ? displayBfdSessions.length : displayRoutes.length
const scopeCount = activeTab === "interfaces" ? scopedItems.length : activeTab === "neighbors" ? scopedNeighbors.length : activeTab === "bfd" ? scopedBfdSessions.length : scopedRoutes.length
const ospfRailItems = useMemo(() => (
ospfServers.map((s) => {
const counts = serverCounts[s.id]
const host = s.label.replace(/^mt-/, "")
return {
id: s.id,
selectable: !writeBusy,
name: host,
host,
site: s.site,
country: s.country || undefined,
meta: isLive && unknownIds.has(Number(s.id)) ? "Данные OSPF неизвестны" : counts
? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}`
: undefined,
}
})
), [ospfServers, serverCounts, isLive, unknownIds, writeBusy])
// Graph always shows full topology (highlight is handled by node click inside tab)
// KPIs reflect the current filter
const totalRouters = new Set(displayItems.map(i => i.routerKey)).size
const totalInterfaces = displayItems.length
const totalAreas = new Set(displayItems.map(i => i.area)).size
return (
{ if (!operationBusy.current) onSelectServer(id) }}
showAll
allCount={ospfServers.length}
loading={isLive && loading && ospfServers.length === 0}
header={
>
}
/>
}
banner={
{TABS.map(t => (
))}
}
>
{unavailable &&
Контекст OSPF недоступен{contextError ?? `Сервер ${filterServerId} отсутствует среди доступных устройств.`}}
onSearch("")}>Сбросить поиск : undefined} />
{/* data source banner */}
{isLive && loading && (
Загрузка OSPF данных…
)}
{isLive && fetchedAt &&
Ответ каталога получен . Время получения браузером, не измерения RouterOS.
{liveServers.filter(s => filterServerId === ALL_SERVERS_ID || String(s.id) === filterServerId).map(s =>
{s.name}: {readReceipts[s.id] ? : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}
)}
{writeReceipt && (filterServerId === ALL_SERVERS_ID || String(writeReceipt.serverId) === filterServerId) &&
API подтвердил оптимизацию устройства {writeReceipt.serverId}:
}
}
{isLive && (liveError || Boolean(liveData?.failures.length)) && {liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}}
{viewIncomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) && Данные выбранного раздела OSPF пока неизвестны.
}
{mode === "mock" && (
Моковые данные
)}
,
iconClassName: "text-muted-foreground",
},
{
id: "ifaces",
label: "Интерфейсов",
value: viewIncomplete ? "—" : totalInterfaces,
icon: ,
iconClassName: "text-info",
},
{
id: "areas",
label: "Зон (Area)",
value: viewIncomplete ? "—" : totalAreas,
icon: ,
iconClassName: "text-primary",
},
]}
/>
{activeTab === "interfaces" && (!viewIncomplete || displayItems.length > 0) && (
{ if (pageMounted.current) setWriteReceipt({ serverId, at: new Date().toISOString() }) }}
onOperationBusy={busy => {
if (!pageMounted.current) return
operationBusy.current = busy; setWriteBusy(busy)
if (!busy) { setLoading(true); setFetchTick(t => t + 1) }
}}
/>
)}
{activeTab === "neighbors" && (!viewIncomplete || displayNeighbors.length > 0) && (
0)}
/>
)}
{activeTab === "routes" && (!viewIncomplete || displayRoutes.length > 0) && }
{activeTab === "bfd" && (!viewIncomplete || displayBfdSessions.length > 0) && }
)
}