fix(ui): сохранять подтверждённый снимок пути при ошибке API
This commit is contained in:
1 parent
55c9349677
commit
5fdd6ef58c
9 files changed
+85
-53
No files matched your search
+1
-1
@@ -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).
|
||||
|
||||
|
||||
@@ -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 и получает честную ошибку, а не ложное отсутствие туннелей.
|
||||
|
||||
@@ -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 не проверен.
|
||||
@@ -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]))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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("ошибка");
|
||||
})
|
||||
Reference in new issue
Block a user