From a3fcba0d431d5753a97a8fce164ae828b64ad8b9 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 7 Oct 2026 15:12:41 +0700 Subject: [PATCH] =?UTF-8?q?fix(uptime):=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B0=D1=80=D1=83=20=D1=83=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D1=81=D1=82=D0=B2=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=D0=B0=D1=80=D0=BD=D0=BE=20=D1=81=D0=BE=D1=85?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=82=D1=8C=20=D1=80=D0=B5=D0=B7=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/uptime.ts | 2 + .../services/speed-run-persistence.test.ts | 66 ++++++++ backend/src/services/speed-run-persistence.ts | 25 +++ backend/src/services/uptime-speed-service.ts | 158 ++++++++++-------- docs/ux-ui-external-acceptance.md | 2 + docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 2 +- docs/ux-ui-stage-12.md | 9 + tests/e2e/uptime-links.spec.ts | 22 +++ 9 files changed, 218 insertions(+), 70 deletions(-) create mode 100644 backend/src/services/speed-run-persistence.test.ts create mode 100644 backend/src/services/speed-run-persistence.ts diff --git a/backend/src/routes/uptime.ts b/backend/src/routes/uptime.ts index 00a26cba..3efe81cb 100644 --- a/backend/src/routes/uptime.ts +++ b/backend/src/routes/uptime.ts @@ -1,3 +1,4 @@ +import { SpeedProbeEndpointsChanged } from "../services/speed-run-persistence.js" import { createPingProbesWriteRoute } from "./ping-probes-write.js" import { createSpeedProbesWriteRoute } from "./speed-probes-write.js" import { replaceSpeedProbes } from "../services/speed-probe-replacement.js" @@ -308,6 +309,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { const message = /timed out|socket hang up|ECONNRESET/i.test(raw) ? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов" : raw + if (e instanceof SpeedProbeEndpointsChanged) return reply.status(409).send({ error: message }) if (/активного IP|Server not found/i.test(raw)) { return reply.status(400).send({ error: message }) } diff --git a/backend/src/services/speed-run-persistence.test.ts b/backend/src/services/speed-run-persistence.test.ts new file mode 100644 index 00000000..52cf5716 --- /dev/null +++ b/backend/src/services/speed-run-persistence.test.ts @@ -0,0 +1,66 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { assertSpeedProbeEndpoints, matchesSpeedProbeEndpoints, persistSpeedRun, SpeedProbeEndpointsChanged } from "./speed-run-persistence.js" +const target = { id: "probe", srcServerId: 1, dstServerId: 2 } +type State = { probe: (typeof target & { average: number }) | null; runs: Array<{ probeId: string | null; average: number }> } +function harness(failure?: "update" | "insert" | "commit") { + let state: State = { probe: { ...target, average: 7 }, runs: [] } + let updates = 0 + return { + state: () => state, + updates: () => updates, + deps: { + transaction: async (work: (tx: State) => Promise) => { + const tx = structuredClone(state) + await work(tx) + if (failure === "commit") throw Error("commit failed") + state = tx + }, + updateProbe: async (tx: State, expected: typeof target) => { + updates++ + if (!matchesSpeedProbeEndpoints(tx.probe, expected)) return false + tx.probe!.average = 0 + if (failure === "update") throw Error("update failed") + return true + }, + insertRun: async (tx: State, probeId: string | null) => { + tx.runs.push({ probeId, average: 0 }) + if (failure === "insert") throw Error("insert failed") + }, + }, + } +} +test("preflight rejects foreign, changed or deleted probe endpoints", () => { + assert.doesNotThrow(() => assertSpeedProbeEndpoints(target, target)) + for (const row of [null, undefined, {}, { ...target, id: "other" }, { ...target, srcServerId: 3 }, { ...target, dstServerId: 3 }, { ...target, srcServerId: "1" }]) { + assert.throws(() => assertSpeedProbeEndpoints(row, target), SpeedProbeEndpointsChanged) + } +}) +test("one transaction publishes an explicit zero snapshot and associated history", async () => { + const h = harness() + await persistSpeedRun(target, h.deps) + assert.equal(h.state().probe!.average, 0) + assert.deepEqual(h.state().runs, [{ probeId: "probe", average: 0 }]) + assert.equal(h.updates(), 1) +}) +for (const changed of ["source", "destination", "deleted"] as const) test(`probe ${changed} during native test retains its snapshot and unlinks old run`, async () => { + const h = harness() + if (changed === "deleted") h.state().probe = null + else h.state().probe![changed === "source" ? "srcServerId" : "dstServerId"] = 3 + await persistSpeedRun(target, h.deps) + assert.equal(h.state().probe?.average ?? 7, 7) + assert.deepEqual(h.state().runs, [{ probeId: null, average: 0 }]) +}) +for (const failure of ["update", "insert", "commit"] as const) test(`${failure} error never publishes a partial snapshot/history`, async () => { + const h = harness(failure) + await assert.rejects(persistSpeedRun(target, h.deps), new RegExp(failure)) + assert.equal(h.state().probe!.average, 7) + assert.deepEqual(h.state().runs, []) +}) +test("ad-hoc run writes history without touching any probe", async () => { + const h = harness() + await persistSpeedRun(null, h.deps) + assert.equal(h.state().probe!.average, 7) + assert.equal(h.updates(), 0) + assert.deepEqual(h.state().runs, [{ probeId: null, average: 0 }]) +}) diff --git a/backend/src/services/speed-run-persistence.ts b/backend/src/services/speed-run-persistence.ts new file mode 100644 index 00000000..dd5c2a74 --- /dev/null +++ b/backend/src/services/speed-run-persistence.ts @@ -0,0 +1,25 @@ +export interface SpeedProbeEndpoints { id: string; srcServerId: number; dstServerId: number } + +export class SpeedProbeEndpointsChanged extends Error { + constructor() { super("Пара устройств speed-пробы изменилась. Обновите список проб перед запуском") } +} +export function matchesSpeedProbeEndpoints(row: unknown, target: SpeedProbeEndpoints): boolean { + if (!row || typeof row !== "object") return false + const probe = row as Partial + return probe.id === target.id && probe.srcServerId === target.srcServerId && probe.dstServerId === target.dstServerId +} +export function assertSpeedProbeEndpoints(row: unknown, target: SpeedProbeEndpoints): void { + if (!matchesSpeedProbeEndpoints(row, target)) throw new SpeedProbeEndpointsChanged() +} + +/** Changed/deleted probes retain their snapshot; history keeps the actual run without a stale association. */ +export async function persistSpeedRun(target: SpeedProbeEndpoints | null, deps: { + transaction: (work: (context: C) => Promise) => Promise + updateProbe: (context: C, target: SpeedProbeEndpoints) => Promise + insertRun: (context: C, associatedProbeId: string | null) => Promise +}): Promise { + await deps.transaction(async context => { + const associated = target ? await deps.updateProbe(context, target) : false + await deps.insertRun(context, associated && target ? target.id : null) + }) +} diff --git a/backend/src/services/uptime-speed-service.ts b/backend/src/services/uptime-speed-service.ts index 39df7cba..17990652 100644 --- a/backend/src/services/uptime-speed-service.ts +++ b/backend/src/services/uptime-speed-service.ts @@ -1,4 +1,4 @@ -import { asc, eq } from "drizzle-orm" +import { and, asc, eq } from "drizzle-orm" import { db } from "../db/index.js" import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js" import type { SpeedRunSnapshot, SpeedScheduledRunSnapshot } from "../types/scheduler-run-snapshot.js" @@ -8,8 +8,14 @@ import { withBtestNodeLocks } from "./btest-node-locks.js" import { MikrotikClient } from "./mikrotik.js" import { parsePing } from "./uptime-collector.js" import { assertBandwidthTestCompleted } from "./bandwidth-test-status.js" +import { assertSpeedProbeEndpoints, persistSpeedRun, type SpeedProbeEndpoints } from "./speed-run-persistence.js" import { readBandwidthTestRates } from "./bandwidth-test-rates.js" +type SpeedWriteTransaction = Parameters[0]>[0] +function speedProbeEndpointsWhere(target: SpeedProbeEndpoints) { + return and(eq(uptimeSpeedProbes.id, target.id), eq(uptimeSpeedProbes.srcServerId, target.srcServerId), eq(uptimeSpeedProbes.dstServerId, target.dstServerId)) +} + function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string { const name = iface.trim() const candidates = rows.filter((a) => { @@ -72,11 +78,16 @@ function humanizeSpeedError(raw: string): string { /** Полный цикл bandwidth-test + after-ping + запись в БД. Вызывать внутри withBtestNodeLocks при необходимости — здесь lock уже снаружи. */ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams): Promise { + const probeId = String(params.probeId ?? "").trim() const srcId = params.srcServerId const dstId = params.dstServerId const src = (await db.select().from(servers).where(eq(servers.id, srcId)).limit(1))[0] const dst = (await db.select().from(servers).where(eq(servers.id, dstId)).limit(1))[0] if (!src || !dst) throw new Error("Server not found") + if (probeId) { + const probe = (await db.select().from(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, probeId)).limit(1))[0] + assertSpeedProbeEndpoints(probe, { id: probeId, srcServerId: srcId, dstServerId: dstId }) + } const protocol = params.protocol === "udp" ? "udp" : "tcp" const direction = params.direction === "transmit" || params.direction === "receive" ? params.direction : "both" @@ -126,44 +137,49 @@ export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams) } } - const probeId = String(params.probeId ?? "").trim() const runId = String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` - if (probeId) { - await db.update(uptimeSpeedProbes).set({ - lastRunAt: pingNow, - lastTxAvgMbps: txFinal, - lastRxAvgMbps: rxFinal, - lastStatus: "done", - lastError: "", - lastPingRttMs: afterBtPing.rttMs, - lastPingLossPct: afterBtPing.lossPct, - lastPingAt: pingNow, - lastPingError: afterBtPing.error ?? "", - updatedAt: pingNow, - }).where(eq(uptimeSpeedProbes.id, probeId)) - } - await db.insert(uptimeSpeedTestRuns).values({ - id: runId, - probeId: probeId || null, - srcServerId: srcId, - dstServerId: dstId, - srcInterface: srcInterface || "", - dstInterface: dstInterface || "", - srcAddress: srcAddress || src.host, - dstAddress: initialAddress, - srcInterfaceAddress, - dstInterfaceAddress, - protocol, - direction, - durationSec, - txAvgMbps: txFinal, - rxAvgMbps: rxFinal, - pingRttMs: afterBtPing.rttMs, - pingLossPct: afterBtPing.lossPct, - pingError: afterBtPing.error ?? null, - status: "done", - error: null, - createdAt: pingNow, + await persistSpeedRun(probeId ? { id: probeId, srcServerId: srcId, dstServerId: dstId } : null, { + transaction: work => db.transaction(work), + updateProbe: async (tx, target) => { + const rows = await tx.update(uptimeSpeedProbes).set({ + lastRunAt: pingNow, + lastTxAvgMbps: txFinal, + lastRxAvgMbps: rxFinal, + lastStatus: "done", + lastError: "", + lastPingRttMs: afterBtPing.rttMs, + lastPingLossPct: afterBtPing.lossPct, + lastPingAt: pingNow, + lastPingError: afterBtPing.error ?? "", + updatedAt: pingNow, + }).where(speedProbeEndpointsWhere(target)).returning({ id: uptimeSpeedProbes.id }) + return rows.length === 1 + }, + insertRun: async (tx, associatedProbeId) => { + await tx.insert(uptimeSpeedTestRuns).values({ + id: runId, + probeId: associatedProbeId, + srcServerId: srcId, + dstServerId: dstId, + srcInterface: srcInterface || "", + dstInterface: dstInterface || "", + srcAddress: srcAddress || src.host, + dstAddress: initialAddress, + srcInterfaceAddress, + dstInterfaceAddress, + protocol, + direction, + durationSec, + txAvgMbps: txFinal, + rxAvgMbps: rxFinal, + pingRttMs: afterBtPing.rttMs, + pingLossPct: afterBtPing.lossPct, + pingError: afterBtPing.error ?? null, + status: "done", + error: null, + createdAt: pingNow, + }) + }, }) return { @@ -208,36 +224,42 @@ export async function recordSpeedTestFailure(params: { }) { const probeId = String(params.probeId ?? "").trim() const message = humanizeSpeedError(params.message) - if (probeId) { - await db.update(uptimeSpeedProbes).set({ - lastRunAt: new Date().toISOString(), - lastStatus: "error", - lastError: message, - updatedAt: new Date().toISOString(), - }).where(eq(uptimeSpeedProbes.id, probeId)) - } - await db.insert(uptimeSpeedTestRuns).values({ - id: String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - probeId: probeId || null, - srcServerId: params.srcServerId, - dstServerId: params.dstServerId, - srcInterface: String(params.srcInterface ?? "").trim(), - dstInterface: String(params.dstInterface ?? "").trim(), - srcAddress: null, - dstAddress: null, - srcInterfaceAddress: null, - dstInterfaceAddress: null, - protocol: params.protocol === "udp" ? "udp" : "tcp", - direction: params.direction === "transmit" || params.direction === "receive" ? params.direction : "both", - durationSec: Math.max(3, Number.parseInt(String(params.durationSec ?? ""), 10) || 10), - txAvgMbps: null, - rxAvgMbps: null, - pingRttMs: null, - pingLossPct: null, - pingError: null, - status: "error", - error: message, - createdAt: new Date().toISOString(), + await persistSpeedRun(probeId ? { id: probeId, srcServerId: params.srcServerId, dstServerId: params.dstServerId } : null, { + transaction: work => db.transaction(work), + updateProbe: async (tx, target) => { + const rows = await tx.update(uptimeSpeedProbes).set({ + lastRunAt: new Date().toISOString(), + lastStatus: "error", + lastError: message, + updatedAt: new Date().toISOString(), + }).where(speedProbeEndpointsWhere(target)).returning({ id: uptimeSpeedProbes.id }) + return rows.length === 1 + }, + insertRun: async (tx, associatedProbeId) => { + await tx.insert(uptimeSpeedTestRuns).values({ + id: String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + probeId: associatedProbeId, + srcServerId: params.srcServerId, + dstServerId: params.dstServerId, + srcInterface: String(params.srcInterface ?? "").trim(), + dstInterface: String(params.dstInterface ?? "").trim(), + srcAddress: null, + dstAddress: null, + srcInterfaceAddress: null, + dstInterfaceAddress: null, + protocol: params.protocol === "udp" ? "udp" : "tcp", + direction: params.direction === "transmit" || params.direction === "receive" ? params.direction : "both", + durationSec: Math.max(3, Number.parseInt(String(params.durationSec ?? ""), 10) || 10), + txAvgMbps: null, + rxAvgMbps: null, + pingRttMs: null, + pingLossPct: null, + pingError: null, + status: "error", + error: message, + createdAt: new Date().toISOString(), + }) + }, }) } diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index b3860f1c..869bac19 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -18,6 +18,7 @@ | PG-4 | Ошибка внутри реального pg_restore | Восстановление не объявляется успешным. Проверить rollback по контрольным бизнес-строкам и схеме, отдельно проверить последовательности | Ошибка процесса, HTTP/UI и независимые запросы до/после | | PG-5 | Два конкурентных backup/restore запроса | Advisory lock исключает конкурирующее выполнение; второй запрос сообщает конфликт | Два request ID, HTTP-коды, журнал фактических процессов | | PG-6 | Длительная фоновая запись и UDP-приём во время подготовки restore | Подтверждена остановка всех writers и отсутствие записей в критическом интервале; после операции потребители восстановлены либо показано предупреждение | Процесс/worker lifecycle, счётчики приёма/записей, состояния jobs | +| PG-8 | Ошибка записи speed snapshot/history и потеря commit acknowledgement | Снимок/история атомарны; неизвестный commit не выдаётся за гарантированный rollback и не вызывает автоматический повтор native test | Состояние PG после восстановления соединения, runId, HTTP/UI | | PG-7 | Неавторизованный пользователь и обычный settings writer | Backup/restore требуют settings admin, отказ происходит до выполнения процесса | 401/403, неизменные контрольные данные, отсутствие pg_dump/pg_restore | Реализация: [bootstrap](../backend/src/db/bootstrap.ts), [migrations](../backend/src/db/migrate.ts), [0008](../backend/drizzle/0008_alert_outbox_outcomes.sql), [0009](../backend/drizzle/0009_alert_outbox_resolution.sql), [database operation](../backend/src/services/system-database-operation.ts), [restore execution](../backend/src/services/system-database-restore-execution.ts). @@ -61,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-8 | Смена пары/удаление speed-пробы до запуска и во время теста | Preflight409 до команды; при поздней смене новый snapshot не перезаписан, actual run сохранён без stale probe association | | ROS-7 | Удаление/отсутствие доступа/частичный отказ одного устройства | Ошибка не стирает форму, последний снимок и объекты остальных устройств; неизвестные метрики не превращаются в0 | Для ROS-1/ROS-2 зафиксировать версию RouterOS и очищенный сырой ответ. Строковый status подтверждается по [официальному CLI reference](https://manual.mikrotik.com/docs/cli-reference/tool/bandwidth-test/); несовместимый REST shape требует отдельной коррекции контракта. Тест скорости способен нагрузить канал — длительность и endpoints выбираются для выделенного стенда. Команды и правила маршрутизации этой UX/UI-серией не расширялись. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index f8978814..e22c8f5e 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -1,6 +1,6 @@ # План реализации UX/UI по пятичасовым окнам -Актуальное продолжение 2026-10-07: 12.4.8 main1176/1176 passed на579e816,34.9min;12.5.0 protocol подготовлен. Следующий12.3.77: привязка speed-пробы к паре устройств и атомарная запись snapshot/history; затем12.4.9 context. Stage12 не завершён, внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.4.8main1176/1176 passed;12.3.77 endpoint ownership/atomic speed persistence завершён (25pure/5browser/backend build). Следующий12.4.9: общийcontext. Затем проверить сброс snapshot при изменении пары в каталоге (12.3.78). Внешний protocol12.5 подготовлен, приёмка не выполнена. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 9bef5b0a..36878740 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -1,6 +1,6 @@ # UX/UI: журнал реализации -Актуальное продолжение 2026-10-07: 12.4.8 main1176/1176 passed на579e816,34.9min;12.5.0 protocol подготовлен. Следующий12.3.77: привязка speed-пробы к паре устройств и атомарная запись snapshot/history; затем12.4.9 context. Stage12 не завершён, внешняя12.5 не выполнена. +Актуальное продолжение 2026-10-07: 12.4.8main1176/1176 passed;12.3.77 endpoint ownership/atomic speed persistence завершён (25pure/5browser/backend build). Следующий12.4.9: общийcontext. Затем проверить сброс snapshot при изменении пары в каталоге (12.3.78). Внешний protocol12.5 подготовлен, приёмка не выполнена. Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index dc42080a..e105b93e 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -860,3 +860,12 @@ Source review: stopScheduler очищает таймеры, stopTrafficFlowListe 2026-10-07. Runtime HEAD579e816:1176/1176passed,0unexpected/skipped/flaky,34.9min. Edge/1worker/retries0/maxFailures12. После старта добавлен только внешний протокол docs5fedf7a; runtime diff579e816→current проверен пустым. [Итог main](ux-ui/acceptance/main-final-2026-10-07.json) записан отдельно от исторического12.4.6. Сырой JSON сохранён в tmp/ux-main-final-20261007.json. Исторические PNG, перегенерированные тестами, восстановлены по проверенному списку tmp/ux-main-regenerated-screens-20261007.json. Чужие skill/AGENTS/config/cache изменения не включены. Этот общий прогон содержит новые large pool/site captions, direction/scope/nullable POST и6mobile geometry cases. Старый terminal focus race в этом прогоне не повторился. Source review дополнительно нашёл обновление speed probe только по id без endpoint ownership; snapshot update и run insert были отдельными операциями. Следующий небольшой12.3.77 защищает привязку пары и атомарность этих двух записей, без смены RouterOS команд; затем12.4.9 полныйcontext. Внешняя12.5 отдельно. + + +## 12.3.77 — пара устройств и атомарная запись speed-run + +2026-10-07. Preflight сверяет probeId/srcServerId/dstServerId до создания RouterOS client/команды, mismatch/deleted даёт409. UPDATE snapshot success/error содержит id AND source AND destination и RETURNING id. Если пара изменилась/проба удалена за время native test, её snapshot не обновляется, история actual endpoints сохраняется с probeId=null. Snapshot и run insert теперь внутри одной Drizzle transaction для success и failure. Ad-hoc run без probeId сохраняет историю без обновления пробы. Команды, параметры, locks, permissions/schema не менялись. + +25 pure/DI tests (9новых persistence: preflight, zero, source/destination/delete race, update/insert/pre-commit failures,ad-hoc;4rate,3status,9scheduler),5browser speed-test (новый409 и прежние ошибки/timeout/nullable/foreignrun), targeted test lint и backend build passed. Pure transaction в памяти моделирует rollback и отказ до commit, не доказывает потерю реального PG commit acknowledgement. Source placement и SQL predicates просмотрены; PG concurrency/transaction semantics требуют12.5. + +Полный main1176 относится к579e816 до backend delta; frontend runtime после него не менялся, добавлен1профильный409тест. Следующий12.4.9context; после него12.3.78: adapter каталога сохраняет observations по id, нужно проверить смену пары и local publish после config save. Текущий77защищает пару при запуске/результате, не является CAS полной версии параметров пробы. diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index d45c13f4..6d967aea 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -796,3 +796,25 @@ for (const invalid of [false, true]) { expect(calls).toBe(2) }) } + +test("Speed-test: конфликт пары устройств сохраняет каталог и освобождает действия", async ({ page }) => { + await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write") + await page.getByRole("button", { name: "Пауза", exact: true }).click() + await page.getByRole("button", { name: /Скорость/ }).click() + let calls = 0 + const message = "Пара устройств speed-пробы изменилась. Обновите список проб перед запуском" + await page.route("**/api/uptime/speed-test", async route => { + calls++ + await route.fulfill({ status: 409, json: { error: message } }) + }) + const run = page.getByRole("button", { name: "Запустить", exact: true }) + await run.click() + await expect(page.getByRole("alert").filter({ hasText: message })).toBeVisible() + await expect(run).toBeEnabled() + await expect(page.getByRole("button", { name: "Редактировать speed-пробу", exact: true })).toBeEnabled() + await expect(page.getByRole("button", { name: "Удалить speed-пробу", exact: true })).toBeEnabled() + await expect(page.locator('[aria-label="Сводка speed-проб"]')).toContainText("0 run") + await expect(run.locator("xpath=../../..")).toContainText("Источник") + await expect(run.locator("xpath=../../..")).toContainText("Назначение") + expect(calls).toBe(1) +})