fix(ui): использовать серверное время и не подменять отсутствующий Ping

This commit is contained in:
Denozordec committed 2026-10-07 17:52:33 +07:00
1 parent 7f18fcb6db
commit 30cc99c68e
9 files changed
+60 -11

No files matched your search

+6 -4
View File
@@ -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,
+2 -2
View File
@@ -6,7 +6,7 @@ import { createSpeedTestRoute } from "./speed-test.js"
import { SpeedProbeEndpointsChanged, SpeedProbeContextChanged } from "../services/speed-run-persistence.js"
const commands: SpeedTestCommand[] = [], failures: Array<SpeedTestCommand & { message: string }> = []
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)
@@ -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,
+2 -2
View File
@@ -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).
+10 -2
View File
@@ -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)
+9 -1
View File
@@ -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.
+8
View File
@@ -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"), /Результат операции неизвестен/)
})
+1
View File
@@ -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(),
+20
View File
@@ -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)
})