diff --git a/backend/src/routes/internet-path.ts b/backend/src/routes/internet-path.ts index d472f944..8fa1e19a 100644 --- a/backend/src/routes/internet-path.ts +++ b/backend/src/routes/internet-path.ts @@ -1,4 +1,5 @@ import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { readStoredInternetPathPayload } from "../services/internet-path-snapshot.js" import { refreshScheduler } from "../services/scheduler.js" import { getInternetPathSettings, @@ -47,13 +48,11 @@ const internetPathRoutes: FastifyPluginAsyncZod = async (app) => { app.get("/internet-path/latest", async (_req, reply) => { const row = await getLatestInternetPathSnapshot() if (!row) return reply.send({ snapshot: null }) - let payload: unknown = row.payloadJson - if (typeof row.payloadJson === "string") { - try { - payload = JSON.parse(row.payloadJson) - } catch { - payload = null - } + let payload: object + try { + payload = readStoredInternetPathPayload(row.payloadJson) + } catch { + return reply.code(502).send({error: "Сохранённый снимок Internet path повреждён"}) } return reply.send({ snapshot: payload, diff --git a/backend/src/services/internet-path-collector.ts b/backend/src/services/internet-path-collector.ts index 4a44e9f6..55c11bbb 100644 --- a/backend/src/services/internet-path-collector.ts +++ b/backend/src/services/internet-path-collector.ts @@ -1,4 +1,4 @@ -import { and, asc, desc, eq } from "drizzle-orm" +import { asc, desc, eq } from "drizzle-orm" import { db } from "../db/index.js" import { filterRules, @@ -12,6 +12,8 @@ import { MikrotikClient } from "./mikrotik.js" import type { InternetPathRunSnapshot } from "../types/scheduler-run-snapshot.js" import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js" +import { collectConfirmedInternetPath, readInternetPathRows } from "./internet-path-snapshot.js" + const INTERNET_TARGET = "1.1.1.1" let collecting = false @@ -66,9 +68,9 @@ async function buildRulesets() { async function readRouteLookup(serverId: number): Promise<{ gateway: string | null; routingMark: string | null }> { const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0] - if (!row) return { gateway: null, routingMark: null } + if (!row) throw new Error(`Internet path: устройство ${serverId} не найдено`) const client = MikrotikClient.fromServer(row) - const routes = await client.get>>("/ip/route").catch(() => []) + const routes = await client.get("/ip/route").then(raw => readInternetPathRows(raw, "routes " + serverId)) const best = routes .filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0") .filter((r) => isTrue(r.active)) @@ -88,12 +90,12 @@ async function readRouteLookup(serverId: number): Promise<{ gateway: string | nu async function readWanRuntime(serverId: number) { const server = (await listServersRead()).find((s) => Number(s.id) === serverId) const row = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0] - if (!server || !row) return null + if (!server || !row) throw new Error(`Internet path: устройство ${serverId} не найдено`) const client = MikrotikClient.fromServer(row) const [dhcpRaw, ipAddrs, routes] = await Promise.all([ - client.get>>("/ip/dhcp-client").catch(() => []), - client.get>>("/ip/address").catch(() => []), - client.get>>("/ip/route").catch(() => []), + client.get("/ip/dhcp-client").then(raw => readInternetPathRows(raw, "DHCP " + serverId)), + client.get("/ip/address").then(raw => readInternetPathRows(raw, "addresses " + serverId)), + client.get("/ip/route").then(raw => readInternetPathRows(raw, "routes " + serverId)), ]) const defaultRoute = routes .filter((r) => String(r["dst-address"] ?? "").trim() === "0.0.0.0/0") @@ -174,7 +176,7 @@ async function collectGreTunnels() { const all = await Promise.all(enabled.map(async (srv) => { try { const client = MikrotikClient.fromServer(srv) - const rows = await client.get>>("/interface/gre") + const rows = await client.get("/interface/gre").then(raw => readInternetPathRows(raw, "GRE " + srv.id)) return rows.map((g, idx) => { const keepalive = String(g.keepalive ?? "0,0").split(",") const inner = parseInnerIps(String(g.comment ?? "")) @@ -202,8 +204,8 @@ async function collectGreTunnels() { : (String(g.running ?? "false") === "true" ? "up" as const : "degraded" as const), } }) - } catch { - return [] + } catch (error) { + throw new Error(`Internet path: GRE устройства ${srv.id}: ${error instanceof Error ? error.message : String(error)}`) } })) return all.flat() @@ -242,28 +244,13 @@ export async function collectInternetPathSnapshotOnce(): Promise s.type === "home-router") - const [greTunnels, rulesets] = await Promise.all([await collectGreTunnels(), Promise.resolve(await buildRulesets())]) - const speedProbes = await mapSpeedProbes() - const routeLookupByServerId: Record = {} - const wanRuntimeByHomeId: Record = {} - for (const h of homes) { - routeLookupByServerId[String(h.id)] = await readRouteLookup(Number(h.id)).catch(() => ({ gateway: null, routingMark: null })) - wanRuntimeByHomeId[String(h.id)] = await readWanRuntime(Number(h.id)).catch(() => null) - } - const payload = { - sampledAt, - internetTarget: INTERNET_TARGET, - servers: serversRead, - greTunnels, - filtersRulesets: rulesets, - speedProbes, - routeLookupByServerId, - wanRuntimeByHomeId, - } + await getSettingsRow() + const data = await collectConfirmedInternetPath(sampledAt, { + servers: listServersRead, gre: collectGreTunnels, filters: buildRulesets, speed: mapSpeedProbes, + route: readRouteLookup, wan: readWanRuntime, + }) + const payload = {...data, internetTarget: INTERNET_TARGET} await db.insert(internetPathSnapshots).values({ sampledAt, payloadJson: payload, @@ -278,7 +265,7 @@ export async function collectInternetPathSnapshotOnce(): Promise s.enabled && s.type === "home-router").length, snapshotSaved: true, } } catch (e) { diff --git a/backend/src/services/internet-path-snapshot.test.ts b/backend/src/services/internet-path-snapshot.test.ts new file mode 100644 index 00000000..22ee7afc --- /dev/null +++ b/backend/src/services/internet-path-snapshot.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict" +import {collectConfirmedInternetPath, readInternetPathRows, readStoredInternetPathPayload} from "./internet-path-snapshot.js" +const servers=[{id:1,type:"home-router",enabled:true},{id:2,type:"home-router",enabled:false},{id:3,type:"exit-node",enabled:true}] +const sampledAt="2026-10-04T00:00:00Z" +const defaults={servers:async()=>servers,gre:async()=>[],filters:async()=>[],speed:async()=>[],route:async(id:number)=>{assert.equal(id,1);return {gateway:null,routingMark:null}},wan:async(id:number)=>{assert.equal(id,1);return {defaultGateway:null,defaultInterface:null,uplinks:[]}}} +const good=await collectConfirmedInternetPath(sampledAt,defaults) +assert.equal(good.collectionComplete,true);assert.deepEqual(Object.keys(good.wanRuntimeByHomeId),["1"]);assert.equal(good.sampledAt,sampledAt) +// A failed mandatory source must not reach the persistence boundary. +for(const source of ["servers","gre","filters","speed","route","wan"] as const){ + let saved=0 + await assert.rejects(async()=>{await collectConfirmedInternetPath(sampledAt,{...defaults,[source]:async()=>{throw Error("denied")}});saved++},/denied/) + assert.equal(saved,0) +} +for(const source of ["route","wan"] as const)await assert.rejects(collectConfirmedInternetPath(sampledAt,{...defaults,[source]:async()=>null}),/не подтвердило/) +assert.deepEqual(readInternetPathRows([],"routes"),[]) +assert.deepEqual(readInternetPathRows([{active:"true",gateway:"192.0.2.1"}],"routes"),[{active:"true",gateway:"192.0.2.1"}]) +for(const raw of [null,{},[null],[[]],[{active:12}],[{active:true}]])assert.throws(()=>readInternetPathRows(raw,"routes"),/повреждён/) +console.log("internet path snapshot tests ok: complete, disabled home, six failed readers, missing runtime, malformed RouterOS") + +assert.deepEqual(readStoredInternetPathPayload('{"collectionComplete":true}'),{collectionComplete:true}) +assert.deepEqual(readStoredInternetPathPayload(good),good) +for(const raw of [null,"null","[1]","bad",[],1])assert.throws(()=>readStoredInternetPathPayload(raw)) diff --git a/backend/src/services/internet-path-snapshot.ts b/backend/src/services/internet-path-snapshot.ts new file mode 100644 index 00000000..b6b45b86 --- /dev/null +++ b/backend/src/services/internet-path-snapshot.ts @@ -0,0 +1,32 @@ +// No database or RouterOS imports: this boundary can be checked with injected readers. +type PathServer = {id: number | string; type: string; enabled: boolean} +export async function collectConfirmedInternetPath(sampledAt: string, readers: { + servers(): Promise; gre(): Promise; filters(): Promise; speed(): Promise

+ route(id: number): Promise; wan(id: number): Promise +}) { + const servers = await readers.servers() + const homes = servers.filter(s => s.enabled && s.type === "home-router") + const [greTunnels, filtersRulesets, speedProbes] = await Promise.all([readers.gre(), readers.filters(), readers.speed()]) + const routeLookupByServerId: Record = {} + const wanRuntimeByHomeId: Record = {} + for (const home of homes) { + const [route, wan] = await Promise.all([readers.route(Number(home.id)), readers.wan(Number(home.id))]) + if (route == null || wan == null) throw new Error("Internet path: устройство " + home.id + " не подтвердило route/WAN") + routeLookupByServerId[String(home.id)] = route + wanRuntimeByHomeId[String(home.id)] = wan + } + return {collectionComplete: true as const, sampledAt, servers, greTunnels, filtersRulesets, speedProbes, routeLookupByServerId, wanRuntimeByHomeId} +} + +export function readInternetPathRows(raw: unknown, source: string): Array> { + if (!Array.isArray(raw) || raw.some(row => !row || typeof row !== "object" || Array.isArray(row) || Object.values(row).some(value => typeof value !== "string"))) { + throw new Error("Internet path: повреждён ответ " + source) + } + return raw as Array> +} + +export function readStoredInternetPathPayload(raw: unknown): object { + const payload: unknown = typeof raw === "string" ? JSON.parse(raw) : raw + if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Сохранённый снимок Internet path повреждён") + return payload +} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 736ac2e5..0ebfaa29 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. Следующий блок12.2.8: ошибки backend collector; затем проверить метрики Internet path, производительность и сквозную регрессию. См. 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 сбора. Следующий блок12.2.9: достоверность метрик отдельных hops 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 13032a76..9c9e839d 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -36,6 +36,14 @@ +## 12.2.8 — подтверждение полного сбора Internet path (2026-10-04) + +Backend больше не скрывает ошибки обязательных GRE/routes/DHCP/address чтений пустыми массивами или null. Pure/DI граница собирает enabled homes и возвращает collectionComplete только после всех обязательных ответов. Неудачный сбор завершается до insert, существующий снимок остаётся; ошибки settings тоже освобождают collecting в finally. Malformed RouterOS rows отклоняются. Повреждённый сохранённый JSON/object даёт HTTP502 вместо успешного null. + +Frontend требует collectionComplete:true: legacy сохранённые снимки не доказывают полноту и сохраняют последний подтверждённый cache/ошибку до свежего сбора. Это согласованное изменение backend/frontend, миграция/удаление данных не нужны; при выпуске обновить обе части и дождаться успешного scheduler run. Права и правила сетевых операций не изменены. + +Проверки: pure/DI backend (успех, disabled home, ошибки шести readers, отсутствующий route/WAN, malformed RouterOS и stored JSON),4 frontend unit,12 path/permissions browser, frontend typecheck и backend/contracts build прошли. ESLint без ошибок; прежний unused import убран. Реальные PG insert/rollback, RouterOS API и scheduler cycle остаются внешней приёмкой. Далее12.2.9: проверить, не выдаются ли configured capacity/server RTT за измерения отдельных hops; затем сводная приёмка12.2 и12.3 производительность. + ## 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. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 4010ceb2..3ac77b25 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -86,3 +86,11 @@ Dashboard читает существующие строгие schemas GRE/VXLAN Снимок валидируется до публикации: обязательные коллекции, даты, ссылки на устройства, 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.8 — подтверждение полного сбора Internet path (2026-10-04) + +Backend больше не скрывает ошибки обязательных GRE/routes/DHCP/address чтений пустыми массивами или null. Pure/DI граница собирает enabled homes и возвращает collectionComplete только после всех обязательных ответов. Неудачный сбор завершается до insert, существующий снимок остаётся; ошибки settings тоже освобождают collecting в finally. Malformed RouterOS rows отклоняются. Повреждённый сохранённый JSON/object даёт HTTP502 вместо успешного null. + +Frontend требует collectionComplete:true: legacy сохранённые снимки не доказывают полноту и сохраняют последний подтверждённый cache/ошибку до свежего сбора. Это согласованное изменение backend/frontend, миграция/удаление данных не нужны; при выпуске обновить обе части и дождаться успешного scheduler run. Права и правила сетевых операций не изменены. + +Проверки: pure/DI backend (успех, disabled home, ошибки шести readers, отсутствующий route/WAN, malformed RouterOS и stored JSON),4 frontend unit,12 path/permissions browser, frontend typecheck и backend/contracts build прошли. ESLint без ошибок; прежний unused import убран. Реальные PG insert/rollback, RouterOS API и scheduler cycle остаются внешней приёмкой. Далее12.2.9: проверить, не выдаются ли configured capacity/server RTT за измерения отдельных hops; затем сводная приёмка12.2 и12.3 производительность. diff --git a/lib/dashboard-path-response.test.ts b/lib/dashboard-path-response.test.ts index 676ac297..090379b7 100644 --- a/lib/dashboard-path-response.test.ts +++ b/lib/dashboard-path-response.test.ts @@ -3,10 +3,10 @@ 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:{}} +const snapshot={collectionComplete:true,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)) + for(const raw of [{snapshot:{...snapshot,collectionComplete:undefined}},{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:[]})) diff --git a/lib/dashboard-path-response.ts b/lib/dashboard-path-response.ts index 1627b714..68a90894 100644 --- a/lib/dashboard-path-response.ts +++ b/lib/dashboard-path-response.ts @@ -5,7 +5,7 @@ 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 snapshot=z.object({collectionComplete:z.literal(true),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) }) diff --git a/tests/e2e/dashboard-data.spec.ts b/tests/e2e/dashboard-data.spec.ts index 74344609..6215556d 100644 --- a/tests/e2e/dashboard-data.spec.ts +++ b/tests/e2e/dashboard-data.spec.ts @@ -13,7 +13,7 @@ async function setup(page: Page, initiallyFailed = false, serverCount = 1, initi port: 443, useSsl: true, verifySsl: true, username: "test", password: "fixture-only", comment: "", lanSubnet: "", wanUplinks: [], createdAt: "2026-09-20T00:00:00Z", updatedAt: "2026-09-20T00:00:00Z", uptime: null, cpuLoad: null, freeMemory: null, totalMemory: null, identityName: null, sessions: 0, polledAt: null, ...row, })) - const snapshot = { sampledAt: "2026-09-20T00:00:00Z", servers, greTunnels: [], filtersRulesets: [], speedProbes: [], routeLookupByServerId: {}, wanRuntimeByHomeId: {} } + const snapshot = { collectionComplete:true, 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 @@ -247,10 +247,10 @@ for(const kind of ["gre","wg","vxlan"] as const)for(const broken of ["missing"," 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})=>{ +for(const broken of ["missing","incomplete","foreign","invalid-date","corrupt-null","legacy","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:{}}}; + else control.pathPayload=broken==="missing"?{}:broken==="corrupt-null"?{snapshot:null,sampledAt:"2026-09-20T00:00:00Z"}:{snapshot:{collectionComplete:broken==="legacy"?undefined:true,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("ошибка"); })