fix(ui): сохранять формы проб после подтверждения API
This commit is contained in:
1 parent
bb9278fe58
commit
2f5b617e8c
4 files changed
+189
-91
No files matched your search
+95
-89
@@ -4,6 +4,7 @@ import probeStyles from "./probe-rows.module.css"
|
||||
|
||||
import { Suspense, useState, useMemo, useEffect, useRef, useCallback } from "react"
|
||||
import Link from "next/link"
|
||||
import { serverOperationError } from "@/lib/server-operations"
|
||||
import { can } from "@/lib/auth"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Alert as ContextAlert, AlertTitle as ContextTitle, AlertDescription as ContextDescription } from "@/components/reui/alert"
|
||||
@@ -1055,9 +1056,14 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
||||
|
||||
function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId: string | null; allProbesHref: string }) {
|
||||
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
||||
const [probeSaving, setProbeSaving] = useState(false)
|
||||
const [probeSaveError, setProbeSaveError] = useState<string | null>(null)
|
||||
const savingRef = useRef(false)
|
||||
const saveContext = useRef(0)
|
||||
const canWrite = can("mm:uptime:write")
|
||||
const [permissionError, setPermissionError] = useState<string | null>(null)
|
||||
const requireWrite = useCallback(() => {
|
||||
if (savingRef.current) return false
|
||||
if (can("mm:uptime:write")) { setPermissionError(null); return true }
|
||||
setPermissionError("Недостаточно прав: изменение проб и запуск измерений требуют разрешения на запись мониторинга.")
|
||||
return false
|
||||
@@ -1065,6 +1071,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
/** Live не зависит от ещё не завершённой проверки /health. */
|
||||
const liveApi = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
useEffect(() => () => { saveContext.current++ }, [apiFetch, liveApi])
|
||||
|
||||
const [overviewState, setOverviewState] = useState<"loading" | "ready" | "error">("loading")
|
||||
const [allServers, setAllServers] = useState<Server[]>([])
|
||||
@@ -1119,7 +1126,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
const overviewReqRef = useRef(0)
|
||||
|
||||
const loadLiveOverview = useCallback(async () => {
|
||||
if (!liveApi) return
|
||||
if (!liveApi || savingRef.current) return
|
||||
const myReq = ++overviewReqRef.current
|
||||
setOpError(null)
|
||||
try {
|
||||
@@ -1127,20 +1134,21 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
apiFetch<BackendServer[]>("/api/servers"),
|
||||
apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h"),
|
||||
])
|
||||
if (myReq !== overviewReqRef.current) return
|
||||
if (myReq !== overviewReqRef.current || savingRef.current) return
|
||||
setAllServers(mapBackendServersToServers(serverRows))
|
||||
setProbes(data.probes)
|
||||
setResources(data.resources)
|
||||
setOverviewState("ready")
|
||||
} catch (e) {
|
||||
if (myReq !== overviewReqRef.current) return
|
||||
if (myReq !== overviewReqRef.current || savingRef.current) return
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime")
|
||||
setOverviewState("error")
|
||||
}
|
||||
}, [apiFetch, liveApi])
|
||||
|
||||
const reloadSpeedData = useCallback(async () => {
|
||||
if (!liveApi) return
|
||||
if (!liveApi || savingRef.current) return
|
||||
const context = saveContext.current
|
||||
type RunRow = {
|
||||
id: string
|
||||
srcServerId: string
|
||||
@@ -1166,6 +1174,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes"),
|
||||
apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs"),
|
||||
])
|
||||
if (context !== saveContext.current || savingRef.current) return
|
||||
setSpeedProbes(sp.probes ?? [])
|
||||
const rows = (runsRes.runs ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
@@ -1190,6 +1199,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}))
|
||||
setSpeedRuns(rows)
|
||||
} catch {
|
||||
if (context !== saveContext.current || savingRef.current) return
|
||||
setSpeedProbes([])
|
||||
setSpeedRuns([])
|
||||
}
|
||||
@@ -1467,10 +1477,11 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}))
|
||||
}, [speedProbes, allServers])
|
||||
|
||||
const persistProbes = useCallback((rows: PingProbe[]) => {
|
||||
const persistProbes = useCallback(async (rows: PingProbe[]) => {
|
||||
if (!liveApi) return
|
||||
void apiFetch("/api/uptime/probes", {
|
||||
await apiFetch("/api/uptime/probes", {
|
||||
method: "PUT",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
body: JSON.stringify({
|
||||
probes: rows.map((p) => ({
|
||||
id: p.id,
|
||||
@@ -1483,7 +1494,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
showOnDashboard: p.showOnDashboard === true,
|
||||
})),
|
||||
}),
|
||||
}).catch(() => {})
|
||||
})
|
||||
}, [apiFetch, liveApi])
|
||||
|
||||
const toggleDashboardStar = useCallback((id: string) => {
|
||||
@@ -1521,10 +1532,11 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
}, [probes, liveApi, apiFetch, loadLiveOverview, requireWrite])
|
||||
|
||||
const persistSpeedProbes = useCallback((rows: SpeedProbeRow[]) => {
|
||||
const persistSpeedProbes = useCallback(async (rows: SpeedProbeRow[]) => {
|
||||
if (!liveApi) return
|
||||
void apiFetch("/api/uptime/speed-probes", {
|
||||
await apiFetch("/api/uptime/speed-probes", {
|
||||
method: "PUT",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
body: JSON.stringify({
|
||||
probes: rows.map((p) => ({
|
||||
id: p.id,
|
||||
@@ -1543,15 +1555,33 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
lastError: p.lastError ?? null,
|
||||
})),
|
||||
}),
|
||||
}).catch(() => {})
|
||||
})
|
||||
}, [apiFetch, liveApi])
|
||||
|
||||
async function saveProbeForm(save: () => Promise<void>, confirm: () => void) {
|
||||
if (!requireWrite()) return
|
||||
savingRef.current = true
|
||||
setProbeSaving(true); setProbeSaveError(null)
|
||||
overviewReqRef.current++
|
||||
const context = ++saveContext.current
|
||||
try {
|
||||
await save()
|
||||
if (context !== saveContext.current) return
|
||||
confirm()
|
||||
} catch (error) {
|
||||
if (context === saveContext.current) setProbeSaveError(serverOperationError(error))
|
||||
} finally {
|
||||
savingRef.current = false
|
||||
setProbeSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── actions ──
|
||||
const toggleProbe = (id: string, v: boolean) => {
|
||||
if (!requireWrite()) return
|
||||
setProbes((prev) => {
|
||||
const next = prev.map((x) => x.id === id ? { ...x, enabled: v } : x)
|
||||
persistProbes(next)
|
||||
void persistProbes(next).catch(error => setOpError(serverOperationError(error)))
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -1565,7 +1595,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
setProbes((prev) => {
|
||||
const next = prev.filter((x) => x.id !== id)
|
||||
persistProbes(next)
|
||||
void persistProbes(next).catch(error => setOpError(serverOperationError(error)))
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -1595,6 +1625,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
const openEditProbe = (probe: PingProbe) => {
|
||||
if (!requireWrite()) return
|
||||
setProbeSaveError(null)
|
||||
setEditingProbeId(probe.id)
|
||||
setNewSrcId(probe.srcServerId)
|
||||
setNewSrcInterface(probe.srcInterface || "")
|
||||
@@ -1606,6 +1637,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
const openAddSheet = () => {
|
||||
if (!requireWrite()) return
|
||||
setProbeSaveError(null)
|
||||
setEditingProbeId(null)
|
||||
setNewSrcId(selectableSources[0]?.id ?? "")
|
||||
setNewSrcInterface("")
|
||||
@@ -1630,31 +1662,15 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
enabled: true,
|
||||
showOnDashboard: false,
|
||||
}
|
||||
if (editingProbeId) {
|
||||
setProbes((prev) => {
|
||||
const next = prev.map((p) => {
|
||||
if (p.id !== editingProbeId) return p
|
||||
return {
|
||||
...p,
|
||||
srcServerId: probeBase.srcServerId,
|
||||
srcInterface: probeBase.srcInterface,
|
||||
name: probeBase.name,
|
||||
target: probeBase.target,
|
||||
filter: probeBase.filter,
|
||||
}
|
||||
})
|
||||
persistProbes(next)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setProbes((prev) => {
|
||||
const next = [...prev, probeBase]
|
||||
persistProbes(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
setEditingProbeId(null)
|
||||
setSheetOpen(false)
|
||||
const next = editingProbeId ? probes.map(p => p.id === editingProbeId ? {
|
||||
...p, srcServerId: probeBase.srcServerId, srcInterface: probeBase.srcInterface,
|
||||
name: probeBase.name, target: probeBase.target, filter: probeBase.filter,
|
||||
} : p) : [...probes, probeBase]
|
||||
void saveProbeForm(() => persistProbes(next), () => {
|
||||
setProbes(next)
|
||||
setEditingProbeId(null)
|
||||
setSheetOpen(false)
|
||||
})
|
||||
}
|
||||
|
||||
const buildSpeedCommand = useCallback((probe: SpeedProbeRow) => {
|
||||
@@ -1876,13 +1892,14 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
return next
|
||||
})
|
||||
persistSpeedProbes(nextRows)
|
||||
void persistSpeedProbes(nextRows).catch(error => setSpeedError(serverOperationError(error)))
|
||||
return nextRows
|
||||
})
|
||||
}
|
||||
|
||||
const openAddSpeedSheet = () => {
|
||||
if (!requireWrite()) return
|
||||
setProbeSaveError(null)
|
||||
setEditingSpeedProbeId(null)
|
||||
const src = selectableSources[0]?.id ?? ""
|
||||
const dst = selectableSources.find((s) => s.id !== src)?.id ?? src
|
||||
@@ -1904,6 +1921,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
const openEditSpeedSheet = (probe: SpeedProbeRow) => {
|
||||
if (!requireWrite()) return
|
||||
setProbeSaveError(null)
|
||||
setEditingSpeedProbeId(probe.id)
|
||||
setSpeedDraft({
|
||||
...probe,
|
||||
@@ -1917,38 +1935,21 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
const saveSpeedProbe = () => {
|
||||
if (!requireWrite()) return
|
||||
if (!speedDraft.srcServerId || !speedDraft.dstServerId) return
|
||||
if (editingSpeedProbeId) {
|
||||
setSpeedProbes((prev) => {
|
||||
const next = prev.map((p) => p.id === editingSpeedProbeId
|
||||
? {
|
||||
...p,
|
||||
...speedDraft,
|
||||
id: editingSpeedProbeId,
|
||||
durationSec: String(speedDraft.durationSec ?? "10"),
|
||||
}
|
||||
: p)
|
||||
persistSpeedProbes(next)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setSpeedProbes((prev) => {
|
||||
const next = [{
|
||||
...speedDraft,
|
||||
id: `sp-${Date.now()}`,
|
||||
}, ...prev]
|
||||
persistSpeedProbes(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
setEditingSpeedProbeId(null)
|
||||
setSpeedSheetOpen(false)
|
||||
const next = editingSpeedProbeId ? speedProbes.map(p => p.id === editingSpeedProbeId
|
||||
? { ...p, ...speedDraft, id: editingSpeedProbeId, durationSec: String(speedDraft.durationSec ?? "10") } : p)
|
||||
: [{ ...speedDraft, id: "sp-" + Date.now() }, ...speedProbes]
|
||||
void saveProbeForm(() => persistSpeedProbes(next), () => {
|
||||
setSpeedProbes(next)
|
||||
setEditingSpeedProbeId(null)
|
||||
setSpeedSheetOpen(false)
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSpeedProbe = (id: string) => {
|
||||
if (!requireWrite()) return
|
||||
setSpeedProbes((prev) => {
|
||||
const next = prev.filter((p) => p.id !== id)
|
||||
persistSpeedProbes(next)
|
||||
void persistSpeedProbes(next).catch(error => setSpeedError(serverOperationError(error)))
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -1991,12 +1992,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
</Button>
|
||||
|
||||
{tab === "probes" && (
|
||||
<Button size="sm" disabled={!canWrite} onClick={openAddSheet}>
|
||||
<Button size="sm" disabled={probeSaving || !canWrite} onClick={openAddSheet}>
|
||||
<PlusIcon className="size-4" />Новая проба
|
||||
</Button>
|
||||
)}
|
||||
{tab === "speed" && (
|
||||
<Button size="sm" disabled={!canWrite} onClick={openAddSpeedSheet}>
|
||||
<Button size="sm" disabled={probeSaving || !canWrite} onClick={openAddSpeedSheet}>
|
||||
<PlusIcon className="size-4" />Новая speed-проба
|
||||
</Button>
|
||||
)}
|
||||
@@ -2151,7 +2152,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
<p className="text-xs text-muted-foreground max-w-xs">
|
||||
Создайте пробу через кнопку «Новая speed-проба» в шапке страницы
|
||||
</p>
|
||||
<Button size="sm" variant="outline" className="mt-1" disabled={!canWrite} onClick={openAddSpeedSheet}>
|
||||
<Button size="sm" variant="outline" className="mt-1" disabled={probeSaving || !canWrite} onClick={openAddSpeedSheet}>
|
||||
<PlusIcon className="size-4" />Новая speed-проба
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2209,7 +2210,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* enable toggle */}
|
||||
<FormToggle disabled={!canWrite} checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
|
||||
<FormToggle disabled={probeSaving || !canWrite} checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
|
||||
|
||||
{/* route: src → dst */}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
@@ -2240,18 +2241,18 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
{/* actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button size="sm" variant="outline" className="h-7 gap-1.5 text-xs"
|
||||
disabled={!canWrite || speedBusy || !canRun}
|
||||
disabled={probeSaving || !canWrite || speedBusy || !canRun}
|
||||
onClick={() => void runSpeedTest(probe)}>
|
||||
<RefreshCwIcon className={cn("size-3", speedBusy && run?.status === "running" && "animate-spin")} />
|
||||
Запустить
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-foreground"
|
||||
disabled={!canWrite} aria-label="Редактировать speed-пробу" onClick={() => openEditSpeedSheet(probe)}
|
||||
disabled={probeSaving || !canWrite} aria-label="Редактировать speed-пробу" onClick={() => openEditSpeedSheet(probe)}
|
||||
title="Редактировать speed-пробу">
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-destructive"
|
||||
disabled={!canWrite} aria-label="Удалить speed-пробу" onClick={() => deleteSpeedProbe(probe.id)}>
|
||||
disabled={probeSaving || !canWrite} aria-label="Удалить speed-пробу" onClick={() => deleteSpeedProbe(probe.id)}>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2513,7 +2514,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-[11px]"
|
||||
disabled={!canWrite || !liveApi || groupBusy}
|
||||
disabled={probeSaving || !canWrite || !liveApi || groupBusy}
|
||||
title={liveApi ? "Ping по всем интерфейсам группы и запись в БД" : "Доступно в режиме Live"}
|
||||
onClick={() => { void refreshProbeGroupPings(srv.id, group) }}
|
||||
>
|
||||
@@ -2547,7 +2548,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={!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} 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}
|
||||
@@ -2610,7 +2611,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-7 p-0 text-muted-foreground hover:text-amber-500"
|
||||
disabled={!canWrite} onClick={() => toggleDashboardStar(p.id)}
|
||||
disabled={probeSaving || !canWrite} onClick={() => toggleDashboardStar(p.id)}
|
||||
title={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"}
|
||||
aria-label={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"}
|
||||
aria-pressed={p.showOnDashboard === true}
|
||||
@@ -2628,12 +2629,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={!canWrite} type="button" aria-label="Редактировать пробу" onClick={() => openEditProbe(p)} title="Редактировать пробу">
|
||||
disabled={probeSaving || !canWrite} 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={!canWrite} type="button" aria-label="Удалить пробу" onClick={() => deleteProbe(p.id)} title="Удалить пробу">
|
||||
disabled={probeSaving || !canWrite} type="button" aria-label="Удалить пробу" onClick={() => deleteProbe(p.id)} title="Удалить пробу">
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2677,9 +2678,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
})}
|
||||
|
||||
<button
|
||||
disabled={!canWrite}
|
||||
disabled={probeSaving || !canWrite}
|
||||
onClick={() => {
|
||||
if (!requireWrite()) return
|
||||
setProbeSaveError(null)
|
||||
setEditingProbeId(null)
|
||||
setNewSrcId(srv.id)
|
||||
setNewSrcInterface("")
|
||||
@@ -2707,6 +2709,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
{/* ── add speed-probe sheet ── */}
|
||||
<Sheet open={speedSheetOpen} onOpenChange={(v) => {
|
||||
if (savingRef.current) return
|
||||
if (!v) {
|
||||
setSpeedSheetOpen(false)
|
||||
setEditingSpeedProbeId(null)
|
||||
@@ -2723,14 +2726,15 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
{editingSpeedProbeId ? "Измените параметры BT-пробы" : "BTTest между выбранными серверами"}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => setSpeedSheetOpen(false)}>
|
||||
<Button disabled={probeSaving} variant="ghost" size="icon-sm" onClick={() => setSpeedSheetOpen(false)}>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{permissionError && <ContextAlert variant="destructive" role="alert"><ContextDescription>{permissionError}</ContextDescription></ContextAlert>}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||||
{probeSaveError && <ContextAlert variant="destructive" role="alert"><ContextDescription>{probeSaveError}</ContextDescription></ContextAlert>}
|
||||
<fieldset disabled={probeSaving} aria-busy={probeSaving} className="flex-1 min-h-0 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||||
<FormField label="Источник">
|
||||
<ServerPickerCards
|
||||
options={selectableSources}
|
||||
@@ -2835,13 +2839,13 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => { setSpeedSheetOpen(false); setEditingSpeedProbeId(null) }}>Отмена</Button>
|
||||
<Button className="flex-1" onClick={saveSpeedProbe} disabled={!canWrite || !speedDraft.srcServerId || !speedDraft.dstServerId || speedDraft.srcServerId === speedDraft.dstServerId}>
|
||||
<Button disabled={probeSaving} variant="outline" className="flex-1" onClick={() => { setSpeedSheetOpen(false); setEditingSpeedProbeId(null) }}>Отмена</Button>
|
||||
<Button className="flex-1" onClick={saveSpeedProbe} disabled={probeSaving || !canWrite || !speedDraft.srcServerId || !speedDraft.dstServerId || speedDraft.srcServerId === speedDraft.dstServerId}>
|
||||
{editingSpeedProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
|
||||
{editingSpeedProbeId ? "Сохранить" : "Добавить"}
|
||||
{probeSaving ? "Сохранение…" : editingSpeedProbeId ? "Сохранить" : "Добавить"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -2849,6 +2853,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
|
||||
{/* ── add probe sheet ── */}
|
||||
<Sheet open={sheetOpen} onOpenChange={(v) => {
|
||||
if (savingRef.current) return
|
||||
if (!v) {
|
||||
setSheetOpen(false)
|
||||
setEditingProbeId(null)
|
||||
@@ -2866,14 +2871,15 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
Ping от выбранного сервера к целевому хосту
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => setSheetOpen(false)}>
|
||||
<Button disabled={probeSaving} variant="ghost" size="icon-sm" onClick={() => setSheetOpen(false)}>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{permissionError && <ContextAlert variant="destructive" role="alert"><ContextDescription>{permissionError}</ContextDescription></ContextAlert>}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||||
{probeSaveError && <ContextAlert variant="destructive" role="alert"><ContextDescription>{probeSaveError}</ContextDescription></ContextAlert>}
|
||||
<fieldset disabled={probeSaving} aria-busy={probeSaving} className="flex-1 min-h-0 overflow-y-auto px-5 py-5 flex flex-col gap-5">
|
||||
|
||||
{/* from → to visual */}
|
||||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 border px-4 py-3">
|
||||
@@ -2937,17 +2943,17 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
items={filters}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<SheetFooter className="px-5 py-4 border-t shrink-0 gap-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => { setSheetOpen(false); setEditingProbeId(null) }}>
|
||||
<Button disabled={probeSaving} variant="outline" className="flex-1" onClick={() => { setSheetOpen(false); setEditingProbeId(null) }}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button className="flex-1"
|
||||
disabled={!canWrite || !newName.trim() || !newTarget.trim() || !newSrcId}
|
||||
disabled={probeSaving || !canWrite || !newName.trim() || !newTarget.trim() || !newSrcId}
|
||||
onClick={handleSaveProbe}>
|
||||
{editingProbeId ? <PencilIcon className="size-4" /> : <PlusIcon className="size-4" />}
|
||||
{editingProbeId ? "Сохранить" : "Добавить"}
|
||||
{probeSaving ? "Сохранение…" : editingProbeId ? "Сохранить" : "Добавить"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
+11
-1
@@ -21,7 +21,7 @@
|
||||
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже |
|
||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; далее ошибки сохранения проб |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; далее действия строк |
|
||||
| 8 | Трафик и статистика | Не начат |
|
||||
| 9 | VPN, Firewall и Filters | Не начат |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
@@ -29,6 +29,16 @@
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 7.3В/7.4А — подтверждённое сохранение форм (2026-09-21)
|
||||
|
||||
- Ping/Speed: формы ожидают PUT, обновляют локальный список и закрываются только после успешного ответа. При 400/403/500, сетевой ошибке или таймауте сохраняются поля и показывается ошибка в Sheet. Общий serverOperationError переиспользован для понятного 403 и неопределённого результата timeout.
|
||||
- Синхронный ref блокирует повторное сохранение до React render; на время запроса заблокированы поля, действия, Отмена и Escape. PUT ограничен 30 секундами. Права повторно проверяются перед сохранением. API по-прежнему заменяет весь список; серверный контракт не менялся.
|
||||
- GET overview/speed, начатые до сохранения, не применяются после него; чтение пропускается во время сохранения. Поздний ответ записи не применяется после смены mode/backend или размонтирования. Не утверждать полное устранение гонок ранее начатых PATCH/операций строк — это следующий блок.
|
||||
- Проверки: 25 сценариев uptime-links прошли суммарно, включая 10 новых (400/403/500 и повтор для двух форм, двойное нажатие/Escape, таймаут). В первом прогоне 2 проверки ошибочно проверяли disabled у fieldset; заменены проверкой реального input, оба повторно прошли. TypeScript и production build прошли (34 страницы). ESLint теста чистый; у uptime прежние 1 ошибка set-state-in-effect и 6 предупреждений.
|
||||
- ReUI audit: сохранены установленные Sheet/Alert, дизайн и тема; fieldset даёт нативную блокировку полей, aria-busy сообщает состояние. HTTP-моки не проверяют реальный JWT/RouterOS.
|
||||
- Следующий шаг 7.4Б: подтверждение удаления, обновление строки только после API при переключении/удалении, обработка ошибки звезды и блокировка конкурирующих записей. Старые row-handlers пока оптимистичные; их PUT теперь сообщает ошибку вместо её поглощения, но rollback/подтверждение не реализованы. Затем полный URL-контекст мониторинга и приёмка 7.5.
|
||||
- Контроль квоты: использовано 65% пятичасовой / 87% недельной; локальный коммит без push, сторонние файлы не включать.
|
||||
|
||||
## Блок 7.3Б — ограничения прав мониторинга (2026-09-21)
|
||||
|
||||
- Uptime использует can("mm:uptime:write") для Ping/Resources/Speed: создание, редактирование, удаление, переключение, звезда дашборда, групповой ping, collect-now и bandwidth-test. Дополнительное создание интерфейса из группы также ограничено. Backend-политика и auth disabled не изменены.
|
||||
|
||||
@@ -61,7 +61,7 @@ URL — источник сохранённого состояния. Back/Forwa
|
||||
3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие.
|
||||
4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении.
|
||||
|
||||
Следующий исполняемый шаг: блок 7.3В/7.4 — подтверждённое сохранение проб Ping/Speed и ошибки API. Ограничения действий мониторинга 7.3Б выполнены; серверный 403 сохранения ещё не принят из-за прежних optimistic persist-функций. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
|
||||
Следующий исполняемый шаг: блок 7.4Б — подтверждение удаления и достоверность действий строк Ping/Speed, включая звезду и конкурирующие записи. Ограничения прав и сохранение обеих форм после API выполнены; ошибки 400/403/500 и timeout сохраняют черновик. Оптимистичные row-handlers ещё требуют доработки. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
|
||||
|
||||
## Решение по синхронизации URL (7.1)
|
||||
|
||||
|
||||
@@ -186,3 +186,85 @@ for (const speed of [false, true]) {
|
||||
expect(control.writes).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
for (const speed of [false, true]) {
|
||||
for (const status of [400, 403, 500]) {
|
||||
test(`сохранение ${speed ? "Speed" : "Ping"}: ${status} сохраняет черновик, повтор подтверждён`, async ({ page }) => {
|
||||
await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write")
|
||||
await page.getByRole("button", { name: "Пауза", exact: true }).click()
|
||||
if (speed) await page.getByRole("button", { name: /Скорость/ }).click()
|
||||
let fail = true
|
||||
const bodies: { probes: Record<string, unknown>[] }[] = []
|
||||
await page.route(speed ? "**/api/uptime/speed-probes" : "**/api/uptime/probes", route => {
|
||||
if (route.request().method() !== "PUT") return route.fallback()
|
||||
bodies.push(route.request().postDataJSON())
|
||||
return route.fulfill({ status: fail ? status : 200, json: fail ? { error: "Сохранение отклонено" } : { ok: true } })
|
||||
})
|
||||
await page.getByRole("button", { name: speed ? "Редактировать speed-пробу" : "Редактировать пробу", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
const input = speed ? dialog.locator('input').filter({ visible: true }).last() : dialog.getByPlaceholder("youtube.com")
|
||||
await input.fill(speed ? "17" : "Черновик проверки")
|
||||
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(dialog.getByRole("alert")).toContainText(status === 403 ? "Недостаточно прав" : "Сохранение отклонено")
|
||||
await expect(input).toHaveValue(speed ? "17" : "Черновик проверки")
|
||||
fail = false
|
||||
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(bodies).toHaveLength(2)
|
||||
expect(bodies[1]).toEqual(bodies[0])
|
||||
expect(bodies[1].probes[0][speed ? "durationSec" : "name"]).toBe(speed ? 17 : "Черновик проверки")
|
||||
})
|
||||
}
|
||||
|
||||
test(`сохранение ${speed ? "Speed" : "Ping"}: один PUT, Escape и отмена заблокированы до ответа`, async ({ page }) => {
|
||||
await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write")
|
||||
await page.getByRole("button", { name: "Пауза", exact: true }).click()
|
||||
if (speed) await page.getByRole("button", { name: /Скорость/ }).click()
|
||||
let calls = 0
|
||||
let release!: () => void
|
||||
const pending = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route(speed ? "**/api/uptime/speed-probes" : "**/api/uptime/probes", async route => {
|
||||
if (route.request().method() !== "PUT") return route.fallback()
|
||||
calls++
|
||||
await pending
|
||||
await route.fulfill({ json: { ok: true } })
|
||||
})
|
||||
await page.getByRole("button", { name: speed ? "Редактировать speed-пробу" : "Редактировать пробу", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("button", { name: "Сохранить", exact: true }).evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
|
||||
await expect(dialog.getByRole("button", { name: "Сохранение…", exact: true })).toBeDisabled()
|
||||
await expect(dialog.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled()
|
||||
await expect(dialog.locator("fieldset input").last()).toBeDisabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toBeVisible()
|
||||
expect(calls).toBe(1)
|
||||
release()
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
}
|
||||
|
||||
for (const speed of [false, true]) {
|
||||
test(`сохранение ${speed ? "Speed" : "Ping"}: таймаут сохраняет форму с неизвестным результатом`, async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const timeout = AbortSignal.timeout.bind(AbortSignal)
|
||||
AbortSignal.timeout = ms => timeout(ms === 30_000 ? 100 : ms)
|
||||
})
|
||||
await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write")
|
||||
await page.getByRole("button", { name: "Пауза", exact: true }).click()
|
||||
if (speed) await page.getByRole("button", { name: /Скорость/ }).click()
|
||||
let release!: () => void
|
||||
const pending = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route(speed ? "**/api/uptime/speed-probes" : "**/api/uptime/probes", async route => {
|
||||
if (route.request().method() !== "PUT") return route.fallback()
|
||||
await pending
|
||||
await route.fulfill({ json: { ok: true } }).catch(() => {})
|
||||
})
|
||||
await page.getByRole("button", { name: speed ? "Редактировать speed-пробу" : "Редактировать пробу", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("button", { name: "Сохранить", exact: true }).click()
|
||||
await expect(dialog.getByRole("alert")).toContainText("Результат операции неизвестен")
|
||||
await expect(dialog.getByRole("button", { name: "Сохранить", exact: true })).toBeEnabled()
|
||||
release()
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user