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") +})