diff --git a/app/(main)/probes/page.tsx b/app/(main)/probes/page.tsx index 9b762bb7..c9003651 100644 --- a/app/(main)/probes/page.tsx +++ b/app/(main)/probes/page.tsx @@ -20,7 +20,7 @@ import { Input } from "@/components/ui/input" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { type ServerTileItem } from "@/components/server-tile-rail" import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data" -import { readProbeOutput } from "@/lib/probe-response" +import { readProbeOutput, probeServersSchema } from "@/lib/probe-response" import { useDataSource } from "@/lib/data-source" import { PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon, @@ -94,18 +94,6 @@ function parseProbeOutput(text: string): OutputLine[] { return text.split("\n").map(t => ({ text: t || " ", kind: inferProbeLineKind(t) })) } -interface BackendServerRow { - id: number - name: string - host: string - site: string - country: string - asn: string - type: Server["type"] - enabled: boolean - status?: Server["status"] - latency?: number | null -} // SpeedProbeApiRow imported from probes-speed-probes-data-grid @@ -589,6 +577,9 @@ function ProbesContent() { const [liveServers, setLiveServers] = useState([]) const [liveLoad, setLiveLoad] = useState<"idle" | "loading" | "error">(() => (isLive ? "loading" : "idle")) + const [catalogError, setCatalogError] = useState(null) + const [catalogRevision, setCatalogRevision] = useState(0) + const [catalogReadAt, setCatalogReadAt] = useState(null) const [greByServer, setGreByServer] = useState>({}) const [tool, setTool] = useState("ping") @@ -609,36 +600,31 @@ function ProbesContent() { const [rosSrcV4, setRosSrcV4] = useState(undefined) useEffect(() => { - if (!isLive) { - queueMicrotask(() => setLiveLoad("idle")) - return - } - setLiveLoad("loading") - void apiFetch("/api/servers") - .then(rows => { - const mapped: Server[] = rows.map(s => ({ - id: String(s.id), - name: s.name || s.host, - host: s.host, - model: "—", - os: "—", - site: s.site, - country: s.country || "UN", - asn: s.asn, - type: s.type, - enabled: s.enabled, - status: (s.status ?? "offline") as Server["status"], - latency: s.latency != null ? Math.round(s.latency) : null, - sessions: 0, - })) - setLiveServers(mapped) + if (!isLive) return + let cancelled = false + const ctrl = new AbortController() + queueMicrotask(() => { if (!cancelled) { setLiveLoad("loading"); setCatalogError(null) } }) + void apiFetch("/api/servers", { signal: ctrl.signal }) + .then(raw => { + const result = probeServersSchema.safeParse(raw) + if (!result.success) throw new Error("Некорректный ответ API: каталог устройств не подтверждён.") + if (cancelled) return + setLiveServers(result.data.map(s => ({ + id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", + site: s.site ?? "", country: s.country ?? "UN", asn: s.asn ?? "", type: s.type, + enabled: s.enabled, status: s.status ?? "unknown", + latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0, + }))) + setCatalogReadAt(Date.now()) setLiveLoad("idle") }) - .catch(() => { - setLiveServers([]) + .catch(error => { + if (cancelled) return + setCatalogError(error instanceof Error ? error.message : "Не удалось прочитать устройства.") setLiveLoad("error") }) - }, [isLive, apiFetch]) + return () => { cancelled = true; ctrl.abort() } + }, [isLive, apiFetch, catalogRevision]) const allServers = useMemo(() => { if (!isLive) return servers @@ -921,7 +907,7 @@ function ProbesContent() { const running = tests.filter(t => t.status === "running") - if (isLive && liveLoad === "loading") { + if (isLive && liveLoad === "loading" && catalogReadAt === null) { return (
@@ -946,6 +932,7 @@ function ProbesContent() { actions={ <> + {isLive && } {running.length > 0 ?