fix(ui): сохранять снимки OSPF при отказе устройств

This commit is contained in:
Denozordec committed 2026-09-30 17:37:23 +07:00
1 parent a89cefcbf2
commit 4254904c3a
9 files changed
+194 -112

No files matched your search

+85 -104
View File
@@ -1,6 +1,8 @@
"use client"
import { useMemo, useState, useEffect } from "react"
import { ospfCatalogSchema, ospfServersSchema, type OspfCatalog } from "@/lib/ospf-response"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { OspfNeighborsDataGrid } from "@/components/data-grids/ospf-neighbors-data-grid"
@@ -12,7 +14,7 @@ import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { toast } from "sonner"
import {
RefreshCwIcon, WandSparklesIcon, SaveIcon, GripVerticalIcon,
RefreshCwIcon, WandSparklesIcon, GripVerticalIcon,
NetworkIcon, RouteIcon, ShieldIcon, ActivityIcon, XIcon,
LayersIcon, RouterIcon, UsersIcon, CheckCircleIcon, AlertCircleIcon,
} from "lucide-react"
@@ -43,6 +45,7 @@ interface OspfItem {
area: string
cost: number
active: boolean
dataUnknown?: boolean
type?: string
useBfd?: boolean
helloInterval?: number
@@ -62,6 +65,7 @@ interface OspfNeighbor {
cost: number
uptime: string
priority: number
dataUnknown?: boolean
}
interface OspfRoute {
@@ -74,6 +78,7 @@ interface OspfRoute {
serverId: string
serverLabel: string
area: string
dataUnknown?: boolean
}
interface BfdSession {
@@ -93,6 +98,7 @@ interface BfdSession {
packetsRx: number
packetsTx: number
stateChanges: number
dataUnknown?: boolean
}
// ─── backend types ────────────────────────────────────────────────────────────
@@ -127,13 +133,6 @@ interface BackendOspfRoute {
destination: string; type: OspfRoute["type"]; cost: number; nextHop: string; via: string; area: string
}
interface BackendOspfAll {
neighbors: BackendNeighbor[]
interfaces: BackendInterface[]
instances: BackendInstance[]
bfdSessions: BackendBfdSession[]
routes?: BackendOspfRoute[]
}
function isRefInterfaceName(name: string): boolean {
return /^\(ref\s+\*.+\)$/.test(name.trim())
@@ -678,12 +677,16 @@ function InterfacesTab({
filterServerId,
backendUrl,
onLiveDataRefresh,
dataReady,
dataIncomplete,
}: {
items: OspfItem[]
isLive: boolean
filterServerId: string
backendUrl: string
onLiveDataRefresh: () => void
dataReady: boolean
dataIncomplete: boolean
}) {
const [items, setItems] = useState<OspfItem[]>(initialItems)
const [dragging, setDragging] = useState<string | null>(null)
@@ -732,24 +735,7 @@ function InterfacesTab({
}, [grouped, isLive])
const needsOptimize = !isLive && items.some(item => hints[item.key] && hints[item.key].optimalCost !== item.cost)
const canOptimizeLive = isLive && filterServerId !== ALL_SERVERS_ID
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])
const canOptimizeLive = isLive && dataReady && filterServerId !== ALL_SERVERS_ID
async function handleLiveOptimize() {
if (!canOptimizeLive) {
@@ -799,14 +785,14 @@ function InterfacesTab({
const h = hints[item.key]
return h && item.cost !== h.optimalCost ? { ...item, cost: h.optimalCost } : item
}))
toast.success("Costs оптимизированы по рекомендациям оптимизатора")
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" />Загрузить
<RefreshCwIcon className="size-4" />{isLive ? "Последний снимок" : "Сбросить предпросмотр"}
</Button>
{needsOptimize && (
<Button variant="outline" size="sm" onClick={handleOptimize}>
@@ -819,14 +805,10 @@ function InterfacesTab({
{optimizing ? "Оптимизация OSPF…" : "Оптимизировать OSPF"}
</Button>
)}
{!isLive && (
<Button size="sm" onClick={() => toast.success("OSPF Interface Templates применены")}>
<SaveIcon className="size-4" />Сохранить
</Button>
)}
</div>
{grouped.length === 0 && (
{grouped.length === 0 && !dataIncomplete && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
OSPF интерфейсы не настроены ни на одном сервере
</div>
@@ -859,7 +841,7 @@ function InterfacesTab({
{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 liveOptimal = isLive ? liveOptimalCost[item.key] : hint?.optimalCost
const costDiffers = liveOptimal !== item.cost
return (
<div key={item.key}
@@ -876,8 +858,9 @@ function InterfacesTab({
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")} />
<span className={cn("inline-block size-1.5 rounded-full shrink-0", !item.dataUnknown && item.active ? "bg-[var(--status-online)]" : "bg-muted-foreground/40")} />
<code className="text-xs font-mono flex-1 min-w-0 truncate">{item.interfaceName}</code>
{item.dataUnknown && <span className="text-xs text-muted-foreground">Неизвестно</span>}
{item.type && item.type !== "broadcast" && (
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">{item.type}</Chip>
)}
@@ -891,10 +874,10 @@ function InterfacesTab({
}>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
{liveOptimal !== undefined && !item.dataUnknown && <Chip color={costDiffers
? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
}>opt {liveOptimal}</Chip>
}>opt {liveOptimal}</Chip>}
</div>
)
})}
@@ -930,13 +913,15 @@ function InterfacesTab({
// ─── neighbors tab ────────────────────────────────────────────────────────────
function NeighborsTab({
neighbors, items, graphNodes, graphEdges, routerIds,
neighbors, items, graphNodes, graphEdges, routerIds, dataIncomplete, graphUnknown,
}: {
neighbors: OspfNeighbor[]
items: OspfItem[]
graphNodes: GraphNode[]
graphEdges: GraphEdge[]
routerIds: Record<string, string>
dataIncomplete: boolean
graphUnknown: boolean
}) {
const [highlightId, setHighlightId] = useState<string | null>(null)
const [selectedId, setSelectedId] = useState<string | null>(null)
@@ -952,21 +937,21 @@ function NeighborsTab({
{
id: "neighbors",
label: "Всего соседей",
value: neighbors.length,
value: dataIncomplete ? "—" : neighbors.length,
icon: <UsersIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "full",
label: "Full",
value: fullCount,
value: dataIncomplete ? "—" : fullCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "not-full",
label: "Не Full",
value: neighbors.length - fullCount,
value: dataIncomplete ? "—" : neighbors.length - fullCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: neighbors.length - fullCount > 0 ? "text-warning" : "text-muted-foreground",
variant: neighbors.length - fullCount > 0 ? "warning" : "default",
@@ -974,7 +959,8 @@ function NeighborsTab({
]}
/>
{graphNodes.length > 0 && (
{graphUnknown && <p role="status" className="text-sm text-muted-foreground">Топология OSPF неизвестна до успешного опроса всех устройств.</p>}
{!graphUnknown && graphNodes.length > 0 && (
<div className="flex rounded-xl overflow-hidden border border-white/[0.06]">
<div className="flex-1 relative" style={{ background: "#060d1a", minWidth: 0 }}>
<TopologyGraph
@@ -1038,7 +1024,7 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) {
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>
<span className="text-sm font-medium">{routes.some(r => r.dataUnknown) ? "Последний снимок: " : ""}{routes.length} маршрутов</span>
{typeCount.map(([type, count]) => (
<span key={type} className={cn("inline-flex items-center rounded border px-2 py-0.5 text-[11px] font-medium", routeTypeClass(type as OspfRoute["type"]))}>
{type}: {count}
@@ -1068,7 +1054,7 @@ function RoutesTab({ routes }: { routes: OspfRoute[] }) {
// ─── BFD tab ──────────────────────────────────────────────────────────────────
function BfdTab({ sessions }: { sessions: BfdSession[] }) {
function BfdTab({ sessions, dataIncomplete }: { sessions: BfdSession[]; dataIncomplete: boolean }) {
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
@@ -1088,21 +1074,21 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
{
id: "sessions",
label: "Сессий BFD",
value: sessions.length,
value: dataIncomplete ? "—" : sessions.length,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Up",
value: upCount,
value: dataIncomplete ? "—" : upCount,
icon: <CheckCircleIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "down",
label: "Down / Admin",
value: downCount,
value: dataIncomplete ? "—" : downCount,
icon: <AlertCircleIcon className="size-4" />,
iconClassName: downCount > 0 ? "text-destructive" : "text-muted-foreground",
variant: downCount > 0 ? "destructive" : "default",
@@ -1110,7 +1096,7 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
{
id: "init",
label: "Init / другие",
value: initCount,
value: dataIncomplete ? "—" : initCount,
icon: <LayersIcon className="size-4" />,
iconClassName: initCount > 0 ? "text-warning" : "text-muted-foreground",
variant: initCount > 0 ? "warning" : "default",
@@ -1135,7 +1121,7 @@ function BfdTab({ sessions }: { sessions: BfdSession[] }) {
<p className="text-xs text-muted-foreground">
BFD обнаруживает сбои быстрее OSPF Hello/Dead таймеров.
Tx&nbsp;/&nbsp;Rx — интервалы отправки и приёма контрольных пакетов.
{totalPkts > 0 && <span className="ml-2 tabular-nums">Всего получено: {totalPkts.toLocaleString()} пакетов.</span>}
{!dataIncomplete && totalPkts > 0 && <span className="ml-2 tabular-nums">Всего получено: {totalPkts.toLocaleString()} пакетов.</span>}
</p>
</div>
)}
@@ -1153,14 +1139,19 @@ const TABS: Array<{ id: OspfTab; label: string; icon: React.ReactNode }> = [
]
export default function OspfPage() {
const { mode, backendUrl } = useDataSource()
return <OspfPageContent key={JSON.stringify([mode, backendUrl])} />
}
function OspfPageContent() {
const [activeTab, setActiveTab] = useState<OspfTab>("interfaces")
const [filterServerId, setFilterServerId] = useState<string>(ALL_SERVERS_ID)
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveData, setLiveData] = useState<BackendOspfAll | null>(null)
const [loading, setLoading] = useState(false)
const [liveData, setLiveData] = useState<OspfCatalog | null>(null)
const [liveServers, setLiveServers] = useState<ReturnType<typeof ospfServersSchema.parse>>([])
const [loading, setLoading] = useState(isLive)
const [fetchedAt, setFetchedAt] = useState<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [fetchTick, setFetchTick] = useState(0)
@@ -1179,11 +1170,18 @@ export default function OspfPage() {
queueMicrotask(() => {
if (cancelled) return
setLoading(true)
setLiveError(null)
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
.then(data => {
void Promise.all([requestJson<unknown>(backendUrl, "/api/ospf/catalog"), requestJson<unknown>(backendUrl, "/api/servers")])
.then(([response, servers]) => {
if (cancelled) return
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
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))
setLiveError(null); setFetchedAt(new Date()); setLoading(false)
})
.catch((err: unknown) => {
if (cancelled) return
@@ -1193,6 +1191,10 @@ export default function OspfPage() {
return () => { cancelled = true }
}, [isLive, backendUrl, fetchTick])
const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData])
const incomplete = isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId))))
const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !liveData.failures.length)
// Derive frontend types from backend data or use mocks
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
if (isLive && liveData) {
@@ -1203,11 +1205,11 @@ export default function OspfPage() {
}
const items = liveData.interfaces
.map(backendToItem)
.map(b => ({ ...backendToItem(b), dataUnknown: unknownIds.has(b.serverId) }))
.filter((item) => !isRefInterfaceName(item.interfaceName))
const neighbors = liveData.neighbors.map(b => backendToNeighbor(b, ifaceMap))
const bfdSessions = (liveData.bfdSessions ?? []).map(backendToBfdSession)
const routes = (liveData.routes ?? []).map(backendToRoute)
const neighbors = liveData.neighbors.map(b => ({ ...backendToNeighbor(b, ifaceMap), dataUnknown: unknownIds.has(b.serverId) }))
const bfdSessions = liveData.bfdSessions.map(b => ({ ...backendToBfdSession(b), dataUnknown: unknownIds.has(b.serverId) }))
const routes = liveData.routes.map(b => ({ ...backendToRoute(b), dataUnknown: unknownIds.has(b.serverId) }))
// Build routerIds from instances
const routerIds: Record<string, string> = {}
@@ -1240,24 +1242,12 @@ export default function OspfPage() {
bfdSessions: MOCK_BFD,
routes: MOCK_ROUTES,
}
}, [isLive, liveData])
}, [isLive, liveData, unknownIds])
// ── 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()]
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
@@ -1266,7 +1256,7 @@ export default function OspfPage() {
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])
}, [isLive, liveData, liveServers])
// Per-server counts for chips (all data, before filtering)
const serverCounts = useMemo(() => {
@@ -1294,12 +1284,12 @@ export default function OspfPage() {
host,
site: s.site,
country: s.country || undefined,
meta: counts
meta: isLive && unknownIds.has(Number(s.id)) ? "Данные OSPF неизвестны" : counts
? `${counts.neighbors}n · ${counts.ifaces}i${counts.bfd > 0 ? ` · ${counts.bfd}b` : ""}`
: undefined,
}
})
), [ospfServers, serverCounts])
), [ospfServers, serverCounts, isLive, unknownIds])
// Graph always shows full topology (highlight is handled by node click inside tab)
// KPIs reflect the current filter
@@ -1309,6 +1299,7 @@ export default function OspfPage() {
return (
<ServerRailLayout
preserveSelection
items={ospfRailItems}
selectedId={filterServerId}
onSelect={setFilterServerId}
@@ -1321,7 +1312,7 @@ export default function OspfPage() {
actions={
<>
<ServerRailMobileButton />
<Button variant="outline" size="sm" onClick={() => setFetchTick(t => t + 1)}>
<Button variant="outline" size="sm" disabled={!isLive || loading} onClick={() => setFetchTick(t => t + 1)}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
Обновить
</Button>
@@ -1333,7 +1324,7 @@ export default function OspfPage() {
<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)}
<button key={t.id} aria-pressed={activeTab === 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
@@ -1355,24 +1346,9 @@ export default function OspfPage() {
<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>
)}
{isLive && fetchedAt && <p role="group" aria-label="Свежесть OSPF" className="text-xs text-muted-foreground">Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>. Время получения браузером, не измерения RouterOS.</p>}
{isLive && (liveError || Boolean(liveData?.failures.length)) && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>{liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}</AlertTitle><AlertDescription>{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}<Button variant="outline" size="sm" disabled={loading} onClick={() => setFetchTick(t => t + 1)} aria-label="Повторить загрузку OSPF">Повторить</Button></AlertDescription></Alert>}
{incomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) && <p role="status">Данные выбранного раздела OSPF пока неизвестны.</p>}
{mode === "mock" && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
Моковые данные
@@ -1385,47 +1361,52 @@ export default function OspfPage() {
{
id: "routers",
label: "Роутеров",
value: totalRouters,
value: incomplete ? "—" : totalRouters,
hint: incomplete ? "неизвестно" : undefined,
icon: <RouterIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "ifaces",
label: "Интерфейсов",
value: totalInterfaces,
value: incomplete ? "—" : totalInterfaces,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "areas",
label: "Зон (Area)",
value: totalAreas,
value: incomplete ? "—" : totalAreas,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-primary",
},
]}
/>
{activeTab === "interfaces" && (
{activeTab === "interfaces" && (!incomplete || displayItems.length > 0) && (
<InterfacesTab
items={displayItems}
isLive={isLive}
filterServerId={filterServerId}
backendUrl={backendUrl}
onLiveDataRefresh={() => setFetchTick(t => t + 1)}
dataReady={dataReady}
dataIncomplete={incomplete}
/>
)}
{activeTab === "neighbors" && (
{activeTab === "neighbors" && (!incomplete || displayNeighbors.length > 0) && (
<NeighborsTab
neighbors={displayNeighbors}
items={displayItems}
graphNodes={graphNodes}
graphEdges={graphEdges}
routerIds={routerIds}
dataIncomplete={incomplete}
graphUnknown={isLive && (!liveData || Boolean(liveError) || unknownIds.size > 0)}
/>
)}
{activeTab === "routes" && <RoutesTab routes={displayRoutes} />}
{activeTab === "bfd" && <BfdTab sessions={displayBfdSessions} />}
{activeTab === "routes" && (!incomplete || displayRoutes.length > 0) && <RoutesTab routes={displayRoutes} />}
{activeTab === "bfd" && (!incomplete || displayBfdSessions.length > 0) && <BfdTab sessions={displayBfdSessions} dataIncomplete={incomplete} />}
</div>
</ServerRailLayout>