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>
+3 -2
View File
@@ -20,6 +20,7 @@ import { ActivityIcon } from "lucide-react"
export interface BfdSessionRow {
id: string
dataUnknown?: boolean
serverId: string
serverLabel: string
localAddr: string
@@ -145,10 +146,10 @@ function OspfBfdDataGrid({ sessions }: OspfBfdDataGridProps) {
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
stateClass(row.original.state),
row.original.dataUnknown ? "bg-muted text-muted-foreground" : stateClass(row.original.state),
)}
>
{row.original.state}
{row.original.dataUnknown ? "Неизвестно" : row.original.state}
</span>
),
meta: {
@@ -20,6 +20,7 @@ import { NetworkIcon } from "lucide-react"
export interface OspfNeighborRow {
id: string
dataUnknown?: boolean
localRouter: string
localLabel: string
localIface: string
@@ -129,10 +130,10 @@ function OspfNeighborsDataGrid({
<span
className={cn(
"inline-flex items-center rounded border px-1.5 py-0.5 text-[11px] font-medium",
stateClass(row.original.state),
row.original.dataUnknown ? "bg-muted text-muted-foreground" : stateClass(row.original.state),
)}
>
{row.original.state}
{row.original.dataUnknown ? "Неизвестно" : row.original.state}
</span>
),
meta: {
@@ -20,6 +20,7 @@ import { RouteIcon } from "lucide-react"
export interface OspfRouteRow {
id: string
dataUnknown?: boolean
destination: string
type: "O" | "O IA" | "O E1" | "O E2"
cost: number
@@ -116,7 +117,7 @@ function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) {
id: "serverLabel",
accessorKey: "serverLabel",
header: ({ column }) => <DataGridSortHeader column={column} title="Роутер" />,
cell: ({ row }) => <span className="font-mono">{row.original.serverLabel}</span>,
cell: ({ row }) => <span className="font-mono">{row.original.serverLabel}{row.original.dataUnknown ? " · Неизвестно" : ""}</span>,
meta: {
headerTitle: "Роутер",
headerClassName: DATA_GRID_CELL_PAD,
+9 -1
View File
@@ -26,11 +26,19 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.9, строгий каталог OSPF; далее экран |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.10, данные OSPF; далее операции |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.10 — достоверность данных OSPF (2026-09-30)
- Экран использует /ospf/catalog и inventory, проверяет все пять списков/поля/ID/failures и включённый контекст. Partial сохраняет набор отказавшего устройства, successful empty очищает; общий/ошибочный ответ сохраняет кэш. Mode/backendUrl изолированы key/cleanup.
- Начальный отказ не выглядит как пустая настройка/нули. KPI неизвестны, строки neighbors/BFD/interfaces/routes явно отмечены при сбое. Топология не считается текущей при неполном опросе; сводка исправного выбранного устройства остаётся доступной. Rail включает включённые устройства без OSPF-строк.
- Постоянный alert/повтор и отметка браузерного получения; повтор Refresh блокируется во время чтения. Оптимизация пока доступна только после полного успешного каталога. Времена успешного опроса каждого устройства, права/lock/поздний write ещё требуют следующего блока.
- Удалён придуманный live optimal cost из порядка интерфейсов; opt показан только после ответа оптимизации. Убрано ложное mock «применены»: локальное изменение обозначено предпросмотром; кнопка сброса не выдаётся за серверный опрос.
- 17 E2E прошли (семь OSPF data + routing/BGP), TypeScript чистый, lint без ошибок с двумя исходными warning stateClass/fmtMs. Далее — операции OSPF, URL/свежесть, unknown state/counters и layout. Квота: 84% окна использовано; локальный коммит без push.
## Блок 10.9 — строгий каталог OSPF (2026-09-30)
- Добавлен /ospf/catalog с пятью списками и failures по устройствам. Строгий reader не скрывает отказ BFD или /ip/route за []; успешный пустой ответ остаётся пустым. Чужой serverId, пустые/повторяющиеся ID в разделе отклоняются.
+4
View File
@@ -76,3 +76,7 @@ server/session/tab и возврат/reload сохраняют контекст;
## Блок 10.9 — OSPF каталог
Строгий /ospf/catalog возвращает failures устройства; BFD/routes ошибки не скрываются. Снимок устройства атомарен, legacy /all и правила оптимизации сохранены. Unit/HTTP/backend build прошли; два исходных lint warning. Далее — экран OSPF.
## Блок 10.10 — OSPF данные
Каталог/inventory валидируются, кэш отказавшего устройства сохраняется, состояния/KPI неизвестны, топология не подменяет полный опрос. Убраны фиктивные live costs/mock применение. 17 E2E/TypeScript/lint прошли (два старых warning). Далее — операции/URL/свежесть/поля/layout.
+17
View File
@@ -0,0 +1,17 @@
import { z } from "zod"
import { vxlanServersSchema } from "@/lib/vxlan-response"
const counter = z.number().int().nonnegative()
const base = z.object({ id: z.string().trim().min(1), serverId: z.number().int().positive(), serverName: z.string(), serverSite: z.string(), serverCountry: z.string().default("UN") })
export const ospfCatalogSchema = z.object({
neighbors: z.array(base.extend({ address: z.string(), routerId: z.string(), instance: z.string(), area: z.string(), areaId: z.string(), interface: z.string(), state: z.string(), uptime: z.string().nullable(), stateChanges: counter, priority: counter })),
interfaces: z.array(base.extend({ instance: z.string(), area: z.string(), areaId: z.string(), interface: z.string(), cost: counter, type: z.string(), disabled: z.boolean(), inactive: z.boolean(), priority: counter, helloInterval: counter, deadInterval: counter, useBfd: z.boolean() })),
instances: z.array(base.extend({ name: z.string(), routerId: z.string(), version: counter, disabled: z.boolean(), inactive: z.boolean(), redistribute: z.string() })),
bfdSessions: z.array(base.extend({ localAddr: z.string(), remoteAddr: z.string(), interface: z.string(), state: z.string(), uptime: z.string().nullable(), multihop: z.boolean(), multiplier: counter, txInterval: counter, rxInterval: counter, holdTime: counter, packetsRx: counter, packetsTx: counter, stateChanges: counter })),
routes: z.array(base.extend({ destination: z.string(), type: z.enum(["O", "O IA", "O E1", "O E2"]), cost: counter, nextHop: z.string(), via: z.string(), area: z.string() })),
failures: z.array(z.object({ serverId: z.number().int().positive(), serverName: z.string(), error: z.string() })),
}).refine(data => {
const failed = new Set(data.failures.map(f => f.serverId))
return failed.size === data.failures.length && [data.neighbors, data.interfaces, data.instances, data.bfdSessions, data.routes].every(rows => new Set(rows.map(row => JSON.stringify([row.serverId, row.id]))).size === rows.length && rows.every(row => !failed.has(row.serverId)))
})
export const ospfServersSchema = vxlanServersSchema
export type OspfCatalog = z.infer<typeof ospfCatalogSchema>
+69
View File
@@ -0,0 +1,69 @@
import { test, expect } from "@playwright/test"
const servers = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, country: "RU", enabled: true, status: null }))
const host = (serverId: number) => ({ serverId, serverName: "Роутер " + serverId, serverSite: "", serverCountry: "RU" })
const neighbor = (serverId: number, address = "192.0.2." + serverId) => ({ ...host(serverId), id: "*1", address, routerId: address, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", state: "Full", uptime: "1h", stateChanges: 0, priority: 1 })
const all = { neighbors: [neighbor(1), neighbor(2)], interfaces: [], instances: [], bfdSessions: [], routes: [], failures: [] }
for (const broken of ["http", "payload", "duplicate", "conflict", "foreign", "inventory"]) {
test("OSPF: неизвестное начало, кэш и восстановление " + broken, async ({ page }) => {
let phase = 0
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
if (path === "/api/servers") return r.fulfill({ json: phase === 2 && broken === "inventory" ? [servers[0], servers[0]] : servers })
if (path === "/api/ospf/catalog") {
if (phase === 0 || phase === 2 && broken === "http") return r.fulfill({ status: 503, json: { error: "OSPF недоступен тест" } })
return r.fulfill({ json: phase !== 2 ? all : broken === "payload" ? { ...all, neighbors: [{}] }
: broken === "duplicate" ? { ...all, neighbors: [neighbor(1), neighbor(1)] }
: broken === "conflict" ? { ...all, failures: [{ serverId: 2, serverName: "Роутер 2", error: "Таймаут" }] }
: broken === "foreign" ? { ...all, neighbors: [neighbor(3)] } : all })
}
return r.fulfill({ json: path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/ospf")
const alert = page.getByRole("alert").filter({ hasText: "Не удалось загрузить OSPF" })
await expect(alert).toContainText("Данные пока неизвестны")
await expect(page.getByLabel("Сводка OSPF", { exact: true })).toContainText("неизвестно")
await expect(page.getByText("OSPF интерфейсы не настроены ни на одном сервере", { exact: true })).toHaveCount(0)
phase = 1
await alert.getByRole("button", { name: "Повторить загрузку OSPF" }).click()
await page.getByRole("button", { name: "Соседи", exact: true }).click()
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
phase = 2
await page.getByRole("button", { name: "Обновить", exact: true }).click()
await expect(alert).toContainText("Показаны последние полученные данные")
await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(2)
await expect(page.getByRole("status").filter({ hasText: "Топология OSPF неизвестна" })).toBeVisible()
phase = 3
await alert.getByRole("button", { name: "Повторить загрузку OSPF" }).click()
await expect(alert).toHaveCount(0)
await expect(rows.filter({ hasText: "Неизвестно" })).toHaveCount(0)
})
}
test("OSPF: частичный отказ сохраняет устройство, успешный пустой опрос очищает", async ({ page }) => {
let phase = 0
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
await page.route("**/api/**", r => {
const path = new URL(r.request().url()).pathname
return r.fulfill({ json: path === "/api/servers" ? servers : path === "/api/ospf/catalog" ? phase === 0 ? all : { ...all, neighbors: [neighbor(1, "198.51.100.1")], failures: phase === 1 ? [{ serverId: 2, serverName: "Роутер 2", error: "Таймаут BFD тест" }] : [] } : path === "/api/auth/config" ? { required: false } : {} })
})
await page.goto("/ospf")
await page.getByRole("button", { name: "Соседи", exact: true }).click()
const rows = page.locator("tr[data-row-id]")
await expect(rows).toHaveCount(2)
phase = 1
await page.getByRole("button", { name: "Обновить", exact: true }).click()
const alert = page.getByRole("alert").filter({ hasText: "Не удалось опросить часть роутеров OSPF" })
await expect(alert).toContainText("Роутер 2: Таймаут BFD тест")
await expect(rows.filter({ hasText: "Роутер 2" })).toContainText("Неизвестно")
await expect(rows.filter({ hasText: "198.51.100.1" })).toContainText("Full")
await page.getByRole("option").filter({ hasText: "Роутер 1" }).click()
await expect(page.getByLabel("Сводка OSPF", { exact: true })).not.toContainText("неизвестно")
await page.getByRole("option").filter({ hasText: "Роутер 2" }).click()
await expect(page.getByLabel("Сводка OSPF", { exact: true })).toContainText("неизвестно")
phase = 2
await alert.getByRole("button", { name: "Повторить загрузку OSPF" }).click()
await expect(rows).toHaveCount(0)
await expect(page.getByText("Нет OSPF-соседей", { exact: true })).toBeVisible()
})
+2 -2
View File
@@ -22,10 +22,10 @@ async function setup(page: Page, path: string, empty = false) {
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const path = new URL(route.request().url()).pathname
const json = path === "/api/ospf/all" ? (empty ? { instances: [], interfaces: [], neighbors: [], routes: [], bfdSessions: [] } : ospf)
const json = path === "/api/ospf/catalog" ? (empty ? { instances: [], interfaces: [], neighbors: [], routes: [], bfdSessions: [], failures: [] } : { ...ospf, failures: [] })
: path === "/api/bgp/catalog" ? { sessions: empty ? [] : bgp, failures: [] }
: path === "/api/auth/config" ? { required: false }
: path === "/api/servers" ? [{ id: 1, name: "Тестовый роутер", host: "192.0.2.100", country: "RU", enabled: true, status: null }] : {}
: path === "/api/servers" ? ids.map(id => ({ id, name: "Тестовый роутер " + id, host: "192.0.2." + id, country: "RU", enabled: true, status: null })) : {}
return route.fulfill({ json })
})
await page.goto(path)