fix(ui): сохранять снимки OSPF при отказе устройств
This commit is contained in:
1 parent
a89cefcbf2
commit
4254904c3a
9 files changed
+194
-112
No files matched your search
+85
-104
@@ -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 / 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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 в разделе отклоняются.
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user