fix(uptime): защитить пару устройств и атомарно сохранить результат
This commit is contained in:
1 parent
08ea416989
commit
a3fcba0d43
9 files changed
+218
-70
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
import { SpeedProbeEndpointsChanged } from "../services/speed-run-persistence.js"
|
||||||
import { createPingProbesWriteRoute } from "./ping-probes-write.js"
|
import { createPingProbesWriteRoute } from "./ping-probes-write.js"
|
||||||
import { createSpeedProbesWriteRoute } from "./speed-probes-write.js"
|
import { createSpeedProbesWriteRoute } from "./speed-probes-write.js"
|
||||||
import { replaceSpeedProbes } from "../services/speed-probe-replacement.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)
|
const message = /timed out|socket hang up|ECONNRESET/i.test(raw)
|
||||||
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
|
? "BTTest timeout/socket hang up: проверь доступность destination, /tool bandwidth-server, firewall и корректность интерфейсов"
|
||||||
: raw
|
: raw
|
||||||
|
if (e instanceof SpeedProbeEndpointsChanged) return reply.status(409).send({ error: message })
|
||||||
if (/активного IP|Server not found/i.test(raw)) {
|
if (/активного IP|Server not found/i.test(raw)) {
|
||||||
return reply.status(400).send({ error: message })
|
return reply.status(400).send({ error: message })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void>) => {
|
||||||
|
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 }])
|
||||||
|
})
|
||||||
@@ -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<SpeedProbeEndpoints>
|
||||||
|
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<C>(target: SpeedProbeEndpoints | null, deps: {
|
||||||
|
transaction: (work: (context: C) => Promise<void>) => Promise<void>
|
||||||
|
updateProbe: (context: C, target: SpeedProbeEndpoints) => Promise<boolean>
|
||||||
|
insertRun: (context: C, associatedProbeId: string | null) => Promise<void>
|
||||||
|
}): Promise<void> {
|
||||||
|
await deps.transaction(async context => {
|
||||||
|
const associated = target ? await deps.updateProbe(context, target) : false
|
||||||
|
await deps.insertRun(context, associated && target ? target.id : null)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { asc, eq } from "drizzle-orm"
|
import { and, asc, eq } from "drizzle-orm"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
|
import { servers, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
|
||||||
import type { SpeedRunSnapshot, SpeedScheduledRunSnapshot } from "../types/scheduler-run-snapshot.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 { MikrotikClient } from "./mikrotik.js"
|
||||||
import { parsePing } from "./uptime-collector.js"
|
import { parsePing } from "./uptime-collector.js"
|
||||||
import { assertBandwidthTestCompleted } from "./bandwidth-test-status.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"
|
import { readBandwidthTestRates } from "./bandwidth-test-rates.js"
|
||||||
|
|
||||||
|
type SpeedWriteTransaction = Parameters<Parameters<typeof db.transaction>[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 {
|
function firstUsableIpOnInterface(rows: RosIpAddress[], iface: string): string {
|
||||||
const name = iface.trim()
|
const name = iface.trim()
|
||||||
const candidates = rows.filter((a) => {
|
const candidates = rows.filter((a) => {
|
||||||
@@ -72,11 +78,16 @@ function humanizeSpeedError(raw: string): string {
|
|||||||
|
|
||||||
/** Полный цикл bandwidth-test + after-ping + запись в БД. Вызывать внутри withBtestNodeLocks при необходимости — здесь lock уже снаружи. */
|
/** Полный цикл bandwidth-test + after-ping + запись в БД. Вызывать внутри withBtestNodeLocks при необходимости — здесь lock уже снаружи. */
|
||||||
export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams): Promise<SpeedTestRunResult> {
|
export async function executeBandwidthSpeedTestInner(params: SpeedTestRunParams): Promise<SpeedTestRunResult> {
|
||||||
|
const probeId = String(params.probeId ?? "").trim()
|
||||||
const srcId = params.srcServerId
|
const srcId = params.srcServerId
|
||||||
const dstId = params.dstServerId
|
const dstId = params.dstServerId
|
||||||
const src = (await db.select().from(servers).where(eq(servers.id, srcId)).limit(1))[0]
|
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]
|
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 (!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 protocol = params.protocol === "udp" ? "udp" : "tcp"
|
||||||
const direction = params.direction === "transmit" || params.direction === "receive" ? params.direction : "both"
|
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)}`
|
const runId = String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
if (probeId) {
|
await persistSpeedRun<SpeedWriteTransaction>(probeId ? { id: probeId, srcServerId: srcId, dstServerId: dstId } : null, {
|
||||||
await db.update(uptimeSpeedProbes).set({
|
transaction: work => db.transaction(work),
|
||||||
lastRunAt: pingNow,
|
updateProbe: async (tx, target) => {
|
||||||
lastTxAvgMbps: txFinal,
|
const rows = await tx.update(uptimeSpeedProbes).set({
|
||||||
lastRxAvgMbps: rxFinal,
|
lastRunAt: pingNow,
|
||||||
lastStatus: "done",
|
lastTxAvgMbps: txFinal,
|
||||||
lastError: "",
|
lastRxAvgMbps: rxFinal,
|
||||||
lastPingRttMs: afterBtPing.rttMs,
|
lastStatus: "done",
|
||||||
lastPingLossPct: afterBtPing.lossPct,
|
lastError: "",
|
||||||
lastPingAt: pingNow,
|
lastPingRttMs: afterBtPing.rttMs,
|
||||||
lastPingError: afterBtPing.error ?? "",
|
lastPingLossPct: afterBtPing.lossPct,
|
||||||
updatedAt: pingNow,
|
lastPingAt: pingNow,
|
||||||
}).where(eq(uptimeSpeedProbes.id, probeId))
|
lastPingError: afterBtPing.error ?? "",
|
||||||
}
|
updatedAt: pingNow,
|
||||||
await db.insert(uptimeSpeedTestRuns).values({
|
}).where(speedProbeEndpointsWhere(target)).returning({ id: uptimeSpeedProbes.id })
|
||||||
id: runId,
|
return rows.length === 1
|
||||||
probeId: probeId || null,
|
},
|
||||||
srcServerId: srcId,
|
insertRun: async (tx, associatedProbeId) => {
|
||||||
dstServerId: dstId,
|
await tx.insert(uptimeSpeedTestRuns).values({
|
||||||
srcInterface: srcInterface || "",
|
id: runId,
|
||||||
dstInterface: dstInterface || "",
|
probeId: associatedProbeId,
|
||||||
srcAddress: srcAddress || src.host,
|
srcServerId: srcId,
|
||||||
dstAddress: initialAddress,
|
dstServerId: dstId,
|
||||||
srcInterfaceAddress,
|
srcInterface: srcInterface || "",
|
||||||
dstInterfaceAddress,
|
dstInterface: dstInterface || "",
|
||||||
protocol,
|
srcAddress: srcAddress || src.host,
|
||||||
direction,
|
dstAddress: initialAddress,
|
||||||
durationSec,
|
srcInterfaceAddress,
|
||||||
txAvgMbps: txFinal,
|
dstInterfaceAddress,
|
||||||
rxAvgMbps: rxFinal,
|
protocol,
|
||||||
pingRttMs: afterBtPing.rttMs,
|
direction,
|
||||||
pingLossPct: afterBtPing.lossPct,
|
durationSec,
|
||||||
pingError: afterBtPing.error ?? null,
|
txAvgMbps: txFinal,
|
||||||
status: "done",
|
rxAvgMbps: rxFinal,
|
||||||
error: null,
|
pingRttMs: afterBtPing.rttMs,
|
||||||
createdAt: pingNow,
|
pingLossPct: afterBtPing.lossPct,
|
||||||
|
pingError: afterBtPing.error ?? null,
|
||||||
|
status: "done",
|
||||||
|
error: null,
|
||||||
|
createdAt: pingNow,
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -208,36 +224,42 @@ export async function recordSpeedTestFailure(params: {
|
|||||||
}) {
|
}) {
|
||||||
const probeId = String(params.probeId ?? "").trim()
|
const probeId = String(params.probeId ?? "").trim()
|
||||||
const message = humanizeSpeedError(params.message)
|
const message = humanizeSpeedError(params.message)
|
||||||
if (probeId) {
|
await persistSpeedRun<SpeedWriteTransaction>(probeId ? { id: probeId, srcServerId: params.srcServerId, dstServerId: params.dstServerId } : null, {
|
||||||
await db.update(uptimeSpeedProbes).set({
|
transaction: work => db.transaction(work),
|
||||||
lastRunAt: new Date().toISOString(),
|
updateProbe: async (tx, target) => {
|
||||||
lastStatus: "error",
|
const rows = await tx.update(uptimeSpeedProbes).set({
|
||||||
lastError: message,
|
lastRunAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
lastStatus: "error",
|
||||||
}).where(eq(uptimeSpeedProbes.id, probeId))
|
lastError: message,
|
||||||
}
|
updatedAt: new Date().toISOString(),
|
||||||
await db.insert(uptimeSpeedTestRuns).values({
|
}).where(speedProbeEndpointsWhere(target)).returning({ id: uptimeSpeedProbes.id })
|
||||||
id: String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
return rows.length === 1
|
||||||
probeId: probeId || null,
|
},
|
||||||
srcServerId: params.srcServerId,
|
insertRun: async (tx, associatedProbeId) => {
|
||||||
dstServerId: params.dstServerId,
|
await tx.insert(uptimeSpeedTestRuns).values({
|
||||||
srcInterface: String(params.srcInterface ?? "").trim(),
|
id: String(params.runId ?? "").trim() || `sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
dstInterface: String(params.dstInterface ?? "").trim(),
|
probeId: associatedProbeId,
|
||||||
srcAddress: null,
|
srcServerId: params.srcServerId,
|
||||||
dstAddress: null,
|
dstServerId: params.dstServerId,
|
||||||
srcInterfaceAddress: null,
|
srcInterface: String(params.srcInterface ?? "").trim(),
|
||||||
dstInterfaceAddress: null,
|
dstInterface: String(params.dstInterface ?? "").trim(),
|
||||||
protocol: params.protocol === "udp" ? "udp" : "tcp",
|
srcAddress: null,
|
||||||
direction: params.direction === "transmit" || params.direction === "receive" ? params.direction : "both",
|
dstAddress: null,
|
||||||
durationSec: Math.max(3, Number.parseInt(String(params.durationSec ?? ""), 10) || 10),
|
srcInterfaceAddress: null,
|
||||||
txAvgMbps: null,
|
dstInterfaceAddress: null,
|
||||||
rxAvgMbps: null,
|
protocol: params.protocol === "udp" ? "udp" : "tcp",
|
||||||
pingRttMs: null,
|
direction: params.direction === "transmit" || params.direction === "receive" ? params.direction : "both",
|
||||||
pingLossPct: null,
|
durationSec: Math.max(3, Number.parseInt(String(params.durationSec ?? ""), 10) || 10),
|
||||||
pingError: null,
|
txAvgMbps: null,
|
||||||
status: "error",
|
rxAvgMbps: null,
|
||||||
error: message,
|
pingRttMs: null,
|
||||||
createdAt: new Date().toISOString(),
|
pingLossPct: null,
|
||||||
|
pingError: null,
|
||||||
|
status: "error",
|
||||||
|
error: message,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
| PG-4 | Ошибка внутри реального pg_restore | Восстановление не объявляется успешным. Проверить rollback по контрольным бизнес-строкам и схеме, отдельно проверить последовательности | Ошибка процесса, HTTP/UI и независимые запросы до/после |
|
| PG-4 | Ошибка внутри реального pg_restore | Восстановление не объявляется успешным. Проверить rollback по контрольным бизнес-строкам и схеме, отдельно проверить последовательности | Ошибка процесса, HTTP/UI и независимые запросы до/после |
|
||||||
| PG-5 | Два конкурентных backup/restore запроса | Advisory lock исключает конкурирующее выполнение; второй запрос сообщает конфликт | Два request ID, HTTP-коды, журнал фактических процессов |
|
| PG-5 | Два конкурентных backup/restore запроса | Advisory lock исключает конкурирующее выполнение; второй запрос сообщает конфликт | Два request ID, HTTP-коды, журнал фактических процессов |
|
||||||
| PG-6 | Длительная фоновая запись и UDP-приём во время подготовки restore | Подтверждена остановка всех writers и отсутствие записей в критическом интервале; после операции потребители восстановлены либо показано предупреждение | Процесс/worker lifecycle, счётчики приёма/записей, состояния jobs |
|
| 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 |
|
| 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).
|
Реализация: [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-4 | Home→JH и JH↔Exit с асимметричной скоростью | Направления DL/UL соответствуют клиентской стороне; model limits применяются к правильному направлению |
|
||||||
| ROS-5 | OSPF preview → действие → перечитывание templates | Preview соответствует исправленным RX/DL и TX/UL. Применение подтверждается фактическим cost; частичный отказ не становится полным успехом |
|
| ROS-5 | OSPF preview → действие → перечитывание templates | Preview соответствует исправленным RX/DL и TX/UL. Применение подтверждается фактическим cost; частичный отказ не становится полным успехом |
|
||||||
| ROS-6 | WireGuard/IPsec/GRE/VXLAN/Firewall/BGP/маршруты | Проблема→устройство→форма→подтверждённое действие→возврат сохраняет контекст. Права и повторное нажатие проверены; фактическая конфигурация и connectivity сверены |
|
| ROS-6 | WireGuard/IPsec/GRE/VXLAN/Firewall/BGP/маршруты | Проблема→устройство→форма→подтверждённое действие→возврат сохраняет контекст. Права и повторное нажатие проверены; фактическая конфигурация и connectivity сверены |
|
||||||
|
| ROS-8 | Смена пары/удаление speed-пробы до запуска и во время теста | Preflight409 до команды; при поздней смене новый snapshot не перезаписан, actual run сохранён без stale probe association |
|
||||||
| ROS-7 | Удаление/отсутствие доступа/частичный отказ одного устройства | Ошибка не стирает форму, последний снимок и объекты остальных устройств; неизвестные метрики не превращаются в0 |
|
| 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-серией не расширялись.
|
Для ROS-1/ROS-2 зафиксировать версию RouterOS и очищенный сырой ответ. Строковый status подтверждается по [официальному CLI reference](https://manual.mikrotik.com/docs/cli-reference/tool/bandwidth-test/); несовместимый REST shape требует отдельной коррекции контракта. Тест скорости способен нагрузить канал — длительность и endpoints выбираются для выделенного стенда. Команды и правила маршрутизации этой UX/UI-серией не расширялись.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# План реализации UX/UI по пятичасовым окнам
|
# План реализации 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). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-04. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# UX/UI: журнал реализации
|
# 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). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения.
|
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.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 изменения не включены.
|
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 отдельно.
|
Этот общий прогон содержит новые 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 полной версии параметров пробы.
|
||||||
@@ -796,3 +796,25 @@ for (const invalid of [false, true]) {
|
|||||||
expect(calls).toBe(2)
|
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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user