From d4f2961f76700e622b732063e704ab24c07b753f Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 4 Oct 2026 10:31:02 +0700 Subject: [PATCH] =?UTF-8?q?fix(api):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D1=8C=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=20speed-=D0=BF=D1=80=D0=BE=D0=B1=20=D0=B8=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/uptime/page.tsx | 31 ++++++++++-------- backend/src/routes/uptime.ts | 28 ++++------------ backend/src/services/speed-probe-dto.test.ts | 8 +++++ backend/src/services/speed-probe-dto.ts | 13 ++++++++ docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 ++++++ docs/ux-ui-stage-12.md | 10 ++++++ docs/ux-ui/screens/speed-probes-error-320.png | Bin 0 -> 56666 bytes lib/probe-schedule-response.ts | 19 ++++------- packages/contracts/src/uptime-speed.ts | 12 +++++++ playwright.config.ts | 2 +- tests/e2e/uptime-context.spec.ts | 23 +++++++++++++ tests/e2e/uptime-speed-data.spec.ts | 16 ++++++++- 13 files changed, 123 insertions(+), 51 deletions(-) create mode 100644 backend/src/services/speed-probe-dto.test.ts create mode 100644 backend/src/services/speed-probe-dto.ts create mode 100644 docs/ux-ui/screens/speed-probes-error-320.png create mode 100644 tests/e2e/uptime-context.spec.ts diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 2d35e9e1..eed0ba78 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -1,6 +1,6 @@ "use client" -import { speedHistoryResponseSchema } from "@mmapp/contracts" +import { speedHistoryResponseSchema, speedProbesResponseSchema } from "@mmapp/contracts" import probeStyles from "./probe-rows.module.css" @@ -1110,6 +1110,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const [speedProbes, setSpeedProbes] = useState([]) const speedReadRevision = useRef(0) const [speedReads, setSpeedReads] = useState>({ probes: { receivedAt: null, error: null }, history: { receivedAt: null, error: null } }) + const speedCatalogReady = !liveApi || (speedReads.probes.receivedAt !== null && speedReads.probes.error === null) const [speedIfaces, setSpeedIfaces] = useState>>({}) const [speedCollapsed, setSpeedCollapsed] = useState>(new Set()) const [speedSheetOpen, setSpeedSheetOpen] = useState(false) @@ -1178,9 +1179,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId } await Promise.all([ read("probes", async () => { - const sp = await apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes") - if (!Array.isArray(sp.probes)) throw new Error("Некорректный список speed-проб") - if (current()) setSpeedProbes(sp.probes) + const result = speedProbesResponseSchema.safeParse(await apiFetch("/api/uptime/speed-probes")) + if (!result.success) throw new Error("Данные проб скорости не подтверждены сервером.") + if (current()) setSpeedProbes(result.data.probes) }), read("history", async () => { const result = speedHistoryResponseSchema.safeParse(await apiFetch("/api/uptime/speed-test/runs")) @@ -1522,6 +1523,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const persistSpeedProbes = useCallback(async (rows: SpeedProbeRow[]) => { if (!liveApi) return + if (!speedCatalogReady) throw new Error("Каталог speed-проб не подтверждён. Обновите данные перед сохранением.") await apiFetch("/api/uptime/speed-probes", { method: "PUT", signal: AbortSignal.timeout(30_000), @@ -1544,7 +1546,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId })), }), }) - }, [apiFetch, liveApi]) + }, [apiFetch, liveApi, speedCatalogReady]) async function saveProbeForm(save: () => Promise, confirm: () => void) { if (!requireWrite()) return @@ -1885,7 +1887,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId } const openAddSpeedSheet = () => { - if (!requireWrite()) return + if (!requireWrite() || !speedCatalogReady) return setProbeSaveError(null) setEditingSpeedProbeId(null) const src = selectableSources[0]?.id ?? "" @@ -2001,7 +2003,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId )} {tab === "speed" && ( - )} @@ -2077,7 +2079,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId Скорость - {speedProbes.length} + {liveApi && speedReads.probes.receivedAt === null ? "—" : speedProbes.length} )} @@ -2096,7 +2098,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const state = speedReads[resource], label = resource === "probes" ? "Speed-пробы" : "История тестов" return

{label}: {state.receivedAt ? "получено " + new Date(state.receivedAt).toLocaleString("ru-RU") : "данные пока неизвестны"}

- {state.error && {label}{state.error} {state.receivedAt ? "Последние данные сохранены." : "Данные пока неизвестны."}} + {state.error && {label}{state.error} {state.receivedAt ? "Последние данные сохранены." : "Данные пока неизвестны."}{resource === "probes" ? " Изменение каталога недоступно до успешного обновления." : ""}}
})} } @@ -2171,7 +2173,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId

Создайте пробу через кнопку «Новая speed-проба» в шапке страницы

- @@ -2229,7 +2231,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
{/* enable toggle */} - updateSpeedProbe(probe.id, { enabled: v })} /> + updateSpeedProbe(probe.id, { enabled: v })} /> {/* route: src → dst */}
@@ -2266,12 +2268,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId Запустить
@@ -2996,11 +2998,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId } function UptimeRoute() { + const { mode, backendUrl } = useDataSource() const params = useSearchParams() const id = params.get("probe") const rest = new URLSearchParams(params.toString()) rest.delete("probe") - return + return } export default function UptimePage() { diff --git a/backend/src/routes/uptime.ts b/backend/src/routes/uptime.ts index 332b0624..87fbd549 100644 --- a/backend/src/routes/uptime.ts +++ b/backend/src/routes/uptime.ts @@ -1,3 +1,4 @@ +import { mapSpeedProbeRead } from "../services/speed-probe-dto.js" import { mapSpeedHistoryRun } from "../services/speed-history-dto.js" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { asc, desc, eq } from "drizzle-orm" @@ -181,28 +182,11 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { app.get("/uptime/speed-probes", async (_req, reply) => { const rows = await db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)) - return reply.send({ - probes: rows.map((r) => ({ - id: r.id, - srcServerId: String(r.srcServerId), - dstServerId: String(r.dstServerId), - srcInterface: r.srcInterface || "", - dstInterface: r.dstInterface || "", - protocol: r.protocol === "udp" ? "udp" : "tcp", - direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both", - durationSec: String(Math.max(3, r.durationSec || 10)), - enabled: r.enabled !== false, - lastRunAt: r.lastRunAt ?? null, - lastTxAvgMbps: r.lastTxAvgMbps ?? null, - lastRxAvgMbps: r.lastRxAvgMbps ?? null, - lastStatus: r.lastStatus ?? null, - lastError: r.lastError ?? null, - lastPingRttMs: r.lastPingRttMs ?? null, - lastPingLossPct: r.lastPingLossPct ?? null, - lastPingAt: r.lastPingAt ?? null, - lastPingError: r.lastPingError ?? null, - })), - }) + try { + return reply.send({ probes: rows.map(mapSpeedProbeRead) }) + } catch { + return reply.code(502).send({ error: "Каталог speed-проб содержит некорректную запись. Данные не подтверждены." }) + } }) app.get("/uptime/speed-test/runs", async (_req, reply) => { diff --git a/backend/src/services/speed-probe-dto.test.ts b/backend/src/services/speed-probe-dto.test.ts new file mode 100644 index 00000000..c45109a6 --- /dev/null +++ b/backend/src/services/speed-probe-dto.test.ts @@ -0,0 +1,8 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { mapSpeedProbeRead } from "./speed-probe-dto.js" +import type { UptimeSpeedProbeRow } from "../db/schema.js" +const row:UptimeSpeedProbeRow={id:"probe-1",srcServerId:1,dstServerId:2,srcInterface:"",dstInterface:"",protocol:"tcp",direction:"both",durationSec:10,enabled:false,lastRunAt:null,lastTxAvgMbps:null,lastRxAvgMbps:0,lastStatus:null,lastError:null,lastPingRttMs:null,lastPingLossPct:0,lastPingAt:null,lastPingError:null,sortOrder:0,createdAt:"2026-10-04T00:00:00Z",updatedAt:"2026-10-04T00:00:00Z"} +test("Speed probe: null/zero/disabled и неизвестный status не подменяются",()=>{const dto=mapSpeedProbeRead(row);assert.equal(dto.enabled,false);assert.equal(dto.lastTxAvgMbps,null);assert.equal(dto.lastRxAvgMbps,0);assert.equal(dto.lastStatus,null);assert.equal(dto.lastPingRttMs,null);assert.equal(dto.lastPingLossPct,0)}) +test("Speed probe: timestamp PostgreSQL сохраняет instant",()=>{assert.equal(mapSpeedProbeRead({...row,lastRunAt:"2026-10-04 07:00:00+07"}).lastRunAt,"2026-10-04T00:00:00.000Z")}) +test("Speed probe: некорректная DB запись отклоняется без fallback",()=>{for(const raw of [{...row,protocol:"bad"},{...row,direction:"bad"},{...row,durationSec:0},{...row,enabled:null},{...row,lastStatus:"bad"},{...row,lastTxAvgMbps:-1},{...row,lastRunAt:"bad"},{...row,srcServerId:0}])assert.throws(()=>mapSpeedProbeRead(raw as UptimeSpeedProbeRow))}) diff --git a/backend/src/services/speed-probe-dto.ts b/backend/src/services/speed-probe-dto.ts new file mode 100644 index 00000000..2d8cff8c --- /dev/null +++ b/backend/src/services/speed-probe-dto.ts @@ -0,0 +1,13 @@ +import { speedProbeReadSchema } from "@mmapp/contracts" +import type { UptimeSpeedProbeRow } from "../db/schema.js" +const timestamp = (value: string | null) => value === null ? null : new Date(value).toISOString() +export function mapSpeedProbeRead(row: UptimeSpeedProbeRow) { + return speedProbeReadSchema.parse({ + id: row.id, srcServerId: String(row.srcServerId), dstServerId: String(row.dstServerId), + srcInterface: row.srcInterface, dstInterface: row.dstInterface, + protocol: row.protocol, direction: row.direction, durationSec: String(row.durationSec), enabled: row.enabled, + lastRunAt: timestamp(row.lastRunAt), lastTxAvgMbps: row.lastTxAvgMbps, lastRxAvgMbps: row.lastRxAvgMbps, + lastStatus: row.lastStatus, lastError: row.lastError, + lastPingRttMs: row.lastPingRttMs, lastPingLossPct: row.lastPingLossPct, lastPingAt: timestamp(row.lastPingAt), lastPingError: row.lastPingError, + }) +} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 70334493..171490d9 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/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. Следующий блок12.3.22:strict speed-probes DTO/source context/запись при неизвестном каталоге; далее network grids и полная регрессия main1102/context.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 context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. Следующий блок12.3.23:строгая транзакционная запись speed-probes; далее network grids и полная main/context регрессия.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 eb11e513..f88b87e6 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -36,6 +36,16 @@ +## 12.3.22 — общий каталог speed-проб и изоляция источника (2026-10-04) + +Схема из probe-schedule-response перенесена в contracts: shared speedProbeReadSchema/speedProbesResponseSchema проверяют enums/duration/boolean/server IDs/уникальность ID и nullable observed values. Uptime и live schedule используют один контракт; ping-after-BT поля сохраняются, optional legacy отсутствие остаётся неизвестным. Pure backend mapSpeedProbeRead сохраняет null/0/false, нормализует DB timestamps; недостоверная запись возвращает502 вместо protocol/direction/duration/enabled fallback. + +UptimeRoute keyed по probe ID/mode/backendUrl: каталог, история, freshness, интерфейсы и формы относятся к своему источнику. UI create/toggle/edit/delete/start и bulk writer защищены speedCatalogReady; первоначальная неизвестность и malformed refresh не допускают полной замены неподтверждённого списка. Cached строки и receivedAt сохранены; warning объясняет, что нужно успешно обновить данные. Навигационный счётчик unknown показывает —, переключатель получил доступное имя. + +60 browser uptime и6 после финальной copy/a11y правки прошли;8 новых context scenarios backend/mode × probes/history × success/error прошли. Второй consumer/diagnostics/schedule/history47 browser прошли, включая1000 probes и1001 rail. Unit mapper3 + shared reader2 прошли без PG. Typecheck/ESLint без ошибок (9 прежних uptime warnings), backend build и frontend/contracts production34 страницы прошли; ReUI checklist и scoped axe error alert пройдены,320px снимок просмотрен. Context файл включён в агрегированную отдельную конфигурацию. Коммит локальный без push. + +Следующий блок12.3.23: строгий PUT speed-probes до DB mutation, отказ без удаления malformed строк и транзакционная полная замена. Текущий backend PUT всё ещё имеет permissive normalize/filter и последовательные mutations; защита UI не заменяет серверную проверку. Затем network grids/полная main/context матрица12.4;12.5 реальная PG/RouterOS/integration приёмка отдельно. + ## 12.4.3 — context delta последних admin изменений (2026-10-04) На a558412 выполнены backups-context и users-context через общий playwright.ux-context.config.ts:36 passed/0 failed/skipped/flaky, Edge/3101/workers1/retries0, наблюдаемые1.4m. Проверены поздние reads при backend/mode switch, schedule/storage writes, create/job poll, delete/restore/download, создание/удаление пользователя. HTTP ответы подставные, реальных внешних операций не было. Отчёт: docs/ux-ui/acceptance/context-delta-2026-10-04.json. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 9fcff42c..8b598845 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -323,3 +323,13 @@ Frontend safeParse отклоняет invalid/duplicate данные и сохр Текущий main inventory по --list:1102 теста/169 файлов. Это перечень, не1102 passed. Ранее main1091 дал1088 passed и3 устаревших fixtures, после исправлений6 профильных прошли; полный context205 прошёл на прежнем HEAD. Последние production delta приняты профильными проверками и builds, полная повторная main/context регрессия остаётся в12.4. Stage12 не закрыт. Следующий шаг12.3.22: strict speed-probes DTO и speed state при смене backend/mode, включая неизвестный каталог перед записью. Затем оставшиеся большие network grids, общая матрица и12.5 внешняя PG/RouterOS/integration приёмка. Текущий блок записан локальным коммитом без push; посторонние изменения skills/AGENTS/tsbuildinfo сохранены. Остаток квоты около3% перед записью журнала. + +## 12.3.22 — общий каталог speed-проб и изоляция источника (2026-10-04) + +Схема из probe-schedule-response перенесена в contracts: shared speedProbeReadSchema/speedProbesResponseSchema проверяют enums/duration/boolean/server IDs/уникальность ID и nullable observed values. Uptime и live schedule используют один контракт; ping-after-BT поля сохраняются, optional legacy отсутствие остаётся неизвестным. Pure backend mapSpeedProbeRead сохраняет null/0/false, нормализует DB timestamps; недостоверная запись возвращает502 вместо protocol/direction/duration/enabled fallback. + +UptimeRoute keyed по probe ID/mode/backendUrl: каталог, история, freshness, интерфейсы и формы относятся к своему источнику. UI create/toggle/edit/delete/start и bulk writer защищены speedCatalogReady; первоначальная неизвестность и malformed refresh не допускают полной замены неподтверждённого списка. Cached строки и receivedAt сохранены; warning объясняет, что нужно успешно обновить данные. Навигационный счётчик unknown показывает —, переключатель получил доступное имя. + +60 browser uptime и6 после финальной copy/a11y правки прошли;8 новых context scenarios backend/mode × probes/history × success/error прошли. Второй consumer/diagnostics/schedule/history47 browser прошли, включая1000 probes и1001 rail. Unit mapper3 + shared reader2 прошли без PG. Typecheck/ESLint без ошибок (9 прежних uptime warnings), backend build и frontend/contracts production34 страницы прошли; ReUI checklist и scoped axe error alert пройдены,320px снимок просмотрен. Context файл включён в агрегированную отдельную конфигурацию. Коммит локальный без push. + +Следующий блок12.3.23: строгий PUT speed-probes до DB mutation, отказ без удаления malformed строк и транзакционная полная замена. Текущий backend PUT всё ещё имеет permissive normalize/filter и последовательные mutations; защита UI не заменяет серверную проверку. Затем network grids/полная main/context матрица12.4;12.5 реальная PG/RouterOS/integration приёмка отдельно. diff --git a/docs/ux-ui/screens/speed-probes-error-320.png b/docs/ux-ui/screens/speed-probes-error-320.png new file mode 100644 index 0000000000000000000000000000000000000000..3c809e55d45095f3a4ef9894b969d51482e20cc9 GIT binary patch literal 56666 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 q zp&Xl-sbE0oRZJV- zBuhS90{}minWY%@sCqYY9c@QPCpl<5QDeGPmXLg_HSc;C(b}g9bBN!}Op1s;yqNK4 zSs6!}k8V}f)t8kU=d5g7V)7)J4YLV9$}1|C&7|smE??;^;WLLOCWtd*?liQRiDJS4 z$ea{Y={fh-a&0tbtwtuAnk=~J|6P;3*`ORAxW=Z{Th~J8eP~=rpBO$d9ltl$AA;jE z_xDF|dtAbyK+60#KTnid>e(J%Rgt{9vVzyIENZ+gk{pCk5u3J-yiM194YR#Rp9^7u$~cYEi2G;cGB^evW}*Sx&$suJ`03VZ^B}b0k@E!+rthX+=W<+|E8Hq zAAj$!83vgW2bc!yp&3upjv3$;J_dJJ6HTY;Tc^7+$X_CXOj&-jz+VJIj3g#wTH4z1 z3kO4X=~;ef1(aiEdKB<}`UHNH4dGn3x3~O`8*50q^rFN9!UA#vnqSC1k;h5X4NpvL z)O{O;LSDDGaV2~>j))s{)lE%tezP$%<3~y3;o-e0p{1M$hd&BEQ?-n4nqDX)*r*6NdvSTW>G4TT#=(Kz=qqQ!j5xv~rIB9< z3J&t{`Evc>#)cRZ)A0B>8!sw%}VQ_)fWG>Q=kX$M%s%q12YD!AX@~NNLNlVR+rq0B0O{A_Gkh<`N7 zXj+vSl$4bb$wH!OR^bqkd*a^F#Zn0q_iO6up)v|MZS|Cwmv=*7SU5RxT>^!&#r#)B zM=?~;;Uy;3Tv0%AqikcxyDO&^E9QuS3Xq8*>t3rigT!}t`46WO<_xr`75>@20FU<(i_hF@*aXK$083ASaBeB;CC{e z$}4#IM4R6fv#_c2PM(O23~f7+$F0@d+gojZp>q5D94p8Por#Ib%t-vTZyf%DMny$s zcz8IG@+Q>S+ORaRzMel&dCl+2!a%Y%j_C>#pgM#iYtjirSMy(#p!$4*W7*`M!Zt>hpuXFUJGxY z)@JHk;}R)j^chtxd=JZDh9ntxqG9!vlDv?mzO{+vP8x&`>ERoo7q?~ zBRH(U^8C|#no6NT3-|>d#0tVXD6gUxS zC`aZe9tZmSdt$lfjC^g6{sjC|!`-O0m?Jsp9aK)DWvPMugEVM%t%NDHt zcXGb-ZzZb&86$b8K8re0@O;XFuabw1eQvL@k!PHqFmA@SnrXncgNPuzaFQ=wXHpd1 zH?;o8n{NYD#Ob)X9(3|ofex;_dd2ToJQ^{u4*T6zT)Z&pNF(7x^(H}?JDfvkbRJod>P^y0O*wi@MQXAPFPG5VDmO4oSt_)W)x7-$KI_6&9)`~`WBcI4mR!gu$Gf8)glh#S%5{qcin)> z1wh?Ez?Q_PPdbsoXJ~L-U{hpgKTY%Lit&a=abRp1Vyd?Sn}1X?Nlx7U!R|QWqDDAc z^)1cclX58X`E;@gh36 z=p>j2yj1ZZ6w6hT4|G~&z1j8MF}>^b%()x8{D+gLoveOQ7A0hAdjI@^qq3r?3(o;p zzOY%0Pg##%wCjFgciAmlV&=$wuEfXG=mZcTc)77Npn{V}_(bHnym-{Gw& z3VNg@>+^Y5ohS3X75w}y^?^91Vp){$MuuxDk=R&J#gD_Yk?zNqwlYHE!>p1{D$Uyq zQN;rWZxKG(qaF<<*O+c2Mu?08xso|pM-r_F`O7H4)#! z^GC|2*Jt;LG2M|3+Srl<-wCRno)7mjhe{jEM!u9Q_CM-lnw-lWs$QjULh8PaSD%L{ zlM{SDiJ2f4>*(x~-`olv%Q^Q4GVxfW8-Kt$V<;3p!b{e}fY=o++F}BQA-lX8F(?UM zHzKV~&KT?AC<5bN@0~U=Pdxf+zd1KFQ~iu9sKiR++%Pyg_Y?)4)RUArlu?QFoy!p4 zGx<8m=-;t;PgffW@1EVd!4vtn(6@_~B%-geRxN(GyLovef8XI5A>tQ*sv*hPFx!YW z7~l8pSc$feV2u&QiL#6%^m@bKj1ICpGG)a9d8D)*kc`+y<~>|0=ixsjOM$2T>(pEM zkHaYYMWLwgeKn3qmZH8uJIsl{EyZcEL^f$1U_|+HmI|RvD4NTVWO9h^Qycj=sLDOK z!T4uWMLWPg-oTUi^u)AdQi^1o)%8ZXE3IpS5VMMHpUdm%>txslFQB?hz|Gx!TD*F2 z=ZHrmdQl@T`LUvKb7pxZ3%+k!qALq~%~;|aJlbKK7K8*J+I(eM`e?ZQ`7@l8_n(1c zYn|Z?#A6+wqsO2aa#VBtouMuKW5>&sE(Bxgn%n8t;IMA(zM=#2i>seFwD5*UuK5*c z4=FhiWqriqI!7uF9CUz=;u+dQ-f<5He0$Ws5SCpc)pqn7mzcMN7zGJT4f-YKpqcZ)%R1( zndLUz-*q%W^Vk;o*+YTfvyCped!&W}h-fb5shiaS?TAPGcI6Mqe_482oLIPM8;uu- zjnz^8+{0D2LvBm9J#4W5>!P+`%s)hhuUJvBGbq?kputAFollumr7BNpL|T~S5M8XO z^L5}6aLNQFGIcza^^wN$`&GD+4;UP!+2aD_HvGx$`KGID`lm3&<%ET*rHjEOk%fZ) zQ>>-wcBnm3J;0ys67i0ePi2aoNoC3W&ry0e_BshKsT@vK7T7TAKa-?14wVc`+4Sk7}s?W{b5xt%hFCN$4BWV+vvPQg(IONI&5hV z;8fPl*m6p1?Hw7@ynnB2(qBst&9z`?JR|B$2IW@&~xDe*1Qsrr*@HJahJ z9>+E8j$ljhhAupgE+_hSAIrl@ygiTR;5lrznUR>|uaZfMQbcR}z$8t-@H)7 zyTP!ASQ1$wXNNf`e9@B7zi{ixb>4g61csrdEeRgYCd6{MuUjdBN8t1Bh2Kl;!-91M zsHxP$EM1DSaoCGxtm=rIw}Ww{tzL`ry0hd2C444xms`HlB|+jMaVqGOpMuqgM-TE+ zNLvki{+(Ts!6M;{Vz>ad<9!meH|7J`xUkig$Bv$4GKDcqD4p#d!<%e{3Xqf8Gcz%@E19~=1 z>IJzOtHgxzcW02K2!$x>fju|Uiha0#a^I?=VNO$gnA2h@J;?yvg6t3PPFmqMR!t)| z5jyHZAMN|8>%$aQ?h}}uFKWZ*c|XAi*0UtQSN^V{WXVd%mi#0 zYvS8AfMry__K)hX)t%TEE2jOSR>@eU=b8Zqs1h-JT3qC}tbtc9o>uY`qCl@r~Eo&inv(rl}VxR9CW z2TxaGN5AwhmnSF*t4F6vq0}MSkvSJ4Ot)pioJeHVzqNl3N(b;@klG-f&c7n5{2vG9Dk>(G>|mhR%I+KiTU89~?bLU8s@0&kU#$BC&7RJ(fdG7bhc6OS&pmg0H;3=8%mH1q|ZK<6LU!#`$xV#BN)k%LV5Yni* z0-O?tO1Av`r3_B)C+T17ZWz&2{Tzx)-75Ye2`v%*$XDnSMI;OOi(4%c9!SS9!l5j= z-Dg=@pJl#%qml#*RHDryeR-;!O1`|Yia$gBdU(xETm8^J-TTgnDGold71be)lyB6+ zDf+*EA@p7P+LfFBJFd*T82_X8>6@Icl*e=bhrY`rzUq2BL`idP?bMt~s)SKZ=2YzX z_;v4N0U?3leN2B2yUBDx*Q>4G7by}B6Y%+>={Bx(p!L5(Hn_!9t2_a9aYyP+&8sbn zS*gGDL(d@8?FRrigV7pCFByA~fXpr3(>%BlRW+_gek- z%Q1cg-c(@5qTY|)U1^V-V1n29y%P|%2kh$l8n4QTjzhrzNm`|KM(AzAoaF|30&0^P z(NswaO*v>{e+lBO@%Kw)8;GMV0Uc)EOMLGIB880?v|^L-*o3O-uRc*-md{nd2vbN$ z7pf)L7Xv0Uhj*PDmo>I?X9TDdQqmR$kr9!w&_uzx-FW-U57Ja_g(!l|Ei^R^g?oI( zr{gGcO-wlP>DT2aiYQ4RC)yuPmXPneAPa^sQWYjXXo#4y_q`z~RmSZLQOf@`rqC$H z2mu)-Gt?2sdR!eI^2oXsXm)hrOmg7+>gyl5t^*_7-VQCJ3%M@@y+0h3k}_ z{g^$-wDIzQbC@r@&nWUB`DLx}3IZgK%_C>~GYr1&tW2Am<9)oUpJkZ9RE4OoZKi&F zrZ2u`xpmm9&+!je_WzrDJCP>W=GCWDt_2Yl*+UPLrn|nr4^tqViR%gF88fgM?34|X zTCu5&w@Dwr1nKVdw|ku=1sid{_x=qiFk3{~>@iMgBLQ|h&BPuNoN8QV3QZ>%*Ill2 z#H(%p&QJwX3i3_A7Bna&AB19Jz3@Kbrz@$4tjjNN≠~wys=9LodexJX;H=oIcMn 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 ZacYVTGHY-s7Vc+ow4UF$?p1|`{}0<*y4C;y literal 0 HcmV?d00001 diff --git a/lib/probe-schedule-response.ts b/lib/probe-schedule-response.ts index 574b9521..949f5954 100644 --- a/lib/probe-schedule-response.ts +++ b/lib/probe-schedule-response.ts @@ -1,13 +1,8 @@ -import {z} from "zod" -const metric=z.number().finite().nonnegative().nullable() -const timestamp=z.string().refine(v=>Number.isFinite(Date.parse(v))).nullable() -export const speedProbeSchema=z.object({ - id:z.string().min(1),srcServerId:z.string().regex(/^[1-9]\d*$/),dstServerId:z.string().regex(/^[1-9]\d*$/),srcInterface:z.string(),dstInterface:z.string(), - protocol:z.enum(["tcp","udp"]),direction:z.enum(["both","transmit","receive"]),durationSec:z.string().regex(/^\d+$/).refine(v=>Number.isSafeInteger(Number(v))&&Number(v)>=3),enabled:z.boolean(), - lastRunAt:timestamp,lastTxAvgMbps:metric,lastRxAvgMbps:metric,lastStatus:z.enum(["done","error"]).nullable(),lastError:z.string().nullable() -}) -const response=z.object({probes:z.array(speedProbeSchema).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)}) -export type SpeedProbeApiRow=z.infer -export function readProbeSchedule(raw:unknown):SpeedProbeApiRow[]{ - const parsed=response.safeParse(raw);if(!parsed.success)throw Error("Данные проб скорости не подтверждены сервером.");return parsed.data.probes +import { speedProbesResponseSchema, type SpeedProbeRead } from "@mmapp/contracts" +export { speedProbeReadSchema as speedProbeSchema } from "@mmapp/contracts" +export type SpeedProbeApiRow = SpeedProbeRead +export function readProbeSchedule(raw: unknown): SpeedProbeApiRow[] { + const parsed = speedProbesResponseSchema.safeParse(raw) + if (!parsed.success) throw Error("Данные проб скорости не подтверждены сервером.") + return parsed.data.probes } diff --git a/packages/contracts/src/uptime-speed.ts b/packages/contracts/src/uptime-speed.ts index f8a0fc2e..77afb330 100644 --- a/packages/contracts/src/uptime-speed.ts +++ b/packages/contracts/src/uptime-speed.ts @@ -15,3 +15,15 @@ export const speedHistoryResponseSchema=z.object({runs:z.array(speedHistoryRunSc 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 + +const probeTimestamp = z.string().datetime({ offset: true }).nullable() +export const speedProbeReadSchema = z.object({ + id: z.string().min(1), 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(["both", "transmit", "receive"]), + durationSec: z.string().regex(/^[0-9]+$/).refine(value => Number.isSafeInteger(Number(value)) && Number(value) >= 3), enabled: z.boolean(), + lastRunAt: probeTimestamp, lastTxAvgMbps: rate, lastRxAvgMbps: rate, lastStatus: z.enum(["done", "error"]).nullable(), lastError: z.string().nullable(), + lastPingRttMs: rate.optional(), lastPingLossPct: z.number().finite().min(0).max(100).nullable().optional(), + lastPingAt: probeTimestamp.optional(), lastPingError: z.string().nullable().optional(), +}) +export const speedProbesResponseSchema = z.object({ probes: z.array(speedProbeReadSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID speed-пробы") }) +export type SpeedProbeRead = z.infer diff --git a/playwright.config.ts b/playwright.config.ts index 0ccba510..903205a6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test" export default defineConfig({ testDir: "./tests/e2e", - testIgnore: ["**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], + testIgnore: ["**/uptime-context.spec.ts", "**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], fullyParallel: false, workers: 1, retries: 0, diff --git a/tests/e2e/uptime-context.spec.ts b/tests/e2e/uptime-context.spec.ts new file mode 100644 index 00000000..a39b571a --- /dev/null +++ b/tests/e2e/uptime-context.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from "@playwright/test" +import { server } from "./fixtures/users" +const headers = { "access-control-allow-origin":"*", "access-control-allow-methods":"GET,PUT,POST,OPTIONS", "access-control-allow-headers":"Content-Type,Authorization" } +const probe = { id:"probe-1", srcServerId:"1", dstServerId:"2", srcInterface:"ether1", dstInterface:"ether1", protocol:"tcp", direction:"both", durationSec:"10", enabled:true,lastRunAt:null,lastTxAvgMbps:null,lastRxAvgMbps:0,lastStatus:null,lastError:null } +const run = {id:"run-1",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"ether1",protocol:"tcp",direction:"both",durationSec:10,txAvgMbps:42,rxAvgMbps:0,status:"done",error:null,createdAt:"2026-10-04T00:00:00Z"} +for (const change of ["backend","mode"] as const) for (const resource of ["probes","history"] as const) for (const failure of [false,true]) test("Uptime speed context/"+change+"/"+resource+"/"+failure,async({page})=>{ + let hold=false,started=false,completed=false;let release!:()=>void;const gate=new Promise(resolve=>release=resolve) + const target=resource === "probes" ? "/api/uptime/speed-probes" : "/api/uptime/speed-test/runs" + await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}})) + await page.route("**/api/**",async r=>{ + const url=new URL(r.request().url()),path=url.pathname,old=url.port === "8000" + const body=path === "/api/auth/config" ? {required:false} : path === "/api/servers" ? [{...server,name:old ? "Старый источник" : "Новый источник"}] : path === "/api/uptime/overview" ? {probes:[],resources:[]} : path === "/api/uptime/speed-probes" ? {probes:[{...probe,id:old ? "old-probe" : "new-probe"}]} : path === "/api/uptime/speed-test/runs" ? {runs:[{...run,id:old ? "old-run" : "new-run"}]} : {} + if(path === target && old && hold){started=true;await gate;try{await r.fulfill(failure ? {headers,status:503,json:{error:"СТАРАЯ ОШИБКА СКОРОСТИ"}} : {headers,json:body})}catch{}finally{completed=true};return} + return r.fulfill({headers,json:body}) + }) + await page.goto("/uptime?tab=speed");await expect(page.locator('tr[data-row-id="old-run"]')).toBeVisible();await page.getByRole("button",{name:"Пауза",exact:true}).click();hold=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect.poll(()=>started).toBe(true) + await page.goto("/settings") + if(change === "backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:"Моковые",exact:true}).click() + await page.goto("/uptime?tab=speed") + if(change === "backend"){await expect(page.locator('tr[data-row-id="new-run"]')).toBeVisible();await expect(page.getByText("Новый источник",{exact:true}).first()).toBeVisible()}else await expect(page.getByRole("button",{name:"Сбросить",exact:true})).toBeVisible() + release();await expect.poll(()=>completed).toBe(true);await expect(page.locator('tr[data-row-id="old-run"]')).toHaveCount(0);await expect(page.getByText("СТАРАЯ ОШИБКА СКОРОСТИ",{exact:true})).toHaveCount(0) + if(change === "mode"){await expect(page.getByText(/^Speed-пробы: получено/)).toHaveCount(0);await expect(page.getByRole("alert",{name:"Чтение скорости: probes",exact:true})).toHaveCount(0)} +}) diff --git a/tests/e2e/uptime-speed-data.spec.ts b/tests/e2e/uptime-speed-data.spec.ts index 9d4ec470..560e6e47 100644 --- a/tests/e2e/uptime-speed-data.spec.ts +++ b/tests/e2e/uptime-speed-data.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@playwright/test" +import axe from "axe-core" import { server } from "./fixtures/users" const run = { id:"history-1", srcServerId:"1", dstServerId:"2", srcInterface:"ether1", dstInterface:"ether1", protocol:"tcp", direction:"both", durationSec:10, txAvgMbps:42, rxAvgMbps:0, status:"done", error:null, createdAt:"2026-10-04T00:00:00Z" } for (const resource of ["probes", "history"] as const) test("Скорость: initial unknown и независимое recovery/" + resource, async ({ page }) => { @@ -30,7 +31,7 @@ test("Скорость: поздняя история не задерживае 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.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,lastError:null}]} : 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"}) @@ -40,3 +41,16 @@ test("Скорость: null не становится нулём, malformed DTO } 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 Мбит/с") }) + +test("Speed-пробы: malformed каталог, cache/freshness и блокировка замены до recovery", async ({page}) => { + const probe={id:"probe-1",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"ether1",protocol:"tcp",direction:"both",durationSec:"10",enabled:true,lastRunAt:null,lastTxAvgMbps:null,lastRxAvgMbps:0,lastStatus:null,lastError:null} + let raw:unknown={probes:[{...probe,enabled:"yes"}]};const writes:string[]=[] + await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;if(r.request().method() !== "GET")writes.push(p);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" ? raw : p === "/api/uptime/speed-test/runs" ? {runs:[]} : {}})}) + await page.goto("/uptime?tab=speed");await page.getByRole("button",{name:"Пауза",exact:true}).click();const alert=page.getByRole("alert",{name:"Чтение скорости: probes",exact:true});await expect(alert).toContainText("Данные пока неизвестны");await expect(page.getByText("Нет speed-проб",{exact:true})).toHaveCount(0);await expect(page.getByRole("button",{name:"Новая speed-проба",exact:true})).toBeDisabled() + const refresh=page.getByRole("button",{name:"Обновить",exact:true});raw={probes:[probe]};await refresh.click();await expect(alert).toHaveCount(0);const toggle=page.getByRole("switch");await expect(toggle).toBeChecked();await expect(toggle).toBeEnabled();const freshness=page.getByRole("status").filter({hasText:/^Speed-пробы: получено/});const before=await freshness.textContent() + for(const invalid of [{probes:[probe,probe]},{probes:[{...probe,lastRxAvgMbps:-1}]},{probes:[{...probe,lastStatus:"other"}]},{probes:[{...probe,lastRunAt:"bad"}]}]) {raw=invalid;await refresh.click();await expect(alert).toContainText("Последние данные сохранены");await expect(freshness).toHaveText(before!);await expect(toggle).toBeChecked();await expect(toggle).toBeDisabled();await expect(page.getByRole("button",{name:"Новая speed-проба",exact:true})).toBeDisabled()} + await page.setViewportSize({width:320,height:1000});expect(await page.evaluate(()=>document.documentElement.scrollWidth <= innerWidth)).toBe(true);await expect(toggle).toHaveAccessibleName(/Включить speed-пробу/) + await page.addScriptTag({content:axe.source});const audit=await page.evaluate(async()=> (window as unknown as {axe:typeof axe}).axe.run(document.querySelector('[aria-label="Чтение скорости: probes"]')!,{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa"]}}));expect(audit.violations).toEqual([]) + await page.addStyleTag({content:"nextjs-portal{display:none!important}"});await page.screenshot({path:"docs/ux-ui/screens/speed-probes-error-320.png",fullPage:true,animations:"disabled"}) + raw={probes:[probe]};await refresh.click();await expect(alert).toHaveCount(0);await expect(toggle).toBeEnabled();expect(writes).toEqual([]) +})