fix(api): сохранять каталог пинг-проб атомарно
This commit is contained in:
1 parent
9c22fa8a68
commit
53c1bfbd3b
15 files changed
+243
-122
No files matched your search
+58
-43
@@ -1,6 +1,8 @@
|
||||
"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"
|
||||
|
||||
@@ -250,7 +252,7 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
|
||||
function StatChip({
|
||||
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 (
|
||||
<button
|
||||
@@ -1094,6 +1096,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
const [overviewState, setOverviewState] = useState<"loading" | "ready" | "error">("loading")
|
||||
const [allServers, setAllServers] = useState<Server[]>([])
|
||||
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 [resources, setResources] = useState<ServerResource[]>([])
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
@@ -1149,13 +1154,17 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
const myReq = ++overviewReqRef.current
|
||||
setOpError(null)
|
||||
try {
|
||||
const [serverRows, data] = await Promise.all([
|
||||
const [serverRows, rawData] = await Promise.all([
|
||||
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
|
||||
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))
|
||||
setProbes(data.probes)
|
||||
setPingReceivedAt(Date.now())
|
||||
setResources(data.resources)
|
||||
setOverviewState("ready")
|
||||
} catch (e) {
|
||||
@@ -1483,26 +1492,21 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
const persistProbes = useCallback(async (rows: PingProbe[]) => {
|
||||
if (!liveApi) return
|
||||
await apiFetch("/api/uptime/probes", {
|
||||
method: "PUT",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
body: JSON.stringify({
|
||||
probes: rows.map((p) => ({
|
||||
id: p.id,
|
||||
srcServerId: p.srcServerId,
|
||||
srcInterface: p.srcInterface || "",
|
||||
name: p.name,
|
||||
target: p.target,
|
||||
filter: p.filter,
|
||||
enabled: p.enabled,
|
||||
showOnDashboard: p.showOnDashboard === true,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
}, [apiFetch, liveApi])
|
||||
if (!pingCatalogReady) throw new Error("Каталог Ping-проб не подтверждён. Обновите данные перед сохранением.")
|
||||
const payload = pingProbesWriteSchema.safeParse({ probes: rows.map(p => ({
|
||||
id: p.id, srcServerId: p.srcServerId, srcInterface: p.srcInterface || "", name: p.name, target: p.target,
|
||||
filter: p.filter, enabled: p.enabled, showOnDashboard: p.showOnDashboard === true,
|
||||
})) })
|
||||
if (!payload.success) throw new Error("Проверьте Ping-пробы: устройство, имя, цель и уникальность записей.")
|
||||
const saved = probeCatalogSavedSchema.safeParse(await apiFetch<unknown>("/api/uptime/probes", {
|
||||
method: "PUT", signal: AbortSignal.timeout(30_000), body: JSON.stringify(payload.data),
|
||||
}))
|
||||
if (!saved.success) throw new Error("Ответ о сохранении Ping-проб не подтверждён. Обновите список перед повторной попыткой.")
|
||||
setProbeSaveWarning(saved.data.warning ?? null)
|
||||
}, [apiFetch, liveApi, pingCatalogReady])
|
||||
|
||||
const toggleDashboardStar = (id: string) => {
|
||||
if (!requireWrite()) return
|
||||
if (!requireWrite() || !pingCatalogReady) return
|
||||
const cur = probes.find(p => p.id === id)
|
||||
if (!cur) return
|
||||
const nextVal = !cur.showOnDashboard
|
||||
@@ -1558,13 +1562,13 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
// ── actions ──
|
||||
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)
|
||||
void saveProbeForm(() => persistProbes(next), () => setProbes(next))
|
||||
}
|
||||
|
||||
const deleteProbe = (id: string) => {
|
||||
if (!requireWrite()) return
|
||||
if (!requireWrite() || !pingCatalogReady) return
|
||||
const probe = probes.find(p => p.id === id)
|
||||
if (!probe) return
|
||||
setProbeSaveError(null)
|
||||
@@ -1595,7 +1599,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
setStatusFilter(prev => prev === s ? "all" : s)
|
||||
|
||||
const openEditProbe = (probe: PingProbe) => {
|
||||
if (!requireWrite()) return
|
||||
if (!requireWrite() || !pingCatalogReady) return
|
||||
setProbeSaveError(null)
|
||||
setEditingProbeId(probe.id)
|
||||
setNewSrcId(probe.srcServerId)
|
||||
@@ -1607,7 +1611,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
|
||||
const openAddSheet = () => {
|
||||
if (!requireWrite()) return
|
||||
if (!requireWrite() || !pingCatalogReady) return
|
||||
draftProbeId.current = "p" + Date.now()
|
||||
setProbeSaveError(null)
|
||||
setEditingProbeId(null)
|
||||
@@ -1988,7 +1992,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
</Button>
|
||||
|
||||
{tab === "probes" && (
|
||||
<Button size="sm" disabled={probeSaving || !canWrite} onClick={openAddSheet}>
|
||||
<Button size="sm" disabled={probeSaving || !canWrite || !pingCatalogReady} onClick={openAddSheet}>
|
||||
<PlusIcon className="size-4" />Новая проба
|
||||
</Button>
|
||||
)}
|
||||
@@ -2052,7 +2056,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
{stats.down}
|
||||
</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>
|
||||
) : t === "resources" ? (
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -2365,8 +2369,19 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
{/* ── probes tab ── */}
|
||||
{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">
|
||||
<Alert variant="destructive" className="py-2 text-xs">
|
||||
<AlertCircleIcon />
|
||||
@@ -2392,10 +2407,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
{/* ── summary + server filter ── */}
|
||||
<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="OK" value={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={stats.down} color="red" active={statusFilter === "down"} onClick={() => cycleStatusFilter("down")} />
|
||||
<StatChip label="Всего" value={pingCatalogKnown ? stats.total : "—"} />
|
||||
<StatChip label="OK" value={pingCatalogKnown ? stats.up : "—"} color="emerald" active={statusFilter === "up"} onClick={() => cycleStatusFilter("up")} />
|
||||
<StatChip label="Предупреждение" value={pingCatalogKnown ? stats.warn : "—"} color="amber" active={statusFilter === "warn"} onClick={() => cycleStatusFilter("warn")} />
|
||||
<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" />
|
||||
|
||||
@@ -2453,7 +2468,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
</Button>
|
||||
|
||||
<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/80 hidden md:inline" title="Отображение на дашборде">
|
||||
звезда — блок «Активные пробы»
|
||||
@@ -2464,7 +2479,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
{/* ── probe groups ── */}
|
||||
<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">
|
||||
<SearchIcon className="size-8 opacity-20" />
|
||||
<p className="text-sm">Пробы не найдены</p>
|
||||
@@ -2560,7 +2575,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
!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
|
||||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||
pulse={p.status === "up" && p.enabled}
|
||||
@@ -2623,7 +2638,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
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 ? "Убрать с дашборда" : "Показать на дашборде"}
|
||||
aria-label={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"}
|
||||
aria-pressed={p.showOnDashboard === true}
|
||||
@@ -2641,12 +2656,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
<div className={cn(probeStyles.actions, "flex justify-end gap-1")}>
|
||||
<Button size="sm" variant="ghost"
|
||||
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" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost"
|
||||
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" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2690,7 +2705,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
})}
|
||||
|
||||
<button
|
||||
disabled={probeSaving || !canWrite}
|
||||
disabled={probeSaving || !canWrite || !pingCatalogReady}
|
||||
onClick={() => {
|
||||
if (!requireWrite()) return
|
||||
setProbeSaveError(null)
|
||||
@@ -2963,7 +2978,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
Отмена
|
||||
</Button>
|
||||
<Button className="flex-1"
|
||||
disabled={probeSaving || !canWrite || !newName.trim() || !newTarget.trim() || !newSrcId}
|
||||
disabled={probeSaving || !canWrite || !pingCatalogReady || !newName.trim() || !newTarget.trim() || !newSrcId}
|
||||
onClick={handleSaveProbe}>
|
||||
{editingProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
|
||||
{probeSaving ? "Сохранение…" : editingProbeId ? "Сохранить" : "Добавить"}
|
||||
@@ -2975,12 +2990,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить пробу?</AlertDialogTitle>
|
||||
<AlertDialogDescription>Проба «{deleteTarget?.label}» будет удалена из мониторинга.</AlertDialogDescription>
|
||||
<AlertDialogDescription>Проба «{deleteTarget?.label}» будет удалена из мониторинга.{deleteTarget?.kind === "ping" ? " Вместе с ней будет удалена история её измерений." : " История speed-тестов сохранится."}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{(probeSaveError || permissionError) && <p role="alert" className="text-sm text-destructive">{probeSaveError || permissionError}</p>}
|
||||
<AlertDialogFooter>
|
||||
<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>
|
||||
</AlertDialogContent>
|
||||
</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")
|
||||
@@ -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-пробы сохранены, но расписание не обновлено. Проверьте состояние сборщика данных." })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createPingProbesWriteRoute } from "./ping-probes-write.js"
|
||||
import { createSpeedProbesWriteRoute } from "./speed-probes-write.js"
|
||||
import { replaceSpeedProbes } from "../services/speed-probe-replacement.js"
|
||||
import { mapSpeedProbeRead } from "../services/speed-probe-dto.js"
|
||||
@@ -217,39 +218,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
refreshScheduler,
|
||||
}))
|
||||
|
||||
app.put("/uptime/probes", async (req, reply) => {
|
||||
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 })
|
||||
})
|
||||
await app.register(createPingProbesWriteRoute({ replace: replaceProbes, refreshScheduler }))
|
||||
|
||||
app.patch("/uptime/probes/:id", async (req, reply) => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
servers,
|
||||
@@ -467,49 +469,19 @@ export async function updateProbeShowOnDashboard(probeId: string, showOnDashboar
|
||||
return r.length > 0
|
||||
}
|
||||
|
||||
export async function replaceProbes(rows: Array<{
|
||||
id: string
|
||||
srcServerId: number
|
||||
srcInterface: string
|
||||
name: string
|
||||
target: string
|
||||
probeFilter: string
|
||||
enabled: boolean
|
||||
showOnDashboard?: boolean
|
||||
intervalSec?: number
|
||||
}>) {
|
||||
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,
|
||||
export async function replaceProbes(rows: PingProbeWrite[]) {
|
||||
await replacePingProbes({ transaction: run => db.transaction(async tx => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('uptime-ping-probes'))`)
|
||||
await run({
|
||||
listIds: async () => (await tx.select({ id: uptimeProbes.id }).from(uptimeProbes)).map(row => row.id),
|
||||
remove: async id => { await tx.delete(uptimeProbes).where(eq(uptimeProbes.id, id)) },
|
||||
upsert: async (row, createdAt) => {
|
||||
const { id, ...patch } = row
|
||||
await tx.insert(uptimeProbes).values({ id, ...patch, createdAt })
|
||||
.onConflictDoUpdate({ target: uptimeProbes.id, set: patch })
|
||||
},
|
||||
})
|
||||
}
|
||||
}) }, rows)
|
||||
}
|
||||
|
||||
export async function readProbeSamplesSince(sinceIso: string) {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
## Актуальная точка продолжения — 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).
|
||||
|
||||
|
||||
+11
-1
@@ -29,13 +29,23 @@
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
|
||||
| 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)
|
||||
|
||||
На 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 снимки сохранены.
|
||||
|
||||
@@ -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-проб (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 |
@@ -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)
|
||||
return {probes,sampledAt:active.length&×.length===active.length?new Date(times.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null}
|
||||
}
|
||||
|
||||
export { overview as dashboardProbeOverviewSchema }
|
||||
@@ -15,3 +15,5 @@ export * from "./ospf-optimizer.js"
|
||||
export * from "./evobgp.js"
|
||||
|
||||
export * from "./uptime-speed.js"
|
||||
|
||||
export * from "./uptime-probes.js"
|
||||
@@ -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>
|
||||
@@ -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 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 }
|
||||
: url.pathname === '/api/servers' ? servers
|
||||
: url.pathname === '/api/uptime/overview' ? { probes, resources: [] }
|
||||
|
||||
@@ -724,3 +724,42 @@ for (const malformed of [false, true]) test("Speed save requires confirmation an
|
||||
}
|
||||
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([])
|
||||
})
|
||||
Reference in new issue
Block a user