From c3a9c00aedfef28f4006e84682444e3710dcd9ac Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 4 Oct 2026 00:18:21 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=B5?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=BA=D0=B8=20=D1=83=D1=87=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20=D0=BF=D1=83=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/dashboard/internet-path-map.tsx | 4 +- .../dashboard/internet-path-summary.tsx | 5 +- docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 8 ++ docs/ux-ui-stage-12.md | 8 ++ lib/dashboard-internet-path.ts | 133 ++++-------------- lib/dashboard-path-response.test.ts | 18 ++- lib/dashboard-path-response.ts | 6 +- tests/e2e/dashboard-data.spec.ts | 11 ++ 9 files changed, 78 insertions(+), 117 deletions(-) diff --git a/components/dashboard/internet-path-map.tsx b/components/dashboard/internet-path-map.tsx index b2b85cf5..ce6a820c 100644 --- a/components/dashboard/internet-path-map.tsx +++ b/components/dashboard/internet-path-map.tsx @@ -379,11 +379,11 @@ export function InternetPathMapCanvas({ model }: { model: InternetPathViewModel {model && (
-

Primary path

+

Рекомендация оптимизатора

{model.primaryPath?.reason ?? "Не определен"}

-

Current path

+

Наблюдаемый default route

{model.currentPath?.reason ?? "Не определен"}

{model.directWan.enabled && ( diff --git a/components/dashboard/internet-path-summary.tsx b/components/dashboard/internet-path-summary.tsx index d709a4ed..2b753392 100644 --- a/components/dashboard/internet-path-summary.tsx +++ b/components/dashboard/internet-path-summary.tsx @@ -14,7 +14,7 @@ import { ArrowRightIcon, HomeIcon, RadioIcon, ServerIcon, GlobeIcon } from "luci function pathStateToServerStatus(state: InternetPathViewModel["pathState"]): ServerStatus { if (state === "healthy") return "online" if (state === "failover" || state === "degraded") return "degraded" - return "offline" + return "unknown" } function HopChip({ @@ -89,6 +89,7 @@ export function InternetPathSummary({ model }: { model: InternetPathViewModel | ) : null}
+

JH/Exit — схема кандидатов. Default route подтверждает только WAN. Метрики — последние подтверждённые пробы участков.

- {model.currentPath?.reason ?? model.primaryPath?.reason ?? "Текущий путь не определён"} + {model.currentPath?.reason ?? "Текущий маршрут не подтверждён"}

) diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 0ebfaa29..89b72693 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 сбора. Следующий блок12.2.9: достоверность метрик отдельных hops 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 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. Следующий блок12.3: производительность на фиксированных больших fixtures; затем12.4 сводная регрессия и production build. См. 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 9c9e839d..b89d672c 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -36,6 +36,14 @@ +## 12.2.9 — метрики отдельных участков Internet path (2026-10-04) + +Configured WAN maxDl/maxUl, общий RTT сервера и GRE эвристики больше не выдаются за наблюдения Home→JH/JH→Exit. Метрики берутся из профильных проб: ping требует дату и отсутствие ошибки, speed — дату/lastStatus:done/отсутствие ошибки. Legacy без metadata и неудачная проба дают неизвестное; ноль остаётся нулём; RX/TX не подменяют друг друга, при обратной пробе направления меняются. Primary и Current используют одинаковую границу подтверждения. + +Active WAN выбирается только по наблюдаемому default interface/route; первый configured uplink и просто bound DHCP не доказывают выбор. Summary unknown больше не маркирует Оффлайн. Подпись поясняет, что JH/Exit — кандидаты, а default route подтверждает WAN; карта различает рекомендацию оптимизатора и наблюдаемый default route. Алгоритм сетевых операций/оптимизатора не изменён. + +Проверки:61 dashboard/permissions browser, включая4 новых hop-состояния и320px,6 path unit (success/error/undated/zero/directions/unmatched WAN), typecheck и ESLint без ошибок/предупреждений в изменённых frontend файлах. Удалены прежние unused helpers/imports; ReUI Frame/Badge/StatusBadge сохранены. Далее12.3: фиксированные большие fixtures, измерения и ограничения; сводная регрессия12.4 должна включить12.2 и production build. Реальный traffic path/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. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 3ac77b25..247e748b 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -94,3 +94,11 @@ Backend больше не скрывает ошибки обязательных 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.9 — метрики отдельных участков Internet path (2026-10-04) + +Configured WAN maxDl/maxUl, общий RTT сервера и GRE эвристики больше не выдаются за наблюдения Home→JH/JH→Exit. Метрики берутся из профильных проб: ping требует дату и отсутствие ошибки, speed — дату/lastStatus:done/отсутствие ошибки. Legacy без metadata и неудачная проба дают неизвестное; ноль остаётся нулём; RX/TX не подменяют друг друга, при обратной пробе направления меняются. Primary и Current используют одинаковую границу подтверждения. + +Active WAN выбирается только по наблюдаемому default interface/route; первый configured uplink и просто bound DHCP не доказывают выбор. Summary unknown больше не маркирует Оффлайн. Подпись поясняет, что JH/Exit — кандидаты, а default route подтверждает WAN; карта различает рекомендацию оптимизатора и наблюдаемый default route. Алгоритм сетевых операций/оптимизатора не изменён. + +Проверки:61 dashboard/permissions browser, включая4 новых hop-состояния и320px,6 path unit (success/error/undated/zero/directions/unmatched WAN), typecheck и ESLint без ошибок/предупреждений в изменённых frontend файлах. Удалены прежние unused helpers/imports; ReUI Frame/Badge/StatusBadge сохранены. Далее12.3: фиксированные большие fixtures, измерения и ограничения; сводная регрессия12.4 должна включить12.2 и production build. Реальный traffic path/PG/RouterOS не проверен. diff --git a/lib/dashboard-internet-path.ts b/lib/dashboard-internet-path.ts index 2ddba5da..d23ba13d 100644 --- a/lib/dashboard-internet-path.ts +++ b/lib/dashboard-internet-path.ts @@ -1,16 +1,5 @@ import {readProbeOutput} from "@/lib/probe-response" import type { GreTunnel, Server, WanUplink } from "@/lib/data" -import { - assignSpeedProbesToGreTunnels, - assignSpeedProbesToWanJhEdges, - mergeGreMetricsWithSpeedProbe, - wanJhEdgeMapKey, -} from "@/lib/map-gre-speed-probe" -import { - buildWanJhEdges, - findServerByGreRemote, - greTunnelProbe, -} from "@/lib/network-map-layout" import { buildLiveOptimizerData, DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS, @@ -84,6 +73,17 @@ export interface HomeWanRuntime { }> } +type DashboardPathProbe = RouteOptimizerSpeedProbe & {lastRunAt?: string | null; lastStatus?: string | null; lastError?: string | null; lastPingError?: string | null} +export function dashboardPathMetrics(probe: DashboardPathProbe | null, reverse = false): InternetPathHop["wanJhMetrics"] { + const dated=(time: string | null | undefined)=>Boolean(time)&&Number.isFinite(Date.parse(time!)) + const known=(value: number | null | undefined)=>typeof value==="number"&&Number.isFinite(value)&&value>=0?value:null + const ping=probe?.enabled && dated(probe.lastPingAt) && (probe.lastPingError===null||probe.lastPingError==="") ? known(probe.lastPingRttMs) : null + const speed=probe?.enabled && dated(probe.lastRunAt) && probe.lastStatus==="done" && (probe.lastError===null||probe.lastError==="") + const dl=speed?known(reverse?probe.lastTxAvgMbps:probe.lastRxAvgMbps):null + const ul=speed?known(reverse?probe.lastRxAvgMbps:probe.lastTxAvgMbps):null + return {pingMs:ping,dlMbps:dl,ulMbps:ul,fromMonitoring:ping!==null||dl!==null||ul!==null} +} + const INTERNET_TARGET = "1.1.1.1" function norm(v: string | null | undefined): string { @@ -133,31 +133,25 @@ function chooseActiveWan( const byGatewayIp = wans.find((w) => norm(w.ip) === norm(runtime.defaultGateway)) if (byGatewayIp) return byGatewayIp } - const runtimeBound = runtime.uplinks.find((u) => (u.dhcpStatus ?? "").toLowerCase() === "bound") - if (runtimeBound) { - const byId = wans.find((w) => norm(w.id) === norm(runtimeBound.id)) - if (byId) return byId - const byIface = wans.find((w) => norm(w.iface) === norm(runtimeBound.iface)) - if (byIface) return byIface - } + } // 2) Fallback: route-lookup const gw = norm(lookup?.gateway) if (gw) { - const byIface = wans.find((w) => norm(w.iface) === gw || gw.includes(norm(w.iface))) + const byIface = wans.find((w) => Boolean(norm(w.iface)) && (norm(w.iface) === gw || gw.includes("%"+norm(w.iface)))) if (byIface) return byIface - const byIp = wans.find((w) => norm(w.ip) === gw || gw.startsWith(`${norm(w.ip)}%`)) + const byIp = wans.find((w) => Boolean(norm(w.ip)) && (norm(w.ip) === gw || gw.startsWith(`${norm(w.ip)}%`))) if (byIp) return byIp } - return wans[0] ?? null + return null } function bestJhForWan( home: Server, wan: WanUplink, jhs: Server[], - probes: RouteOptimizerSpeedProbe[], + probes: DashboardPathProbe[], ): Server | null { const pool = probes.filter((p) => p.srcServerId === home.id && p.enabled !== false) const ranked = jhs @@ -176,7 +170,7 @@ function bestJhForWan( return ranked[0]?.jh ?? null } -function bestExitForJh(jh: Server, exits: Server[], probes: RouteOptimizerSpeedProbe[]): Server | null { +function bestExitForJh(jh: Server, exits: Server[], probes: DashboardPathProbe[]): Server | null { const pool = probes.filter((p) => p.enabled !== false) const ranked = exits .map((ex) => { @@ -197,7 +191,7 @@ function pickHomeJhProbe( homeId: string, wanIface: string, jhId: string, - probes: RouteOptimizerSpeedProbe[], + probes: DashboardPathProbe[], ): RouteOptimizerSpeedProbe | null { const ifaceNorm = norm(wanIface) const list = probes @@ -216,7 +210,7 @@ function pickHomeJhProbe( return list[0] ?? null } -function pickJhExitProbe(jhId: string, exitId: string, probes: RouteOptimizerSpeedProbe[]): RouteOptimizerSpeedProbe | null { +function pickJhExitProbe(jhId: string, exitId: string, probes: DashboardPathProbe[]): RouteOptimizerSpeedProbe | null { const list = probes .filter((p) => p.enabled !== false) .filter((p) => ( @@ -232,14 +226,10 @@ function pickJhExitProbe(jhId: string, exitId: string, probes: RouteOptimizerSpe return list[0] ?? null } -function metricValue(primary: number | null | undefined, secondary: number | null | undefined): number | null { - return primary ?? secondary ?? null -} - export function buildDashboardInternetPath(args: { servers: Server[] greTunnels: GreTunnel[] - probes: RouteOptimizerSpeedProbe[] + probes: DashboardPathProbe[] filtersRulesets: FiltersRulesetRow[] routeLookupByServerId: Record wanRuntimeByHomeId?: Record @@ -317,18 +307,8 @@ export function buildDashboardInternetPath(args: { wan: primaryWan, jumpHost: primaryJh, exitNode: primaryEx, - wanJhMetrics: { - pingMs: primaryJh.latency, - dlMbps: primaryWan.maxDl, - ulMbps: primaryWan.maxUl, - fromMonitoring: false, - }, - jhExitMetrics: { - pingMs: primaryEx.latency, - dlMbps: null, - ulMbps: null, - fromMonitoring: false, - }, + wanJhMetrics: dashboardPathMetrics(pickHomeJhProbe(home.id, primaryWan.iface, primaryJh.id, args.probes)), + jhExitMetrics: (()=>{const p=pickJhExitProbe(primaryJh.id,primaryEx.id,args.probes);return dashboardPathMetrics(p,p?.srcServerId!==primaryJh.id)})(), } : null @@ -339,73 +319,8 @@ export function buildDashboardInternetPath(args: { wan: currentWan, jumpHost: currentJh, exitNode: currentExit, - wanJhMetrics: (() => { - const wanJh = buildWanJhEdges(args.servers).find((e) => - e.homeId === home.id - && e.jhId === currentJh.id - && (home.wanUplinks?.[e.wanIdx]?.id ?? "") === currentWan.id, - ) - const fallback = { - pingMs: wanJh?.pingMs ?? currentJh.latency, - dlMbps: wanJh?.dlMbps ?? currentWan.maxDl, - ulMbps: currentWan.maxUl, - } - if (!wanJh) return { ...fallback, fromMonitoring: false } - const byEdge = assignSpeedProbesToWanJhEdges([wanJh], args.servers, args.probes) - const sp = byEdge.get(wanJhEdgeMapKey(wanJh)) - const merged = mergeGreMetricsWithSpeedProbe(sp, fallback) - return { - pingMs: merged.pingMs, - dlMbps: metricValue(merged.dlMbps, merged.ulMbps), - ulMbps: metricValue(merged.ulMbps, merged.dlMbps), - fromMonitoring: merged.hasSpeedMonitor, - } - })(), - jhExitMetrics: (() => { - const tunnel = args.greTunnels.find((t) => { - const from = args.servers.find((s) => s.id === String(t.serverId)) - if (!from) return false - const to = findServerByGreRemote(args.servers, t.remoteAddress) - if (!to) return false - return ( - (from.id === currentJh.id && to.id === currentExit.id) - || (from.id === currentExit.id && to.id === currentJh.id) - ) - }) - const fallback = tunnel - ? greTunnelProbe(tunnel) - : { pingMs: currentExit.latency, dlMbps: null, ulMbps: null } - if (!tunnel) { - return { - pingMs: fallback.pingMs, - dlMbps: fallback.dlMbps, - ulMbps: fallback.ulMbps, - fromMonitoring: false, - } - } - const fromServer = args.servers.find((s) => s.id === String(tunnel.serverId)) - const toServer = fromServer ? findServerByGreRemote(args.servers, tunnel.remoteAddress) : undefined - if (!fromServer || !toServer) { - return { - pingMs: fallback.pingMs, - dlMbps: fallback.dlMbps, - ulMbps: fallback.ulMbps, - fromMonitoring: false, - } - } - const byTunnel = assignSpeedProbesToGreTunnels( - [{ tunnel, fromServer, toServer }], - args.probes, - ) - const sp = byTunnel.get(tunnel.id) - const merged = mergeGreMetricsWithSpeedProbe(sp, fallback) - return { - pingMs: merged.pingMs, - dlMbps: metricValue(merged.dlMbps, merged.ulMbps), - ulMbps: metricValue(merged.ulMbps, merged.dlMbps), - fromMonitoring: merged.hasSpeedMonitor, - } - })(), + wanJhMetrics: dashboardPathMetrics(pickHomeJhProbe(home.id,currentWan.iface,currentJh.id,args.probes)), + jhExitMetrics: (()=>{const p=pickJhExitProbe(currentJh.id,currentExit.id,args.probes);return dashboardPathMetrics(p,p?.srcServerId!==currentJh.id)})(), } : null diff --git a/lib/dashboard-path-response.test.ts b/lib/dashboard-path-response.test.ts index 090379b7..c27d66be 100644 --- a/lib/dashboard-path-response.test.ts +++ b/lib/dashboard-path-response.test.ts @@ -1,7 +1,7 @@ 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 {resolveDefaultRouteLookup,buildDashboardInternetPath,dashboardPathMetrics} from "./dashboard-internet-path" import {servers} from "./data" 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",()=>{ @@ -20,3 +20,19 @@ 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) }) + +test("path metrics use dated successful hop observations, preserve RX/TX and reject configured fallback",()=>{ + const p={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:0,lastRxAvgMbps:5,lastPingAt:snapshot.sampledAt,lastRunAt:snapshot.sampledAt,lastStatus:"done",lastError:null,lastPingError:null} + assert.deepEqual(dashboardPathMetrics(p),{pingMs:0,dlMbps:5,ulMbps:0,fromMonitoring:true});assert.deepEqual(dashboardPathMetrics(p,true),{pingMs:0,dlMbps:0,ulMbps:5,fromMonitoring:true}) + assert.deepEqual(dashboardPathMetrics(null),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false}) + assert.deepEqual(dashboardPathMetrics({...p,lastRunAt:null,lastPingAt:null}),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false}) + assert.deepEqual(dashboardPathMetrics({...p,lastStatus:"error",lastPingError:"denied"}),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false}) + assert.equal(dashboardPathMetrics({...p,lastRxAvgMbps:null}).dlMbps,null) + assert.deepEqual(dashboardPathMetrics({...p,enabled:false}),{pingMs:null,dlMbps:null,ulMbps:null,fromMonitoring:false}) +}) + +test("observed default route does not select the first WAN or a merely bound DHCP lease",()=>{ + const home={...servers.find(s=>s.type==="home-router")!,id:"1"};const runtime={defaultGateway:"203.0.113.254",defaultInterface:"unknown-interface",uplinks:home.wanUplinks!.map(w=>({id:w.id,iface:w.iface,name:w.name,isp:w.isp,configuredIp:w.ip,leasedIp:w.ip,dhcpStatus:"bound",isDefault:false}))} + const result=buildDashboardInternetPath({servers:[home,...servers.filter(s=>s.type!=="home-router")],greTunnels:[],filtersRulesets:[],probes:[],routeLookupByServerId:{"1":{gateway:"203.0.113.254",routingMark:null}},wanRuntimeByHomeId:{"1":runtime}}) + assert.equal(result?.activeWanUplink,null);assert.equal(result?.currentPath,null);assert.equal(result?.directWan.gateway,"203.0.113.254") +}) diff --git a/lib/dashboard-path-response.ts b/lib/dashboard-path-response.ts index 68a90894..c652bc97 100644 --- a/lib/dashboard-path-response.ts +++ b/lib/dashboard-path-response.ts @@ -5,12 +5,14 @@ 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({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 pathProbe=optimizerProbesSchema.shape.probes.element.extend({lastRunAt:z.iso.datetime({offset:true}).nullable().optional(),lastStatus:z.enum(["done","error"]).nullable().optional(),lastError:z.string().nullable().optional(),lastPingError:z.string().nullable().optional()}) +const pathProbes=z.array(pathProbe).refine(rows=>new Set(rows.map(p=>p.id)).size===rows.length) +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:pathProbes,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 parseDashboardSpeedProbes(raw:unknown){const parsed=z.object({probes:pathProbes}).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/tests/e2e/dashboard-data.spec.ts b/tests/e2e/dashboard-data.spec.ts index 6215556d..a15f9fd1 100644 --- a/tests/e2e/dashboard-data.spec.ts +++ b/tests/e2e/dashboard-data.spec.ts @@ -254,3 +254,14 @@ for(const broken of ["missing","incomplete","foreign","invalid-date","corrupt-nu 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("ошибка"); }) + +for(const state of ["unmeasured","measured","failed","unknown"])test("Dashboard Internet path hop observations/"+state,async({page})=>{ + const control=await setup(page);await expect(refresh(page)).toBeEnabled();const at="2026-10-04T00:00:00Z"; + const nodes=["home-router","jump-host","exit-node"].map((type,i)=>({id:i+1,name:"Path node "+(i+1),host:"192.0.2."+(i+1),site:"TEST",country:"RU",asn:"64512",type,enabled:true,status:state==="unknown"&&i===0?null:"online",latency:99,os:"7",model:"CHR",sessions:0,wanUplinks:i===0?[{id:"wan1",name:"WAN",isp:"Test ISP",iface:"ether1",ip:"192.0.2.10",maxDl:999,maxUl:888}]:[]})); + const probe={id:"hop1",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:0,lastRxAvgMbps:5,lastPingAt:state==="unmeasured"?null:at,lastRunAt:state==="unmeasured"?null:at,lastStatus:state==="failed"?"error":"done",lastError:state==="failed"?"denied":null,lastPingError:state==="failed"?"denied":null}; + control.pathPayload={snapshot:{collectionComplete:true,sampledAt:at,servers:nodes,greTunnels:[],filtersRulesets:[],speedProbes:[probe],routeLookupByServerId:{"1":{gateway:"192.0.2.254",routingMark:null}},wanRuntimeByHomeId:{"1":{defaultGateway:"192.0.2.254",defaultInterface:"ether1",uplinks:[{id:"wan1",name:"WAN",isp:"Test ISP",iface:"ether1",configuredIp:"192.0.2.10",leasedIp:"192.0.2.10",dhcpStatus:"bound",isDefault:true}]}}}}; + await refresh(page).click();await expect(refresh(page)).toBeEnabled();await expect(page.locator('[data-source="internetPath"]')).not.toContainText("ошибка");const strip=page.getByText("JH/Exit — схема кандидатов. Default route подтверждает только WAN. Метрики — последние подтверждённые пробы участков.",{exact:true}).locator("..");await expect(strip).toBeVisible();await expect(strip).not.toContainText("999");await expect(strip).not.toContainText("99 мс"); + if(state==="measured"||state==="unknown"){await expect(strip.getByText("0 мс",{exact:true})).toBeVisible();await expect(strip.getByText("5 ↓ Мбит/с",{exact:true})).toBeVisible()}else{await expect(strip.getByText("0 мс",{exact:true})).toHaveCount(0);await expect(strip.getByText("5 ↓ Мбит/с",{exact:true})).toHaveCount(0)} + if(state==="unknown"){await expect(strip.getByText("Неизвестно",{exact:true})).toBeVisible();await expect(strip.getByText("Оффлайн",{exact:true})).toHaveCount(0)} + await page.setViewportSize({width:320,height:900});await expect(strip).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true) +})