fix(api): сохранять каталог speed-проб атомарно
This commit is contained in:
1 parent
d4f2961f76
commit
908974302e
9 files changed
+204
-86
No files matched your search
+15
-24
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { speedHistoryResponseSchema, speedProbesResponseSchema } from "@mmapp/contracts"
|
||||
import { speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema } from "@mmapp/contracts"
|
||||
|
||||
import probeStyles from "./probe-rows.module.css"
|
||||
|
||||
@@ -1074,6 +1074,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ kind: "ping" | "speed"; id: string; label: string } | null>(null)
|
||||
const draftProbeId = useRef("")
|
||||
const [probeSaving, setProbeSaving] = useState(false)
|
||||
const [probeSaveWarning, setProbeSaveWarning] = useState<string | null>(null)
|
||||
const [probeSaveError, setProbeSaveError] = useState<string | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
const saveContext = useRef(0)
|
||||
@@ -1524,34 +1525,23 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
const persistSpeedProbes = useCallback(async (rows: SpeedProbeRow[]) => {
|
||||
if (!liveApi) return
|
||||
if (!speedCatalogReady) throw new Error("Каталог speed-проб не подтверждён. Обновите данные перед сохранением.")
|
||||
await apiFetch("/api/uptime/speed-probes", {
|
||||
method: "PUT",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
body: JSON.stringify({
|
||||
probes: rows.map((p) => ({
|
||||
id: p.id,
|
||||
srcServerId: p.srcServerId,
|
||||
dstServerId: p.dstServerId,
|
||||
srcInterface: p.srcInterface || "",
|
||||
dstInterface: p.dstInterface || "",
|
||||
protocol: p.protocol,
|
||||
direction: p.direction,
|
||||
durationSec: Number.parseInt(p.durationSec, 10) || 10,
|
||||
enabled: p.enabled,
|
||||
lastRunAt: p.lastRunAt ?? null,
|
||||
lastTxAvgMbps: p.lastTxAvgMbps ?? null,
|
||||
lastRxAvgMbps: p.lastRxAvgMbps ?? null,
|
||||
lastStatus: p.lastStatus ?? null,
|
||||
lastError: p.lastError ?? null,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
const payload = speedProbesWriteSchema.safeParse({ probes: rows.map(p => ({
|
||||
id: p.id, srcServerId: p.srcServerId, dstServerId: p.dstServerId,
|
||||
srcInterface: p.srcInterface || "", dstInterface: p.dstInterface || "",
|
||||
protocol: p.protocol, direction: p.direction, durationSec: p.durationSec, enabled: p.enabled,
|
||||
})) })
|
||||
if (!payload.success) throw new Error("Проверьте speed-пробы: источник и назначение должны отличаться, длительность — целое число от 3 секунд.")
|
||||
const saved = speedProbesSavedSchema.safeParse(await apiFetch<unknown>("/api/uptime/speed-probes", {
|
||||
method: "PUT", signal: AbortSignal.timeout(30_000), body: JSON.stringify(payload.data),
|
||||
}))
|
||||
if (!saved.success) throw new Error("Ответ о сохранении speed-проб не подтверждён. Обновите список перед повторной попыткой.")
|
||||
setProbeSaveWarning(saved.data.warning ?? null)
|
||||
}, [apiFetch, liveApi, speedCatalogReady])
|
||||
|
||||
async function saveProbeForm(save: () => Promise<void>, confirm: () => void) {
|
||||
if (!requireWrite()) return
|
||||
savingRef.current = true
|
||||
setProbeSaving(true); setProbeSaveError(null)
|
||||
setProbeSaving(true); setProbeSaveError(null); setProbeSaveWarning(null)
|
||||
overviewReqRef.current++
|
||||
const context = ++saveContext.current
|
||||
try {
|
||||
@@ -2014,6 +2004,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
{!canWrite && <ContextAlert variant="info" role="status"><ContextTitle>Только просмотр</ContextTitle><ContextDescription>Изменение проб и запуск измерений требуют разрешения на запись. Обновление данных загружает сохранённые результаты.</ContextDescription></ContextAlert>}
|
||||
{permissionError && !sheetOpen && !speedSheetOpen && !deleteTarget && <ContextAlert variant="destructive" role="alert"><ContextDescription>{permissionError}</ContextDescription></ContextAlert>}
|
||||
|
||||
{probeSaveWarning && <ContextAlert variant="warning" role="status"><ContextDescription>{probeSaveWarning}</ContextDescription></ContextAlert>}
|
||||
{probeSaveError && !sheetOpen && !speedSheetOpen && !deleteTarget && <ContextAlert variant="destructive" role="alert"><ContextDescription>{probeSaveError}</ContextDescription></ContextAlert>}
|
||||
|
||||
{urlError && <ContextAlert variant="warning" role="alert"><ContextTitle>Параметры ссылки требуют внимания</ContextTitle><ContextDescription>{urlError}<Button type="button" variant="outline" size="sm" onClick={resetUrl}>Сбросить параметры мониторинга</Button></ContextDescription></ContextAlert>}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import { SignJWT } from "jose"
|
||||
import { createSpeedProbesWriteRoute } from "./speed-probes-write.js"
|
||||
import { speedProbesWriteSchema, type SpeedProbeWrite } from "@mmapp/contracts"
|
||||
import { replaceSpeedProbes, type SpeedProbePatch, type SpeedProbeStore } from "../services/speed-probe-replacement.js"
|
||||
import { permissionForRequest } from "../lib/permissions.js"
|
||||
const probe = { id: "s1", srcServerId: "1", dstServerId: 2, srcInterface: " ether1 ", dstInterface: "", protocol: "tcp", direction: "both", durationSec: "10", enabled: false }
|
||||
let transactions = 0, refreshes = 0, failWrite = false, failScheduler = false
|
||||
let rows = new Map<string, SpeedProbePatch & { lastTxAvgMbps: number | null }>([["old", { ...speedProbesWriteSchema.parse({ probes: [probe] }).probes[0], id: "old", sortOrder: 0, updatedAt: "old", lastTxAvgMbps: 12 }]])
|
||||
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 })
|
||||
if (failWrite) throw new Error("database write failed")
|
||||
} })
|
||||
rows = draft
|
||||
} }
|
||||
const app = Fastify()
|
||||
await app.register(createSpeedProbesWriteRoute({ replace: probes => replaceSpeedProbes(store, probes, "2026-10-04T00:00:00Z"), refreshScheduler: async () => { refreshes++; if (failScheduler) throw new Error("scheduler unavailable") } }), { prefix: "/api" })
|
||||
const put = (payload: unknown) => app.inject({ method: "PUT", url: "/api/uptime/speed-probes", headers: { "content-type": "application/json" }, payload: JSON.stringify(payload) })
|
||||
for (const payload of [null, {}, { probes: null }, { probes: {} }, { probes: [probe, probe] }, ...[
|
||||
{ id: "" }, { srcServerId: "1junk" }, { srcServerId: 0 }, { srcServerId: 9007199254740992 }, { dstServerId: 1 },
|
||||
{ durationSec: "10junk" }, { durationSec: 2 }, { durationSec: 3.5 }, { durationSec: 2147483648 },
|
||||
{ protocol: "bad" }, { direction: "bad" }, { enabled: "true" }, { enabled: undefined }, { srcInterface: null },
|
||||
].map(patch => ({ probes: [probe, { ...probe, id: "s2", ...patch }] }))]) {
|
||||
assert.equal((await put(payload)).statusCode, 400)
|
||||
}
|
||||
assert.equal(transactions, 0); assert.equal(refreshes, 0); assert.ok(rows.has("old"))
|
||||
failWrite = true
|
||||
assert.equal((await put({ probes: [probe] })).statusCode, 500)
|
||||
assert.ok(rows.has("old")); assert.equal(rows.size, 1); assert.equal(refreshes, 0)
|
||||
failWrite = false
|
||||
const saved = await put({ probes: [probe] }); assert.equal(saved.statusCode, 200); assert.deepEqual(saved.json(), { ok: true })
|
||||
assert.equal(rows.has("old"), false); assert.equal(rows.get("s1")?.enabled, false); assert.equal(rows.get("s1")?.srcInterface, "ether1")
|
||||
rows.get("s1")!.lastTxAvgMbps = 42
|
||||
await put({ probes: [{ ...probe, durationSec: 20, lastTxAvgMbps: 0 }] })
|
||||
assert.equal(rows.get("s1")?.lastTxAvgMbps, 42); assert.equal(rows.get("s1")?.durationSec, 20)
|
||||
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)
|
||||
failScheduler = false
|
||||
assert.equal((await put({ probes: [] })).statusCode, 200); assert.equal(rows.size, 0)
|
||||
assert.equal(permissionForRequest("PUT", "/api/uptime/speed-probes"), "mm:uptime:write")
|
||||
// An ordered full replacement strips observations, preserves caller order and does not mutate the input.
|
||||
const input = speedProbesWriteSchema.parse({ probes: [probe, { ...probe, id: "s2", enabled: true }] }).probes as SpeedProbeWrite[]
|
||||
await replaceSpeedProbes(store, input)
|
||||
assert.deepEqual([...rows.values()].map(row => [row.id, row.sortOrder]), [["s1", 0], ["s2", 1]])
|
||||
assert.equal("sortOrder" in input[0], false)
|
||||
await app.close()
|
||||
console.log("Speed probes HTTP/DI: invalid catalog causes no writes; modeled rollback; order/observations; scheduler warning after commit; explicit empty replacement")
|
||||
|
||||
process.env.AUTH_REQUIRED = "true"; process.env.AUTH_JWT_SECRET = "speed-probes-test-secret"; process.env.AUTH_ISSUER = "https://auth.test.local"; process.env.NODE_ENV = "test"
|
||||
const { default: authPlugin, requireAuth } = await import("../plugins/auth.js")
|
||||
const secured = Fastify(); await secured.register(authPlugin); secured.addHook("preHandler", requireAuth)
|
||||
let authorizedWrites = 0
|
||||
await secured.register(createSpeedProbesWriteRoute({ replace: async () => { authorizedWrites++ }, refreshScheduler: async () => undefined }), { prefix: "/api" })
|
||||
const send = (token?: string) => secured.inject({ method: "PUT", url: "/api/uptime/speed-probes", payload: { probes: [] }, headers: token ? { authorization: "Bearer " + token } : {} })
|
||||
assert.equal((await send()).statusCode, 401); assert.equal(authorizedWrites, 0)
|
||||
for (const permission of ["mm:uptime:read", "mm:servers:write", "mm:uptime:write"]) {
|
||||
const token = await new SignJWT({ sub: "test", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" }).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("speed-probes-test-secret"))
|
||||
assert.equal((await send(token)).statusCode, permission === "mm:uptime:write" ? 200 : 403)
|
||||
}
|
||||
assert.equal(authorizedWrites, 1); await secured.close()
|
||||
console.log("Speed probes RBAC: signed JWT rejects missing/read/servers-write permissions before mutation")
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { speedProbesWriteSchema, type SpeedProbeWrite } from "@mmapp/contracts"
|
||||
export function createSpeedProbesWriteRoute(deps: {
|
||||
replace: (probes: SpeedProbeWrite[]) => Promise<void>
|
||||
refreshScheduler: () => Promise<unknown>
|
||||
}): FastifyPluginAsyncZod {
|
||||
return async app => {
|
||||
app.put("/uptime/speed-probes", async (req, reply) => {
|
||||
const parsed = speedProbesWriteSchema.safeParse(req.body)
|
||||
if (!parsed.success) return reply.code(400).send({ error: "Список speed-проб некорректен. Проверьте устройства, длительность и уникальность записей. Изменения не сохранены." })
|
||||
await deps.replace(parsed.data.probes)
|
||||
try {
|
||||
await deps.refreshScheduler()
|
||||
return reply.send({ ok: true })
|
||||
} catch (error) {
|
||||
req.log.error({ err: error }, "Speed probes saved; scheduler refresh failed")
|
||||
return reply.send({ ok: true, warning: "Пробы сохранены, но расписание не обновлено. Проверьте состояние сборщика данных." })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createSpeedProbesWriteRoute } from "./speed-probes-write.js"
|
||||
import { replaceSpeedProbes } from "../services/speed-probe-replacement.js"
|
||||
import { mapSpeedProbeRead } from "../services/speed-probe-dto.js"
|
||||
import { mapSpeedHistoryRun } from "../services/speed-history-dto.js"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import { asc, desc, eq, sql } from "drizzle-orm"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, serverSnapshots, uptimeSpeedProbes, uptimeSpeedTestRuns } from "../db/schema.js"
|
||||
import { MikrotikClient } from "../services/mikrotik.js"
|
||||
@@ -198,68 +200,22 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
}
|
||||
})
|
||||
|
||||
app.put("/uptime/speed-probes", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
probes?: Array<{
|
||||
id?: string
|
||||
srcServerId?: string | number
|
||||
dstServerId?: string | number
|
||||
srcInterface?: string
|
||||
dstInterface?: string
|
||||
protocol?: "tcp" | "udp"
|
||||
direction?: "transmit" | "receive" | "both"
|
||||
durationSec?: string | number
|
||||
enabled?: boolean
|
||||
}>
|
||||
}
|
||||
const normalized = (body.probes ?? [])
|
||||
.map((p, i) => ({
|
||||
id: p.id || `sp-${Date.now()}-${i}`,
|
||||
srcServerId: Number.parseInt(String(p.srcServerId ?? ""), 10),
|
||||
dstServerId: Number.parseInt(String(p.dstServerId ?? ""), 10),
|
||||
srcInterface: String(p.srcInterface ?? "").trim(),
|
||||
dstInterface: String(p.dstInterface ?? "").trim(),
|
||||
protocol: p.protocol === "udp" ? "udp" : "tcp",
|
||||
direction: p.direction === "transmit" || p.direction === "receive" ? p.direction : "both",
|
||||
durationSec: Math.max(3, Number.parseInt(String(p.durationSec ?? ""), 10) || 10),
|
||||
enabled: p.enabled !== false,
|
||||
}))
|
||||
.filter((p) => Number.isFinite(p.srcServerId) && Number.isFinite(p.dstServerId) && p.srcServerId !== p.dstServerId)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const existing = await db.select({ id: uptimeSpeedProbes.id }).from(uptimeSpeedProbes)
|
||||
const nextIds = new Set(normalized.map((p) => p.id))
|
||||
|
||||
for (const row of existing) {
|
||||
if (nextIds.has(row.id)) continue
|
||||
await db.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, row.id))
|
||||
}
|
||||
|
||||
for (let i = 0; i < normalized.length; i += 1) {
|
||||
const p = normalized[i]
|
||||
const patch = {
|
||||
srcServerId: p.srcServerId,
|
||||
dstServerId: p.dstServerId,
|
||||
srcInterface: p.srcInterface,
|
||||
dstInterface: p.dstInterface,
|
||||
protocol: p.protocol as "tcp" | "udp",
|
||||
direction: p.direction as "transmit" | "receive" | "both",
|
||||
durationSec: p.durationSec,
|
||||
enabled: p.enabled,
|
||||
sortOrder: i,
|
||||
updatedAt: now,
|
||||
}
|
||||
const updated = await db.update(uptimeSpeedProbes).set(patch).where(eq(uptimeSpeedProbes.id, p.id)).returning({ id: uptimeSpeedProbes.id })
|
||||
if (updated.length > 0) continue
|
||||
await db.insert(uptimeSpeedProbes).values({
|
||||
id: p.id,
|
||||
...patch,
|
||||
createdAt: now,
|
||||
await app.register(createSpeedProbesWriteRoute({
|
||||
replace: probes => replaceSpeedProbes({ transaction: run => db.transaction(async tx => {
|
||||
// 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),
|
||||
remove: async id => { await tx.delete(uptimeSpeedProbes).where(eq(uptimeSpeedProbes.id, id)) },
|
||||
upsert: async (row, createdAt) => {
|
||||
const { id, ...patch } = row
|
||||
await tx.insert(uptimeSpeedProbes).values({ id, ...patch, createdAt })
|
||||
.onConflictDoUpdate({ target: uptimeSpeedProbes.id, set: patch })
|
||||
},
|
||||
})
|
||||
}
|
||||
await refreshScheduler()
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
}) }, probes),
|
||||
refreshScheduler,
|
||||
}))
|
||||
|
||||
app.put("/uptime/probes", async (req, reply) => {
|
||||
const body = req.body as {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SpeedProbeWrite } from "@mmapp/contracts"
|
||||
export type SpeedProbePatch = SpeedProbeWrite & { sortOrder: number; updatedAt: string }
|
||||
export type SpeedProbeTransaction = {
|
||||
listIds: () => Promise<string[]>
|
||||
remove: (id: string) => Promise<void>
|
||||
upsert: (row: SpeedProbePatch, createdAt: string) => 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. */
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -36,6 +36,16 @@
|
||||
|
||||
|
||||
|
||||
## 12.3.23 — атомарная запись speed-проб (2026-10-04)
|
||||
|
||||
Общий write DTO требует явный probes массив, уникальные ID, безопасные положительные ID устройств, разные источник/назначение, целую длительность от3 секунд, protocol/direction/enabled. Missing/null/malformed каталог отклоняется400 целиком до обращения к хранилищу; явный probes:[] сохраняет намеренное удаление последней записи. Измерения из запроса исключены и не перезаписывают подтверждённые наблюдения.
|
||||
|
||||
Backend заменяет каталог в одной Drizzle transaction с pg_advisory_xact_lock для полной замены из разных процессов. Upsert обновляет только конфигурацию и порядок, timestamp создания сохранён. Ошибка refreshScheduler после commit возвращает ok:true с отдельным предупреждением; UI показывает предупреждение и требует подтверждённый ответ ok:true, сохраняет черновик при malformed acknowledgement. Неверная длительность отклоняется до PUT без parseInt fallback.
|
||||
|
||||
65 browser uptime/save/read сценариев прошли. HTTP/DI проверены malformed/no side effects, моделируемый rollback, explicit empty replacement, порядок и сохранение измерений, scheduler warning; signed JWT401/403/write проверен без PG. Typecheck, backend build, ESLint без ошибок (9 прежних uptime warnings) прошли. Production frontend build проверен отдельно. Реальный PostgreSQL rollback/advisory lock и работа scheduler на устройстве остаются внешней приёмкой12.5; модель DI не доказывает их.
|
||||
|
||||
Следующий блок12.3.24: большой OSPF route catalog — аудит фактического источника, пагинация50, поиск/сортировка по полному списку, refresh/error/recovery и320px. Затем остальные network grids и общая матрица12.4. Коммит локальный без push.
|
||||
|
||||
## 12.3.22 — общий каталог speed-проб и изоляция источника (2026-10-04)
|
||||
|
||||
Схема из probe-schedule-response перенесена в contracts: shared speedProbeReadSchema/speedProbesResponseSchema проверяют enums/duration/boolean/server IDs/уникальность ID и nullable observed values. Uptime и live schedule используют один контракт; ping-after-BT поля сохраняются, optional legacy отсутствие остаётся неизвестным. Pure backend mapSpeedProbeRead сохраняет null/0/false, нормализует DB timestamps; недостоверная запись возвращает502 вместо protocol/direction/duration/enabled fallback.
|
||||
|
||||
@@ -333,3 +333,14 @@ UptimeRoute keyed по probe ID/mode/backendUrl: каталог, история,
|
||||
60 browser uptime и6 после финальной copy/a11y правки прошли;8 новых context scenarios backend/mode × probes/history × success/error прошли. Второй consumer/diagnostics/schedule/history47 browser прошли, включая1000 probes и1001 rail. Unit mapper3 + shared reader2 прошли без PG. Typecheck/ESLint без ошибок (9 прежних uptime warnings), backend build и frontend/contracts production34 страницы прошли; ReUI checklist и scoped axe error alert пройдены,320px снимок просмотрен. Context файл включён в агрегированную отдельную конфигурацию. Коммит локальный без push.
|
||||
|
||||
Следующий блок12.3.23: строгий PUT speed-probes до DB mutation, отказ без удаления malformed строк и транзакционная полная замена. Текущий backend PUT всё ещё имеет permissive normalize/filter и последовательные mutations; защита UI не заменяет серверную проверку. Затем network grids/полная main/context матрица12.4;12.5 реальная PG/RouterOS/integration приёмка отдельно.
|
||||
|
||||
## 12.3.23 — атомарная запись speed-проб (2026-10-04)
|
||||
|
||||
Общий write DTO требует явный probes массив, уникальные ID, безопасные положительные ID устройств, разные источник/назначение, целую длительность от3 секунд, protocol/direction/enabled. Missing/null/malformed каталог отклоняется400 целиком до обращения к хранилищу; явный probes:[] сохраняет намеренное удаление последней записи. Измерения из запроса исключены и не перезаписывают подтверждённые наблюдения.
|
||||
|
||||
Backend заменяет каталог в одной Drizzle transaction с pg_advisory_xact_lock для полной замены из разных процессов. Upsert обновляет только конфигурацию и порядок, timestamp создания сохранён. Ошибка refreshScheduler после commit возвращает ok:true с отдельным предупреждением; UI показывает предупреждение и требует подтверждённый ответ ok:true, сохраняет черновик при malformed acknowledgement. Неверная длительность отклоняется до PUT без parseInt fallback.
|
||||
|
||||
65 browser uptime/save/read сценариев прошли. HTTP/DI проверены malformed/no side effects, моделируемый rollback, explicit empty replacement, порядок и сохранение измерений, scheduler warning; signed JWT401/403/write проверен без PG. Typecheck, backend build, ESLint без ошибок (9 прежних uptime warnings) прошли. Production frontend build проверен отдельно. Реальный PostgreSQL rollback/advisory lock и работа scheduler на устройстве остаются внешней приёмкой12.5; модель DI не доказывает их.
|
||||
|
||||
Следующий блок12.3.24: большой OSPF route catalog — аудит фактического источника, пагинация50, поиск/сортировка по полному списку, refresh/error/recovery и320px. Затем остальные network grids и общая матрица12.4. Коммит локальный без push.
|
||||
|
||||
@@ -27,3 +27,14 @@ export const speedProbeReadSchema = z.object({
|
||||
})
|
||||
export const speedProbesResponseSchema = z.object({ probes: z.array(speedProbeReadSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID speed-пробы") })
|
||||
export type SpeedProbeRead = z.infer<typeof speedProbeReadSchema>
|
||||
|
||||
const speedPositiveInteger = z.union([z.number(), z.string().regex(/^[1-9][0-9]*$/).transform(Number)]).pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER))
|
||||
export const speedProbeWriteSchema = z.object({
|
||||
id: z.string().trim().min(1), srcServerId: speedPositiveInteger, dstServerId: speedPositiveInteger,
|
||||
srcInterface: z.string().trim(), dstInterface: z.string().trim(),
|
||||
protocol: z.enum(["tcp", "udp"]), direction: z.enum(["both", "transmit", "receive"]),
|
||||
durationSec: speedPositiveInteger.pipe(z.number().min(3).max(2147483647)), enabled: z.boolean(),
|
||||
}).refine(row => row.srcServerId !== row.dstServerId, "Источник и назначение должны отличаться")
|
||||
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>
|
||||
@@ -688,3 +688,39 @@ for (const active of [false, true]) test(`Speed interfaces: загрузка и
|
||||
}
|
||||
expect(interfaceCalls).toBe(2)
|
||||
})
|
||||
|
||||
for (const invalid of ["2", "10junk", "3.5"]) test("Speed draft rejects invalid duration without PUT/" + invalid, async ({ page }) => {
|
||||
const control = await setup(page, "/uptime", 0, false, "mm:uptime:write")
|
||||
await page.getByRole("button", { name: "Пауза", exact: true }).click()
|
||||
await page.getByRole("button", { name: /Скорость/ }).click()
|
||||
await page.getByRole("button", { name: "Редактировать speed-пробу", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog"), input = dialog.locator("input").filter({ visible: true }).last()
|
||||
await input.fill(invalid)
|
||||
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(dialog.getByRole("alert")).toContainText("Проверьте speed-пробы")
|
||||
await expect(dialog).toBeVisible(); expect(control.writes).toEqual([])
|
||||
})
|
||||
for (const malformed of [false, true]) test("Speed save requires confirmation and shows scheduler warning/" + malformed, async ({ page }) => {
|
||||
await setup(page, "/uptime", 0, false, "mm:uptime:write")
|
||||
await page.getByRole("button", { name: "Пауза", exact: true }).click()
|
||||
await page.getByRole("button", { name: /Скорость/ }).click()
|
||||
let calls = 0
|
||||
await page.route("**/api/uptime/speed-probes", route => {
|
||||
if (route.request().method() !== "PUT") return route.fallback()
|
||||
calls++; const body = route.request().postDataJSON()
|
||||
expect(body.probes[0]).not.toHaveProperty("lastRunAt")
|
||||
return route.fulfill({ json: malformed ? {} : { ok: true, warning: "Пробы сохранены, но расписание не обновлено. Проверьте состояние сборщика данных." } })
|
||||
})
|
||||
await page.getByRole("button", { name: "Редактировать speed-пробу", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog"), input = dialog.locator("input").filter({ visible: true }).last()
|
||||
await input.fill("17")
|
||||
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
if (malformed) {
|
||||
await expect(dialog.getByRole("alert")).toContainText("Ответ о сохранении speed-проб не подтверждён")
|
||||
await expect(input).toHaveValue("17")
|
||||
} else {
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(page.getByRole("status").filter({ hasText: "Пробы сохранены, но расписание" })).toBeVisible()
|
||||
}
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
Reference in new issue
Block a user