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"
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>