fix(api): сохранить неизвестные значения истории скорости
This commit is contained in:
1 parent
e5ff69e243
commit
a558412a27
13 files changed
+119
-70
No files matched your search
+20
-35
@@ -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<void>) => {
|
||||
@@ -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<unknown>("/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 (
|
||||
<div className="px-6 py-4 border-b bg-muted/10 shrink-0">
|
||||
<KpiStatGrid
|
||||
@@ -2223,10 +2208,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
(r.srcInterface ?? "") === probe.srcInterface &&
|
||||
(r.dstInterface ?? "") === probe.dstInterface,
|
||||
)
|
||||
const viewStatus = run?.status ?? (probe.lastStatus === "error" ? "error" : probe.lastRunAt ? "done" : null)
|
||||
const viewTx = run?.txAvgMbps ?? Math.round(probe.lastTxAvgMbps ?? 0)
|
||||
const viewRx = run?.rxAvgMbps ?? Math.round(probe.lastRxAvgMbps ?? 0)
|
||||
const maxVal = Math.max(viewTx, viewRx, 1)
|
||||
const viewStatus = run?.status ?? (probe.lastStatus === "error" || probe.lastStatus === "done" ? probe.lastStatus : null)
|
||||
const viewTx = run ? run.txAvgMbps : probe.lastTxAvgMbps == null ? null : Math.round(probe.lastTxAvgMbps)
|
||||
const viewRx = run ? run.rxAvgMbps : probe.lastRxAvgMbps == null ? null : Math.round(probe.lastRxAvgMbps)
|
||||
const maxVal = Math.max(viewTx ?? 0, viewRx ?? 0, 1)
|
||||
const canRun = probe.enabled && !!probe.srcServerId && !!probe.dstServerId && probe.srcServerId !== probe.dstServerId
|
||||
const viewAfterBtPing =
|
||||
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>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<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>
|
||||
<span className={cn("w-24 text-right font-mono tabular-nums font-semibold", r.textColor)}>
|
||||
{r.val} Мбит/с
|
||||
{r.val === null ? "Неизвестно" : `${r.val} Мбит/с`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")})
|
||||
@@ -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"
|
||||
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 }) => <DataGridSortHeader column={column} title="TX avg" />,
|
||||
cell: ({ row }) => {
|
||||
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 (
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
@@ -159,11 +161,13 @@ function UptimeSpeedHistoryDataGrid({ runs, servers }: UptimeSpeedHistoryDataGri
|
||||
},
|
||||
{
|
||||
id: "rxAvgMbps",
|
||||
accessorKey: "rxAvgMbps",
|
||||
accessorFn: row => row.rxAvgMbps ?? undefined,
|
||||
sortUndefined: "last",
|
||||
header: ({ column }) => <DataGridSortHeader column={column} title="RX avg" />,
|
||||
cell: ({ row }) => {
|
||||
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 (
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
|
||||
@@ -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.
|
||||
+1
-1
@@ -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).
|
||||
|
||||
|
||||
+11
-1
@@ -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 исключают поздние ответы прежнего чтения.
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -13,3 +13,5 @@ export * from "./statistics.js"
|
||||
export * from "./ospf-optimizer.js"
|
||||
|
||||
export * from "./evobgp.js"
|
||||
|
||||
export * from "./uptime-speed.js"
|
||||
@@ -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>
|
||||
@@ -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<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 Мбит/с")
|
||||
})
|
||||
Reference in new issue
Block a user