fix(ui): сохранить данные дашборда при сбоях и гонках запросов

This commit is contained in:
Denozordec committed 2026-09-20 00:58:58 +07:00
1 parent 0d418e2e39
commit 6a726b13b4
6 files changed
+316 -94

No files matched your search

+98 -66
View File
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { usePathname } from "next/navigation"
import {
dashLatency,
@@ -29,6 +29,8 @@ import {
} from "@/lib/dashboard-internet-path"
import type { FiltersRulesetRow, RouteOptimizerSpeedProbe } from "@/lib/route-optimizer-data"
import { updateDashboardSource, type DashboardSource, type DashboardSources } from "@/lib/dashboard-source-state"
const MOCK_DASH_STARS_LS = "mm:dashboard-probe-ids"
const UPTIME_PROBES_CHANGED = "mm:uptime-probes-changed"
@@ -257,12 +259,19 @@ export function useDashboardLive() {
const [error, setError] = useState<string | null>(null)
const [mockDashEpoch, setMockDashEpoch] = useState(0)
const fetchSnapshot = useCallback(async (silent: boolean) => {
const [sources, setSources] = useState<DashboardSources>({})
const snapshotRequest = useRef(0)
const eventsRequest = useRef(0)
const recordSource = useCallback((source: DashboardSource, error: string | null, sampledAt?: string) => {
setSources(previous => updateDashboardSource(previous, source, error, Date.now(), sampledAt))
}, [])
const fetchSnapshot = useCallback(async (_silent: boolean) => {
if (!isLive) return
if (!silent) {
setLoading(true)
setInternetPathLoading(true)
}
const request = ++snapshotRequest.current
const current = () => request === snapshotRequest.current
setLoading(true)
setInternetPathLoading(true)
try {
const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes, vxRes] = await Promise.allSettled([
apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"),
@@ -276,27 +285,23 @@ export function useDashboardLive() {
apiFetch<{ tunnels?: Array<{ id: string; name: string; status: OverlayItem["status"] }> }>("/api/vxlan"),
])
const hardFail = overviewRes.status === "rejected" && serversRes.status === "rejected"
if (hardFail) {
const reason = overviewRes.reason
setError(reason instanceof Error ? reason.message : "Не удалось загрузить дашборд")
} else {
setError(null)
if (!current()) return
const results = { probes: overviewRes, servers: serversRes, bgp: br, gre: greRes, wg: wgRes, traffic: trafficRes, vxlan: vxRes }
for (const [key, result] of Object.entries(results)) {
recordSource(key as DashboardSource, result.status === "rejected"
? result.reason instanceof Error ? result.reason.message : "Источник недоступен" : null)
}
setInternetPathError(null)
const failures = Object.values(results).filter(result => result.status === "rejected").length
setError(failures ? "Не все источники обновлены. Последние полученные данные сохранены." : null)
if (overviewRes.status === "fulfilled") {
setLiveProbes(overviewRes.value.probes)
} else {
setLiveProbes([])
}
let serversMapped: Server[] = []
if (serversRes.status === "fulfilled") {
serversMapped = serversRes.value.map(mapBackendToServer)
setLiveServers(serversMapped)
} else {
setLiveServers([])
}
if (br.status === "fulfilled") {
@@ -310,8 +315,6 @@ export function useDashboardLive() {
}
}
setBgp({ prefixSum, establishedCount })
} else {
setBgp(null)
}
const greItems: OverlayItem[] =
@@ -344,7 +347,11 @@ export function useDashboardLive() {
status: t.status === "up" ? "up" : "down",
}))
: []
setOverlayItems([...greItems, ...wgItems, ...vxItems])
setOverlayItems(previous => [
...(greRes.status === "fulfilled" ? greItems : previous?.filter(item => item.kind === "gre") ?? []),
...(wgRes.status === "fulfilled" ? wgItems : previous?.filter(item => item.kind === "wg") ?? []),
...(vxRes.status === "fulfilled" ? vxItems : previous?.filter(item => item.kind === "vxlan") ?? []),
])
if (trafficRes.status === "fulfilled") {
const rows = trafficRes.value.servers ?? []
@@ -354,15 +361,14 @@ export function useDashboardLive() {
rxNow: rows.reduce((n, r) => n + (r.rxNow ?? 0), 0),
txNow: rows.reduce((n, r) => n + (r.txNow ?? 0), 0),
})
} else {
setTrafficSeries(null)
}
const greMapped =
greRes.status === "fulfilled" ? (greRes.value.tunnels ?? []).map(apiGreToGreTunnel) : []
if (serversMapped.length > 0) {
const snap = ipRes.status === "fulfilled" ? ipRes.value.snapshot : null
if (ipRes.status === "rejected") throw ipRes.reason
const snap = ipRes.value.snapshot
if (snap || serversMapped.length > 0) {
if (snap) {
setInternetPath(
buildDashboardInternetPath({
@@ -375,28 +381,41 @@ export function useDashboardLive() {
}),
)
} else {
if (serversRes.status === "rejected" || fr.status === "rejected" || greRes.status === "rejected") {
throw new Error("Не удалось получить исходные данные Internet path")
}
let lookupFailed = false
const checkedFetch: typeof apiFetch = async (path, init) => {
if (!current()) throw new Error("Обновление заменено новым запросом")
try { return await apiFetch(path, init) }
catch (error) { lookupFailed = true; throw error }
}
const filterRulesets: FiltersRulesetRow[] =
fr.status === "fulfilled" ? ((fr.value.rulesets as FiltersRulesetRow[]) ?? []) : []
const homes = serversMapped.filter((s) => s.type === "home-router")
const lookups = await Promise.all(
homes.map(async (h) => ({
id: h.id,
lookup: await resolveDefaultRouteLookup(apiFetch, h.id),
lookup: await resolveDefaultRouteLookup(checkedFetch, h.id),
})),
)
if (!current()) return
const wanRuntimeRows = await Promise.all(
homes.map(async (h) => {
try {
const rt = await apiFetch<HomeWanRuntime>(`/api/servers/${h.id}/wan-runtime`)
const rt = await checkedFetch<HomeWanRuntime>(`/api/servers/${h.id}/wan-runtime`)
return { id: h.id, runtime: rt }
} catch {
return { id: h.id, runtime: null }
}
}),
)
const speedRes = await apiFetch<{ probes?: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes").catch(
if (!current()) return
const speedRes = await checkedFetch<{ probes?: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes").catch(
() => ({ probes: [] }),
)
if (!current()) return
if (lookupFailed) throw new Error("Не удалось обновить все данные Internet path")
const lookupById = Object.fromEntries(lookups.map((x) => [x.id, x.lookup]))
const wanRuntimeById = Object.fromEntries(wanRuntimeRows.map((x) => [x.id, x.runtime]))
setInternetPath(
@@ -411,68 +430,76 @@ export function useDashboardLive() {
)
}
} else {
if (serversRes.status === "rejected") throw serversRes.reason
setInternetPath(null)
}
recordSource("internetPath", null, snap?.sampledAt)
setInternetPathError(null)
} catch (e) {
if (!current()) return
const msg = e instanceof Error ? e.message : "Не удалось загрузить дашборд"
setInternetPathError(msg)
setInternetPath(null)
recordSource("internetPath", msg)
} finally {
if (!silent) {
if (current()) {
setLoading(false)
setInternetPathLoading(false)
}
}
}, [apiFetch, backendUrl, isLive])
}, [apiFetch, backendUrl, isLive, recordSource])
const fetchRecentEvents = useCallback(async (silent: boolean) => {
const fetchRecentEvents = useCallback(async (_silent: boolean) => {
if (!isLive) {
setRecentEvents([])
setEventsError(null)
return
}
if (!silent) setEventsLoading(true)
const request = ++eventsRequest.current
const current = () => request === eventsRequest.current
setEventsLoading(true)
try {
const rows = await listEvents(backendUrl, { limit: 6 })
if (!current()) return
setRecentEvents(rows)
recordSource("events", null)
setEventsError(null)
} catch (err) {
setRecentEvents([])
setEventsError(err instanceof Error ? err.message : "Не удалось загрузить события")
if (!current()) return
const message = err instanceof Error ? err.message : "Не удалось загрузить события"
recordSource("events", message)
setEventsError(message)
} finally {
if (!silent) setEventsLoading(false)
if (current()) setEventsLoading(false)
}
}, [backendUrl, isLive])
}, [backendUrl, isLive, recordSource])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveProbes(null)
setLiveServers(null)
setOverlayItems(null)
setBgp(null)
setTrafficSeries(null)
setError(null)
setInternetPath(null)
setInternetPathError(null)
})
return
}
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setLiveProbes(null)
setLiveServers(null)
setOverlayItems(null)
setBgp(null)
setTrafficSeries(null)
setError(null)
setInternetPath(null)
setInternetPathError(null)
setRecentEvents([])
setEventsError(null)
setSources({})
setLoading(false)
setEventsLoading(false)
setInternetPathLoading(false)
void fetchSnapshot(false)
void fetchRecentEvents(false)
})
return () => {
cancelled = true
snapshotRequest.current += 1
eventsRequest.current += 1
}
}, [isLive, fetchSnapshot])
useEffect(() => {
queueMicrotask(() => {
void fetchRecentEvents(false)
})
}, [fetchRecentEvents])
}, [fetchSnapshot, fetchRecentEvents])
useEffect(() => {
const id = setInterval(() => {
@@ -493,13 +520,6 @@ export function useDashboardLive() {
return () => clearInterval(id)
}, [isLive, fetchSnapshot])
useEffect(() => {
if (!isLive) return
queueMicrotask(() => {
void fetchSnapshot(true)
})
}, [pathname, isLive, fetchSnapshot])
useEffect(() => {
const bumpMock = () => setMockDashEpoch((x) => x + 1)
const onStorage = (e: StorageEvent) => {
@@ -649,7 +669,7 @@ export function useDashboardLive() {
const onlineCount = servers.filter((s) => s.status === "online").length
const probeDown = enabledProbes.filter((p) => p.status === "down").length
const probeWarn = enabledProbes.filter((p) => p.status === "warn").length
const dataPending = isLive && !error && (liveServers === null || liveProbes === null)
const dataPending = isLive && loading && liveServers === null && liveProbes === null
const kpiLoading = !prefsHydrated || dataPending
const probesSubtitle = !prefsHydrated
@@ -658,8 +678,8 @@ export function useDashboardLive() {
? mockActiveProbes.length > 0
? `${mockActiveProbes.length} на дашборде · демо`
: "Нет проб на дашборде · отметьте ★ в мониторинге"
: error && liveProbes === null
? error
: sources.probes?.error && liveProbes === null
? "Данные проб недоступны"
: activeProbes.length > 0
? `${activeProbes.length} на дашборде · 1 ч`
: "Нет проб на дашборде · отметьте ★ в мониторинге"
@@ -669,6 +689,18 @@ export function useDashboardLive() {
isLive,
loading: kpiLoading,
error,
sources,
refreshing: isLive && (loading || eventsLoading),
availability: {
servers: !isLive || liveServers !== null,
probes: !isLive || liveProbes !== null,
overlay: !isLive || ["gre", "wg", "vxlan"].every(key => sources[key as DashboardSource]?.receivedAt != null),
},
reliable: {
servers: !isLive || (liveServers !== null && !sources.servers?.error),
probes: !isLive || (liveProbes !== null && !sources.probes?.error),
overlay: !isLive || ["gre", "wg", "vxlan"].every(key => sources[key as DashboardSource]?.receivedAt != null && !sources[key as DashboardSource]?.error),
},
retry: () => {
void fetchSnapshot(false)
void fetchRecentEvents(false)