fix(api): сохранить неизвестные значения истории скорости

This commit is contained in:
Denozordec committed 2026-10-04 05:19:34 +07:00
1 parent e5ff69e243
commit a558412a27
13 files changed
+119 -70

No files matched your search

+20 -35
View File
@@ -1,5 +1,7 @@
"use client" "use client"
import { speedHistoryResponseSchema } from "@mmapp/contracts"
import probeStyles from "./probe-rows.module.css" import probeStyles from "./probe-rows.module.css"
import { Suspense, useState, useMemo, useEffect, useRef, useCallback } from "react" import { Suspense, useState, useMemo, useEffect, useRef, useCallback } from "react"
@@ -698,8 +700,8 @@ interface SpeedTestRun {
protocol: "tcp" | "udp" protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both" direction: "transmit" | "receive" | "both"
durationSec: number durationSec: number
txAvgMbps: number txAvgMbps: number | null
rxAvgMbps: number rxAvgMbps: number | null
status: "running" | "done" | "error" status: "running" | "done" | "error"
command: string command: string
lines: string[] lines: string[]
@@ -1164,26 +1166,6 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const reloadSpeedData = useCallback(async (writeContext?: number) => { const reloadSpeedData = useCallback(async (writeContext?: number) => {
if (!liveApi || (savingRef.current && writeContext !== saveContext.current)) return if (!liveApi || (savingRef.current && writeContext !== saveContext.current)) return
const context = saveContext.current 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 revision = ++speedReadRevision.current
const current = () => revision === speedReadRevision.current && context === saveContext.current && (!savingRef.current || writeContext === context) const current = () => revision === speedReadRevision.current && context === saveContext.current && (!savingRef.current || writeContext === context)
const read = async (resource: "probes" | "history", task: () => Promise<void>) => { const read = async (resource: "probes" | "history", task: () => Promise<void>) => {
@@ -1201,8 +1183,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
if (current()) setSpeedProbes(sp.probes) if (current()) setSpeedProbes(sp.probes)
}), }),
read("history", async () => { read("history", async () => {
const runsRes = await apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs") const result = speedHistoryResponseSchema.safeParse(await apiFetch<unknown>("/api/uptime/speed-test/runs"))
if (!Array.isArray(runsRes.runs)) throw new Error("Некорректная история тестов") if (!result.success) throw new Error("Некорректная история тестов скорости")
const runsRes = result.data
const rows = (runsRes.runs ?? []).map((r) => ({ const rows = (runsRes.runs ?? []).map((r) => ({
id: r.id, id: r.id,
startedAt: Date.parse(r.createdAt), startedAt: Date.parse(r.createdAt),
@@ -1213,9 +1196,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
protocol: r.protocol, protocol: r.protocol,
direction: r.direction, direction: r.direction,
durationSec: r.durationSec, durationSec: r.durationSec,
txAvgMbps: Math.round(r.txAvgMbps ?? 0), txAvgMbps: r.txAvgMbps === null ? null : Math.round(r.txAvgMbps),
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0), rxAvgMbps: r.rxAvgMbps === null ? null : Math.round(r.rxAvgMbps),
status: (r.status === "error" ? "error" : "done") as "error" | "done", status: r.status,
command: "", command: "",
lines: r.error ? [`status: error`, r.error] : [], lines: r.error ? [`status: error`, r.error] : [],
afterBtPing: r.afterBtPing ?? null, afterBtPing: r.afterBtPing ?? null,
@@ -2121,8 +2104,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{(() => { {(() => {
const doneRuns = speedRuns.filter(r => r.status === "done") const doneRuns = speedRuns.filter(r => r.status === "done")
const runningCnt = speedRuns.filter(r => r.status === "running").length const runningCnt = speedRuns.filter(r => r.status === "running").length
const maxTx = doneRuns.length ? Math.max(...doneRuns.map(r => r.txAvgMbps)) : null const txValues = doneRuns.flatMap(r => r.txAvgMbps === null ? [] : [r.txAvgMbps])
const maxRx = doneRuns.length ? Math.max(...doneRuns.map(r => r.rxAvgMbps)) : null 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 ( return (
<div className="px-6 py-4 border-b bg-muted/10 shrink-0"> <div className="px-6 py-4 border-b bg-muted/10 shrink-0">
<KpiStatGrid <KpiStatGrid
@@ -2223,10 +2208,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
(r.srcInterface ?? "") === probe.srcInterface && (r.srcInterface ?? "") === probe.srcInterface &&
(r.dstInterface ?? "") === probe.dstInterface, (r.dstInterface ?? "") === probe.dstInterface,
) )
const viewStatus = run?.status ?? (probe.lastStatus === "error" ? "error" : probe.lastRunAt ? "done" : null) const viewStatus = run?.status ?? (probe.lastStatus === "error" || probe.lastStatus === "done" ? probe.lastStatus : null)
const viewTx = run?.txAvgMbps ?? Math.round(probe.lastTxAvgMbps ?? 0) const viewTx = run ? run.txAvgMbps : probe.lastTxAvgMbps == null ? null : Math.round(probe.lastTxAvgMbps)
const viewRx = run?.rxAvgMbps ?? Math.round(probe.lastRxAvgMbps ?? 0) const viewRx = run ? run.rxAvgMbps : probe.lastRxAvgMbps == null ? null : Math.round(probe.lastRxAvgMbps)
const maxVal = Math.max(viewTx, viewRx, 1) const maxVal = Math.max(viewTx ?? 0, viewRx ?? 0, 1)
const canRun = probe.enabled && !!probe.srcServerId && !!probe.dstServerId && probe.srcServerId !== probe.dstServerId const canRun = probe.enabled && !!probe.srcServerId && !!probe.dstServerId && probe.srcServerId !== probe.dstServerId
const viewAfterBtPing = const viewAfterBtPing =
run?.status === "done" && run.afterBtPing run?.status === "done" && run.afterBtPing
@@ -2314,10 +2299,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
<span className="w-8 font-semibold text-muted-foreground">{r.label}</span> <span className="w-8 font-semibold text-muted-foreground">{r.label}</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden"> <div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full", r.color)} <div className={cn("h-full rounded-full", r.color)}
style={{ width: `${Math.max((r.val / maxVal) * 100, r.val > 0 ? 3 : 0)}%` }} /> style={{ width: `${r.val === null ? 0 : Math.max((r.val / maxVal) * 100, r.val > 0 ? 3 : 0)}%` }} />
</div> </div>
<span className={cn("w-24 text-right font-mono tabular-nums font-semibold", r.textColor)}> <span className={cn("w-24 text-right font-mono tabular-nums font-semibold", r.textColor)}>
{r.val} Мбит/с {r.val === null ? "Неизвестно" : `${r.val} Мбит/с`}
</span> </span>
</div> </div>
))} ))}
+6 -27
View File
@@ -1,3 +1,4 @@
import { mapSpeedHistoryRun } from "../services/speed-history-dto.js"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { asc, desc, eq } from "drizzle-orm" import { asc, desc, eq } from "drizzle-orm"
import { db } from "../db/index.js" import { db } from "../db/index.js"
@@ -206,33 +207,11 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/uptime/speed-test/runs", async (_req, reply) => { app.get("/uptime/speed-test/runs", async (_req, reply) => {
const rows = await db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200) const rows = await db.select().from(uptimeSpeedTestRuns).orderBy(desc(uptimeSpeedTestRuns.createdAt)).limit(200)
return reply.send({ try {
runs: rows.map((r) => ({ return reply.send({ runs: rows.map(mapSpeedHistoryRun) })
id: r.id, } catch {
probeId: r.probeId ?? null, return reply.code(502).send({ error: "История скорости содержит некорректную запись. Результаты не подтверждены." })
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,
})),
})
}) })
app.put("/uptime/speed-probes", async (req, reply) => { app.put("/uptime/speed-probes", async (req, reply) => {
@@ -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")})
+17
View File
@@ -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},
})
}
@@ -29,8 +29,8 @@ export interface SpeedTestRunRow {
protocol: "tcp" | "udp" protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both" direction: "transmit" | "receive" | "both"
durationSec: number durationSec: number
txAvgMbps: number txAvgMbps: number | null
rxAvgMbps: number rxAvgMbps: number | null
status: "running" | "done" | "error" status: "running" | "done" | "error"
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
srcInterfaceAddress?: string | null srcInterfaceAddress?: string | null
@@ -136,11 +136,13 @@ function UptimeSpeedHistoryDataGrid({ runs, servers }: UptimeSpeedHistoryDataGri
}, },
{ {
id: "txAvgMbps", id: "txAvgMbps",
accessorKey: "txAvgMbps", accessorFn: row => row.txAvgMbps ?? undefined,
sortUndefined: "last",
header: ({ column }) => <DataGridSortHeader column={column} title="TX avg" />, header: ({ column }) => <DataGridSortHeader column={column} title="TX avg" />,
cell: ({ row }) => { cell: ({ row }) => {
const run = row.original const run = row.original
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1) if (run.txAvgMbps === null) return <span className="text-xs text-muted-foreground">Неизвестно</span>
const maxVal = Math.max(run.txAvgMbps ?? 0, run.rxAvgMbps ?? 0, 1)
return ( return (
<div className="flex items-center gap-2 min-w-[120px]"> <div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden"> <div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
@@ -159,11 +161,13 @@ function UptimeSpeedHistoryDataGrid({ runs, servers }: UptimeSpeedHistoryDataGri
}, },
{ {
id: "rxAvgMbps", id: "rxAvgMbps",
accessorKey: "rxAvgMbps", accessorFn: row => row.rxAvgMbps ?? undefined,
sortUndefined: "last",
header: ({ column }) => <DataGridSortHeader column={column} title="RX avg" />, header: ({ column }) => <DataGridSortHeader column={column} title="RX avg" />,
cell: ({ row }) => { cell: ({ row }) => {
const run = row.original const run = row.original
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1) if (run.rxAvgMbps === null) return <span className="text-xs text-muted-foreground">Неизвестно</span>
const maxVal = Math.max(run.txAvgMbps ?? 0, run.rxAvgMbps ?? 0, 1)
return ( return (
<div className="flex items-center gap-2 min-w-[120px]"> <div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden"> <div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
+2
View File
@@ -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.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.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.
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-04 ## Актуальная точка продолжения — 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). Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+11 -1
View File
@@ -29,13 +29,23 @@
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 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) ## 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 исключают поздние ответы прежнего чтения. Promise.all с общей очисткой заменён независимыми обработчиками каждого GET: готовый ресурс появляется без ожидания второго, при API/malformed array ошибке прежний список и receivedAt сохраняются. На speed вкладке видны freshness и отдельные ReUI warning alerts. Первоначально неизвестный список не выдаётся за пустой: нет ложного Нет speed-проб, KPI показывают —. Revision/context/write guards исключают поздние ответы прежнего чтения.
+10
View File
@@ -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 профиле. 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. Это приёмка состояний чтения, а не полного 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+2
View File
@@ -13,3 +13,5 @@ export * from "./statistics.js"
export * from "./ospf-optimizer.js" export * from "./ospf-optimizer.js"
export * from "./evobgp.js" export * from "./evobgp.js"
export * from "./uptime-speed.js"
+17
View File
@@ -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<string>();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<typeof speedHistoryRunSchema>
+13
View File
@@ -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() 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<void>(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) 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<void>(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 Мбит/с")
})