diff --git a/app/(main)/probes/page.tsx b/app/(main)/probes/page.tsx
index bbdbf709..9b762bb7 100644
--- a/app/(main)/probes/page.tsx
+++ b/app/(main)/probes/page.tsx
@@ -20,6 +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 { useDataSource } from "@/lib/data-source"
import {
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
@@ -33,7 +34,7 @@ import { requestJson } from "@/shared/api/http-client"
// ─── types ────────────────────────────────────────────────────────────────────
type DiagTool = "ping" | "traceroute" | "bandwidth" | "dns" | "route" | "mtu"
-type RunStatus = "running" | "done" | "error"
+type RunStatus = "running" | "done" | "error" | "cancelled"
type TraceProto = "icmp" | "udp" | "tcp"
type DnsType = "A" | "AAAA" | "MX" | "NS" | "TXT" | "CNAME" | "PTR"
@@ -576,6 +577,12 @@ function ScheduleTab({
// ─── page ─────────────────────────────────────────────────────────────────────
export default function ProbesPage() {
+ const { mode, backendUrl, prefsHydrated } = useDataSource()
+ if (!prefsHydrated) return
Загрузка настроек диагностики…
+ return
+}
+
+function ProbesContent() {
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
@@ -716,6 +723,13 @@ export default function ProbesPage() {
/** Живой POST /probes/run — отмена через fetch AbortSignal */
const liveProbeRunRef = useRef<{ testId: string; ctrl: AbortController } | null>(null)
+ const runLock = useRef(false)
+ const alive = useRef(true)
+ useEffect(() => {
+ alive.current = true
+ return () => { alive.current = false; liveProbeRunRef.current?.ctrl.abort() }
+ }, [])
+
const bwTunnels = tunnelsForSrc
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
@@ -748,15 +762,18 @@ export default function ProbesPage() {
// ── run test ──
const runTest = useCallback(async () => {
const srv = allServers.find(s => s.id === srcId)
- if (!srv) return
+ if (!srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
const id = String(nextId.current++)
const numericServerId = Number.parseInt(srcId, 10)
if (isLive) {
if (!Number.isFinite(numericServerId)) return
+ runLock.current = true
setRunBusy(true)
const abortCtrl = new AbortController()
liveProbeRunRef.current = { testId: id, ctrl: abortCtrl }
+ let timedOut = false
+ const timeout = setTimeout(() => { timedOut = true; abortCtrl.abort() }, 60_000)
setTests(p => [{
id,
tool,
@@ -793,32 +810,34 @@ export default function ProbesPage() {
bwProto,
bwDuration,
}
- const res = await apiFetch<{ output: string }>(`/api/servers/${numericServerId}/probes/run`, {
+ const res = await apiFetch(`/api/servers/${numericServerId}/probes/run`, {
method: "POST",
body: JSON.stringify(body),
signal: abortCtrl.signal,
})
- const lines = parseProbeOutput(res.output ?? "")
- const looksErr = (res.output ?? "").trim().toLowerCase().startsWith("error:")
+ if (!alive.current || abortCtrl.signal.aborted) return
+ const output = readProbeOutput(res)
+ const lines = parseProbeOutput(output || "Запрос выполнен: API вернул пустой вывод.")
setTests(p => p.map(t => (t.id === id
? {
...t,
lines,
totalLines: lines.length,
- status: looksErr ? "error" : "done",
+ status: "done",
}
: t)))
} catch (e) {
- const aborted =
+ if (!alive.current) return
+ const aborted = abortCtrl.signal.aborted ||
(typeof DOMException !== "undefined" && e instanceof DOMException && e.name === "AbortError")
|| (e instanceof Error && e.name === "AbortError")
if (aborted) {
setTests(p => p.map(t => (t.id === id
? {
...t,
- lines: [{ text: "Запрос отменён (стоп или закрытие запроса).", kind: "dim" }],
+ lines: [{ text: timedOut ? "Время ожидания истекло. Результат на устройстве не подтверждён; повтор выполняется вручную." : "Ожидание отменено. Остановка на устройстве не подтверждена.", kind: timedOut ? "err" : "dim" }],
totalLines: 1,
- status: "done",
+ status: timedOut ? "error" : "cancelled",
}
: t)))
} else {
@@ -833,8 +852,10 @@ export default function ProbesPage() {
: t)))
}
} finally {
+ clearTimeout(timeout)
if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current = null
- setRunBusy(false)
+ runLock.current = false
+ if (alive.current) setRunBusy(false)
}
return
}
@@ -867,6 +888,7 @@ export default function ProbesPage() {
setTab("history")
}, [
isLive,
+ liveLoad,
allServers,
srcId,
tool,
@@ -887,12 +909,15 @@ export default function ProbesPage() {
])
const stopTest = (id: string) => {
- if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
+ if (liveProbeRunRef.current?.testId === id) { liveProbeRunRef.current.ctrl.abort(); return }
setTests(p => p.map(t => (t.id === id && t.status === "running"
- ? { ...t, status: "done" as const, totalLines: t.lines.length }
+ ? { ...t, status: "cancelled" as const, totalLines: t.lines.length }
: t)))
}
- const clearTest = (id: string) => setTests(p => p.filter(t => t.id !== id))
+ const clearTest = (id: string) => {
+ if (liveProbeRunRef.current?.testId === id) liveProbeRunRef.current.ctrl.abort()
+ setTests(p => p.filter(t => t.id !== id))
+ }
const running = tests.filter(t => t.status === "running")
@@ -923,10 +948,7 @@ export default function ProbesPage() {
{running.length > 0
?
@@ -984,6 +1006,7 @@ export default function ProbesPage() {
setTarget(e.target.value)}
placeholder={tool === "dns" ? "google.com" : tool === "route" ? "8.8.8.8" : "8.8.8.8"}
+ aria-label={tool === "dns" ? "Домен" : "Цель диагностики"}
className="font-mono text-sm"
onKeyDown={e => e.key === "Enter" && runTest()}
/>
@@ -1066,7 +1089,7 @@ export default function ProbesPage() {