fix(ui): показывать частичные сбои опроса VXLAN

This commit is contained in:
Denozordec committed 2026-09-30 09:42:43 +07:00
1 parent 4dc2ee2129
commit e163a9ed9d
9 files changed
+120 -41

No files matched your search

+23 -6
View File
@@ -38,6 +38,7 @@ interface BackendServer {
}
interface VxlanApiResponse {
failures?: Array<{ serverId: string; serverName?: string; error: string }>
tunnels: VxlanTunnel[]
}
@@ -139,6 +140,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
const [liveTunnels, setLiveTunnels] = useState<VxlanTunnel[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(true)
const [failures, setFailures] = useState<NonNullable<VxlanApiResponse["failures"]>>([])
const [hasLoaded, setHasLoaded] = useState(false)
const loadEpoch = useRef(0)
useEffect(() => () => { loadEpoch.current += 1 }, [backendUrl, isLive])
@@ -156,7 +158,12 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
])
if (epoch !== loadEpoch.current) return
if (!Array.isArray(tunnelsRes?.tunnels) || !Array.isArray(serversRes)) throw new Error("Некорректный ответ загрузки VXLAN")
setLiveTunnels(tunnelsRes.tunnels)
const nextFailures = tunnelsRes.failures ?? []
if (!Array.isArray(nextFailures) || nextFailures.some(f => !f || typeof f.serverId !== "string" || typeof f.error !== "string")) throw new Error("Некорректный ответ загрузки VXLAN")
const failedIds = new Set(nextFailures.map(f => f.serverId))
const enabledIds = new Set(serversRes.filter(s => s.enabled).map(s => String(s.id)))
setLiveTunnels(previous => [...tunnelsRes.tunnels.filter(t => !failedIds.has(t.serverId)), ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
setFailures(nextFailures)
setHasLoaded(true)
setLiveServers(serversRes.filter((s) => s.enabled).map(mapBackendServer))
} catch (e) {
@@ -224,6 +231,8 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
)
}, [search, scopedTunnels, serverById])
const failedServerIds = useMemo(() => isLive ? failures.map(f => f.serverId) : [], [isLive, failures])
const scopedFailures = failures.filter(f => effectiveServerId === ALL_SERVERS_ID || f.serverId === effectiveServerId)
const upCount = scopedTunnels.filter((t) => t.status === "up").length
const vnis = new Set(scopedTunnels.map((t) => t.vni)).size
@@ -261,6 +270,13 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
}
>
<div className="flex flex-col gap-5">
{isLive && scopedFailures.length > 0 && <ContextAlert variant="warning" role="alert">
<ContextTitle>Не все устройства ответили</ContextTitle>
<ContextDescription>
<p>Не удалось опросить: {scopedFailures.map(f => f.serverName ?? f.serverId).join(", ")}. Для этих устройств сохранены последние данные; текущее состояние туннелей неизвестно.</p>
<Button variant="outline" size="sm" disabled={loading} onClick={() => void loadLive()}>Повторить опрос</Button>
</ContextDescription>
</ContextAlert>}
{selectedTunnelId !== null && (
<ContextAlert variant={liveError || ((!isLive || !loading) && !selectedTunnel) ? "warning" : "info"} role="status" aria-label="VXLAN по ссылке">
<ContextTitle className="line-clamp-none wrap-anywhere">{selectedTunnel ? "Выбран VXLAN: " + selectedTunnel.name : "VXLAN по ссылке"}</ContextTitle>
@@ -283,7 +299,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
)}
{isLive && loading && <p role="status" className="text-sm text-muted-foreground">{hasLoaded ? "Обновление VXLAN…" : "Загрузка VXLAN…"}</p>}
{isLive && liveError && hasLoaded && <p className="text-sm text-muted-foreground">Показаны последние полученные данные; они могут быть устаревшими.</p>}
{selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && (
{selectedTunnelId === null && isLive && !loading && displayTunnels.length === 0 && !liveError && scopedFailures.length === 0 && (
<div className="rounded-md border border-border bg-muted/30 px-4 py-6 text-center text-sm text-muted-foreground">
На опрошенных серверах нет VXLAN-интерфейсов
</div>
@@ -300,28 +316,28 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
{
id: "tunnels",
label: "Туннелей",
value: isLive && !hasLoaded ? "—" : scopedTunnels.length,
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : scopedTunnels.length,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "up",
label: "Активных",
value: isLive && !hasLoaded ? "—" : upCount,
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : upCount,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "vni",
label: "Уникальных VNI",
value: isLive && !hasLoaded ? "—" : vnis,
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : vnis,
icon: <LayersIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "servers",
label: "Серверов",
value: isLive && !hasLoaded ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size,
value: isLive && (!hasLoaded || scopedFailures.length > 0) ? "—" : new Set(scopedTunnels.map((t) => t.serverId)).size,
icon: <NetworkIcon className="size-4" />,
iconClassName: "text-primary",
},
@@ -348,6 +364,7 @@ function VxlanPageContent({ selectedServerId, selectedTunnelId, allTunnelsHref,
/>
<VxlanDataGrid
tunnels={filtered}
failedServerIds={failedServerIds}
servers={displayServers}
onExport={setExportTunnel}
/>