From a558412a2722b25614d922a655eca28cb0b89a8b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 4 Oct 2026 05:19:34 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BD=D1=8B=D0=B5=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BA=D0=BE=D1=80=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/uptime/page.tsx | 55 +++++++----------- backend/src/routes/uptime.ts | 33 ++--------- .../src/services/speed-history-dto.test.ts | 10 ++++ backend/src/services/speed-history-dto.ts | 17 ++++++ .../uptime-speed-history-data-grid.tsx | 16 +++-- docs/ux-ui-performance.md | 2 + docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 12 +++- docs/ux-ui-stage-12.md | 10 ++++ docs/ux-ui/screens/speed-history-null-320.png | Bin 0 -> 54075 bytes packages/contracts/src/index.ts | 2 + packages/contracts/src/uptime-speed.ts | 17 ++++++ tests/e2e/uptime-speed-data.spec.ts | 13 +++++ 13 files changed, 119 insertions(+), 70 deletions(-) create mode 100644 backend/src/services/speed-history-dto.test.ts create mode 100644 backend/src/services/speed-history-dto.ts create mode 100644 docs/ux-ui/screens/speed-history-null-320.png create mode 100644 packages/contracts/src/uptime-speed.ts 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 0000000000000000000000000000000000000000..28a6e613a9bf285e1b03504c46ca89dd98b86e43 GIT binary patch literal 54075 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 z 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 literal 0 HcmV?d00001 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 Мбит/с") +})