fix(ui): сохранять подтверждённый снимок пути при ошибке API

This commit is contained in:
Denozordec committed 2026-10-04 00:07:21 +07:00
1 parent 55c9349677
commit 5fdd6ef58c
9 files changed
+85 -53

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. Следующий блок12.2.7: strict Internet path snapshot/fallback и ошибки backend collector, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. Следующий блок12.2.8: ошибки backend collector; затем проверить метрики Internet path, производительность и сквозную регрессию. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+6
View File
@@ -36,6 +36,12 @@
## 12.2.7 — строгий снимок Internet path и запасные запросы (2026-10-04)
Снимок валидируется до публикации: обязательные коллекции, даты, ссылки на устройства, runtime/route для enabled home. Повреждённый сохранённый null с датой не считается отсутствием снимка. Filters stale/error, malformed speed/WAN и diagnostic error не превращаются в пустые успешные данные. После ошибки сохраняются последний снимок и время; независимые источники обновляются. Настроенный WAN без наблюдаемого default route не создаёт выдуманный маршрут/100 Mbps uplink; состояние unknown.
Проверки:56 dashboard/permissions browser (7 новых cache/recovery cases),4 unit, typecheck прошли; ESLint без ошибок, два прежних unused-helper warning в dashboard-internet-path. ReUI Frame/base-nova и общие компоненты не изменены. Backend collector пока скрывает часть RouterOS ошибок — это обязательный следующий блок12.2.8, затем проверка метрик Internet path и сводная приёмка12.2. Реальный PG/RouterOS не проверен.
## 12.2.6 — полные каталоги GRE/WireGuard/VXLAN (2026-10-03)
Dashboard читает существующие строгие schemas GRE/VXLAN/WireGuard, требует явный результат failures (реальный backend передаёт его всегда для общего каталога), проверяет уникальность устройств/интерфейсов. Device failures/malformed/missing/duplicate не становятся успешным пустым каталогом: последний снимок соответствующего kind и время остаются, остальные источники обновляются независимо. Unknown status не переименовывается в down; допустимый VXLAN status передаётся напрямую. KPI указывает «последний успешный снимок» при неполном обновлении; очередь не утверждает исправность. Старый сервер с legacy каталогом без failures требует обновления API и получает честную ошибку, а не ложное отсутствие туннелей.
+6
View File
@@ -80,3 +80,9 @@ RTT агрегируется в60 настоящих временных корз
Dashboard читает существующие строгие schemas GRE/VXLAN/WireGuard, требует явный результат failures (реальный backend передаёт его всегда для общего каталога), проверяет уникальность устройств/интерфейсов. Device failures/malformed/missing/duplicate не становятся успешным пустым каталогом: последний снимок соответствующего kind и время остаются, остальные источники обновляются независимо. Unknown status не переименовывается в down; допустимый VXLAN status передаётся напрямую. KPI указывает «последний успешный снимок» при неполном обновлении; очередь не утверждает исправность. Старый сервер с legacy каталогом без failures требует обновления API и получает честную ошибку, а не ложное отсутствие туннелей.
Проверки:49 dashboard/permissions browser (9 новых invalid/partial/cache/recovery cases),3 overlay unit, typecheck/ESLint прошли. Unused callback dependency удалена; installed ReUI/Frame/KPI unchanged, checklist применён. Fixtures содержат полные DTO, включая failures. Далее12.2.7: strict Internet path snapshot/fallback; отдельно устранить swallowed ошибки backend collector до сводной приёмки12.2.
## 12.2.7 — строгий снимок Internet path и запасные запросы (2026-10-04)
Снимок валидируется до публикации: обязательные коллекции, даты, ссылки на устройства, runtime/route для enabled home. Повреждённый сохранённый null с датой не считается отсутствием снимка. Filters stale/error, malformed speed/WAN и diagnostic error не превращаются в пустые успешные данные. После ошибки сохраняются последний снимок и время; независимые источники обновляются. Настроенный WAN без наблюдаемого default route не создаёт выдуманный маршрут/100 Mbps uplink; состояние unknown.
Проверки:56 dashboard/permissions browser (7 новых cache/recovery cases),4 unit, typecheck прошли; ESLint без ошибок, два прежних unused-helper warning в dashboard-internet-path. ReUI Frame/base-nova и общие компоненты не изменены. Backend collector пока скрывает часть RouterOS ошибок — это обязательный следующий блок12.2.8, затем проверка метрик Internet path и сводная приёмка12.2. Реальный PG/RouterOS не проверен.
+8 -21
View File
@@ -19,15 +19,14 @@ import type { EventItem } from "@mmapp/contracts/events"
import { buildLatencySeriesByProbeSource } from "@/lib/dashboard-latency"
import {
buildDashboardInternetPath,
type HomeWanRuntime,
resolveDefaultRouteLookup,
type InternetPathViewModel,
} 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"
import {can,ensureAuthConfig} from "@/lib/auth"
import {parseDashboardPath,parseDashboardFilters,parseDashboardSpeedProbes,parseDashboardWanRuntime} from "@/lib/dashboard-path-response"
import {parseDashboardGre,parseDashboardWireGuard,parseDashboardVxlan,type DashboardGreTunnel} from "@/lib/dashboard-overlays"
import type {DashboardProbe} from "@/lib/dashboard-probes"
import {parseDashboardServers,parseDashboardProbes,type DashboardServerRow} from "@/lib/dashboard-readers"
@@ -65,16 +64,6 @@ type BackendServerRow=DashboardServerRow
type ApiGreTunnelRow=DashboardGreTunnel
interface InternetPathSnapshotPayload {
sampledAt: string
servers: BackendServerRow[]
greTunnels: ApiGreTunnelRow[]
filtersRulesets: FiltersRulesetRow[]
speedProbes: RouteOptimizerSpeedProbe[]
routeLookupByServerId: Record<string, { gateway: string | null; routingMark: string | null } | null>
wanRuntimeByHomeId: Record<string, HomeWanRuntime | null>
}
export type OverlayKind = "gre" | "wg" | "vxlan"
export interface OverlayItem {
@@ -226,9 +215,9 @@ export function useDashboardLive() {
const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes, vxRes] = await Promise.allSettled([
apiFetch<unknown>("/api/uptime/overview?range=1h").then(parseDashboardProbes),
apiFetch<unknown>("/api/servers").then(parseDashboardServers),
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
apiFetch<unknown>("/api/filters/rules").then(parseDashboardFilters),
apiFetch<unknown>("/api/bgp/catalog").then(parseDashboardBgp),
apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"),
apiFetch<unknown>("/api/internet-path/latest").then(parseDashboardPath),
apiFetch<unknown>("/api/filters/gre-tunnels").then(parseDashboardGre),
apiFetch<unknown>("/api/wireguard").then(parseDashboardWireGuard),
apiFetch<unknown>("/api/traffic/servers?range=1h").then(parseDashboardTraffic),
@@ -321,9 +310,8 @@ export function useDashboardLive() {
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 filterRulesets = fr.status === "fulfilled" ? fr.value.rulesets : []
const homes = serversMapped.filter((s) => s.type === "home-router" && s.enabled)
const lookups = await Promise.all(
homes.map(async (h) => ({
id: h.id,
@@ -334,17 +322,16 @@ export function useDashboardLive() {
const wanRuntimeRows = await Promise.all(
homes.map(async (h) => {
try {
const rt = await checkedFetch<HomeWanRuntime>(`/api/servers/${h.id}/wan-runtime`)
const rt = await checkedFetch<unknown>(`/api/servers/${h.id}/wan-runtime`).then(parseDashboardWanRuntime)
return { id: h.id, runtime: rt }
} catch {
lookupFailed=true
return { id: h.id, runtime: null }
}
}),
)
if (!current()) return
const speedRes = await checkedFetch<{ probes?: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes").catch(
() => ({ probes: [] }),
)
const speedRes = await checkedFetch<unknown>("/api/uptime/speed-probes").then(parseDashboardSpeedProbes).catch(error=>{lookupFailed=true;throw Error("Не удалось обновить все данные Internet path: "+(error instanceof Error?error.message:String(error)))})
if (!current()) return
if (lookupFailed) throw new Error("Не удалось обновить все данные Internet path")
const lookupById = Object.fromEntries(lookups.map((x) => [x.id, x.lookup]))
+12 -25
View File
@@ -1,3 +1,4 @@
import {readProbeOutput} from "@/lib/probe-response"
import type { GreTunnel, Server, WanUplink } from "@/lib/data"
import {
assignSpeedProbesToGreTunnels,
@@ -152,18 +153,6 @@ function chooseActiveWan(
return wans[0] ?? null
}
function syntheticWanForHome(home: Server): WanUplink {
return {
id: `wan-auto-${home.id}`,
name: "WAN-AUTO",
isp: "auto",
iface: "auto",
ip: home.host || "0.0.0.0",
maxDl: 100,
maxUl: 100,
}
}
function bestJhForWan(
home: Server,
wan: WanUplink,
@@ -293,11 +282,13 @@ export function buildDashboardInternetPath(args: {
}
: null
const activeWan = chooseActiveWan(
const observedRoute=Boolean(runtime?.defaultGateway||args.routeLookupByServerId[home.id]?.gateway)
const routeKnown=runtime!=null&&args.routeLookupByServerId[home.id]!=null
const activeWan = observedRoute ? chooseActiveWan(
home,
args.routeLookupByServerId[home.id] ?? null,
runtime,
) ?? syntheticWanForHome(home)
) : null
const currentJh = activeWan ? bestJhForWan(home, activeWan, jhs, args.probes) : null
const currentExit = currentJh ? bestExitForJh(currentJh, exits, args.probes) : null
const currentPath: InternetPathRoute | null = activeWan
@@ -308,7 +299,7 @@ export function buildDashboardInternetPath(args: {
: null
const pathState: InternetPathViewModel["pathState"] =
primaryPath && currentPath
!routeKnown||home.status==="unknown"||(currentJh?.status==="unknown")||(currentExit?.status==="unknown")?"unknown":primaryPath && currentPath
// Нормальный production-кейс: часть трафика идет через BGP (JH→EN),
// часть — напрямую в провайдера WAN uplink.
? "healthy"
@@ -431,15 +422,15 @@ export function buildDashboardInternetPath(args: {
directWan: {
enabled: Boolean(runtime?.defaultGateway || args.routeLookupByServerId[home.id]?.gateway),
gateway: runtime?.defaultGateway ?? args.routeLookupByServerId[home.id]?.gateway ?? null,
iface: runtime?.defaultInterface ?? activeWan.iface ?? null,
iface: runtime?.defaultInterface ?? activeWan?.iface ?? null,
leasedIp:
runtime?.uplinks.find((u) => u.isDefault)?.leasedIp
?? runtime?.uplinks.find((u) => norm(u.iface) === norm(activeWan.iface))?.leasedIp
?? runtime?.uplinks.find((u) => norm(u.iface) === norm(activeWan?.iface))?.leasedIp
?? null,
provider:
runtime?.uplinks.find((u) => u.isDefault)?.isp
?? runtime?.uplinks.find((u) => norm(u.iface) === norm(activeWan.iface))?.isp
?? activeWan.isp
?? runtime?.uplinks.find((u) => norm(u.iface) === norm(activeWan?.iface))?.isp
?? activeWan?.isp
?? null,
},
}
@@ -449,8 +440,7 @@ export async function resolveDefaultRouteLookup(
apiFetch: <T>(path: string, init?: RequestInit) => Promise<T>,
serverId: string,
): Promise<RouteLookupResult | null> {
try {
const payload = await apiFetch<{ output?: string }>(`/api/servers/${serverId}/probes/run`, {
const payload = await apiFetch<unknown>(`/api/servers/${serverId}/probes/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -458,8 +448,5 @@ export async function resolveDefaultRouteLookup(
target: INTERNET_TARGET,
}),
})
return parseRouteLookupOutput(payload.output ?? "")
} catch {
return null
}
return parseRouteLookupOutput(readProbeOutput(payload))
}
+22
View File
@@ -0,0 +1,22 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {parseDashboardPath,parseDashboardSpeedProbes,parseDashboardWanRuntime,parseDashboardFilters} from "./dashboard-path-response"
import {resolveDefaultRouteLookup,buildDashboardInternetPath} from "./dashboard-internet-path"
import {servers} from "./data"
const snapshot={sampledAt:"2026-10-03T00:00:00Z",servers:[],greTunnels:[],filtersRulesets:[],speedProbes:[],routeLookupByServerId:{},wanRuntimeByHomeId:{}}
test("path validates explicit empty snapshot and rejects missing fields, foreign IDs and mismatched time",()=>{
assert.deepEqual(parseDashboardPath({snapshot:null}),{snapshot:null});assert.ok(parseDashboardPath({snapshot}))
for(const raw of [{},{snapshot:{}},{snapshot:null,sampledAt:snapshot.sampledAt},{snapshot:{...snapshot,speedProbes:undefined}},{snapshot:{...snapshot,routeLookupByServerId:{"99":null}}},{snapshot,sampledAt:"2026-10-03T01:00:00Z"}])assert.throws(()=>parseDashboardPath(raw))
})
test("path fallback validates rules, speed probes and WAN rather than silently using empty data",()=>{
assert.ok(parseDashboardSpeedProbes({probes:[]}));assert.ok(parseDashboardFilters({rulesets:[]}));assert.ok(parseDashboardWanRuntime({defaultGateway:null,defaultInterface:null,uplinks:[]}))
for(const work of [()=>parseDashboardSpeedProbes({}),()=>parseDashboardFilters({rulesets:[],stale:true}),()=>parseDashboardWanRuntime({}),()=>parseDashboardWanRuntime({defaultGateway:null,defaultInterface:null,uplinks:[{}]})])assert.throws(work)
})
test("route diagnostic rejects malformed and error output; no route is a confirmed empty result",async()=>{
for(const raw of [{},{output:"Error: denied"},{output:12}])await assert.rejects(resolveDefaultRouteLookup(async<T>()=>raw as T,"1"))
assert.deepEqual(await resolveDefaultRouteLookup(async<T>()=>({output:""}) as T,"1"),{gateway:null,routingMark:null})
})
test("configured WAN alone does not prove an observed current route",()=>{
const mapped=servers.map((s,i)=>({...s,id:String(i+1)}));const result=buildDashboardInternetPath({servers:mapped,greTunnels:[],filtersRulesets:[],probes:[],routeLookupByServerId:{},wanRuntimeByHomeId:{}})
assert.ok(result);assert.equal(result.pathState,"unknown");assert.equal(result.currentPath,null);assert.equal(result.activeWanUplink,null)
})
+16
View File
@@ -0,0 +1,16 @@
import {z} from "zod"
import {dashboardServersSchema} from "@/lib/dashboard-readers"
import {greResponseSchema} from "@/lib/gre-response"
import {filtersResponseSchema} from "@/lib/filters-response"
import {optimizerProbesSchema,optimizerRulesSchema} from "@/lib/route-optimizer-response"
const route=z.object({gateway:z.string().nullable(),routingMark:z.string().nullable()})
export const dashboardWanRuntimeSchema=z.object({defaultGateway:z.string().nullable(),defaultInterface:z.string().nullable(),uplinks:z.array(z.object({id:z.string().min(1),iface:z.string(),name:z.string(),isp:z.string(),configuredIp:z.string(),leasedIp:z.string().nullable(),dhcpStatus:z.string().nullable(),isDefault:z.boolean()})).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)})
const snapshot=z.object({sampledAt:z.iso.datetime({offset:true}),servers:dashboardServersSchema,greTunnels:greResponseSchema.shape.tunnels,filtersRulesets:filtersResponseSchema.shape.rulesets,speedProbes:optimizerProbesSchema.shape.probes,routeLookupByServerId:z.record(z.string(),route.nullable()),wanRuntimeByHomeId:z.record(z.string(),dashboardWanRuntimeSchema.nullable())}).refine(data=>{
const ids=new Set(data.servers.map(s=>String(s.id))),homes=data.servers.filter(s=>s.enabled&&s.type==="home-router")
return data.greTunnels.every(t=>ids.has(t.serverId))&&data.filtersRulesets.every(r=>ids.has(r.serverId))&&data.speedProbes.every(p=>ids.has(p.srcServerId)&&ids.has(p.dstServerId))&&Object.keys(data.routeLookupByServerId).every(id=>ids.has(id))&&Object.keys(data.wanRuntimeByHomeId).every(id=>ids.has(id))&&homes.every(h=>data.routeLookupByServerId[String(h.id)]!=null&&data.wanRuntimeByHomeId[String(h.id)]!=null)
})
const response=z.object({snapshot:snapshot.nullable(),sampledAt:z.iso.datetime({offset:true}).optional()}).refine(data=>data.snapshot===null?data.sampledAt===undefined:!data.sampledAt||Date.parse(data.sampledAt)===Date.parse(data.snapshot.sampledAt))
export function parseDashboardPath(raw:unknown){const parsed=response.safeParse(raw);if(!parsed.success)throw Error("Снимок Internet path не подтверждён: данные неполны или повреждены");return parsed.data}
export function parseDashboardFilters(raw:unknown){const parsed=optimizerRulesSchema.safeParse(raw);if(!parsed.success||parsed.data.error)throw Error("Данные Filters для Internet path не подтверждены");return parsed.data}
export function parseDashboardSpeedProbes(raw:unknown){const parsed=optimizerProbesSchema.safeParse(raw);if(!parsed.success)throw Error("Данные проб скорости для Internet path не подтверждены");return parsed.data}
export function parseDashboardWanRuntime(raw:unknown){const parsed=dashboardWanRuntimeSchema.safeParse(raw);if(!parsed.success)throw Error("Данные WAN для Internet path не подтверждены");return parsed.data}
+2 -2
View File
@@ -1,7 +1,7 @@
import {z} from "zod"
import {serverReadSchema} from "@mmapp/contracts/servers"
const server=serverReadSchema.pick({id:true,name:true,host:true,site:true,country:true,asn:true,type:true,enabled:true,status:true,latency:true,os:true,model:true}).extend({id:z.number().int().positive(),host:z.string().trim().min(1),latency:z.number().finite().nonnegative().nullable(),sessions:z.number().int().nonnegative(),wanUplinks:serverReadSchema.shape.wanUplinks.optional()})
const servers=z.array(server).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)
export const dashboardServersSchema=z.array(server).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)
export {parseDashboardProbes} from "@/lib/dashboard-probes"
export type DashboardServerRow=z.infer<typeof server>
export function parseDashboardServers(raw:unknown){const parsed=servers.safeParse(raw);if(!parsed.success)throw Error("Некорректный каталог серверов");return parsed.data}
export function parseDashboardServers(raw:unknown){const parsed=dashboardServersSchema.safeParse(raw);if(!parsed.success)throw Error("Некорректный каталог серверов");return parsed.data}
+12 -4
View File
@@ -5,7 +5,7 @@ import {dashboardGre,dashboardVxlan,dashboardWg} from "./fixtures/dashboard-over
import {bgpSession} from "./fixtures/bgp"
async function setup(page: Page, initiallyFailed = false, serverCount = 1, initialBgpPayload?:unknown) {
const control = { failed: initiallyFailed, partial: false, prefixes: 321 as number|null, grePayload:undefined as unknown,wgPayload:undefined as unknown,vxlanPayload:undefined as unknown,serversPayload:undefined as unknown,probesPayload:undefined as unknown, trafficPayload: undefined as unknown, bgpPayload: initialBgpPayload as unknown, hold: false, holdPath: "/api/bgp/catalog", fallback: false, failFallback: false, pending: 0, release: () => {} }
const control = { pathPayload:undefined as unknown,filterPayload:undefined as unknown,speedPayload:undefined as unknown, failed: initiallyFailed, partial: false, prefixes: 321 as number|null, grePayload:undefined as unknown,wgPayload:undefined as unknown,vxlanPayload:undefined as unknown,serversPayload:undefined as unknown,probesPayload:undefined as unknown, trafficPayload: undefined as unknown, bgpPayload: initialBgpPayload as unknown, hold: false, holdPath: "/api/bgp/catalog", fallback: false, failFallback: false, pending: 0, release: () => {} }
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
control.release = release
@@ -30,13 +30,13 @@ async function setup(page: Page, initiallyFailed = false, serverCount = 1, initi
const json = path === "/api/servers" ? control.serversPayload !== undefined ? control.serversPayload : servers
: path === "/api/uptime/overview" ? control.probesPayload !== undefined ? control.probesPayload : { probes: [] }
: path === "/api/bgp/catalog" ? control.bgpPayload !== undefined ? control.bgpPayload : {sessions:[bgpSession(1,prefixes)],failures:[]}
: path === "/api/filters/rules" ? { rulesets: [] }
: path === "/api/filters/rules" ? control.filterPayload!==undefined?control.filterPayload:{ rulesets: [] }
: path === "/api/filters/gre-tunnels" ? control.grePayload!==undefined?control.grePayload:{ tunnels: [dashboardGre],failures:[] }
: path === "/api/wireguard" ? control.wgPayload!==undefined?control.wgPayload:{ interfaces: [],failures:[] }
: path === "/api/vxlan" ? control.vxlanPayload!==undefined?control.vxlanPayload:{ tunnels: [],failures:[] }
: path === "/api/traffic/servers" ? control.trafficPayload !== undefined ? control.trafficPayload : { servers: [{ id: "1", rxNow: 1000, txNow: 500, rxSeries: [1000], txSeries: [500],observations:{rxNow:true,txNow:true,rxSeries:[true],txSeries:[true],sampledAt:"2026-09-20T00:00:00Z"} }] }
: path === "/api/internet-path/latest" ? { snapshot: control.fallback ? null : snapshot }
: path === "/api/uptime/speed-probes" ? { probes: [] }
: path === "/api/internet-path/latest" ? control.pathPayload!==undefined?control.pathPayload:{ snapshot: control.fallback ? null : snapshot }
: path === "/api/uptime/speed-probes" ? control.speedPayload!==undefined?control.speedPayload:{ 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 })
})
@@ -246,3 +246,11 @@ for(const state of ["legacy","unmeasured","zero","failure","gaps","invalid"])tes
for(const kind of ["gre","wg","vxlan"] as const)for(const broken of ["missing","partial","duplicate"])test("Dashboard overlay complete source and cache/"+kind+"/"+broken,async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="'+kind+'"]'),controlKey=kind==="gre"?"grePayload":kind==="wg"?"wgPayload":"vxlanPayload",rowsKey=kind==="wg"?"interfaces":"tunnels",row=kind==="gre"?dashboardGre:kind==="wg"?dashboardWg:dashboardVxlan;control[controlKey]={[rowsKey]:[row],failures:[]};await refresh(page).click();await expect(source).not.toContainText("ошибка");const receivedAt=await source.locator("time").getAttribute("datetime");control[controlKey]=broken==="missing"?{}:broken==="partial"?{[rowsKey]:[row],failures:[{serverId:"2",serverName:"Узел без ответа",error:"timeout"}]}:{[rowsKey]:[row,row],failures:[]};await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time")).toHaveAttribute("datetime",receivedAt!);await expect(page.getByRole("link").filter({hasText:"Туннели работают"})).toContainText("Последний успешный снимок");await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");if(kind==="gre")await expect(page.getByText("GRE сохранён",{exact:true})).toBeVisible();control[controlKey]={[rowsKey]:[],failures:[]};await refresh(page).click();await expect(source).not.toContainText("ошибка");if(kind==="gre")await expect(page.getByText("GRE сохранён",{exact:true})).toHaveCount(0)
})
for(const broken of ["missing","incomplete","foreign","invalid-date","corrupt-null","filters","speed"])test("Dashboard Internet path keeps last confirmed source/"+broken,async({page})=>{
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="internetPath"]');await expect(source).not.toContainText("ошибка");const times=await source.locator("time").evaluateAll(nodes=>nodes.map(n=>n.getAttribute("datetime")));
if(broken==="filters"||broken==="speed"){control.fallback=true;if(broken==="filters")control.filterPayload={rulesets:[],stale:true};else control.speedPayload={}}
else control.pathPayload=broken==="missing"?{}:broken==="corrupt-null"?{snapshot:null,sampledAt:"2026-09-20T00:00:00Z"}:{snapshot:{sampledAt:broken==="invalid-date"?"invalid":"2026-09-20T00:00:00Z",servers:[],greTunnels:[],filtersRulesets:[],speedProbes:broken==="incomplete"?undefined:[],routeLookupByServerId:broken==="foreign"?{"99":null}:{},wanRuntimeByHomeId:{}}};
await refresh(page).click();await expect(source).toContainText("ошибка обновления");expect(await source.locator("time").evaluateAll(nodes=>nodes.map(n=>n.getAttribute("datetime")))).toEqual(times);await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");
control.pathPayload=undefined;control.filterPayload=undefined;control.speedPayload=undefined;control.fallback=false;await refresh(page).click();await expect(source).not.toContainText("ошибка");
})