diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 5ea522fb..736ac2e5 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index c093d7ce..13032a76 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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 и получает честную ошибку, а не ложное отсутствие туннелей. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 2d29fef8..4010ceb2 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -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 не проверен. diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts index 9be20352..09338dce 100644 --- a/hooks/use-dashboard-live.ts +++ b/hooks/use-dashboard-live.ts @@ -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 - wanRuntimeByHomeId: Record -} - 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("/api/uptime/overview?range=1h").then(parseDashboardProbes), apiFetch("/api/servers").then(parseDashboardServers), - apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"), + apiFetch("/api/filters/rules").then(parseDashboardFilters), apiFetch("/api/bgp/catalog").then(parseDashboardBgp), - apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"), + apiFetch("/api/internet-path/latest").then(parseDashboardPath), apiFetch("/api/filters/gre-tunnels").then(parseDashboardGre), apiFetch("/api/wireguard").then(parseDashboardWireGuard), apiFetch("/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(`/api/servers/${h.id}/wan-runtime`) + const rt = await checkedFetch(`/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("/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])) diff --git a/lib/dashboard-internet-path.ts b/lib/dashboard-internet-path.ts index 27a26f56..2ddba5da 100644 --- a/lib/dashboard-internet-path.ts +++ b/lib/dashboard-internet-path.ts @@ -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: (path: string, init?: RequestInit) => Promise, serverId: string, ): Promise { - try { - const payload = await apiFetch<{ output?: string }>(`/api/servers/${serverId}/probes/run`, { + const payload = await apiFetch(`/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)) } diff --git a/lib/dashboard-path-response.test.ts b/lib/dashboard-path-response.test.ts new file mode 100644 index 00000000..676ac297 --- /dev/null +++ b/lib/dashboard-path-response.test.ts @@ -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()=>raw as T,"1")) + assert.deepEqual(await resolveDefaultRouteLookup(async()=>({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) +}) diff --git a/lib/dashboard-path-response.ts b/lib/dashboard-path-response.ts new file mode 100644 index 00000000..1627b714 --- /dev/null +++ b/lib/dashboard-path-response.ts @@ -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} diff --git a/lib/dashboard-readers.ts b/lib/dashboard-readers.ts index f497ed0d..f04854e9 100644 --- a/lib/dashboard-readers.ts +++ b/lib/dashboard-readers.ts @@ -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 -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} diff --git a/tests/e2e/dashboard-data.spec.ts b/tests/e2e/dashboard-data.spec.ts index 1fab60a7..74344609 100644 --- a/tests/e2e/dashboard-data.spec.ts +++ b/tests/e2e/dashboard-data.spec.ts @@ -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(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("ошибка"); +})