From e4f951175ccd56437a9781e0a55f06f34f638053 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 7 Oct 2026 15:52:58 +0700 Subject: [PATCH] =?UTF-8?q?fix(uptime):=20=D1=81=D0=B1=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=8B=20=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/uptime/page.tsx | 20 ++++++--- backend/src/routes/speed-probes-write.test.ts | 11 ++++- backend/src/routes/uptime.ts | 7 +-- .../src/services/speed-probe-replacement.ts | 15 ++++--- docs/ux-ui-external-acceptance.md | 1 + docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 2 +- docs/ux-ui-stage-12.md | 11 +++++ lib/speed-probe-snapshot.test.ts | 27 +++++++++++ lib/speed-probe-snapshot.ts | 15 +++++++ packages/contracts/src/uptime-speed.ts | 6 +++ tests/e2e/uptime-links.spec.ts | 45 ++++++++++++++++++- 12 files changed, 142 insertions(+), 20 deletions(-) create mode 100644 lib/speed-probe-snapshot.test.ts create mode 100644 lib/speed-probe-snapshot.ts diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 2300cf52..b1a83673 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -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 ? { diff --git a/backend/src/routes/speed-probes-write.test.ts b/backend/src/routes/speed-probes-write.test.ts index f6cc49d1..5013d13d 100644 --- a/backend/src/routes/speed-probes-write.test.ts +++ b/backend/src/routes/speed-probes-write.test.ts @@ -11,8 +11,8 @@ let rows = new Map([ 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) diff --git a/backend/src/routes/uptime.ts b/backend/src/routes/uptime.ts index 3efe81cb..ae5e89a8 100644 --- a/backend/src/routes/uptime.ts +++ b/backend/src/routes/uptime.ts @@ -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), diff --git a/backend/src/services/speed-probe-replacement.ts b/backend/src/services/speed-probe-replacement.ts index 96676e8b..a8baa17d 100644 --- a/backend/src/services/speed-probe-replacement.ts +++ b/backend/src/services/speed-probe-replacement.ts @@ -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 + listProbes: () => Promise remove: (id: string) => Promise - upsert: (row: SpeedProbePatch, createdAt: string) => Promise + upsert: (row: SpeedProbePatch, createdAt: string, resetObservations: boolean) => Promise } export type SpeedProbeStore = { transaction: (run: (tx: SpeedProbeTransaction) => Promise) => Promise } -/** 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 { 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) + } }) } diff --git a/docs/ux-ui-external-acceptance.md b/docs/ux-ui-external-acceptance.md index 869bac19..b62fbd69 100644 --- a/docs/ux-ui-external-acceptance.md +++ b/docs/ux-ui-external-acceptance.md @@ -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 | diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index f05b3f67..2f85f736 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 672a2dd0..9960abd9 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 510f6dcb..75b02518 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -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 отдельно. diff --git a/lib/speed-probe-snapshot.test.ts b/lib/speed-probe-snapshot.test.ts new file mode 100644 index 00000000..6464693f --- /dev/null +++ b/lib/speed-probe-snapshot.test.ts @@ -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) +}) diff --git a/lib/speed-probe-snapshot.ts b/lib/speed-probe-snapshot.ts new file mode 100644 index 00000000..67fc27e4 --- /dev/null +++ b/lib/speed-probe-snapshot.ts @@ -0,0 +1,15 @@ +import { emptySpeedProbeObservations, type SpeedProbeRead } from "@mmapp/contracts" +type Observations = Pick +type Pair = { id: string; srcServerId: string; dstServerId: string } +/** Publish confirmed configuration while preserving the latest snapshot only for the same pair. */ +export function publishSpeedProbeSnapshots>(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 } + }) +} diff --git a/packages/contracts/src/uptime-speed.ts b/packages/contracts/src/uptime-speed.ts index 13e3616c..7e99dc1d 100644 --- a/packages/contracts/src/uptime-speed.ts +++ b/packages/contracts/src/uptime-speed.ts @@ -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 + +/** 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, +} diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index 6d967aea..c2454742 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -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) +})