fix(ui): сохранить данные дашборда при сбоях и гонках запросов
This commit is contained in:
1 parent
0d418e2e39
commit
6a726b13b4
6 files changed
+316
-94
No files matched your search
@@ -24,9 +24,9 @@ import { DashboardEventsTimeline } from "@/components/dashboard/dashboard-events
|
||||
import { InternetPathPanel } from "@/components/dashboard/internet-path-panel"
|
||||
import { DashboardActiveProbesDataGrid } from "@/components/data-grids/dashboard-active-probes-data-grid"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { DashboardDataStatus } from "@/components/dashboard/dashboard-data-status"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { fmtRate } from "@/lib/fmt-rate"
|
||||
import { useDashboardLive, type AttentionRow } from "@/hooks/use-dashboard-live"
|
||||
@@ -69,17 +69,7 @@ export default function DashboardPage() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{dash.error && !dash.loading ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Не удалось обновить дашборд</AlertTitle>
|
||||
<AlertDescription className="flex flex-wrap items-center gap-2">
|
||||
<span>{dash.error}</span>
|
||||
<Button variant="outline" size="sm" onClick={dash.retry}>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{dash.isLive && <DashboardDataStatus sources={dash.sources} refreshing={dash.refreshing} onRefresh={dash.retry} />}
|
||||
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка дашборда"
|
||||
@@ -88,8 +78,8 @@ export default function DashboardPage() {
|
||||
{
|
||||
id: "fleet",
|
||||
label: "Флот",
|
||||
value: `${dash.onlineCount} / ${dash.totalServers}`,
|
||||
hint: !dash.isLive
|
||||
value: dash.availability.servers ? `${dash.onlineCount} / ${dash.totalServers}` : "—",
|
||||
hint: !dash.availability.servers ? "нет данных" : !dash.isLive
|
||||
? "демо"
|
||||
: offline > 0
|
||||
? `${offline} offline`
|
||||
@@ -104,8 +94,8 @@ export default function DashboardPage() {
|
||||
{
|
||||
id: "overlay",
|
||||
label: "Оверлей",
|
||||
value: String(dash.overlay.up),
|
||||
hint: dash.overlay.total > 0 ? `${dash.overlay.down} down` : "нет туннелей",
|
||||
value: dash.availability.overlay ? String(dash.overlay.up) : "—",
|
||||
hint: !dash.availability.overlay ? "не все источники доступны" : dash.overlay.total > 0 ? `${dash.overlay.down} down` : "нет туннелей",
|
||||
href: "/gre",
|
||||
icon: <CableIcon />,
|
||||
iconClassName: "text-info",
|
||||
@@ -123,8 +113,8 @@ export default function DashboardPage() {
|
||||
{
|
||||
id: "health",
|
||||
label: "Здоровье",
|
||||
value: String(health),
|
||||
hint: `${dash.probeDown} down · ${dash.probeWarn} warn`,
|
||||
value: dash.availability.probes ? String(health) : "—",
|
||||
hint: dash.availability.probes ? `${dash.probeDown} down · ${dash.probeWarn} warn` : "нет данных",
|
||||
href: "/uptime",
|
||||
icon: <BellIcon />,
|
||||
iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground",
|
||||
@@ -268,8 +258,8 @@ export default function DashboardPage() {
|
||||
icon: ServerIcon,
|
||||
count: dash.attentionServers.length,
|
||||
countVariant: "warning-light",
|
||||
emptyTitle: "Все online",
|
||||
emptyDescription: "Нет офлайн или degraded узлов.",
|
||||
emptyTitle: dash.reliable.servers ? "Все online" : "Состояние не подтверждено",
|
||||
emptyDescription: dash.reliable.servers ? "Нет офлайн или degraded узлов." : "Проверьте состояние источников выше.",
|
||||
children: <AttentionRows rows={dash.attentionServers} />,
|
||||
},
|
||||
{
|
||||
@@ -279,8 +269,8 @@ export default function DashboardPage() {
|
||||
iconClassName: "text-info",
|
||||
count: dash.attentionOverlay.length,
|
||||
countVariant: "destructive-light",
|
||||
emptyTitle: "Туннели up",
|
||||
emptyDescription: "GRE и WireGuard в норме.",
|
||||
emptyTitle: dash.reliable.overlay ? "Туннели up" : "Состояние не подтверждено",
|
||||
emptyDescription: dash.reliable.overlay ? "GRE и WireGuard в норме." : "Проверьте состояние источников выше.",
|
||||
children: <AttentionRows rows={dash.attentionOverlay} />,
|
||||
},
|
||||
{
|
||||
@@ -290,8 +280,8 @@ export default function DashboardPage() {
|
||||
iconClassName: "text-destructive",
|
||||
count: dash.attentionProbes.length,
|
||||
countVariant: "destructive-light",
|
||||
emptyTitle: "Пробы up",
|
||||
emptyDescription: "Нет down или warn.",
|
||||
emptyTitle: dash.reliable.probes ? "Пробы up" : "Состояние не подтверждено",
|
||||
emptyDescription: dash.reliable.probes ? "Нет down или warn." : "Проверьте состояние источников выше.",
|
||||
children: <AttentionRows rows={dash.attentionProbes} />,
|
||||
},
|
||||
]}
|
||||
@@ -319,8 +309,8 @@ export default function DashboardPage() {
|
||||
{dash.activeProbes.length === 0 && !dash.probesLoading ? (
|
||||
<EmptyState
|
||||
className="py-10"
|
||||
title="Нет проб на дашборде"
|
||||
description="Включите пробу и отметьте ★ на странице мониторинга."
|
||||
title={dash.availability.probes ? "Нет проб на дашборде" : "Данные проб недоступны"}
|
||||
description={dash.availability.probes ? "Включите пробу и отметьте ★ на странице мониторинга." : "Повторите обновление данных."}
|
||||
action={
|
||||
<Link href="/uptime" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
Открыть мониторинг
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DASHBOARD_SOURCES, type DashboardSource, type DashboardSources } from "@/lib/dashboard-source-state"
|
||||
|
||||
export function DashboardDataStatus({ sources, refreshing, onRefresh }: {
|
||||
sources: DashboardSources
|
||||
refreshing: boolean
|
||||
onRefresh: () => void
|
||||
}) {
|
||||
const failed = Object.values(sources).filter(source => source.error)
|
||||
const summary = failed.length ? `Не обновлены источники: ${failed.length}` : "Состояние источников"
|
||||
return <div className="flex flex-col gap-2">
|
||||
{failed.length > 0 && <Alert variant="destructive">
|
||||
<AlertTitle>{summary}</AlertTitle>
|
||||
<AlertDescription>Последние полученные данные сохранены. Если источник ещё не загрузился, его показатели неизвестны.</AlertDescription>
|
||||
</Alert>}
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<details className="min-w-0 text-sm" open={failed.length > 0 || undefined}>
|
||||
<summary className="cursor-pointer">Источники и время получения</summary>
|
||||
<ul className="mt-2 flex flex-col gap-1 text-xs">
|
||||
{(Object.keys(DASHBOARD_SOURCES) as DashboardSource[]).map(key => {
|
||||
const source = sources[key]
|
||||
return <li key={key} data-source={key} className={`break-words ${source?.error ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{DASHBOARD_SOURCES[key]}: {source?.error ? `ошибка обновления — ${source.error}. ` : ""}
|
||||
{source?.receivedAt != null ? <>Получено <time dateTime={new Date(source.receivedAt).toISOString()}>{new Date(source.receivedAt).toLocaleString("ru-RU")}</time>{source.error ? " · данные могут быть устаревшими" : ""}</> : "Успешных загрузок ещё нет"}
|
||||
{source?.sampledAt && Number.isFinite(Date.parse(source.sampledAt)) && <> · замер <time dateTime={source.sampledAt}>{new Date(source.sampledAt).toLocaleString("ru-RU")}</time></>}
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
<div className="flex items-center gap-2">
|
||||
<span role="status" className="text-xs text-muted-foreground">{refreshing ? "Обновление данных…" : "Обновление завершено"}</span>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onRefresh} disabled={refreshing}>Обновить данные</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+15
-1
@@ -19,7 +19,7 @@
|
||||
| 2 | Общая оболочка Data Grid/TanStack v9, Filters и эталон серверов | Завершён 2026-09-13, с перечисленными ограничениями приёмки |
|
||||
| 3 | Основные таблицы и справочники | Завершён 2026-09-13, границы проверок ниже |
|
||||
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже |
|
||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Не начат |
|
||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
||||
| 6 | Навигация и дашборд | Не начат |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Не начат |
|
||||
| 8 | Трафик и статистика | Не начат |
|
||||
@@ -367,6 +367,20 @@ TypeScript до и после первого браузерного прогон
|
||||
|
||||
**Следующий этап 5:** `hooks/use-dashboard-live.ts` и `app/(main)/dashboard/page.tsx`. Подтверждено чтением: fetchSnapshot уже использует Promise.allSettled, но при ошибках очищает probes/servers/BGP/traffic/overlay, а fetchRecentEvents очищает события; обновления запускаются из нескольких effects и таймеров без защиты результатов запросов. Ввести состояния/свежесть по источникам и сохранение последних успешных данных; отличать начальную ошибку от частичного сбоя и фонового обновления. Защитить смену backend/mode и перекрывающиеся запросы от поздних результатов, включая дополнительные запросы Internet Path. Проверить HTTP-моками первоначальную ошибку, частичный сбой, повторное восстановление и гонки; профильный UX показывать без подмены неизвестных данных нулями. Зафиксировать отдельным коммитом этапа 5.
|
||||
|
||||
## Этап 5 — состояния данных дашборда (2026-09-20)
|
||||
|
||||
`use-dashboard-live` сохраняет последние успешные servers/probes/BGP/traffic/events и отдельные GRE/WireGuard/VXLAN при сбоях. Состояние и время успешного получения ведутся по девяти источникам; Internet Path дополнительно показывает серверный sampledAt. Новый DashboardDataStatus использует существующие Alert/Button, сообщает частичный сбой, раскрывает время/ошибки источников и позволяет повторить загрузку. Неизвестные KPI отображаются как «—», пустые очереди проблем при недоступных источниках больше не утверждают «всё исправно». При фоновом обновлении сохранённые данные остаются видимыми.
|
||||
|
||||
Для snapshot и событий введены независимые счётчики запросов: применяются только результаты последнего запроса, включая дополнительные await Internet Path; cleanup инвалидирует незавершённые ответы при смене контекста или размонтировании. Убран дублирующий первоначальную загрузку effect по pathname. Смена backend/mode очищает данные предыдущего контекста. Это защита состояния, а не отмена уже отправленных HTTP-запросов.
|
||||
|
||||
Internet Path использует успешный серверный снимок независимо от сбоя списка серверов. Ошибка snapshot или обязательных данных fallback сохраняет старую модель; ошибки дополнительных lookup/wan-runtime/speed-probes не маскируются новым пустым снимком. Сами сетевые команды и правила выбора маршрутов не менялись.
|
||||
|
||||
Проверки: TypeScript и scoped ESLint — успешно; **5 браузерных сценариев passed**: начальная ошибка и восстановление, частичный сбой с сохранением данных/событий/времени, поздний основной ответ, ошибка дополнительной загрузки Internet Path, поздний дополнительный ответ. Для проверки поздних ответов тест дожидается завершения HTTP и двух кадров отрисовки. Сценарий частичного сбоя дополнительно повторён на ширине 390; скриншоты 1440/390 просмотрены, новый блок не обрезан, восстановление доступно. Получен ReUI checklist; используются существующая композиция, семантические цвета, role=status/alert и подписанная кнопка type=button. Общая основа ReUI не менялась, полная регрессия/production build не повторялись после успешной приёмки этапа 4 в этом окне.
|
||||
|
||||
Границы: браузерные проверки идут через HTTP-моки; реальная свежесть RouterOS не подтверждена. Время «Получено» обозначает успешный ответ API, не новый замер устройства; sampledAt показан отдельно, где доступен. Смена backend/mode защищена cleanup, но отдельный браузерный сценарий смены backend не запускался (в e2e backend фиксирован конфигурацией). Существующие доменные допущения вроде null server.status → offline и оценочных метрик оптимизатора относятся к следующим профильным этапам.
|
||||
|
||||
Квота: 41% пятичасовой на старте, 73% после профильных проверок (недельная 11%). Следующий этап 6 — общая оболочка и дашборд: показать одну схему ПК/телефона по App Shell 12/Stats 12, затем улучшать навигацию, контекст и очередь проблем с переходами к объектам. Этот этап сохранить отдельным локальным коммитом без push; настройки skills/AGENTS/.codex не включать.
|
||||
|
||||
## Карта миграции (исходная инвентаризация этапа 0)
|
||||
|
||||
Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация.
|
||||
|
||||
+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)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const DASHBOARD_SOURCES = {
|
||||
servers: "Серверы", probes: "Пробы", bgp: "BGP", gre: "GRE",
|
||||
wg: "WireGuard", vxlan: "VXLAN", traffic: "Трафик",
|
||||
internetPath: "Internet path", events: "События",
|
||||
} as const
|
||||
|
||||
export type DashboardSource = keyof typeof DASHBOARD_SOURCES
|
||||
export type DashboardSources = Partial<Record<DashboardSource, { receivedAt: number | null; error: string | null; sampledAt?: string }>>
|
||||
|
||||
export function updateDashboardSource(previous: DashboardSources, source: DashboardSource, error: string | null, now: number, sampledAt?: string): DashboardSources {
|
||||
return { ...previous, [source]: { receivedAt: error ? previous[source]?.receivedAt ?? null : now, error, sampledAt: error ? previous[source]?.sampledAt : sampledAt } }
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { test, expect, type Page } from "@playwright/test"
|
||||
|
||||
async function setup(page: Page, initiallyFailed = false) {
|
||||
const control = { failed: initiallyFailed, partial: false, prefixes: 321, hold: false, holdPath: "/api/bgp/sessions", fallback: false, failFallback: false, pending: 0, release: () => {} }
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
control.release = release
|
||||
const servers = [{ id: 1, name: "Узел", host: "192.0.2.1", site: "TEST", country: "RU", asn: "64512", type: "exit-node", enabled: true, status: "online", latency: 10, os: "7", model: "CHR" }]
|
||||
const snapshot = { sampledAt: "2026-09-20T00:00:00Z", servers, greTunnels: [], filtersRulesets: [], speedProbes: [], routeLookupByServerId: {}, wanRuntimeByHomeId: {} }
|
||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (path === "/api/auth/config") return route.fulfill({ json: { required: false } })
|
||||
if (path === "/api/evobgp/settings") return route.fulfill({ json: { enabled: false, baseUrl: "", secretConfigured: false } })
|
||||
const fail = control.failed || (control.failFallback && path === "/api/uptime/speed-probes") || (control.partial && ["/api/bgp/sessions", "/api/filters/gre-tunnels", "/api/events", "/api/internet-path/latest", "/api/traffic/servers"].includes(path))
|
||||
const prefixes = control.prefixes
|
||||
if (control.hold && path === control.holdPath) {
|
||||
control.hold = false
|
||||
control.pending += 1
|
||||
await gate
|
||||
}
|
||||
if (fail) return route.fulfill({ status: 503, json: { error: "Источник временно недоступен" } })
|
||||
const json = path === "/api/servers" ? servers
|
||||
: path === "/api/uptime/overview" ? { probes: [] }
|
||||
: path === "/api/bgp/sessions" ? [{ state: "Established", prefixesRx: prefixes }]
|
||||
: path === "/api/filters/rules" ? { rulesets: [] }
|
||||
: path === "/api/filters/gre-tunnels" ? { tunnels: [{ id: "g1", name: "GRE сохранён", status: "down", serverId: "1" }] }
|
||||
: path === "/api/wireguard" ? { interfaces: [], peers: [] }
|
||||
: path === "/api/vxlan" ? { tunnels: [] }
|
||||
: path === "/api/traffic/servers" ? { servers: [{ id: "1", rxNow: 1000, txNow: 500, rxSeries: [1000], txSeries: [500] }] }
|
||||
: path === "/api/internet-path/latest" ? { snapshot: control.fallback ? null : snapshot }
|
||||
: path === "/api/uptime/speed-probes" ? { probes: [] }
|
||||
: path === "/api/events" ? { events: [{ id: "e1", title: "Событие сохранено", message: "Тестовое событие", level: "info", sourceModule: "system", eventType: "test", createdAt: "2026-09-20T00:00:00Z", entityId: null, entityType: null, payload: {} }] } : {}
|
||||
return route.fulfill({ json })
|
||||
})
|
||||
await page.goto("/dashboard")
|
||||
return control
|
||||
}
|
||||
|
||||
const refresh = (page: Page) => page.getByRole("button", { name: "Обновить данные", exact: true })
|
||||
const bgp = (page: Page) => page.getByRole("link").filter({ hasText: "BGP Rx" })
|
||||
|
||||
test("Дашборд: первоначальная ошибка не подменяется нулями и исправным состоянием", async ({ page }) => {
|
||||
const control = await setup(page, true)
|
||||
await expect(refresh(page)).toBeEnabled()
|
||||
await expect(page.getByText("Не обновлены источники: 9", { exact: true }).first()).toBeVisible()
|
||||
await expect(page.getByRole("link").filter({ hasText: "Флот" })).toContainText("—")
|
||||
await expect(bgp(page)).toContainText("—")
|
||||
await expect(page.getByText("Все online", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByText("Пробы up", { exact: true })).toHaveCount(0)
|
||||
control.failed = false
|
||||
await refresh(page).click()
|
||||
await expect(bgp(page)).toContainText("321")
|
||||
await expect(page.getByText("Не обновлены источники: 9", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("Дашборд: частичный сбой сохраняет данные, события и время последнего успеха", async ({ page }) => {
|
||||
const control = await setup(page)
|
||||
await expect(refresh(page)).toBeEnabled()
|
||||
await expect(bgp(page)).toContainText("321")
|
||||
const source = page.locator('[data-source="bgp"]')
|
||||
const receivedAt = await source.locator("time").getAttribute("datetime")
|
||||
control.partial = true
|
||||
await refresh(page).click()
|
||||
await expect(source).toContainText("ошибка обновления")
|
||||
await expect(source.locator("time")).toHaveAttribute("datetime", receivedAt!)
|
||||
await expect(bgp(page)).toContainText("321")
|
||||
await expect(page.getByText("Событие сохранено", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("GRE сохранён", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка")
|
||||
await page.screenshot({ path: "test-results/dashboard-data-desktop.png", fullPage: true })
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(source).toBeVisible()
|
||||
await page.screenshot({ path: "test-results/dashboard-data-mobile.png", fullPage: true })
|
||||
control.partial = false
|
||||
control.prefixes = 654
|
||||
await refresh(page).click()
|
||||
await expect(bgp(page)).toContainText("654")
|
||||
await expect(page.getByText(/Не обновлены источники:/)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("Дашборд: поздний ответ не заменяет свежие данные при фоновом обновлении", async ({ page }) => {
|
||||
const control = await setup(page)
|
||||
await expect(bgp(page)).toContainText("321")
|
||||
await expect(refresh(page)).toBeEnabled()
|
||||
control.hold = true
|
||||
control.prefixes = 111
|
||||
await refresh(page).click()
|
||||
await expect.poll(() => control.pending).toBe(1)
|
||||
await expect(bgp(page)).toContainText("321")
|
||||
await expect(page.getByText("Обновление данных…", { exact: true })).toBeVisible()
|
||||
control.prefixes = 999
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("mm:uptime-probes-changed")))
|
||||
await expect(bgp(page)).toContainText("999")
|
||||
const oldResponse = page.waitForResponse(response => response.url().includes("/api/bgp/sessions"))
|
||||
control.release()
|
||||
await (await oldResponse).finished()
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
||||
await expect(refresh(page)).toBeEnabled()
|
||||
await expect(bgp(page)).toContainText("999")
|
||||
})
|
||||
|
||||
test("Internet path: ошибка дополнительных запросов сохраняет прежний снимок", async ({ page }) => {
|
||||
const control = await setup(page)
|
||||
await expect(refresh(page)).toBeEnabled()
|
||||
const source = page.locator('[data-source="internetPath"]')
|
||||
const timestamp = await source.locator("time").first().getAttribute("datetime")
|
||||
control.fallback = true
|
||||
control.failFallback = true
|
||||
await refresh(page).click()
|
||||
await expect(source).toContainText("Не удалось обновить все данные Internet path")
|
||||
await expect(source.locator("time").first()).toHaveAttribute("datetime", timestamp!)
|
||||
await expect(source.locator("time").last()).toHaveAttribute("datetime", "2026-09-20T00:00:00Z")
|
||||
})
|
||||
|
||||
test("Internet path: поздний дополнительный запрос не заменяет новый снимок", async ({ page }) => {
|
||||
const control = await setup(page)
|
||||
await expect(refresh(page)).toBeEnabled()
|
||||
control.fallback = true
|
||||
control.holdPath = "/api/uptime/speed-probes"
|
||||
control.hold = true
|
||||
await refresh(page).click()
|
||||
await expect.poll(() => control.pending).toBe(1)
|
||||
control.fallback = false
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("mm:uptime-probes-changed")))
|
||||
await expect(refresh(page)).toBeEnabled()
|
||||
const source = page.locator('[data-source="internetPath"]')
|
||||
const timestamp = await source.locator("time").first().getAttribute("datetime")
|
||||
const response = page.waitForResponse(r => r.url().includes("/api/uptime/speed-probes"))
|
||||
control.release()
|
||||
await (await response).finished()
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
||||
await expect(source.locator("time").first()).toHaveAttribute("datetime", timestamp!)
|
||||
await expect(source.locator("time").last()).toHaveAttribute("datetime", "2026-09-20T00:00:00Z")
|
||||
})
|
||||
Reference in new issue
Block a user