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

+17 -27
View File
@@ -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
View File
@@ -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
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)
+12
View File
@@ -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 } }
}
+135
View File
@@ -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")
})