fix(api): подтверждать полный сбор пути без скрытых ошибок

This commit is contained in:
Denozordec committed 2026-10-04 00:11:17 +07:00
1 parent 5fdd6ef58c
commit d4ae1c3127
10 files changed
+102 -46

No files matched your search

+6 -7
View File
@@ -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,
+19 -32
View File
@@ -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<Array<Record<string, string>>>("/ip/route").catch(() => [])
const routes = await client.get<unknown>("/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<Array<Record<string, string>>>("/ip/dhcp-client").catch(() => []),
client.get<Array<Record<string, string>>>("/ip/address").catch(() => []),
client.get<Array<Record<string, string>>>("/ip/route").catch(() => []),
client.get<unknown>("/ip/dhcp-client").then(raw => readInternetPathRows(raw, "DHCP " + serverId)),
client.get<unknown>("/ip/address").then(raw => readInternetPathRows(raw, "addresses " + serverId)),
client.get<unknown>("/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<Array<Record<string, string>>>("/interface/gre")
const rows = await client.get<unknown>("/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<InternetPathRun
}
collecting = true
const started = Date.now()
const settings = await getSettingsRow()
try {
const serversRead = await listServersRead()
const homes = serversRead.filter((s) => s.type === "home-router")
const [greTunnels, rulesets] = await Promise.all([await collectGreTunnels(), Promise.resolve(await buildRulesets())])
const speedProbes = await mapSpeedProbes()
const routeLookupByServerId: Record<string, { gateway: string | null; routingMark: string | null }> = {}
const wanRuntimeByHomeId: Record<string, unknown> = {}
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<InternetPathRun
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
job: "internet_path",
sampledAt,
homes: homes.length,
homes: data.servers.filter(s => s.enabled && s.type === "home-router").length,
snapshotSaved: true,
}
} catch (e) {
@@ -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))
@@ -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<S extends PathServer, G, F, P, R extends object, W extends object>(sampledAt: string, readers: {
servers(): Promise<S[]>; gre(): Promise<G>; filters(): Promise<F>; speed(): Promise<P>
route(id: number): Promise<R | null>; wan(id: number): Promise<W | null>
}) {
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<string, R> = {}
const wanRuntimeByHomeId: Record<string, W> = {}
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<Record<string, string>> {
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<Record<string, string>>
}
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
}
+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. Следующий блок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).
+8
View File
@@ -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.
+8
View File
@@ -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 производительность.
+2 -2
View File
@@ -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:[]}))
+1 -1
View File
@@ -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)
})
+3 -3
View File
@@ -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("ошибка");
})