fix(ui): сохранить данные дашборда при сбоях и гонках запросов
This commit is contained in:
1 parent
0d418e2e39
commit
6a726b13b4
6 files changed
+316
-94
No files matched your search
+98
-66
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user