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>
|
||||
|
||||
Reference in new issue
Block a user