fix(api): сохранять каталог пинг-проб атомарно

This commit is contained in:
Denozordec committed 2026-10-04 11:53:48 +07:00
1 parent 9c22fa8a68
commit 53c1bfbd3b
15 files changed
+243 -122

No files matched your search

+58 -43
View File
@@ -1,6 +1,8 @@
"use client" "use client"
import { speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema } from "@mmapp/contracts" import { dashboardProbeOverviewSchema } from "@/lib/dashboard-probes"
import { speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema, pingProbesWriteSchema, probeCatalogSavedSchema } from "@mmapp/contracts"
import probeStyles from "./probe-rows.module.css" import probeStyles from "./probe-rows.module.css"
@@ -250,7 +252,7 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
function StatChip({ function StatChip({
label, value, color, active, onClick, label, value, color, active, onClick,
}: { }: {
label: string; value: number; color?: "emerald" | "amber" | "red"; active?: boolean; onClick?: () => void label: string; value: number | string; color?: "emerald" | "amber" | "red"; active?: boolean; onClick?: () => void
}) { }) {
return ( return (
<button <button
@@ -1094,6 +1096,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const [overviewState, setOverviewState] = useState<"loading" | "ready" | "error">("loading") const [overviewState, setOverviewState] = useState<"loading" | "ready" | "error">("loading")
const [allServers, setAllServers] = useState<Server[]>([]) const [allServers, setAllServers] = useState<Server[]>([])
const { tab, setTab, search, setSearch, statusFilter, setStatusFilter, serverFilter, setServerFilter, error: urlError, resetFilters, resetUrl } = useUptimeUrlState() const { tab, setTab, search, setSearch, statusFilter, setStatusFilter, serverFilter, setServerFilter, error: urlError, resetFilters, resetUrl } = useUptimeUrlState()
const [pingReceivedAt, setPingReceivedAt] = useState<number | null>(null)
const pingCatalogKnown = !liveApi || pingReceivedAt !== null
const pingCatalogReady = !liveApi || overviewState === "ready"
const [probes, setProbes] = useState<PingProbe[]>([]) const [probes, setProbes] = useState<PingProbe[]>([])
const [resources, setResources] = useState<ServerResource[]>([]) const [resources, setResources] = useState<ServerResource[]>([])
const [isPaused, setIsPaused] = useState(false) const [isPaused, setIsPaused] = useState(false)
@@ -1149,13 +1154,17 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const myReq = ++overviewReqRef.current const myReq = ++overviewReqRef.current
setOpError(null) setOpError(null)
try { try {
const [serverRows, data] = await Promise.all([ const [serverRows, rawData] = await Promise.all([
apiFetch<BackendServer[]>("/api/servers"), apiFetch<BackendServer[]>("/api/servers"),
apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h"), apiFetch<unknown>("/api/uptime/overview?range=1h"),
]) ])
if (myReq !== overviewReqRef.current || context !== saveContext.current || (savingRef.current && writeContext !== context)) return if (myReq !== overviewReqRef.current || context !== saveContext.current || (savingRef.current && writeContext !== context)) return
const overview = dashboardProbeOverviewSchema.safeParse(rawData)
if (!overview.success || !pingProbesWriteSchema.safeParse({ probes: overview.data.probes }).success || !rawData || typeof rawData !== "object" || !Array.isArray((rawData as { resources?: unknown }).resources)) throw new Error("Ответ сервера содержит некорректные Ping-пробы.")
const data = { probes: overview.data.probes, resources: (rawData as { resources: ServerResource[] }).resources }
setAllServers(mapBackendServersToServers(serverRows)) setAllServers(mapBackendServersToServers(serverRows))
setProbes(data.probes) setProbes(data.probes)
setPingReceivedAt(Date.now())
setResources(data.resources) setResources(data.resources)
setOverviewState("ready") setOverviewState("ready")
} catch (e) { } catch (e) {
@@ -1483,26 +1492,21 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const persistProbes = useCallback(async (rows: PingProbe[]) => { const persistProbes = useCallback(async (rows: PingProbe[]) => {
if (!liveApi) return if (!liveApi) return
await apiFetch("/api/uptime/probes", { if (!pingCatalogReady) throw new Error("Каталог Ping-проб не подтверждён. Обновите данные перед сохранением.")
method: "PUT", const payload = pingProbesWriteSchema.safeParse({ probes: rows.map(p => ({
signal: AbortSignal.timeout(30_000), id: p.id, srcServerId: p.srcServerId, srcInterface: p.srcInterface || "", name: p.name, target: p.target,
body: JSON.stringify({ filter: p.filter, enabled: p.enabled, showOnDashboard: p.showOnDashboard === true,
probes: rows.map((p) => ({ })) })
id: p.id, if (!payload.success) throw new Error("Проверьте Ping-пробы: устройство, имя, цель и уникальность записей.")
srcServerId: p.srcServerId, const saved = probeCatalogSavedSchema.safeParse(await apiFetch<unknown>("/api/uptime/probes", {
srcInterface: p.srcInterface || "", method: "PUT", signal: AbortSignal.timeout(30_000), body: JSON.stringify(payload.data),
name: p.name, }))
target: p.target, if (!saved.success) throw new Error("Ответ о сохранении Ping-проб не подтверждён. Обновите список перед повторной попыткой.")
filter: p.filter, setProbeSaveWarning(saved.data.warning ?? null)
enabled: p.enabled, }, [apiFetch, liveApi, pingCatalogReady])
showOnDashboard: p.showOnDashboard === true,
})),
}),
})
}, [apiFetch, liveApi])
const toggleDashboardStar = (id: string) => { const toggleDashboardStar = (id: string) => {
if (!requireWrite()) return if (!requireWrite() || !pingCatalogReady) return
const cur = probes.find(p => p.id === id) const cur = probes.find(p => p.id === id)
if (!cur) return if (!cur) return
const nextVal = !cur.showOnDashboard const nextVal = !cur.showOnDashboard
@@ -1558,13 +1562,13 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
// ── actions ── // ── actions ──
const toggleProbe = (id: string, v: boolean) => { const toggleProbe = (id: string, v: boolean) => {
if (!requireWrite()) return if (!requireWrite() || !pingCatalogReady) return
const next = probes.map(p => p.id === id ? { ...p, enabled: v } : p) const next = probes.map(p => p.id === id ? { ...p, enabled: v } : p)
void saveProbeForm(() => persistProbes(next), () => setProbes(next)) void saveProbeForm(() => persistProbes(next), () => setProbes(next))
} }
const deleteProbe = (id: string) => { const deleteProbe = (id: string) => {
if (!requireWrite()) return if (!requireWrite() || !pingCatalogReady) return
const probe = probes.find(p => p.id === id) const probe = probes.find(p => p.id === id)
if (!probe) return if (!probe) return
setProbeSaveError(null) setProbeSaveError(null)
@@ -1595,7 +1599,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
setStatusFilter(prev => prev === s ? "all" : s) setStatusFilter(prev => prev === s ? "all" : s)
const openEditProbe = (probe: PingProbe) => { const openEditProbe = (probe: PingProbe) => {
if (!requireWrite()) return if (!requireWrite() || !pingCatalogReady) return
setProbeSaveError(null) setProbeSaveError(null)
setEditingProbeId(probe.id) setEditingProbeId(probe.id)
setNewSrcId(probe.srcServerId) setNewSrcId(probe.srcServerId)
@@ -1607,7 +1611,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
} }
const openAddSheet = () => { const openAddSheet = () => {
if (!requireWrite()) return if (!requireWrite() || !pingCatalogReady) return
draftProbeId.current = "p" + Date.now() draftProbeId.current = "p" + Date.now()
setProbeSaveError(null) setProbeSaveError(null)
setEditingProbeId(null) setEditingProbeId(null)
@@ -1988,7 +1992,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
</Button> </Button>
{tab === "probes" && ( {tab === "probes" && (
<Button size="sm" disabled={probeSaving || !canWrite} onClick={openAddSheet}> <Button size="sm" disabled={probeSaving || !canWrite || !pingCatalogReady} onClick={openAddSheet}>
<PlusIcon className="size-4" />Новая проба <PlusIcon className="size-4" />Новая проба
</Button> </Button>
)} )}
@@ -2052,7 +2056,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{stats.down} {stats.down}
</span> </span>
)} )}
<span className="text-[10px] font-mono opacity-50">{probes.length}</span> <span className="text-[10px] font-mono opacity-50">{pingCatalogKnown ? probes.length : "—"}</span>
</span> </span>
) : t === "resources" ? ( ) : t === "resources" ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@@ -2365,8 +2369,19 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{/* ── probes tab ── */} {/* ── probes tab ── */}
{tab === "probes" && <> {tab === "probes" && <>
{liveApi && !pingCatalogReady && <div className="px-4 pt-3 md:px-6">
<ContextAlert variant="warning" role={overviewState === "error" ? "alert" : "status"} aria-label="Чтение Ping-проб">
<AlertCircleIcon aria-hidden="true" />
<ContextTitle>Каталог не подтверждён</ContextTitle>
<ContextDescription>
<p>{opError ?? "Ожидание ответа сервера."}</p>
<p>{pingReceivedAt !== null ? "Показан последний успешный список, полученный " + new Date(pingReceivedAt).toLocaleTimeString("ru-RU") + "." : "Данные пока неизвестны."}</p>
<p>Изменения доступны после успешного обновления.</p>
</ContextDescription>
</ContextAlert>
</div>}
{opError && ( {opError && pingCatalogReady && (
<div className="px-6 pt-4"> <div className="px-6 pt-4">
<Alert variant="destructive" className="py-2 text-xs"> <Alert variant="destructive" className="py-2 text-xs">
<AlertCircleIcon /> <AlertCircleIcon />
@@ -2392,10 +2407,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{/* ── summary + server filter ── */} {/* ── summary + server filter ── */}
<div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap"> <div className="border-b bg-muted/20 px-6 py-3 flex items-center gap-3 flex-wrap">
<StatChip label="Всего" value={stats.total} /> <StatChip label="Всего" value={pingCatalogKnown ? stats.total : "—"} />
<StatChip label="OK" value={stats.up} color="emerald" active={statusFilter === "up"} onClick={() => cycleStatusFilter("up")} /> <StatChip label="OK" value={pingCatalogKnown ? stats.up : "—"} color="emerald" active={statusFilter === "up"} onClick={() => cycleStatusFilter("up")} />
<StatChip label="Предупреждение" value={stats.warn} color="amber" active={statusFilter === "warn"} onClick={() => cycleStatusFilter("warn")} /> <StatChip label="Предупреждение" value={pingCatalogKnown ? stats.warn : "—"} color="amber" active={statusFilter === "warn"} onClick={() => cycleStatusFilter("warn")} />
<StatChip label="Недоступен" value={stats.down} color="red" active={statusFilter === "down"} onClick={() => cycleStatusFilter("down")} /> <StatChip label="Недоступен" value={pingCatalogKnown ? stats.down : "—"} color="red" active={statusFilter === "down"} onClick={() => cycleStatusFilter("down")} />
<div className="w-px h-4 bg-border mx-1 shrink-0" /> <div className="w-px h-4 bg-border mx-1 shrink-0" />
@@ -2453,7 +2468,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
</Button> </Button>
<p className="text-xs text-muted-foreground shrink-0"> <p className="text-xs text-muted-foreground shrink-0">
{filtered.length} из {probes.length} проб {pingCatalogKnown ? filtered.length + " из " + probes.length + " проб" : "Каталог проб пока неизвестен"}
<span className="text-muted-foreground/70 hidden md:inline"> · </span> <span className="text-muted-foreground/70 hidden md:inline"> · </span>
<span className="text-muted-foreground/80 hidden md:inline" title="Отображение на дашборде"> <span className="text-muted-foreground/80 hidden md:inline" title="Отображение на дашборде">
звезда — блок «Активные пробы» звезда — блок «Активные пробы»
@@ -2464,7 +2479,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{/* ── probe groups ── */} {/* ── probe groups ── */}
<div className="p-4 md:p-6 flex flex-col gap-4"> <div className="p-4 md:p-6 flex flex-col gap-4">
{grouped.length === 0 && ( {pingCatalogKnown && grouped.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-2"> <div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-2">
<SearchIcon className="size-8 opacity-20" /> <SearchIcon className="size-8 opacity-20" />
<p className="text-sm">Пробы не найдены</p> <p className="text-sm">Пробы не найдены</p>
@@ -2560,7 +2575,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
!p.enabled && "opacity-40", !p.enabled && "opacity-40",
)} )}
> >
<label className={probeStyles.toggle}><span className="sr-only">Включить пробу {p.name}</span><span className={probeStyles.label} aria-hidden="true">Включена</span><FormToggle disabled={probeSaving || !canWrite} checked={p.enabled} onChange={v => toggleProbe(p.id, v)} /></label> <label className={probeStyles.toggle}><span className="sr-only">Включить пробу {p.name}</span><span className={probeStyles.label} aria-hidden="true">Включена</span><FormToggle disabled={probeSaving || !canWrite || !pingCatalogReady} checked={p.enabled} onChange={v => toggleProbe(p.id, v)} /></label>
<div className={probeStyles.status}><StatusDot <div className={probeStyles.status}><StatusDot
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"} status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
pulse={p.status === "up" && p.enabled} pulse={p.status === "up" && p.enabled}
@@ -2623,7 +2638,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
size="sm" size="sm"
variant="ghost" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-amber-500" className="size-7 p-0 text-muted-foreground hover:text-amber-500"
disabled={probeSaving || !canWrite} onClick={() => toggleDashboardStar(p.id)} disabled={probeSaving || !canWrite || !pingCatalogReady} onClick={() => toggleDashboardStar(p.id)}
title={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"} title={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"}
aria-label={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"} aria-label={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"}
aria-pressed={p.showOnDashboard === true} aria-pressed={p.showOnDashboard === true}
@@ -2641,12 +2656,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
<div className={cn(probeStyles.actions, "flex justify-end gap-1")}> <div className={cn(probeStyles.actions, "flex justify-end gap-1")}>
<Button size="sm" variant="ghost" <Button size="sm" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground" className="size-7 p-0 text-muted-foreground hover:text-foreground"
disabled={probeSaving || !canWrite} type="button" aria-label="Редактировать пробу" onClick={() => openEditProbe(p)} title="Редактировать пробу"> disabled={probeSaving || !canWrite || !pingCatalogReady} type="button" aria-label="Редактировать пробу" onClick={() => openEditProbe(p)} title="Редактировать пробу">
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
</Button> </Button>
<Button size="sm" variant="ghost" <Button size="sm" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-destructive" className="size-7 p-0 text-muted-foreground hover:text-destructive"
disabled={probeSaving || !canWrite} type="button" aria-label="Удалить пробу" onClick={() => deleteProbe(p.id)} title="Удалить пробу"> disabled={probeSaving || !canWrite || !pingCatalogReady} type="button" aria-label="Удалить пробу" onClick={() => deleteProbe(p.id)} title="Удалить пробу">
<TrashIcon className="size-3.5" /> <TrashIcon className="size-3.5" />
</Button> </Button>
</div> </div>
@@ -2690,7 +2705,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
})} })}
<button <button
disabled={probeSaving || !canWrite} disabled={probeSaving || !canWrite || !pingCatalogReady}
onClick={() => { onClick={() => {
if (!requireWrite()) return if (!requireWrite()) return
setProbeSaveError(null) setProbeSaveError(null)
@@ -2963,7 +2978,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
Отмена Отмена
</Button> </Button>
<Button className="flex-1" <Button className="flex-1"
disabled={probeSaving || !canWrite || !newName.trim() || !newTarget.trim() || !newSrcId} disabled={probeSaving || !canWrite || !pingCatalogReady || !newName.trim() || !newTarget.trim() || !newSrcId}
onClick={handleSaveProbe}> onClick={handleSaveProbe}>
{editingProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />} {editingProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
{probeSaving ? "Сохранение…" : editingProbeId ? "Сохранить" : "Добавить"} {probeSaving ? "Сохранение…" : editingProbeId ? "Сохранить" : "Добавить"}
@@ -2975,12 +2990,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Удалить пробу?</AlertDialogTitle> <AlertDialogTitle>Удалить пробу?</AlertDialogTitle>
<AlertDialogDescription>Проба «{deleteTarget?.label}» будет удалена из мониторинга.</AlertDialogDescription> <AlertDialogDescription>Проба «{deleteTarget?.label}» будет удалена из мониторинга.{deleteTarget?.kind === "ping" ? " Вместе с ней будет удалена история её измерений." : " История speed-тестов сохранится."}</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
{(probeSaveError || permissionError) && <p role="alert" className="text-sm text-destructive">{probeSaveError || permissionError}</p>} {(probeSaveError || permissionError) && <p role="alert" className="text-sm text-destructive">{probeSaveError || permissionError}</p>}
<AlertDialogFooter> <AlertDialogFooter>
<Button type="button" variant="outline" disabled={probeSaving} onClick={() => setDeleteTarget(null)}>Отмена</Button> <Button type="button" variant="outline" disabled={probeSaving} onClick={() => setDeleteTarget(null)}>Отмена</Button>
<Button type="button" variant="destructive" disabled={probeSaving || !canWrite} onClick={confirmProbeDelete}>{probeSaving ? "Удаление…" : "Удалить"}</Button> <Button type="button" variant="destructive" disabled={probeSaving || !canWrite || (deleteTarget?.kind === "ping" ? !pingCatalogReady : !speedCatalogReady)} onClick={confirmProbeDelete}>{probeSaving ? "Удаление…" : "Удалить"}</Button>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
@@ -0,0 +1,52 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { SignJWT } from "jose"
import { createPingProbesWriteRoute } from "./ping-probes-write.js"
import { pingProbesWriteSchema } from "@mmapp/contracts"
import { replacePingProbes, type PingProbePatch, type PingProbeStore } from "../services/ping-probe-replacement.js"
const probe = { id: "p1", srcServerId: "1", name: " Проверка ", target: "192.0.2.2", enabled: false, showOnDashboard: true }
let transactions = 0, refreshes = 0, failWrite = false, failScheduler = false
let rows = new Map<string, PingProbePatch>([["old", { ...pingProbesWriteSchema.parse({ probes: [probe] }).probes[0], id: "old", probeFilter: "main", sortOrder: 0, updatedAt: "old", intervalSec: 60 }]])
let history = new Map([["old", [42]]])
const store: PingProbeStore = { transaction: async run => {
transactions++; const draft = new Map(rows), samples = new Map(history)
await run({ listIds: async () => [...draft.keys()], remove: async id => { draft.delete(id); samples.delete(id) }, upsert: async patch => {
draft.set(patch.id, { ...draft.get(patch.id), ...patch })
if (failWrite) throw new Error("write failed")
} })
rows = draft; history = samples
} }
const app = Fastify(); await app.register(createPingProbesWriteRoute({ replace: rows => replacePingProbes(store, rows), refreshScheduler: async () => { refreshes++; if (failScheduler) throw new Error("scheduler failed") } }), { prefix: "/api" })
const put = (payload: unknown) => app.inject({ method: "PUT", url: "/api/uptime/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 }, { name: " " }, { target: " " },
{ enabled: undefined }, { enabled: "true" }, { showOnDashboard: "false" }, { filter: false }, { srcInterface: null },
{ intervalSec: -1 }, { intervalSec: "10junk" }, { intervalSec: 3.5 }, { intervalSec: 2147483648 },
].map(patch => ({ probes: [probe, { ...probe, id: "p2", ...patch }] }))]) assert.equal((await put(payload)).statusCode, 400)
assert.equal(transactions, 0); assert.equal(refreshes, 0); assert.deepEqual(history.get("old"), [42])
failWrite = true; assert.equal((await put({ probes: [probe] })).statusCode, 500)
assert.ok(rows.has("old")); assert.deepEqual(history.get("old"), [42]); assert.equal(refreshes, 0)
failWrite = false; assert.equal((await put({ probes: [{ ...probe, intervalSec: 60 }] })).statusCode, 200)
assert.equal(rows.get("p1")?.name, "Проверка"); assert.equal(rows.get("p1")?.enabled, false); assert.equal(history.has("old"), false)
history.set("p1", [0, 42]); await put({ probes: [{ ...probe, showOnDashboard: undefined }] })
assert.equal(rows.get("p1")?.intervalSec, 60); assert.equal(rows.get("p1")?.showOnDashboard, true); assert.deepEqual(history.get("p1"), [0, 42])
for (const intervalSec of [3, 0, "", null]) {
await put({ probes: [{ ...probe, intervalSec }] }); assert.equal(rows.get("p1")?.intervalSec, intervalSec === 3 ? 5 : 0)
}
failScheduler = true; const saved = await put({ probes: [{ ...probe, name: "Изменена" }] })
assert.equal(saved.statusCode, 200); assert.equal(saved.json().ok, true); assert.match(saved.json().warning, /Ping-пробы сохранены/); assert.equal(rows.get("p1")?.name, "Изменена")
failScheduler = false; await put({ probes: [probe, { ...probe, id: "p2", name: "Вторая" }] })
assert.deepEqual([...rows.values()].map(row => [row.id, row.sortOrder]), [["p1", 0], ["p2", 1]])
assert.equal((await put({ probes: [] })).statusCode, 200); assert.equal(rows.size, 0); assert.equal(history.size, 0)
await app.close(); console.log("Ping probes HTTP/DI: whole request validated before mutation; modeled atomic rollback including history; omitted interval preserved; explicit delete and scheduler warning")
process.env.AUTH_REQUIRED = "true"; process.env.AUTH_JWT_SECRET = "ping-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 writes = 0; await secured.register(createPingProbesWriteRoute({ replace: async () => { writes++ }, refreshScheduler: async () => undefined }), { prefix: "/api" })
const send = (token?: string) => secured.inject({ method: "PUT", url: "/api/uptime/probes", payload: { probes: [] }, headers: token ? { authorization: "Bearer " + token } : {} })
assert.equal((await send()).statusCode, 401); assert.equal(writes, 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("ping-probes-test-secret"))
assert.equal((await send(token)).statusCode, permission === "mm:uptime:write" ? 200 : 403)
}
assert.equal(writes, 1); await secured.close(); console.log("Ping probes RBAC: unauthorized requests have no storage side effects")
+16
View File
@@ -0,0 +1,16 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { pingProbesWriteSchema, type PingProbeWrite } from "@mmapp/contracts"
export function createPingProbesWriteRoute(deps: { replace: (probes: PingProbeWrite[]) => Promise<void>; refreshScheduler: () => Promise<unknown> }): FastifyPluginAsyncZod {
return async app => {
app.put("/uptime/probes", async (req, reply) => {
const parsed = pingProbesWriteSchema.safeParse(req.body)
if (!parsed.success) return reply.code(400).send({ error: "Список Ping-проб некорректен. Проверьте устройства, имена, цели и уникальность записей. Изменения не сохранены." })
await deps.replace(parsed.data.probes)
try { await deps.refreshScheduler(); return reply.send({ ok: true }) }
catch (error) {
req.log.error({ err: error }, "Ping probes saved; scheduler refresh failed")
return reply.send({ ok: true, warning: "Ping-пробы сохранены, но расписание не обновлено. Проверьте состояние сборщика данных." })
}
})
}
}
+2 -33
View File
@@ -1,3 +1,4 @@
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"
import { mapSpeedProbeRead } from "../services/speed-probe-dto.js" import { mapSpeedProbeRead } from "../services/speed-probe-dto.js"
@@ -217,39 +218,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
refreshScheduler, refreshScheduler,
})) }))
app.put("/uptime/probes", async (req, reply) => { await app.register(createPingProbesWriteRoute({ replace: replaceProbes, refreshScheduler }))
const body = req.body as {
probes?: Array<{
id?: string
srcServerId?: string | number
srcInterface?: string
name?: string
target?: string
filter?: string
enabled?: boolean
showOnDashboard?: boolean
intervalSec?: number | string
}>
}
const normalized = (body.probes ?? [])
.map((p, i) => ({
id: p.id || `p-${Date.now()}-${i}`,
srcServerId: Number.parseInt(String(p.srcServerId ?? ""), 10),
srcInterface: String(p.srcInterface ?? "").trim(),
name: String(p.name ?? "").trim(),
target: String(p.target ?? "").trim(),
probeFilter: String(p.filter ?? "—"),
enabled: p.enabled !== false,
showOnDashboard: p.showOnDashboard === true,
intervalSec: p.intervalSec == null || p.intervalSec === ""
? undefined
: Number.parseInt(String(p.intervalSec), 10),
}))
.filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target)
await replaceProbes(normalized)
await refreshScheduler()
return reply.send({ ok: true })
})
app.patch("/uptime/probes/:id", async (req, reply) => { app.patch("/uptime/probes/:id", async (req, reply) => {
const params = req.params as { id?: string } const params = req.params as { id?: string }
@@ -0,0 +1,21 @@
import type { PingProbeWrite } from "@mmapp/contracts"
export type PingProbePatch = Omit<PingProbeWrite, "filter"> & { probeFilter: string; sortOrder: number; updatedAt: string }
export type PingProbeTransaction = {
listIds: () => Promise<string[]>
remove: (id: string) => Promise<void>
upsert: (row: PingProbePatch, createdAt: string) => Promise<void>
}
export type PingProbeStore = { transaction: (run: (tx: PingProbeTransaction) => Promise<void>) => Promise<void> }
export async function replacePingProbes(store: PingProbeStore, probes: PingProbeWrite[], 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()) {
const { filter, intervalSec, showOnDashboard, ...config } = row
await tx.upsert({ ...config, probeFilter: filter, sortOrder, updatedAt: now,
...(intervalSec === undefined ? {} : { intervalSec: intervalSec > 0 ? Math.max(5, intervalSec) : 0 }),
...(showOnDashboard === undefined ? {} : { showOnDashboard }),
}, now)
}
})
}
+15 -43
View File
@@ -1,4 +1,6 @@
import { and, asc, eq, gte, inArray, max, or } from "drizzle-orm" import { replacePingProbes } from "./ping-probe-replacement.js"
import type { PingProbeWrite } from "@mmapp/contracts"
import { and, asc, eq, gte, inArray, max, or, sql } from "drizzle-orm"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { import {
servers, servers,
@@ -467,49 +469,19 @@ export async function updateProbeShowOnDashboard(probeId: string, showOnDashboar
return r.length > 0 return r.length > 0
} }
export async function replaceProbes(rows: Array<{ export async function replaceProbes(rows: PingProbeWrite[]) {
id: string await replacePingProbes({ transaction: run => db.transaction(async tx => {
srcServerId: number await tx.execute(sql`select pg_advisory_xact_lock(hashtext('uptime-ping-probes'))`)
srcInterface: string await run({
name: string listIds: async () => (await tx.select({ id: uptimeProbes.id }).from(uptimeProbes)).map(row => row.id),
target: string remove: async id => { await tx.delete(uptimeProbes).where(eq(uptimeProbes.id, id)) },
probeFilter: string upsert: async (row, createdAt) => {
enabled: boolean const { id, ...patch } = row
showOnDashboard?: boolean await tx.insert(uptimeProbes).values({ id, ...patch, createdAt })
intervalSec?: number .onConflictDoUpdate({ target: uptimeProbes.id, set: patch })
}>) { },
const now = new Date().toISOString()
const existing = await db.select({ id: uptimeProbes.id }).from(uptimeProbes)
const nextIds = new Set(rows.map((r) => r.id))
for (const p of existing) {
if (nextIds.has(p.id)) continue
await db.delete(uptimeProbes).where(eq(uptimeProbes.id, p.id))
}
for (let i = 0; i < rows.length; i += 1) {
const r = rows[i]
const intervalSec = r.intervalSec != null && r.intervalSec > 0 ? Math.max(5, Math.floor(r.intervalSec)) : 0
const patch = {
srcServerId: r.srcServerId,
srcInterface: r.srcInterface || "",
name: r.name,
target: r.target,
probeFilter: r.probeFilter || "—",
enabled: r.enabled,
showOnDashboard: r.showOnDashboard ?? false,
intervalSec,
sortOrder: i,
updatedAt: now,
}
const updated = await db.update(uptimeProbes).set(patch).where(eq(uptimeProbes.id, r.id)).returning({ id: uptimeProbes.id })
if (updated.length > 0) continue
await db.insert(uptimeProbes).values({
id: r.id,
...patch,
createdAt: now,
}) })
} }) }, rows)
} }
export async function readProbeSamplesSince(sinceIso: string) { export async function readProbeSamplesSince(sinceIso: string) {
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-04 ## Актуальная точка продолжения — 2026-10-04
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принLine truncated Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принLine truncated
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+11 -1
View File
@@ -29,13 +29,23 @@
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.25 приняты локально в описанных границах;12.4.4 context213 и12.4.5 main1110 прошли, остаются Ping write/network grids/внешняя приёмка | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.25 приняты локально в описанных границах;12.4.4 context213 и12.4.5 main1110 прошли, 12.3.26 Ping write принят локально; остаются WireGuard counters/network grids/внешняя приёмка |
## 12.3.26 — атомарная запись каталога Ping-проб (2026-10-04)
Shared write DTO проверяет весь список до DB: обязательный массив, уникальные ID, безопасные ID устройств, непустые имена/цели, boolean и целые интервалы. Некорректные строки не отбрасываются с последующим удалением других проб. Замена выполняется в PostgreSQL transaction с advisory lock; конфигурация обновляется отдельно от измерений. Оmitted interval/dashboard сохраняются для существующих строк, явный пустой список означает удаление. Ошибка refreshScheduler после commit возвращает подтверждённое ok:true с предупреждением.
UI проверяет Ping-каталог до публикации, сохраняет последний успешный список при ошибке и запрещает замену неподтверждённого каталога. Первоначально неизвестные количества показаны как —. PUT требует подтверждённый ответ; malformed ack сохраняет черновик. Один ReUI Alert показывает причину и время последнего успешного списка. Диалог удаления точно описывает каскадное удаление Ping history; speed history сохраняется. Снимок320px: [Ping catalog error](ux-ui/screens/ping-catalog-error-320.png).
Проверки: uptime-links62/62; context uptime/backend-switch11/11; pure/HTTP DI6/6 (включая JWT401/403, отсутствие mutations до validation и моделированный rollback с историей). TypeScript без incremental и ESLint прошли (9 исходных warnings Uptime); backend и frontend production build прошли. ReUI Alert API/usage проверены, primitives/base-nova не изменены. Fixture backend-switch дополнен реальным backend полем showOnDashboard. Общая регрессия1110/213 относится к предыдущим HEAD, после delta целиком ещё не повторена.
Реальный PG rollback/advisory lock/RouterOS не проверены; DI не заменяет внешнюю12.5. Whole-catalog PUT остаётся last writer wins без revision/ETag. Resources entries остаются legacy DTO (проверена только array оболочка). Следующий блок12.3.27: WireGuard missing/zero RX/TX в деталях и таблице пиров; далее network grids. Коммит локальный, без push.
## 12.4.5 — повтор основной регрессии (2026-10-04) ## 12.4.5 — повтор основной регрессии (2026-10-04)
На HEAD21859be полный playwright.config.ts дал1110 из1110 passed/171 файл за26.6min;0 unexpected/0 skipped/0 flaky, Edge/workers1/retries0/maxFailures12. Прежние3 устаревшие fixtures из12.4.1 исправлены и теперь прошли в общей матрице. Включены последние speed read/write и большие OSPF/BFD профили, двухтемная доступность оболочки, права, responsive и сквозные API scenarios. Доказательство: docs/ux-ui/acceptance/main-repeat-2026-10-04.json с точным HEAD и результатами по файлам. Context213/25 и unit36 приняты отдельно в12.4.4. Исторические PNG, пересозданные этим прогоном, восстановлены по явному проверенному списку; новые принятые OSPF/BFD снимки сохранены. На HEAD21859be полный playwright.config.ts дал1110 из1110 passed/171 файл за26.6min;0 unexpected/0 skipped/0 flaky, Edge/workers1/retries0/maxFailures12. Прежние3 устаревшие fixtures из12.4.1 исправлены и теперь прошли в общей матрице. Включены последние speed read/write и большие OSPF/BFD профили, двухтемная доступность оболочки, права, responsive и сквозные API scenarios. Доказательство: docs/ux-ui/acceptance/main-repeat-2026-10-04.json с точным HEAD и результатами по файлам. Context213/25 и unit36 приняты отдельно в12.4.4. Исторические PNG, пересозданные этим прогоном, восстановлены по явному проверенному списку; новые принятые OSPF/BFD снимки сохранены.
+11
View File
@@ -380,3 +380,14 @@ Backend заменяет каталог в одной Drizzle transaction с pg_
Следующий блок12.3.26: строгий Ping write DTO до DB, атомарная замена, защита записи неизвестного каталога, подтверждение scheduler warning и точная copy о каскадном удалении истории. Затем12.3.27 WireGuard missing/zero counters и оставшиеся network grids. Внешняя12.5 отдельно; коммит локальный без push. Следующий блок12.3.26: строгий Ping write DTO до DB, атомарная замена, защита записи неизвестного каталога, подтверждение scheduler warning и точная copy о каскадном удалении истории. Затем12.3.27 WireGuard missing/zero counters и оставшиеся network grids. Внешняя12.5 отдельно; коммит локальный без push.
## 12.3.26 — атомарная запись каталога Ping-проб (2026-10-04)
Shared write DTO проверяет весь список до DB: обязательный массив, уникальные ID, безопасные ID устройств, непустые имена/цели, boolean и целые интервалы. Некорректные строки не отбрасываются с последующим удалением других проб. Замена выполняется в PostgreSQL transaction с advisory lock; конфигурация обновляется отдельно от измерений. Оmitted interval/dashboard сохраняются для существующих строк, явный пустой список означает удаление. Ошибка refreshScheduler после commit возвращает подтверждённое ok:true с предупреждением.
UI проверяет Ping-каталог до публикации, сохраняет последний успешный список при ошибке и запрещает замену неподтверждённого каталога. Первоначально неизвестные количества показаны как —. PUT требует подтверждённый ответ; malformed ack сохраняет черновик. Один ReUI Alert показывает причину и время последнего успешного списка. Диалог удаления точно описывает каскадное удаление Ping history; speed history сохраняется. Снимок320px: [Ping catalog error](ux-ui/screens/ping-catalog-error-320.png).
Проверки: uptime-links62/62; context uptime/backend-switch11/11; pure/HTTP DI6/6 (включая JWT401/403, отсутствие mutations до validation и моделированный rollback с историей). TypeScript без incremental и ESLint прошли (9 исходных warnings Uptime); backend и frontend production build прошли. ReUI Alert API/usage проверены, primitives/base-nova не изменены. Fixture backend-switch дополнен реальным backend полем showOnDashboard. Общая регрессия1110/213 относится к предыдущим HEAD, после delta целиком ещё не повторена.
Реальный PG rollback/advisory lock/RouterOS не проверены; DI не заменяет внешнюю12.5. Whole-catalog PUT остаётся last writer wins без revision/ETag. Resources entries остаются legacy DTO (проверена только array оболочка). Следующий блок12.3.27: WireGuard missing/zero RX/TX в деталях и таблице пиров; далее network grids. Коммит локальный, без push.
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+2
View File
@@ -21,3 +21,5 @@ export function parseDashboardProbes(raw:unknown):{probes:DashboardProbe[];sampl
const active=probes.filter(p=>p.enabled),times=active.map(p=>p.sampledAt).filter((v):v is string=>!!v) const active=probes.filter(p=>p.enabled),times=active.map(p=>p.sampledAt).filter((v):v is string=>!!v)
return {probes,sampledAt:active.length&&times.length===active.length?new Date(times.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null} return {probes,sampledAt:active.length&&times.length===active.length?new Date(times.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null}
} }
export { overview as dashboardProbeOverviewSchema }
+2
View File
@@ -15,3 +15,5 @@ export * from "./ospf-optimizer.js"
export * from "./evobgp.js" export * from "./evobgp.js"
export * from "./uptime-speed.js" export * from "./uptime-speed.js"
export * from "./uptime-probes.js"
+12
View File
@@ -0,0 +1,12 @@
import { z } from "zod"
const serverId = z.union([z.number(), z.string().regex(/^[1-9][0-9]*$/).transform(Number)]).pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER))
const interval = z.union([z.number(), z.string().regex(/^[0-9]+$/).transform(Number), z.literal("").transform(() => 0), z.null().transform(() => 0)]).pipe(z.number().int().min(0).max(2147483647))
export const pingProbeWriteSchema = z.object({
id: z.string().trim().min(1), srcServerId: serverId,
srcInterface: z.string().trim().default(""), name: z.string().trim().min(1), target: z.string().trim().min(1),
filter: z.string().trim().transform(value => value || "—").default("—"), enabled: z.boolean(),
showOnDashboard: z.boolean().optional(), intervalSec: interval.optional(),
})
export const pingProbesWriteSchema = z.object({ probes: z.array(pingProbeWriteSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID Ping-пробы") })
export const probeCatalogSavedSchema = z.object({ ok: z.literal(true), warning: z.string().min(1).optional() })
export type PingProbeWrite = z.infer<typeof pingProbeWriteSchema>
+1 -1
View File
@@ -19,7 +19,7 @@ for (const status of [200, 500]) {
} }
} }
const servers = [{ id: 1, name: isNew ? 'Новый источник' : 'Старый источник', host: '192.0.2.1', type: 'exit-node', enabled: true, country: 'RU', site: 'TEST', status: 'online', wanUplinks: [] }] const servers = [{ id: 1, name: isNew ? 'Новый источник' : 'Старый источник', host: '192.0.2.1', type: 'exit-node', enabled: true, country: 'RU', site: 'TEST', status: 'online', wanUplinks: [] }]
const probes = [{ id: 'p1', srcServerId: '1', name: isNew ? 'Новая проверка' : 'Старая проверка', target: '192.0.2.10', filter: 'main', rtt: 10, loss: 0, status: 'up', series: [10], enabled: true }] const probes = [{ id: 'p1', srcServerId: '1', name: isNew ? 'Новая проверка' : 'Старая проверка', target: '192.0.2.10', filter: 'main', rtt: 10, loss: 0, status: 'up', series: [10], enabled: true, showOnDashboard: false }]
const json = url.pathname === '/api/auth/config' ? { required: false } const json = url.pathname === '/api/auth/config' ? { required: false }
: url.pathname === '/api/servers' ? servers : url.pathname === '/api/servers' ? servers
: url.pathname === '/api/uptime/overview' ? { probes, resources: [] } : url.pathname === '/api/uptime/overview' ? { probes, resources: [] }
+39
View File
@@ -724,3 +724,42 @@ for (const malformed of [false, true]) test("Speed save requires confirmation an
} }
expect(calls).toBe(1) expect(calls).toBe(1)
}) })
for (const malformed of [false, true]) test("Ping save requires confirmation and separates scheduler failure/" + malformed, async ({ page }) => {
await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write")
await page.getByRole("button", { name: "Пауза", exact: true }).click()
let calls = 0
await page.route("**/api/uptime/probes", route => { if (route.request().method() !== "PUT") return route.fallback(); calls++; return route.fulfill({ json: malformed ? {} : { ok: true, warning: "Ping-пробы сохранены, но расписание не обновлено. Проверьте состояние сборщика данных." } }) })
await page.getByRole("button", { name: "Редактировать пробу", exact: true }).click()
const dialog = page.getByRole("dialog"), input = dialog.getByPlaceholder("youtube.com")
await input.fill("Черновик")
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
if (malformed) { await expect(dialog.getByRole("alert")).toContainText("Ответ о сохранении Ping-проб не подтверждён"); await expect(input).toHaveValue("Черновик") }
else { await expect(dialog).toBeHidden(); await expect(page.getByRole("status").filter({ hasText: "Ping-пробы сохранены, но расписание" })).toBeVisible() }
expect(calls).toBe(1)
})
test("Ping catalog: malformed initial/refresh disables replacement, recovery and history deletion copy", async ({ page }, info) => {
const control = await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write")
await page.getByRole("button", { name: "Пауза", exact: true }).click()
let malformed = true
await page.route("**/api/uptime/overview*", route => malformed ? route.fulfill({ json: { probes: [{ id: "broken" }], resources: [] } }) : route.fallback())
await page.reload(); await page.getByRole("button", { name: "Пауза", exact: true }).click()
const warning = page.getByRole("alert", { name: "Чтение Ping-проб", exact: true })
await expect(warning).toContainText("Данные пока неизвестны")
await expect(page.getByRole("button", { name: "Новая проба", exact: true }).first()).toBeDisabled()
await expect(page.getByText("Пробы не найдены", { exact: true })).toHaveCount(0)
malformed = false; await page.getByRole("button", { name: "Обновить данные", exact: true }).click()
await expect(warning).toHaveCount(0); await expect(page.getByRole("button", { name: "Редактировать пробу", exact: true })).toHaveCount(1)
malformed = true; await page.getByRole("button", { name: "Обновить данные", exact: true }).click()
await expect(warning).toContainText("Показан последний успешный список")
await expect(page.getByRole("button", { name: "Редактировать пробу", exact: true }).first()).toBeDisabled()
await page.setViewportSize({ width: 320, height: 1000 })
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
await page.screenshot({ path: info.outputPath("ping-catalog-error-320.png"), animations: "disabled" })
malformed = false; await page.getByRole("button", { name: "Обновить данные", exact: true }).click()
await page.getByRole("button", { name: "Удалить пробу", exact: true }).first().click()
await expect(page.getByRole("alertdialog")).toContainText("Вместе с ней будет удалена история её измерений")
await page.getByRole("alertdialog").getByRole("button", { name: "Отмена", exact: true }).click()
expect(control.writes).toEqual([])
})