fix(api): проверить каталог speed-проб и изолировать источник

This commit is contained in:
Denozordec committed 2026-10-04 10:31:02 +07:00
1 parent f30319ac55
commit d4f2961f76
13 files changed
+123 -51

No files matched your search

+17 -14
View File
@@ -1,6 +1,6 @@
"use client"
import { speedHistoryResponseSchema } from "@mmapp/contracts"
import { speedHistoryResponseSchema, speedProbesResponseSchema } from "@mmapp/contracts"
import probeStyles from "./probe-rows.module.css"
@@ -1110,6 +1110,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const [speedProbes, setSpeedProbes] = useState<SpeedProbeRow[]>([])
const speedReadRevision = useRef(0)
const [speedReads, setSpeedReads] = useState<Record<"probes" | "history", { receivedAt: number | null; error: string | null }>>({ probes: { receivedAt: null, error: null }, history: { receivedAt: null, error: null } })
const speedCatalogReady = !liveApi || (speedReads.probes.receivedAt !== null && speedReads.probes.error === null)
const [speedIfaces, setSpeedIfaces] = useState<Record<string, Array<{ name: string; running: boolean; disabled: boolean; addresses: string[] }>>>({})
const [speedCollapsed, setSpeedCollapsed] = useState<Set<string>>(new Set())
const [speedSheetOpen, setSpeedSheetOpen] = useState(false)
@@ -1178,9 +1179,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
}
await Promise.all([
read("probes", async () => {
const sp = await apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes")
if (!Array.isArray(sp.probes)) throw new Error("Некорректный список speed-проб")
if (current()) setSpeedProbes(sp.probes)
const result = speedProbesResponseSchema.safeParse(await apiFetch<unknown>("/api/uptime/speed-probes"))
if (!result.success) throw new Error("Данные проб скорости не подтверждены сервером.")
if (current()) setSpeedProbes(result.data.probes)
}),
read("history", async () => {
const result = speedHistoryResponseSchema.safeParse(await apiFetch<unknown>("/api/uptime/speed-test/runs"))
@@ -1522,6 +1523,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const persistSpeedProbes = useCallback(async (rows: SpeedProbeRow[]) => {
if (!liveApi) return
if (!speedCatalogReady) throw new Error("Каталог speed-проб не подтверждён. Обновите данные перед сохранением.")
await apiFetch("/api/uptime/speed-probes", {
method: "PUT",
signal: AbortSignal.timeout(30_000),
@@ -1544,7 +1546,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
})),
}),
})
}, [apiFetch, liveApi])
}, [apiFetch, liveApi, speedCatalogReady])
async function saveProbeForm(save: () => Promise<void>, confirm: () => void) {
if (!requireWrite()) return
@@ -1885,7 +1887,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
}
const openAddSpeedSheet = () => {
if (!requireWrite()) return
if (!requireWrite() || !speedCatalogReady) return
setProbeSaveError(null)
setEditingSpeedProbeId(null)
const src = selectableSources[0]?.id ?? ""
@@ -2001,7 +2003,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
</Button>
)}
{tab === "speed" && (
<Button size="sm" disabled={probeSaving || !canWrite} onClick={openAddSpeedSheet}>
<Button size="sm" disabled={probeSaving || !canWrite || !speedCatalogReady} onClick={openAddSpeedSheet}>
<PlusIcon className="size-4" />Новая speed-проба
</Button>
)}
@@ -2077,7 +2079,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
<span className="flex items-center gap-2">
<ArrowUpDownIcon className="size-3.5" />
<span>Скорость</span>
<span className="text-[10px] font-mono opacity-50">{speedProbes.length}</span>
<span className="text-[10px] font-mono opacity-50">{liveApi && speedReads.probes.receivedAt === null ? "—" : speedProbes.length}</span>
</span>
)}
</button>
@@ -2096,7 +2098,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const state = speedReads[resource], label = resource === "probes" ? "Speed-пробы" : "История тестов"
return <div key={resource}>
<p role="status" className="text-xs text-muted-foreground">{label}: {state.receivedAt ? "получено " + new Date(state.receivedAt).toLocaleString("ru-RU") : "данные пока неизвестны"}</p>
{state.error && <ContextAlert variant="warning" aria-label={"Чтение скорости: " + resource}><ContextTitle>{label}</ContextTitle><ContextDescription>{state.error} {state.receivedAt ? "Последние данные сохранены." : "Данные пока неизвестны."}</ContextDescription></ContextAlert>}
{state.error && <ContextAlert variant="warning" aria-label={"Чтение скорости: " + resource}><ContextTitle>{label}</ContextTitle><ContextDescription>{state.error} {state.receivedAt ? "Последние данные сохранены." : "Данные пока неизвестны."}{resource === "probes" ? " Изменение каталога недоступно до успешного обновления." : ""}</ContextDescription></ContextAlert>}
</div>
})}
</div>}
@@ -2171,7 +2173,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={probeSaving || !canWrite} onClick={openAddSpeedSheet}>
<Button size="sm" variant="outline" className="mt-1" disabled={probeSaving || !canWrite || !speedCatalogReady} onClick={openAddSpeedSheet}>
<PlusIcon className="size-4" />Новая speed-проба
</Button>
</div>
@@ -2229,7 +2231,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={probeSaving || !canWrite} checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
<FormToggle ariaLabel={"Включить speed-пробу " + server.name + " → " + (dst?.name ?? probe.dstServerId)} disabled={probeSaving || !canWrite || !speedCatalogReady} 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">
@@ -2266,12 +2268,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
Запустить
</Button>
<Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-foreground"
disabled={probeSaving || !canWrite} aria-label="Редактировать speed-пробу" onClick={() => openEditSpeedSheet(probe)}
disabled={probeSaving || !canWrite || !speedCatalogReady} 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={probeSaving || !canWrite} aria-label="Удалить speed-пробу" onClick={() => deleteSpeedProbe(probe.id)}>
disabled={probeSaving || !canWrite || !speedCatalogReady} aria-label="Удалить speed-пробу" onClick={() => deleteSpeedProbe(probe.id)}>
<TrashIcon className="size-3.5" />
</Button>
</div>
@@ -2996,11 +2998,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
}
function UptimeRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams()
const id = params.get("probe")
const rest = new URLSearchParams(params.toString())
rest.delete("probe")
return <UptimePageContent key={id === null ? "list" : "probe:" + id} selectedProbeId={id} allProbesHref={"/uptime" + (rest.size ? "?" + rest : "")} />
return <UptimePageContent key={JSON.stringify([id, mode, backendUrl])} selectedProbeId={id} allProbesHref={"/uptime" + (rest.size ? "?" + rest : "")} />
}
export default function UptimePage() {