diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 15df1dbf..2d35e9e1 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -1,5 +1,7 @@ "use client" +import { speedHistoryResponseSchema } from "@mmapp/contracts" + import probeStyles from "./probe-rows.module.css" import { Suspense, useState, useMemo, useEffect, useRef, useCallback } from "react" @@ -698,8 +700,8 @@ interface SpeedTestRun { protocol: "tcp" | "udp" direction: "transmit" | "receive" | "both" durationSec: number - txAvgMbps: number - rxAvgMbps: number + txAvgMbps: number | null + rxAvgMbps: number | null status: "running" | "done" | "error" command: string lines: string[] @@ -1164,26 +1166,6 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const reloadSpeedData = useCallback(async (writeContext?: number) => { if (!liveApi || (savingRef.current && writeContext !== saveContext.current)) return const context = saveContext.current - type RunRow = { - id: string - srcServerId: string - dstServerId: string - srcInterface: string - dstInterface: string - protocol: "tcp" | "udp" - direction: "transmit" | "receive" | "both" - durationSec: number - txAvgMbps: number - rxAvgMbps: number - status: "done" | "error" - error?: string | null - srcAddress?: string | null - dstAddress?: string | null - srcInterfaceAddress?: string | null - dstInterfaceAddress?: string | null - afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null - createdAt: string - } const revision = ++speedReadRevision.current const current = () => revision === speedReadRevision.current && context === saveContext.current && (!savingRef.current || writeContext === context) const read = async (resource: "probes" | "history", task: () => Promise) => { @@ -1201,8 +1183,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId if (current()) setSpeedProbes(sp.probes) }), read("history", async () => { - const runsRes = await apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs") - if (!Array.isArray(runsRes.runs)) throw new Error("Некорректная история тестов") + const result = speedHistoryResponseSchema.safeParse(await apiFetch("/api/uptime/speed-test/runs")) + if (!result.success) throw new Error("Некорректная история тестов скорости") + const runsRes = result.data const rows = (runsRes.runs ?? []).map((r) => ({ id: r.id, startedAt: Date.parse(r.createdAt), @@ -1213,9 +1196,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId protocol: r.protocol, direction: r.direction, durationSec: r.durationSec, - txAvgMbps: Math.round(r.txAvgMbps ?? 0), - rxAvgMbps: Math.round(r.rxAvgMbps ?? 0), - status: (r.status === "error" ? "error" : "done") as "error" | "done", + txAvgMbps: r.txAvgMbps === null ? null : Math.round(r.txAvgMbps), + rxAvgMbps: r.rxAvgMbps === null ? null : Math.round(r.rxAvgMbps), + status: r.status, command: "", lines: r.error ? [`status: error`, r.error] : [], afterBtPing: r.afterBtPing ?? null, @@ -2121,8 +2104,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {(() => { const doneRuns = speedRuns.filter(r => r.status === "done") const runningCnt = speedRuns.filter(r => r.status === "running").length - const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null - const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null + const txValues = doneRuns.flatMap(r => r.txAvgMbps === null ? [] : [r.txAvgMbps]) + const maxTx = txValues.length ? Math.max(...txValues) : null + const rxValues = doneRuns.flatMap(r => r.rxAvgMbps === null ? [] : [r.rxAvgMbps]) + const maxRx = rxValues.length ? Math.max(...rxValues) : null return (
{r.label}
0 ? 3 : 0)}%` }} /> + style={{ width: `${r.val === null ? 0 : Math.max((r.val / maxVal) * 100, r.val > 0 ? 3 : 0)}%` }} />
- {r.val} Мбит/с + {r.val === null ? "Неизвестно" : `${r.val} Мбит/с`}
))} diff --git a/backend/src/routes/uptime.ts b/backend/src/routes/uptime.ts index 7061b57c..332b0624 100644 --- a/backend/src/routes/uptime.ts +++ b/backend/src/routes/uptime.ts @@ -1,3 +1,4 @@ +import { mapSpeedHistoryRun } from "../services/speed-history-dto.js" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { asc, desc, eq } from "drizzle-orm" import { db } from "../db/index.js" @@ -206,33 +207,11 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { app.get("/uptime/speed-test/runs", async (_req, reply) => { const rows = await db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200) - return reply.send({ - runs: rows.map((r) => ({ - id: r.id, - probeId: r.probeId ?? null, - srcServerId: String(r.srcServerId), - dstServerId: String(r.dstServerId), - srcInterface: r.srcInterface || "", - dstInterface: r.dstInterface || "", - srcAddress: r.srcAddress ?? null, - dstAddress: r.dstAddress ?? null, - srcInterfaceAddress: r.srcInterfaceAddress ?? null, - dstInterfaceAddress: r.dstInterfaceAddress ?? null, - protocol: r.protocol === "udp" ? "udp" : "tcp", - direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both", - durationSec: Math.max(3, r.durationSec || 10), - txAvgMbps: r.txAvgMbps ?? 0, - rxAvgMbps: r.rxAvgMbps ?? 0, - afterBtPing: { - rttMs: r.pingRttMs ?? null, - lossPct: r.pingLossPct ?? null, - error: r.pingError ?? null, - }, - status: r.status === "error" ? "error" : "done", - error: r.error ?? null, - createdAt: r.createdAt, - })), - }) + try { + return reply.send({ runs: rows.map(mapSpeedHistoryRun) }) + } catch { + return reply.code(502).send({ error: "История скорости содержит некорректную запись. Результаты не подтверждены." }) + } }) app.put("/uptime/speed-probes", async (req, reply) => { diff --git a/backend/src/services/speed-history-dto.test.ts b/backend/src/services/speed-history-dto.test.ts new file mode 100644 index 00000000..e71e74aa --- /dev/null +++ b/backend/src/services/speed-history-dto.test.ts @@ -0,0 +1,10 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { mapSpeedHistoryRun, type StoredSpeedRun } from "./speed-history-dto.js" +import { speedHistoryResponseSchema } from "@mmapp/contracts" +const row:StoredSpeedRun={id:"run-1",probeId:null,srcServerId:1,dstServerId:2,srcInterface:null,dstInterface:null,srcAddress:null,dstAddress:null,srcInterfaceAddress:null,dstInterfaceAddress:null,protocol:"tcp",direction:"both",durationSec:10,txAvgMbps:null,rxAvgMbps:0,pingRttMs:null,pingLossPct:0,pingError:null,status:"done",error:null,createdAt:"2026-10-04T00:00:00Z"} +test("История: неизвестный TX остаётся null, подтверждённые нули RX/loss остаются нулями",()=>{const dto=mapSpeedHistoryRun(row);assert.equal(dto.txAvgMbps,null);assert.equal(dto.rxAvgMbps,0);assert.equal(dto.afterBtPing?.rttMs,null);assert.equal(dto.afterBtPing?.lossPct,0)}) +test("История: нельзя подменить invalid status/protocol/direction/duration/date/rate/server",()=>{for(const invalid of [{status:"running"},{status:"unknown"},{protocol:"unknown"},{direction:"unknown"},{durationSec:0},{createdAt:"bad"},{txAvgMbps:-1},{rxAvgMbps:NaN},{srcServerId:0}])assert.throws(()=>mapSpeedHistoryRun({...row,...invalid}))}) +test("История: полный DTO, пустой список и уникальные ID",()=>{const dto=mapSpeedHistoryRun(row);assert.deepEqual(speedHistoryResponseSchema.parse({runs:[]}),{runs:[]});assert.equal(speedHistoryResponseSchema.safeParse({runs:[dto,dto]}).success,false);assert.equal(speedHistoryResponseSchema.safeParse({runs:[{...dto,txAvgMbps:undefined}]}).success,false)}) + +test("История: timestamp PostgreSQL нормализуется без изменения времени",()=>{assert.equal(mapSpeedHistoryRun({...row,createdAt:"2026-10-04 07:00:00+07"}).createdAt,"2026-10-04T00:00:00.000Z")}) diff --git a/backend/src/services/speed-history-dto.ts b/backend/src/services/speed-history-dto.ts new file mode 100644 index 00000000..2efca3fe --- /dev/null +++ b/backend/src/services/speed-history-dto.ts @@ -0,0 +1,17 @@ +import { speedHistoryRunSchema } from "@mmapp/contracts" +export type StoredSpeedRun = { + id:string;probeId:string|null;srcServerId:number;dstServerId:number;srcInterface:string|null;dstInterface:string|null; + srcAddress:string|null;dstAddress:string|null;srcInterfaceAddress:string|null;dstInterfaceAddress:string|null; + protocol:string;direction:string;durationSec:number;txAvgMbps:number|null;rxAvgMbps:number|null; + pingRttMs:number|null;pingLossPct:number|null;pingError:string|null;status:string;error:string|null;createdAt:string; +} +export function mapSpeedHistoryRun(row:StoredSpeedRun) { + return speedHistoryRunSchema.parse({ + id:row.id,probeId:row.probeId,srcServerId:String(row.srcServerId),dstServerId:String(row.dstServerId), + srcInterface:row.srcInterface ?? "",dstInterface:row.dstInterface ?? "", + srcAddress:row.srcAddress,dstAddress:row.dstAddress,srcInterfaceAddress:row.srcInterfaceAddress,dstInterfaceAddress:row.dstInterfaceAddress, + protocol:row.protocol,direction:row.direction,durationSec:row.durationSec, + txAvgMbps:row.txAvgMbps,rxAvgMbps:row.rxAvgMbps,status:row.status,error:row.error,createdAt:new Date(row.createdAt).toISOString(), + afterBtPing:{rttMs:row.pingRttMs,lossPct:row.pingLossPct,error:row.pingError}, + }) +} diff --git a/components/data-grids/uptime-speed-history-data-grid.tsx b/components/data-grids/uptime-speed-history-data-grid.tsx index bd4408eb..9bc8c986 100644 --- a/components/data-grids/uptime-speed-history-data-grid.tsx +++ b/components/data-grids/uptime-speed-history-data-grid.tsx @@ -29,8 +29,8 @@ export interface SpeedTestRunRow { protocol: "tcp" | "udp" direction: "transmit" | "receive" | "both" durationSec: number - txAvgMbps: number - rxAvgMbps: number + txAvgMbps: number | null + rxAvgMbps: number | null status: "running" | "done" | "error" afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null srcInterfaceAddress?: string | null @@ -136,11 +136,13 @@ function UptimeSpeedHistoryDataGrid({ runs, servers }: UptimeSpeedHistoryDataGri }, { id: "txAvgMbps", - accessorKey: "txAvgMbps", + accessorFn: row => row.txAvgMbps ?? undefined, + sortUndefined: "last", header: ({ column }) => , cell: ({ row }) => { const run = row.original - const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1) + if (run.txAvgMbps === null) return Неизвестно + const maxVal = Math.max(run.txAvgMbps ?? 0, run.rxAvgMbps ?? 0, 1) return (
@@ -159,11 +161,13 @@ function UptimeSpeedHistoryDataGrid({ runs, servers }: UptimeSpeedHistoryDataGri }, { id: "rxAvgMbps", - accessorKey: "rxAvgMbps", + accessorFn: row => row.rxAvgMbps ?? undefined, + sortUndefined: "last", header: ({ column }) => , cell: ({ row }) => { const run = row.original - const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1) + if (run.rxAvgMbps === null) return Неизвестно + const maxVal = Math.max(run.txAvgMbps ?? 0, run.rxAvgMbps ?? 0, 1) return (
diff --git a/docs/ux-ui-performance.md b/docs/ux-ui-performance.md index 2b61e741..9e8fcf1d 100644 --- a/docs/ux-ui-performance.md +++ b/docs/ux-ui-performance.md @@ -72,3 +72,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич 12.3.18:users1000/263680 JSON bytes —1000→50 rows/37505→2393 DOM nodes/921ms dev ready. Whole-catalog name/login/email search, global sort/page20/refresh/details/error recovery/1000→1/правильный пользователь в форме/320px/axe проверены;32 browser/type/lint/build. Поиск не сохраняется в URL; полные DTO остаются в памяти. 12.3.19:backups1000/171812 JSON bytes —1000→50 rows/43580→2462 DOM nodes/898ms dev ready. Whole-catalog search/Filters/URL/reload/page20/refresh/503 cache/1000→1/правильный filename/возврат focus/320px/axe приняты;39+4 browser/type/lint/build. Полный DB список ещё загружается, производительность PG не измерялась. + +12.3.21: history200 остаётся50 DOM rows/2367 nodes/1150ms dev ready. Strict DTO и null rates приняты от pure DB mapper до grid/KPI/card;60+5 browser и unit4/builds. Это не проверка реального PostgreSQL/RouterOS. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index f7ed8dbd..b2756583 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-04 -Этап 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.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. Следующий блок12.3.21:strict history DTO/null rates от DB до UI, затем network grids и финальная матрица12.4;12.5 отдельно. См. 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.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. Следующий блок12.4.3:users/backups delta context; затем12.3.22 strict speed-probes DTO/source context, network grids и финальная матрица12.4;12.5 отдельно. См. 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 959c44f2..ce2d0330 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -29,13 +29,23 @@ | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | -| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13 wide pivot и12.3.14 history200 приняты локально | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.21 приняты локально в описанных границах; общая повторная регрессия последних delta остаётся | +## 12.3.21 — строгая история скорости от DB до UI (2026-10-04) + +Общий speedHistoryResponseSchema в contracts проверяет обязательные rates, finite/nonnegative или null, enums status/protocol/direction, duration/date/server IDs и уникальность run IDs. Pure mapSpeedHistoryRun сохраняет null rates и реальные нули; PostgreSQL timestamp нормализуется в ISO без изменения времени. Backend больше не подменяет неизвестные поля status=done/protocol=tcp/direction=both/duration=10/rate=0: некорректная запись возвращает502 с понятным сообщением. DB cap200 сохранён, миграция БД не требуется. + +Frontend safeParse отклоняет invalid/duplicate данные и сохраняет последний успешный список/receivedAt по12.3.20. History grid показывает Неизвестно для null, sortUndefined=last; KPI maximum использует только известные rates. Связанная карточка speed-пробы не подставляет прежние999 Мбит/с вместо null нового результата и не объявляет done только по timestamp. Подтверждённый0 остаётся0. Контракт rates теперь number|null: backend/frontend должны обновляться вместе; сетевые write/start операции не менялись. + +60 browser uptime-links/history200/speed-data прошли после финальной production правки; ещё5 speed-data после добавления320px/no overflow/снимка прошли.4 pure mapper/schema tests без PG прошли; typecheck/ESLint без ошибок (9 прежних предупреждений uptime), backend build и production frontend/contracts34 страницы прошли. Мобильный снимок просмотрен. Реальное чтение PG/RouterOS не проверялось. Большая history200:50 rows/2367 DOM nodes/1150ms dev ready, не SLA. + +Следующий короткий блок12.4.3: delta context users/backups после их pagination/search/focus изменений, зафиксировать проверенный HEAD. Затем12.3.22: строгий speed-probes DTO и смена источника данных; независимые read states не заменяют эту проверку. Далее network grids и итоговая общая матрица12.4. Полный main/context после последних delta ещё не повторялся;12.5 внешняя приёмка отдельно. Коммит локальный без push. + ## 12.3.20 — независимое чтение speed-проб и истории (2026-10-04) Promise.all с общей очисткой заменён независимыми обработчиками каждого GET: готовый ресурс появляется без ожидания второго, при API/malformed array ошибке прежний список и receivedAt сохраняются. На speed вкладке видны freshness и отдельные ReUI warning alerts. Первоначально неизвестный список не выдаётся за пустой: нет ложного Нет speed-проб, KPI показывают —. Revision/context/write guards исключают поздние ответы прежнего чтения. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 7c114e17..d16b6fe9 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -305,3 +305,13 @@ Promise.all с общей очисткой заменён независимым 58 из59 browser uptime-links/large-history/new-data прошли; первоначальный race fixture задерживал только первый GET, а StrictMode запускает две initial волны. Исправлено задержкой всей старой волны и ожиданием реально полученного позднего ответа; все4 новых browser прошли. Проверены оба initial failures, partial success,503/malformed/cache/freshness/recovery и late response. TypeScript/ESLint без ошибок (9 исходных предупреждений uptime страницы), production contracts/frontend34 страницы прошли. DOM history200 остаётся50 rows/2343 nodes в этом dev профиле. Это приёмка состояний чтения, а не полного DTO: legacy backend null→0 и fallback status/protocol остаются. Следующий блок12.3.21: общий строгий контракт speed history, сохранение null rates от DB до grid/KPI, pure mapper/unit и browser неизвестных/ошибочных значений. Затем большие network grids и итоговая матрица12.4; внешний12.5 отдельно. Коммит локальный без push. + +## 12.3.21 — строгая история скорости от DB до UI (2026-10-04) + +Общий speedHistoryResponseSchema в contracts проверяет обязательные rates, finite/nonnegative или null, enums status/protocol/direction, duration/date/server IDs и уникальность run IDs. Pure mapSpeedHistoryRun сохраняет null rates и реальные нули; PostgreSQL timestamp нормализуется в ISO без изменения времени. Backend больше не подменяет неизвестные поля status=done/protocol=tcp/direction=both/duration=10/rate=0: некорректная запись возвращает502 с понятным сообщением. DB cap200 сохранён, миграция БД не требуется. + +Frontend safeParse отклоняет invalid/duplicate данные и сохраняет последний успешный список/receivedAt по12.3.20. History grid показывает Неизвестно для null, sortUndefined=last; KPI maximum использует только известные rates. Связанная карточка speed-пробы не подставляет прежние999 Мбит/с вместо null нового результата и не объявляет done только по timestamp. Подтверждённый0 остаётся0. Контракт rates теперь number|null: backend/frontend должны обновляться вместе; сетевые write/start операции не менялись. + +60 browser uptime-links/history200/speed-data прошли после финальной production правки; ещё5 speed-data после добавления320px/no overflow/снимка прошли.4 pure mapper/schema tests без PG прошли; typecheck/ESLint без ошибок (9 прежних предупреждений uptime), backend build и production frontend/contracts34 страницы прошли. Мобильный снимок просмотрен. Реальное чтение PG/RouterOS не проверялось. Большая history200:50 rows/2367 DOM nodes/1150ms dev ready, не SLA. + +Следующий короткий блок12.4.3: delta context users/backups после их pagination/search/focus изменений, зафиксировать проверенный HEAD. Затем12.3.22: строгий speed-probes DTO и смена источника данных; независимые read states не заменяют эту проверку. Далее network grids и итоговая общая матрица12.4. Полный main/context после последних delta ещё не повторялся;12.5 внешняя приёмка отдельно. Коммит локальный без push. diff --git a/docs/ux-ui/screens/speed-history-null-320.png b/docs/ux-ui/screens/speed-history-null-320.png new file mode 100644 index 00000000..28a6e613 Binary files /dev/null and b/docs/ux-ui/screens/speed-history-null-320.png differ diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index bd87c647..4deabc0f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -13,3 +13,5 @@ export * from "./statistics.js" export * from "./ospf-optimizer.js" export * from "./evobgp.js" + +export * from "./uptime-speed.js" diff --git a/packages/contracts/src/uptime-speed.ts b/packages/contracts/src/uptime-speed.ts new file mode 100644 index 00000000..f8a0fc2e --- /dev/null +++ b/packages/contracts/src/uptime-speed.ts @@ -0,0 +1,17 @@ +import { z } from "zod" +const rate = z.number().finite().nonnegative().nullable() +export const speedHistoryRunSchema = z.object({ + id:z.string().min(1), probeId:z.string().nullable().optional(), + srcServerId:z.string().regex(/^[1-9][0-9]*$/), dstServerId:z.string().regex(/^[1-9][0-9]*$/), + srcInterface:z.string(), dstInterface:z.string(), + protocol:z.enum(["tcp","udp"]), direction:z.enum(["transmit","receive","both"]), durationSec:z.number().int().positive(), + txAvgMbps:rate, rxAvgMbps:rate, status:z.enum(["done","error"]), error:z.string().nullable().optional(), + createdAt:z.string().datetime({offset:true}), + srcAddress:z.string().nullable().optional(),dstAddress:z.string().nullable().optional(), + srcInterfaceAddress:z.string().nullable().optional(),dstInterfaceAddress:z.string().nullable().optional(), + afterBtPing:z.object({rttMs:rate,lossPct:z.number().finite().min(0).max(100).nullable(),error:z.string().nullable()}).nullable().optional(), +}) +export const speedHistoryResponseSchema=z.object({runs:z.array(speedHistoryRunSchema).superRefine((rows,ctx)=>{ + const ids=new Set();for(const row of rows){if(ids.has(row.id))ctx.addIssue({code:"custom",message:"Повторный ID истории скорости"});ids.add(row.id)} +})}) +export type SpeedHistoryRun=z.infer diff --git a/tests/e2e/uptime-speed-data.spec.ts b/tests/e2e/uptime-speed-data.spec.ts index a47661f0..9d4ec470 100644 --- a/tests/e2e/uptime-speed-data.spec.ts +++ b/tests/e2e/uptime-speed-data.spec.ts @@ -27,3 +27,16 @@ test("Скорость: поздняя история не задерживае await page.goto("/uptime?tab=speed");await page.getByRole("button",{name:"Пауза",exact:true}).click();await expect(page.getByText("Нет speed-проб",{exact:true})).toBeVisible();await expect(page.getByText("История тестов: данные пока неизвестны",{exact:true})).toBeVisible() newer=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.locator('tr[data-row-id="current"]')).toBeVisible();const late=page.waitForResponse(async response => response.url().endsWith("/api/uptime/speed-test/runs") && (await response.json()).runs[0].id === "stale");release();await (await late).finished();await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))));await expect.poll(()=>historyCalls).toBeGreaterThan(1);await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeEnabled();await expect(page.locator('tr[data-row-id="stale"]')).toHaveCount(0) }) + +test("Скорость: null не становится нулём, malformed DTO сохраняет последний результат", async ({page}) => { + let entries:unknown[]=[{...run,txAvgMbps:null,rxAvgMbps:0}] + await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p === "/api/auth/config" ? {required:false} : p === "/api/servers" ? [server] : p === "/api/uptime/overview" ? {probes:[],resources:[]} : p === "/api/uptime/speed-probes" ? {probes:[{id:"probe-1",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"ether1",protocol:"tcp",direction:"both",durationSec:"10",enabled:true,lastStatus:"done",lastRunAt:"2026-10-03T00:00:00Z",lastTxAvgMbps:999,lastRxAvgMbps:0}]} : p === "/api/uptime/speed-test/runs" ? {runs:entries} : {}})}) + await page.goto("/uptime?tab=speed");await page.getByRole("button",{name:"Пауза",exact:true}).click();const row=page.locator('tr[data-row-id="history-1"]');await expect(row).toContainText("Неизвестно");await expect(row).toContainText("0 Мбит/с");await expect(page.getByText("999 Мбит/с",{exact:true})).toHaveCount(0) + await page.setViewportSize({width:320,height:1000});expect(await page.evaluate(()=>document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await page.addStyleTag({content:"nextjs-portal{display:none!important}"});await page.screenshot({path:"docs/ux-ui/screens/speed-history-null-320.png",fullPage:true,animations:"disabled"}) + const summary=page.locator('[aria-label="Сводка speed-проб"]');await expect(summary).toContainText("Макс TX");await expect(summary).toContainText("—");await expect(summary).toContainText("0 Мбит/с") + for(const bad of [{...run,status:"unknown"},{...run,txAvgMbps:-1},{...run,createdAt:"bad"},run]) { + entries=bad === run ? [run,run] : [bad];await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByRole("alert",{name:"Чтение скорости: history",exact:true})).toContainText("Некорректная история тестов скорости");await expect(row).toContainText("Неизвестно") + } + entries=[{...run,txAvgMbps:0}];await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByRole("alert",{name:"Чтение скорости: history",exact:true})).toHaveCount(0);await expect(row).not.toContainText("Неизвестно");await expect(row).toContainText("0 Мбит/с") +})