fix(ui): сбрасывать снимок при изменении параметров speed-пробы

This commit is contained in:
Denozordec committed 2026-10-07 17:41:14 +07:00
1 parent 090206c893
commit 7f18fcb6db
11 files changed
+106 -22

No files matched your search

+2 -5
View File
@@ -5,7 +5,7 @@ import { parseSpeedRunResponse } from "@/lib/speed-run-response"
import { dashboardProbeOverviewSchema } from "@/lib/dashboard-probes"
import { speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema, pingProbesWriteSchema, probeCatalogSavedSchema } from "@mmapp/contracts"
import { sameSpeedMeasurement, speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema, pingProbesWriteSchema, probeCatalogSavedSchema } from "@mmapp/contracts"
import probeStyles from "./probe-rows.module.css"
@@ -2155,10 +2155,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{srvProbes.map((probe) => {
const dst = allServers.find((s) => s.id === probe.dstServerId)
const run = speedRuns.find((r) =>
r.srcServerId === probe.srcServerId &&
r.dstServerId === probe.dstServerId &&
(r.srcInterface ?? "") === probe.srcInterface &&
(r.dstInterface ?? "") === probe.dstInterface,
sameSpeedMeasurement(r, probe),
)
const useRun = run && (run.status === "running" || (
probe.lastStatus != null && probe.lastRunAt != null && run.startedAt >= Date.parse(probe.lastRunAt)
@@ -36,7 +36,15 @@ const saved = await put({ probes: [probe] }); assert.equal(saved.statusCode, 200
assert.equal(rows.has("old"), false); assert.equal(rows.get("s1")?.enabled, false); assert.equal(rows.get("s1")?.srcInterface, "ether1")
rows.get("s1")!.lastTxAvgMbps = 42
await put({ probes: [{ ...probe, durationSec: 20, lastTxAvgMbps: 0 }] })
assert.equal(rows.get("s1")?.lastTxAvgMbps, 42); assert.equal(rows.get("s1")?.durationSec, 20)
assert.equal(rows.get("s1")?.lastTxAvgMbps, null); assert.equal(rows.get("s1")?.durationSec, 20)
for (const edit of [{ srcInterface: "ether3" }, { dstInterface: "ether3" }, { protocol: "udp" }, { direction: "receive" }]) {
rows.get("s1")!.lastTxAvgMbps = 42
await put({ probes: [{ ...probe, ...edit }] })
assert.equal(rows.get("s1")?.lastTxAvgMbps, null)
rows.get("s1")!.lastTxAvgMbps = 0
await put({ probes: [{ ...probe, ...edit, enabled: true }] })
assert.equal(rows.get("s1")?.lastTxAvgMbps, 0)
}
const beforePairChange = rows.get("s1")!.srcServerId
await put({ probes: [{ ...probe, srcServerId: 2, dstServerId: 1 }] })
assert.notEqual(rows.get("s1")?.srcServerId, beforePairChange)
+1 -1
View File
@@ -208,7 +208,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
// Serialize complete replacements from all backend processes, including an empty catalog.
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('uptime-speed-probes'))`)
await run({
listProbes: async () => tx.select({ id: uptimeSpeedProbes.id, srcServerId: uptimeSpeedProbes.srcServerId, dstServerId: uptimeSpeedProbes.dstServerId }).from(uptimeSpeedProbes).for("update"),
listProbes: async () => tx.select().from(uptimeSpeedProbes).for("update"),
remove: async id => { await tx.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, id)) },
upsert: async (row, createdAt, resetObservations) => {
const { id, ...patch } = row
@@ -1,20 +1,21 @@
import { matchesSpeedProbeEndpoints, type SpeedProbeEndpoints } from "./speed-run-persistence.js"
import { sameSpeedMeasurement } from "@mmapp/contracts"
import type { SpeedProbeWrite } from "@mmapp/contracts"
export type SpeedProbePatch = SpeedProbeWrite & { sortOrder: number; updatedAt: string }
export type SpeedProbeTransaction = {
listProbes: () => Promise<SpeedProbeEndpoints[]>
listProbes: () => Promise<SpeedProbeWrite[]>
remove: (id: string) => Promise<void>
upsert: (row: SpeedProbePatch, createdAt: string, resetObservations: boolean) => Promise<void>
}
export type SpeedProbeStore = { transaction: (run: (tx: SpeedProbeTransaction) => Promise<void>) => Promise<void> }
/** The adapter must lock the catalog and commit all configuration changes atomically. Observations are preserved for the same pair and reset when its endpoints change. */
/** The adapter must lock the catalog and commit all configuration changes atomically. Observations are preserved only for the same measurement parameters. */
export async function replaceSpeedProbes(store: SpeedProbeStore, probes: SpeedProbeWrite[], now = new Date().toISOString()): Promise<void> {
await store.transaction(async tx => {
const nextIds = new Set(probes.map(row => row.id))
const previous = new Map((await tx.listProbes()).map(row => [row.id, row]))
for (const id of previous.keys()) if (!nextIds.has(id)) await tx.remove(id)
for (const [sortOrder, row] of probes.entries()) {
const reset = !matchesSpeedProbeEndpoints(previous.get(row.id), row)
const old = previous.get(row.id)
const reset = !old || !sameSpeedMeasurement(old, row)
await tx.upsert({ ...row, sortOrder, updatedAt: now }, now, reset)
}
})
+4 -4
View File
@@ -1,12 +1,12 @@
# План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-07: 12.3.80 завершён: preflight и conditional UPDATE сверяют полные параметры speed-пробы; 23 pure, HTTP409/RBAC и backend build прошли. Следующий12.3.81: сброс observations при изменении параметров в каталоге/UI. Общие main1176/context221 относятся к прежним указанным HEAD. Внешняя12.5 не выполнена.
Актуальное продолжение 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 не выполнена.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-07. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-04
## Актуальная точка продолжения — 2026-10-07
Этапы0–11 завершены в описанных локальных границах. Этап12 продолжается: доступность12.1, достоверность12.2.1–12.2.10 и профили12.3.1–12.3.63 записаны в журнале. Последняя общая main регрессия12.4.6 на991f2b3:1161 из1162 passed,1 focus race терминала, исправленный в12.4.6.1 с20+15 профильными проверками; context12.4.7 наb1f8326 дала221/221passed. Полный источник данных сохраняется, большие DOM ограничены страницами/окнами. Внешняя12.5 и реальные интеграции не приняты. Подробная история и точные ограничения: docs/ux-ui-stage-12.md.
Этапы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).
Реальные 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.80 завершён: preflight и conditional UPDATE сверяют полные параметры speed-пробы; 23 pure, HTTP409/RBAC и backend build прошли. Следующий12.3.81: сброс observations при изменении параметров в каталоге/UI. Общие main1176/context221 относятся к прежним указанным HEAD. Внешняя12.5 не выполнена.
Актуальное продолжение 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 не выполнена.
Общий план: [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.80 завершены. Общая main1176/1176 на579e816 и context221/221 наa3fcba0 прошли; последующий функциональный delta проверен73browser/8context. Следующий12.3.81; внешняя12.5 не выполнена. |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе. Локальные блоки до12.3.81 завершены. Общая main1176/1176 на579e816 и context221/221 наa3fcba0 прошли; последующий функциональный delta проверен73browser/8context. Следующий12.3.82; внешняя12.5 не выполнена. |
## 12.3.81 — снимки и история при изменении параметров измерения (2026-10-07)
Общий sameSpeedMeasurement сравнивает пару, интерфейсы, протокол, направление и длительность в валидных read/write формах. Каталог читает полный конфигурационный снимок под прежним FOR UPDATE; смена любого параметра сбрасывает9 observations. Frontend публикует это правило только после API confirmation и выбирает историю по тем же параметрам. Enable/reorder сохраняют последние наблюдения, в том числе0; history не удаляется. ReUI компоненты и сетевые команды сохранены.
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.80 — принадлежность результата параметрам speed-пробы (2026-10-07)
+10
View File
@@ -1,5 +1,7 @@
# Этап 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 не выполнена.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
| Блок | Проверка и результат | Критерий остановки |
@@ -910,3 +912,11 @@ Preflight перед созданием RouterOS client проверяет id,
23 pure rate/status/persistence проверки прошли: 7 новых проверяют каждый изменённый параметр для late success/error, неизменный контекст с явным0 и preflight. HTTP/DI проверил409 как для пары, так и для параметров, прежние26 invalid inputs/defaults/RBAC; backend/contracts build passed. Реального PostgreSQL/RouterOS не было: source review условного SQL и DI не заменяют12.5. Это сравнение параметров, не версия строки и не защита от ABA изменения с возвратом тех же значений.
Следующий12.3.81: каталог и frontend пока сбрасывают observations только при смене пары; распространить правило на все параметры измерения, сохраняя историю и enable/reorder.
## 12.3.81 — снимки и история при изменении параметров измерения (2026-10-07)
Общий sameSpeedMeasurement сравнивает пару, интерфейсы, протокол, направление и длительность в валидных read/write формах. Каталог читает полный конфигурационный снимок под прежним FOR UPDATE; смена любого параметра сбрасывает9 observations. Frontend публикует это правило только после API confirmation и выбирает историю по тем же параметрам. Enable/reorder сохраняют последние наблюдения, в том числе0; history не удаляется. ReUI компоненты и сетевые команды сохранены.
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.
+7 -1
View File
@@ -2,7 +2,7 @@ import { test } from "node:test"
import assert from "node:assert/strict"
import { emptySpeedProbeObservations } from "@mmapp/contracts"
import { publishSpeedProbeSnapshots } from "./speed-probe-snapshot"
const probe = { id: "p", srcServerId: "1", dstServerId: "2", enabled: true, ...emptySpeedProbeObservations, lastTxAvgMbps: 0 as number | null, lastRxAvgMbps: 80 as number | null, lastStatus: "done" as "done" | "error" | null, lastRunAt: "2026-10-07T00:00:00Z" as string | null }
const probe = { id: "p", srcServerId: "1", dstServerId: "2", srcInterface: "ether1", dstInterface: "ether2", protocol: "tcp" as const, direction: "both" as "both" | "transmit" | "receive", durationSec: "10", enabled: true, ...emptySpeedProbeObservations, lastTxAvgMbps: 0 as number | null, lastRxAvgMbps: 80 as number | null, lastStatus: "done" as "done" | "error" | null, lastRunAt: "2026-10-07T00:00:00Z" as string | null }
test("same pair preserves latest zero and snapshot instead of readonly values copied into a stale draft", () => {
const next = { ...probe, enabled: false, lastTxAvgMbps: 999, lastRxAvgMbps: 999 }
const [saved] = publishSpeedProbeSnapshots([probe], [next])
@@ -25,3 +25,9 @@ test("new row and reordered existing rows retain correct ownership without mutat
assert.deepEqual(saved.map(p => p.id), ["new", "p"])
assert.equal(next.lastRxAvgMbps, 80)
})
for (const edit of [{ srcInterface: "ether3" }, { dstInterface: "ether3" }, { protocol: "udp" as const }, { direction: "transmit" as const }, { durationSec: "20" }]) test("changed measurement " + Object.keys(edit)[0] + " resets all observations without altering history", () => {
const [saved] = publishSpeedProbeSnapshots([probe], [{ ...probe, ...edit }])
for (const key of Object.keys(emptySpeedProbeObservations)) assert.equal(saved[key as keyof typeof emptySpeedProbeObservations], null)
assert.equal(probe.lastRxAvgMbps, 80)
})
+4 -4
View File
@@ -1,12 +1,12 @@
import { emptySpeedProbeObservations, type SpeedProbeRead } from "@mmapp/contracts"
import { emptySpeedProbeObservations, sameSpeedMeasurement, type SpeedMeasurementParameters, type SpeedProbeRead } from "@mmapp/contracts"
type Observations = Pick<SpeedProbeRead, keyof typeof emptySpeedProbeObservations>
type Pair = { id: string; srcServerId: string; dstServerId: string }
/** Publish confirmed configuration while preserving the latest snapshot only for the same pair. */
type Pair = { id: string } & SpeedMeasurementParameters
/** Publish confirmed configuration while preserving the latest snapshot only for the same measurement parameters. */
export function publishSpeedProbeSnapshots<T extends Pair & Partial<Observations>>(previous: readonly T[], next: readonly T[]) {
const byId = new Map(previous.map(row => [row.id, row]))
return next.map(row => {
const old = byId.get(row.id)
if (!old || old.srcServerId !== row.srcServerId || old.dstServerId !== row.dstServerId) {
if (!old || !sameSpeedMeasurement(old, row)) {
return { ...row, ...emptySpeedProbeObservations }
}
const observations = Object.fromEntries(Object.keys(emptySpeedProbeObservations).map(key => [key, old[key as keyof Observations] ?? null])) as Observations
+12
View File
@@ -56,3 +56,15 @@ export const speedTestCommandSchema = z.object({
durationSec: speedPositiveInteger.pipe(z.number().min(3).max(2147483647)).default(10),
}).refine(row => row.srcServerId !== row.dstServerId, "Источник и назначение должны отличаться")
export type SpeedTestCommand = z.infer<typeof speedTestCommandSchema>
export type SpeedMeasurementParameters = {
srcServerId: string | number; dstServerId: string | number; srcInterface?: string; dstInterface?: string;
protocol: "tcp" | "udp"; direction: "both" | "transmit" | "receive"; durationSec: string | number
}
/** Compare validated configuration across HTTP read/write forms; enable/order are not measurement parameters. */
export function sameSpeedMeasurement(a: SpeedMeasurementParameters, b: SpeedMeasurementParameters): boolean {
return String(a.srcServerId) === String(b.srcServerId) && String(a.dstServerId) === String(b.dstServerId)
&& (a.srcInterface ?? "").trim() === (b.srcInterface ?? "").trim()
&& (a.dstInterface ?? "").trim() === (b.dstInterface ?? "").trim()
&& a.protocol === b.protocol && a.direction === b.direction && Number(a.durationSec) === Number(b.durationSec)
}
+42
View File
@@ -861,3 +861,45 @@ test("Speed snapshot: смена пары сбрасывает снимок по
await expect(history).toContainText("80")
expect(writes).toBe(3)
})
test("Speed snapshot: смена длительности сбрасывает снимок, прежняя история не накладывается", async ({ page }) => {
await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write", false, true)
await page.getByRole("button", { name: "Пауза", exact: true }).click()
await page.getByRole("button", { name: /Скорость/ }).click()
await page.route("**/api/uptime/speed-test", route => route.fulfill({ json: { ok: true, result: { runId: route.request().postDataJSON().runId, txAvgMbps: 30, rxAvgMbps: 80 } } }))
let fail = true, writes = 0
await page.route("**/api/uptime/speed-probes", route => {
if (route.request().method() !== "PUT") return route.fallback()
writes++
return route.fulfill(fail ? { status: 500, json: { error: "Пара не сохранена" } } : { json: { ok: true } })
})
const run = page.getByRole("button", { name: "Запустить", exact: true })
const row = () => page.getByRole("button", { name: "Запустить", exact: true, includeHidden: true }).locator("xpath=../../..")
await run.click()
await expect(row().getByText("80 Мбит/с", { exact: true })).toBeVisible()
const history = page.getByRole("table")
await expect(history).toContainText("80")
const summary = page.locator('[aria-label="Сводка speed-проб"]')
await expect(summary).toContainText("1 run")
await page.getByRole("button", { name: "Редактировать speed-пробу", exact: true }).click()
const dialog = page.getByRole("dialog")
const duration = () => dialog.locator('[data-slot="field"]').filter({ has: page.locator('[data-slot="field-label"]').filter({ hasText: /^Сек$/ }) }).getByRole("textbox")
await duration().fill("20")
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog.getByRole("alert")).toContainText("Пара не сохранена")
await expect(row()).toContainText("80 Мбит/с")
fail = false
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(row()).toContainText("20")
await expect(row()).not.toContainText("80 Мбит/с")
await expect(summary).toContainText("1 run")
await expect(history).toContainText("80")
await page.getByRole("button", { name: "Редактировать speed-пробу", exact: true }).click()
await duration().fill("10")
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(dialog).toBeHidden()
await expect(row()).not.toContainText("80 Мбит/с")
await expect(history).toContainText("80")
expect(writes).toBe(3)
})