fix(uptime): сбросить снимок при смене пары и сохранить историю

This commit is contained in:
Denozordec committed 2026-10-07 15:52:58 +07:00
1 parent 8627459bfa
commit e4f951175c
12 files changed
+142 -20

No files matched your search

+13 -7
View File
@@ -1,5 +1,6 @@
"use client"
import { publishSpeedProbeSnapshots } from "@/lib/speed-probe-snapshot"
import { parseSpeedRunResponse } from "@/lib/speed-run-response"
import { dashboardProbeOverviewSchema } from "@/lib/dashboard-probes"
@@ -1528,6 +1529,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
})
}
const publishSpeedProbes = (rows: SpeedProbeRow[]) => setSpeedProbes(previous => publishSpeedProbeSnapshots(previous, rows))
const persistSpeedProbes = useCallback(async (rows: SpeedProbeRow[]) => {
if (!liveApi) return
if (!speedCatalogReady) throw new Error("Каталог speed-проб не подтверждён. Обновите данные перед сохранением.")
@@ -1871,7 +1874,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
}
return next
})
void saveProbeForm(() => persistSpeedProbes(nextRows), () => setSpeedProbes(nextRows))
void saveProbeForm(() => persistSpeedProbes(nextRows), () => publishSpeedProbes(nextRows))
}
const openAddSpeedSheet = () => {
@@ -1916,7 +1919,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
? { ...p, ...speedDraft, id: editingSpeedProbeId, durationSec: String(speedDraft.durationSec ?? "10") } : p)
: [{ ...speedDraft, id: speedDraft.id }, ...speedProbes]
void saveProbeForm(() => persistSpeedProbes(next), () => {
setSpeedProbes(next)
publishSpeedProbes(next)
setEditingSpeedProbeId(null)
setSpeedSheetOpen(false)
})
@@ -1943,7 +1946,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const ids = readMockDashboardStarIds(); ids.delete(id); writeMockDashboardStarIds(ids)
}
window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED))
} else setSpeedProbes(nextSpeed)
} else publishSpeedProbes(nextSpeed)
setDeleteTarget(null)
})
}
@@ -2199,13 +2202,16 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
(r.srcInterface ?? "") === probe.srcInterface &&
(r.dstInterface ?? "") === probe.dstInterface,
)
const viewStatus = run?.status ?? (probe.lastStatus === "error" || probe.lastStatus === "done" ? probe.lastStatus : null)
const viewTx = run ? run.txAvgMbps : probe.lastTxAvgMbps == null ? null : Math.round(probe.lastTxAvgMbps)
const viewRx = run ? run.rxAvgMbps : probe.lastRxAvgMbps == null ? null : Math.round(probe.lastRxAvgMbps)
const useRun = run && (run.status === "running" || (
probe.lastStatus != null && probe.lastRunAt != null && run.startedAt >= Date.parse(probe.lastRunAt)
))
const viewStatus = useRun ? run.status : (probe.lastStatus === "error" || probe.lastStatus === "done" ? probe.lastStatus : null)
const viewTx = useRun ? run.txAvgMbps : probe.lastTxAvgMbps == null ? null : Math.round(probe.lastTxAvgMbps)
const viewRx = useRun ? run.rxAvgMbps : probe.lastRxAvgMbps == null ? null : Math.round(probe.lastRxAvgMbps)
const maxVal = Math.max(viewTx ?? 0, viewRx ?? 0, 1)
const canRun = probe.enabled && !!probe.srcServerId && !!probe.dstServerId && probe.srcServerId !== probe.dstServerId
const viewAfterBtPing =
run?.status === "done" && run.afterBtPing
useRun && run.status === "done" && run.afterBtPing
? run.afterBtPing
: probe.lastPingAt
? {
@@ -11,8 +11,8 @@ let rows = new Map<string, SpeedProbePatch & { lastTxAvgMbps: number | null }>([
const store: SpeedProbeStore = { transaction: async run => {
transactions++
const draft = new Map(rows)
await run({ listIds: async () => [...draft.keys()], remove: async id => { draft.delete(id) }, upsert: async patch => {
draft.set(patch.id, { ...patch, lastTxAvgMbps: draft.get(patch.id)?.lastTxAvgMbps ?? null })
await run({ listProbes: async () => [...draft.values()], remove: async id => { draft.delete(id) }, upsert: async (patch, _createdAt, resetObservations) => {
draft.set(patch.id, { ...patch, lastTxAvgMbps: resetObservations ? null : draft.get(patch.id)?.lastTxAvgMbps ?? null })
if (failWrite) throw new Error("database write failed")
} })
rows = draft
@@ -37,6 +37,13 @@ assert.equal(rows.has("old"), false); assert.equal(rows.get("s1")?.enabled, fals
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)
const beforePairChange = rows.get("s1")!.srcServerId
await put({ probes: [{ ...probe, srcServerId: 2, dstServerId: 1 }] })
assert.notEqual(rows.get("s1")?.srcServerId, beforePairChange)
assert.equal(rows.get("s1")?.lastTxAvgMbps, null)
rows.get("s1")!.lastTxAvgMbps = 0
await put({ probes: [{ ...probe, srcServerId: 2, dstServerId: 1, enabled: true }] })
assert.equal(rows.get("s1")?.lastTxAvgMbps, 0)
failScheduler = true
const warning = await put({ probes: [{ ...probe, durationSec: 30 }] }); assert.equal(warning.statusCode, 200)
assert.equal(warning.json().ok, true); assert.match(warning.json().warning, /Пробы сохранены/); assert.equal(rows.get("s1")?.durationSec, 30)
+4 -3
View File
@@ -1,3 +1,4 @@
import { emptySpeedProbeObservations } from "@mmapp/contracts"
import { SpeedProbeEndpointsChanged } from "../services/speed-run-persistence.js"
import { createPingProbesWriteRoute } from "./ping-probes-write.js"
import { createSpeedProbesWriteRoute } from "./speed-probes-write.js"
@@ -207,12 +208,12 @@ 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({
listIds: async () => (await tx.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes)).map(row => row.id),
listProbes: async () => tx.select({ id: uptimeSpeedProbes.id, srcServerId: uptimeSpeedProbes.srcServerId, dstServerId: uptimeSpeedProbes.dstServerId }).from(uptimeSpeedProbes).for("update"),
remove: async id => { await tx.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, id)) },
upsert: async (row, createdAt) => {
upsert: async (row, createdAt, resetObservations) => {
const { id, ...patch } = row
await tx.insert(uptimeSpeedProbes).values({ id, ...patch, createdAt })
.onConflictDoUpdate({ target: uptimeSpeedProbes.id, set: patch })
.onConflictDoUpdate({ target: uptimeSpeedProbes.id, set: { ...patch, ...(resetObservations ? emptySpeedProbeObservations : {}) } })
},
})
}) }, probes),
@@ -1,16 +1,21 @@
import { matchesSpeedProbeEndpoints, type SpeedProbeEndpoints } from "./speed-run-persistence.js"
import type { SpeedProbeWrite } from "@mmapp/contracts"
export type SpeedProbePatch = SpeedProbeWrite & { sortOrder: number; updatedAt: string }
export type SpeedProbeTransaction = {
listIds: () => Promise<string[]>
listProbes: () => Promise<SpeedProbeEndpoints[]>
remove: (id: string) => Promise<void>
upsert: (row: SpeedProbePatch, createdAt: 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 never written here. */
/** 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. */
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))
for (const id of await tx.listIds()) if (!nextIds.has(id)) await tx.remove(id)
for (const [sortOrder, row] of probes.entries()) await tx.upsert({ ...row, sortOrder, updatedAt: now }, now)
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)
await tx.upsert({ ...row, sortOrder, updatedAt: now }, now, reset)
}
})
}
+1
View File
@@ -62,6 +62,7 @@ SELECT min(resolution_version), max(resolution_version) FROM alert_outbox;
| ROS-4 | Home→JH и JH↔Exit с асимметричной скоростью | Направления DL/UL соответствуют клиентской стороне; model limits применяются к правильному направлению |
| ROS-5 | OSPF preview → действие → перечитывание templates | Preview соответствует исправленным RX/DL и TX/UL. Применение подтверждается фактическим cost; частичный отказ не становится полным успехом |
| ROS-6 | WireGuard/IPsec/GRE/VXLAN/Firewall/BGP/маршруты | Проблема→устройство→форма→подтверждённое действие→возврат сохраняет контекст. Права и повторное нажатие проверены; фактическая конфигурация и connectivity сверены |
| ROS-9 | Смена пары в каталоге и конкурентное завершение native test | Новый snapshot unknown, старый run сохранён с actual endpoints; enable/reorder сохраняют измеренный0. Проверить row locks/условный UPDATE в реальном PG |
| ROS-8 | Смена пары/удаление speed-пробы до запуска и во время теста | Preflight409 до команды; при поздней смене новый snapshot не перезаписан, actual run сохранён без stale probe association |
| ROS-7 | Удаление/отсутствие доступа/частичный отказ одного устройства | Ошибка не стирает форму, последний снимок и объекты остальных устройств; неизвестные метрики не превращаются в0 |
+1 -1
View File
@@ -1,6 +1,6 @@
# План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-07: 12.4.8main1176/1176 на579e816 и12.4.9context221/221 наa3fcba0 passed. Следующий12.3.78: сброс probe snapshot при смене пары в каталоге/backend и frontend publish. Внешний protocol12.5 подготовлен, приёмка не выполнена; stage12 не завершён.
Актуальное продолжение 2026-10-07: 12.3.78 завершён: snapshot reset смены пары, stale draft/история,14pure+backend HTTP/RBAC,73browser/8context/type/lint/build. Общие main1176(579e816)/context221(a3fcba0) и текущий delta различаются. Следующий12.3.79: строгий runtime body POST /uptime/speed-test (prefix IDs/невалидные enums/duration) до native операции. Внешняя12.5 не выполнена.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
+1 -1
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации
Актуальное продолжение 2026-10-07: 12.4.8main1176/1176 на579e816 и12.4.9context221/221 наa3fcba0 passed. Следующий12.3.78: сброс probe snapshot при смене пары в каталоге/backend и frontend publish. Внешний protocol12.5 подготовлен, приёмка не выполнена; stage12 не завершён.
Актуальное продолжение 2026-10-07: 12.3.78 завершён: snapshot reset смены пары, stale draft/история,14pure+backend HTTP/RBAC,73browser/8context/type/lint/build. Общие main1176(579e816)/context221(a3fcba0) и текущий delta различаются. Следующий12.3.79: строгий runtime body POST /uptime/speed-test (prefix IDs/невалидные enums/duration) до native операции. Внешняя12.5 не выполнена.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения.
+11
View File
@@ -876,3 +876,14 @@ Source review: stopScheduler очищает таймеры, stopTrafficFlowListe
2026-10-07, HEADa3fcba0.221/221passed,26files,0unexpected/skipped/flaky,7.5min. Edge/1worker/retries0/maxFailures12. [Итог context](ux-ui/acceptance/context-final-2026-10-07.json) сохранён отдельно от старых матриц; source tmp/ux-context-final-20261007.json. Смена backend/mode, ABA/late data/write/permission, закрытие scope и checkpoint прошли.
Main1176 на579e816 и backend ownership delta25pure/5browser наa3fcba0 остаются разными доказательствами, HEAD не подменён. Следующий78: при смене пары adapter upsert и frontend optimistic publish сохраняли старые observations; исправить после общего прогона и проверить профиль. Внешняя12.5 всё ещё не выполнена.
## 12.3.78 — снимок при изменении пары и приоритет истории
2026-10-07. Catalog replacement читает и блокирует существующие probe rows внутри прежней транзакции/advisory lock. Смена source/destination или новый id сбрасывает9наблюдаемых полей вnull, перестановка/enable и та же пара сохраняют measurements. История не удаляется. Frontend публикует configuration только после подтверждения; той же паре берёт последние observations из текущего state вместо readonly копии старого draft. Новый target получаетnull. Общий emptySpeedProbeObservations переиспользован через contracts; ReUI primitives/theme/composition сохранены.
Строка пробы выбирает running run, затем более новый подтверждённый run той же пары/интерфейсов только при существующем snapshot lastStatus/lastRunAt; иначе текущий snapshot. Это исключает старый history overlay после смены пары/возврата и сохраняет приоритет нового nullable результата над старым999. Первые два новых test runs выявили ошибки relative has/inert selector; исправлены проверки области формы/фонового state. Первый общий профиль72/73 показал реальную регрессию приоритета новогоnull над старым snapshot, она исправлена без изменения теста. Финальный73/73passed.
14pure (5новых frontend snapshot+9persistence), HTTP/DI/RBAC speed-probes write, contracts/backend build,73browser uptime/large history,8uptime context, non-incremental TypeScript, targeted lint (0errors,9предыдущих предупреждений uptime) и frontend production build passed. Финальная новая проверка: API save error сохраняет прежние80; успешная смена/возврат пары не показывает их как новый snapshot, history/KPI1run сохранены. Historic2speed PNG восстановлены. Реальное FOR UPDATE/concurrency требует12.5; здесь DI и source review.
Этот delta выполнен после общих main579e816/contexta3fcba0; общие результаты не переименованы в проверку нового HEAD. Следующий79: POST command body ещё использует parseInt/enum defaults; проверить строгий DTO до DB/native, сохранив валидные ad-hoc/scheduled сценарии и существующие команды/права. Полный CAS параметров пробы и реальные native timestamps также не объявлены проверенными. Внешняя12.5 отдельно.
+27
View File
@@ -0,0 +1,27 @@
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 }
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])
assert.equal(saved.enabled, false)
assert.equal(saved.lastTxAvgMbps, 0)
assert.equal(saved.lastRxAvgMbps, 80)
assert.equal(saved.lastStatus, "done")
assert.equal(next.lastTxAvgMbps, 999)
})
for (const field of ["srcServerId", "dstServerId", "id"] as const) test(`changed ${field} starts with unknown measurements`, () => {
const [saved] = publishSpeedProbeSnapshots([probe], [{ ...probe, [field]: "3" }])
for (const key of Object.keys(emptySpeedProbeObservations)) assert.equal(saved[key as keyof typeof emptySpeedProbeObservations], null)
assert.equal(probe.lastRxAvgMbps, 80)
})
test("new row and reordered existing rows retain correct ownership without mutation", () => {
const next = { ...probe, id: "new" }
const saved = publishSpeedProbeSnapshots([probe], [next, probe])
assert.equal(saved[0].lastStatus, null)
assert.equal(saved[1].lastTxAvgMbps, 0)
assert.deepEqual(saved.map(p => p.id), ["new", "p"])
assert.equal(next.lastRxAvgMbps, 80)
})
+15
View File
@@ -0,0 +1,15 @@
import { emptySpeedProbeObservations, 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. */
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) {
return { ...row, ...emptySpeedProbeObservations }
}
const observations = Object.fromEntries(Object.keys(emptySpeedProbeObservations).map(key => [key, old[key as keyof Observations] ?? null])) as Observations
return { ...row, ...observations }
})
}
+6
View File
@@ -38,3 +38,9 @@ export const speedProbeWriteSchema = z.object({
export const speedProbesWriteSchema = z.object({ probes: z.array(speedProbeWriteSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID speed-пробы") })
export const speedProbesSavedSchema = z.object({ ok: z.literal(true), warning: z.string().min(1).optional() })
export type SpeedProbeWrite = z.infer<typeof speedProbeWriteSchema>
/** A changed device pair has no measurements; configuration writes cannot invent them. */
export const emptySpeedProbeObservations = {
lastRunAt: null, lastTxAvgMbps: null, lastRxAvgMbps: null, lastStatus: null, lastError: null,
lastPingRttMs: null, lastPingLossPct: null, lastPingAt: null, lastPingError: null,
}
+44 -1
View File
@@ -1,6 +1,6 @@
import { test, expect, type Page } from "@playwright/test"
async function setup(page: Page, url: string, failed = 0, longNames = false, permission?: string, isAdmin = false) {
async function setup(page: Page, url: string, failed = 0, longNames = false, permission?: string, isAdmin = false, thirdServer = false) {
const control = { writes: [] as { method: string; path: string; body: unknown }[], failed, posts: [] as { path: string; body: unknown }[] }
const servers = [{ id: 1, name: "Источник", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "64512", status: "online", latency: 1, os: "7", model: "CHR", sessions:0, wanUplinks: [] }]
const probes = ["p1", "p/2"].map(id => ({ id, srcServerId: "1", name: "Проверка сети", target: "192.0.2.10", filter: "main", rtt: null, loss: 100, status: "down", series: [0], enabled: true, showOnDashboard: false }))
@@ -8,6 +8,7 @@ async function setup(page: Page, url: string, failed = 0, longNames = false, per
await page.addInitScript(token => localStorage.setItem("mmapp_token", token), uptimeToken(permission, isAdmin))
servers.push({ ...servers[0], id: 2, name: "Назначение", host: "192.0.2.2" })
}
if (thirdServer) servers.push({ ...servers[0], id: 3, name: "Новое назначение", host: "192.0.2.3" })
if (longNames) {
servers[0].name = "Источник-с-длинным-названием-площадки-для-проверки-переноса"
probes.forEach(p => { p.name = "Проверка-доступности-длинного-имени-сетевого-узла"; p.target = "router-with-a-long-hostname.example.test"; p.filter = "Фильтр-с-длинным-названием" })
@@ -818,3 +819,45 @@ test("Speed-test: конфликт пары устройств сохраняе
await expect(run.locator("xpath=../../..")).toContainText("Назначение")
expect(calls).toBe(1)
})
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 destination = () => dialog.locator('[data-slot="field"]').filter({ has: page.locator('[data-slot="field-label"]').filter({ hasText: /^Назначение$/ }) })
await destination().getByRole("button", { name: /Новое назначение/ }).click()
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("Новое назначение")
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 destination().getByRole("button", { name: /Назначение / }).click()
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)
})