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 00000000..3c809e55 Binary files /dev/null and b/docs/ux-ui/screens/speed-probes-error-320.png differ 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([]) +})