diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index f4ebbdbc..33f96d5e 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -1739,6 +1739,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId : "" setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({ ...r, + startedAt: result.createdAt ? Date.parse(result.createdAt) : r.startedAt, txAvgMbps: tx, rxAvgMbps: rx, status: "done", @@ -1752,7 +1753,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId ...(pingNote ? [pingNote] : []), ], }) : r)) - const ts = new Date().toISOString() + // Legacy receipts have no server timestamp; retain receipt time for compatibility. + const ts = result.createdAt ?? new Date().toISOString() setSpeedProbes((prev) => prev.map((p) => p.id === probe.id ? ({ ...p, lastRunAt: ts, @@ -1762,7 +1764,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId lastError: "", lastPingRttMs: ap?.rttMs ?? null, lastPingLossPct: ap?.lossPct ?? null, - lastPingAt: ts, + lastPingAt: ap ? ts : null, lastPingError: ap?.error ?? "", }) : p)) } else { @@ -2166,8 +2168,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const maxVal = Math.max(viewTx ?? 0, viewRx ?? 0, 1) const canRun = probe.enabled && !!probe.srcServerId && !!probe.dstServerId && probe.srcServerId !== probe.dstServerId const viewAfterBtPing = - useRun && run.status === "done" && run.afterBtPing - ? run.afterBtPing + useRun && run.status === "done" + ? run.afterBtPing ?? null : probe.lastPingAt ? { rttMs: probe.lastPingRttMs ?? null, diff --git a/backend/src/routes/speed-test.test.ts b/backend/src/routes/speed-test.test.ts index 87c7238e..cfd37699 100644 --- a/backend/src/routes/speed-test.test.ts +++ b/backend/src/routes/speed-test.test.ts @@ -6,7 +6,7 @@ import { createSpeedTestRoute } from "./speed-test.js" import { SpeedProbeEndpointsChanged, SpeedProbeContextChanged } from "../services/speed-run-persistence.js" const commands: SpeedTestCommand[] = [], failures: Array = [] let lookups = 0, missing = false, problem: Error | null = null -const result = (c: SpeedTestCommand) => ({ runId: c.runId ?? "ad-hoc", srcInterface: c.srcInterface || null, dstInterface: c.dstInterface || null, address: "192.0.2.2", srcAddress: "192.0.2.1", dstAddress: "192.0.2.2", srcInterfaceAddress: null, dstInterfaceAddress: null, protocol: c.protocol, direction: c.direction, durationSec: c.durationSec, txAvgMbps: null, rxAvgMbps: 0, txSeries: [], rxSeries: [0], raw: [{ status: "done testing" }], afterBtPing: { rttMs: null, lossPct: null, error: null } }) +const result = (c: SpeedTestCommand) => ({ createdAt: "2026-10-07T00:00:00Z", runId: c.runId ?? "ad-hoc", srcInterface: c.srcInterface || null, dstInterface: c.dstInterface || null, address: "192.0.2.2", srcAddress: "192.0.2.1", dstAddress: "192.0.2.2", srcInterfaceAddress: null, dstInterfaceAddress: null, protocol: c.protocol, direction: c.direction, durationSec: c.durationSec, txAvgMbps: null, rxAvgMbps: 0, txSeries: [], rxSeries: [0], raw: [{ status: "done testing" }], afterBtPing: { rttMs: null, lossPct: null, error: null } }) const deps = { serverExists: async (_id: number) => { lookups++; return !missing }, run: async (c: SpeedTestCommand) => { commands.push(c); if (problem) throw problem; return result(c) }, @@ -19,7 +19,7 @@ const bad = [null, [], {}, { ...base, srcServerId: "1junk" }, { ...base, srcServ for (const body of bad) assert.equal((await send(body)).statusCode, 400, JSON.stringify(body)) assert.equal(lookups, 0); assert.equal(commands.length, 0); assert.equal(failures.length, 0) assert.equal(speedTestCommandSchema.safeParse({ ...base, durationSec: Infinity }).success, false) -const first = await send(base); assert.equal(first.statusCode, 200); assert.equal(first.json().result.txAvgMbps, null); assert.equal(first.json().result.rxAvgMbps, 0) +const first = await send(base); assert.equal(first.statusCode, 200); assert.equal(first.json().result.txAvgMbps, null); assert.equal(first.json().result.rxAvgMbps, 0); assert.equal(first.json().result.createdAt, "2026-10-07T00:00:00Z") assert.deepEqual(commands[0], { ...base, srcInterface: "", dstInterface: "", protocol: "tcp", direction: "both", durationSec: 10 }) const explicit = await send({ srcServerId: "1", dstServerId: "2", runId: " run-2 ", probeId: " probe ", srcInterface: " ether1 ", protocol: "udp", direction: "receive", durationSec: "3", lastTxAvgMbps: 999 }) assert.equal(explicit.statusCode, 200); assert.equal(commands[1].runId, "run-2"); assert.equal(commands[1].probeId, "probe"); assert.equal(commands[1].srcInterface, "ether1"); assert.equal(commands[1].protocol, "udp"); assert.equal(commands[1].direction, "receive"); assert.equal(commands[1].durationSec, 3); assert.equal("lastTxAvgMbps" in commands[1], false) diff --git a/backend/src/services/uptime-speed-service.ts b/backend/src/services/uptime-speed-service.ts index 25c3873a..30642d20 100644 --- a/backend/src/services/uptime-speed-service.ts +++ b/backend/src/services/uptime-speed-service.ts @@ -49,6 +49,7 @@ export interface SpeedTestRunParams { } export interface SpeedTestRunResult { + createdAt: string runId: string srcInterface: string | null dstInterface: string | null @@ -186,6 +187,7 @@ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams) }) return { + createdAt: pingNow, runId, srcInterface: srcInterface || null, dstInterface: dstInterface || null, diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 8962f82b..c1eb774f 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,12 +1,12 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-07: 12.3.81 завершён: смена любых параметров speed-пробы сбрасывает9 observations, история сохранена; 74browser/8context,10pure,HTTP/RBAC,types/lint и contracts/backend/frontend builds прошли. Следующий12.3.82: server timestamps в POST receipt и отсутствие выдуманного Ping при неполном ответе. Общие main1176/context221 относятся к указанным прежним HEAD. Внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.3.82 завершён: server createdAt в POST receipt, отсутствие Ping не получает дату и старый результат; 76browser,4pure,HTTP/RBAC,types/lint и builds прошли. Следующий12.3.83: неизвестный RTT не считать timeout. Общие main1176/context221 относятся к прежним HEAD. Внешняя12.5 не выполнена. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-07. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. ## Актуальная точка продолжения — 2026-10-07 -Этапы0–11 завершены в описанных локальных границах. Этап12 продолжается; блоки до12.3.81 отражены в журнале. Общие main12.4.8 на579e816:1176/1176 и context12.4.9 наa3fcba0:221/221 passed. Последующие изменения speed-test проверены профильными тестами; эти общие прогоны не относятся к новому HEAD. Внешняя12.5 и реальные интеграции не приняты. Точные результаты и ограничения: [ux-ui-stage-12.md](ux-ui-stage-12.md). +Этапы0–11 завершены в описанных локальных границах. Этап12 продолжается; блоки до12.3.82 отражены в журнале. Общие main12.4.8 на579e816:1176/1176 и context12.4.9 наa3fcba0:221/221 passed. Последующие изменения speed-test проверены профильными тестами; эти общие прогоны не относятся к новому HEAD. Внешняя12.5 и реальные интеграции не приняты. Точные результаты и ограничения: [ux-ui-stage-12.md](ux-ui-stage-12.md). Реальные 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 315b2aa8..866afaf3 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-07: 12.3.81 завершён: смена любых параметров speed-пробы сбрасывает9 observations, история сохранена; 74browser/8context,10pure,HTTP/RBAC,types/lint и contracts/backend/frontend builds прошли. Следующий12.3.82: server timestamps в POST receipt и отсутствие выдуманного Ping при неполном ответе. Общие main1176/context221 относятся к указанным прежним HEAD. Внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.3.82 завершён: server createdAt в POST receipt, отсутствие Ping не получает дату и старый результат; 76browser,4pure,HTTP/RBAC,types/lint и builds прошли. Следующий12.3.83: неизвестный RTT не считать timeout. Общие main1176/context221 относятся к прежним HEAD. Внешняя12.5 не выполнена. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). @@ -32,7 +32,15 @@ | 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.3.81 завершены. Общая main1176/1176 на579e816 и context221/221 наa3fcba0 прошли; последующий функциональный delta проверен73browser/8context. Следующий12.3.82; внешняя12.5 не выполнена. | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе. Локальные блоки до12.3.82 завершены. Общая main1176/1176 на579e816 и context221/221 наa3fcba0 прошли; последующий функциональный delta проверен73browser/8context. Следующий12.3.83; внешняя12.5 не выполнена. | + +## 12.3.82 — серверное время результата и отсутствующий Ping (2026-10-07) + +Backend возвращает createdAt из того же pingNow, который записывает в snapshot/history: время перед after-ping, не начало bandwidth-test. Frontend валидирует ISO timestamp, использует его в истории и локальном snapshot. Старый receipt без createdAt совместим: история сохраняет клиентское начало, snapshot использует время получения; это ограничение совместимости, не подтверждённое серверное время. Неопционально переданный неверный timestamp отклоняет receipt. + +Без afterBtPing новая операция не получает lastPingAt; выбор нового done run не подставляет прежний Ping snapshot. Явный0 сохранён. 4 pure receipt проверки, HTTP/RBAC с server timestamp passthrough, types/lint и contracts/backend/frontend builds прошли. Финальный76/76 browser прошёл. Первый прогон остановлен после ошибки нового селектора: в selected-probe scope кнопка называется «Обновить данные»; исправлены только новые тесты, отдельно2/2 и затем полный76/76 passed. Проверены дата из сервера в таблице, старый99 не наложен, отсутствующий Ping и новый0. Исторические3 PNG восстановлены. + +Проверка согласованности журналов до этого блока:104 одинаковых numbered records в обоих документах, все с датами, без пропусков/дублей. Следующий12.3.83: таблица и карточки пока выводят timeout при любом null RTT; неизвестный/отсутствующий результат должен быть отделён от подтверждённого отсутствия ответов. Внешняя12.5 открыта; общий main/context не объявлен проверкой нового HEAD. ## 12.3.81 — снимки и история при изменении параметров измерения (2026-10-07) diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 3154fec2..ae2b71a1 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -1,6 +1,6 @@ # Этап 12: итоговая приёмка UX/UI -Актуальное продолжение 2026-10-07: 12.3.81 завершён: смена любых параметров speed-пробы сбрасывает9 observations, история сохранена; 74browser/8context,10pure,HTTP/RBAC,types/lint и contracts/backend/frontend builds прошли. Следующий12.3.82: server timestamps в POST receipt и отсутствие выдуманного Ping при неполном ответе. Общие main1176/context221 относятся к указанным прежним HEAD. Внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.3.82 завершён: server createdAt в POST receipt, отсутствие Ping не получает дату и старый результат; 76browser,4pure,HTTP/RBAC,types/lint и builds прошли. Следующий12.3.83: неизвестный RTT не считать timeout. Общие main1176/context221 относятся к прежним HEAD. Внешняя12.5 не выполнена. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. @@ -920,3 +920,11 @@ Preflight перед созданием RouterOS client проверяет id, 10 pure snapshot checks, HTTP/DI/RBAC каталога (все изменяемые параметры и сохранение0 при enable),74browser uptime/large history,8uptime context, targeted lint0errors/0warnings, non-incremental TypeScript и contracts/backend/frontend production builds прошли. Новый браузерный сценарий: save500 сохраняет80; успешная смена длительности20 и возврат10 оставляют snapshot unknown, история80 и KPI1run остаются. Перегенерированные3 исторических PNG восстановлены. Реальные PG locks/native tests требуют12.5. Следующий12.3.82: POST receipt пока не возвращает серверное время, live publish использует время браузера и ставит Ping date даже без afterBtPing; исправить совместимо и проверить неполный/невалидный receipt. Общие main/context результаты не переименованы в проверки нового HEAD. + +## 12.3.82 — серверное время результата и отсутствующий Ping (2026-10-07) + +Backend возвращает createdAt из того же pingNow, который записывает в snapshot/history: время перед after-ping, не начало bandwidth-test. Frontend валидирует ISO timestamp, использует его в истории и локальном snapshot. Старый receipt без createdAt совместим: история сохраняет клиентское начало, snapshot использует время получения; это ограничение совместимости, не подтверждённое серверное время. Неопционально переданный неверный timestamp отклоняет receipt. + +Без afterBtPing новая операция не получает lastPingAt; выбор нового done run не подставляет прежний Ping snapshot. Явный0 сохранён. 4 pure receipt проверки, HTTP/RBAC с server timestamp passthrough, types/lint и contracts/backend/frontend builds прошли. Финальный76/76 browser прошёл. Первый прогон остановлен после ошибки нового селектора: в selected-probe scope кнопка называется «Обновить данные»; исправлены только новые тесты, отдельно2/2 и затем полный76/76 passed. Проверены дата из сервера в таблице, старый99 не наложен, отсутствующий Ping и новый0. Исторические3 PNG восстановлены. + +Проверка согласованности журналов до этого блока:104 одинаковых numbered records в обоих документах, все с датами, без пропусков/дублей. Следующий12.3.83: таблица и карточки пока выводят timeout при любом null RTT; неизвестный/отсутствующий результат должен быть отделён от подтверждённого отсутствия ответов. Внешняя12.5 открыта; общий main/context не объявлен проверкой нового HEAD. diff --git a/lib/speed-run-response.test.ts b/lib/speed-run-response.test.ts index 60fb2b6e..94e8b9dc 100644 --- a/lib/speed-run-response.test.ts +++ b/lib/speed-run-response.test.ts @@ -15,3 +15,11 @@ test("invalid or foreign receipts cannot publish measurements", () => { test("invalid post-test ping rejects the whole receipt", () => { assert.throws(() => parseSpeedRunResponse({ ...receipt(10, 0), result: { ...receipt(10, 0).result, afterBtPing: { rttMs: null, lossPct: 101, error: null } } }, "run-1")) }) + +test("server persistence time is validated while legacy receipt remains compatible", () => { + const base = receipt(10, 0) + const withTime = (createdAt: unknown) => ({ ...base, result: { ...base.result, createdAt } }) + assert.equal(parseSpeedRunResponse(withTime("2026-10-07T07:00:00+07:00"), "run-1").createdAt, "2026-10-07T07:00:00+07:00") + assert.equal(parseSpeedRunResponse(base, "run-1").createdAt, undefined) + for (const value of [null, "", "yesterday", 123, "2026-10-07"]) assert.throws(() => parseSpeedRunResponse(withTime(value), "run-1"), /Результат операции неизвестен/) +}) diff --git a/lib/speed-run-response.ts b/lib/speed-run-response.ts index 195e3174..38865435 100644 --- a/lib/speed-run-response.ts +++ b/lib/speed-run-response.ts @@ -6,6 +6,7 @@ const response = z.object({ ok: z.literal(true), result: z.object({ runId: z.string().min(1), + createdAt: z.string().datetime({ offset: true }).optional(), txAvgMbps: metric, rxAvgMbps: metric, raw: z.array(z.record(z.string(), z.unknown())).optional(), diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index 43d223f7..6dd2f184 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -903,3 +903,23 @@ test("Speed snapshot: смена длительности сбрасывает await expect(history).toContainText("80") expect(writes).toBe(3) }) + +for (const ping of [false, true]) test("Speed receipt: серверное время и отсутствие старого Ping/" + ping, async ({ page }) => { + await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write") + await page.getByRole("button", { name: "Пауза", exact: true }).click() + await page.route("**/api/uptime/speed-probes", route => route.fulfill({ json: { probes: [{ id: "s1", srcServerId: "1", dstServerId: "2", srcInterface: "", dstInterface: "", protocol: "tcp", direction: "both", durationSec: "10", enabled: true, lastRunAt: "2026-10-05T00:00:00Z", lastTxAvgMbps: 12, lastRxAvgMbps: 20, lastStatus: "done", lastError: null, lastPingRttMs: 99, lastPingLossPct: 0, lastPingAt: "2026-10-05T00:00:00Z", lastPingError: null }] } })) + await page.getByRole("button", { name: /Скорость/ }).click() + await page.getByRole("button", { name: "Обновить данные", exact: true }).click() + const launch = page.getByRole("button", { name: "Запустить", exact: true }) + const probe = launch.locator("xpath=../../..") + await expect(probe).toContainText("99 мс") + const createdAt = "2026-10-06T00:00:00Z" + await page.route("**/api/uptime/speed-test", route => route.fulfill({ json: { ok: true, result: { runId: route.request().postDataJSON().runId, createdAt, txAvgMbps: 30, rxAvgMbps: 80, ...(ping ? { afterBtPing: { rttMs: 0, lossPct: 0, error: null } } : {}) } } })) + await launch.click() + await expect(probe).toContainText("80 Мбит/с") + await expect(probe).not.toContainText("99 мс") + if (ping) await expect(probe).toContainText("0 мс") + else await expect(probe.getByText("Ping", { exact: true })).toHaveCount(0) + const expectedTime = await page.evaluate(value => new Date(value).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit", day: "2-digit", month: "2-digit" }), createdAt) + await expect(page.getByRole("table")).toContainText(expectedTime) +})