diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx
index b234cb37..acd3ba2f 100644
--- a/app/(main)/dashboard/page.tsx
+++ b/app/(main)/dashboard/page.tsx
@@ -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() {
- {dash.error && !dash.loading ? (
-
- Не удалось обновить дашборд
-
- {dash.error}
-
-
-
- ) : null}
+ {dash.isLive &&
}
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: ,
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: ,
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: ,
},
{
@@ -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: ,
},
{
@@ -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: ,
},
]}
@@ -319,8 +309,8 @@ export default function DashboardPage() {
{dash.activeProbes.length === 0 && !dash.probesLoading ? (
Открыть мониторинг
diff --git a/components/dashboard/dashboard-data-status.tsx b/components/dashboard/dashboard-data-status.tsx
new file mode 100644
index 00000000..d67e7fa3
--- /dev/null
+++ b/components/dashboard/dashboard-data-status.tsx
@@ -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
+ {failed.length > 0 &&
+ {summary}
+ Последние полученные данные сохранены. Если источник ещё не загрузился, его показатели неизвестны.
+ }
+
+
0 || undefined}>
+ Источники и время получения
+
+ {(Object.keys(DASHBOARD_SOURCES) as DashboardSource[]).map(key => {
+ const source = sources[key]
+ return -
+ {DASHBOARD_SOURCES[key]}: {source?.error ? `ошибка обновления — ${source.error}. ` : ""}
+ {source?.receivedAt != null ? <>Получено {source.error ? " · данные могут быть устаревшими" : ""}> : "Успешных загрузок ещё нет"}
+ {source?.sampledAt && Number.isFinite(Date.parse(source.sampledAt)) && <> · замер >}
+
+ })}
+
+
+
+ {refreshing ? "Обновление данных…" : "Обновление завершено"}
+
+
+
+
+}
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 4f25fe74..ced7a6f0 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -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. Список ниже сохранён как историческая инвентаризация.
diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts
index 4e788707..6e9b5eb2 100644
--- a/hooks/use-dashboard-live.ts
+++ b/hooks/use-dashboard-live.ts
@@ -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(null)
const [mockDashEpoch, setMockDashEpoch] = useState(0)
- const fetchSnapshot = useCallback(async (silent: boolean) => {
+ const [sources, setSources] = useState({})
+ 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(`/api/servers/${h.id}/wan-runtime`)
+ const rt = await checkedFetch(`/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)
diff --git a/lib/dashboard-source-state.ts b/lib/dashboard-source-state.ts
new file mode 100644
index 00000000..cd04d296
--- /dev/null
+++ b/lib/dashboard-source-state.ts
@@ -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>
+
+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 } }
+}
diff --git a/tests/e2e/dashboard-data.spec.ts b/tests/e2e/dashboard-data.spec.ts
new file mode 100644
index 00000000..52da1656
--- /dev/null
+++ b/tests/e2e/dashboard-data.spec.ts
@@ -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(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")
+})