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>
|
||||
|
||||
Reference in new issue
Block a user