fix(ui): различать результат и отмену диагностики

This commit is contained in:
Denozordec committed 2026-10-01 04:07:08 +07:00
1 parent 8bda85129f
commit 6fa8e5bfd1
9 files changed
+132 -20

No files matched your search

+42 -18
View File
@@ -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 <p role="status">Загрузка настроек диагностики…</p>
return <ProbesContent key={JSON.stringify([mode, backendUrl])} />
}
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<unknown>(`/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() {
<ServerRailMobileButton />
{running.length > 0
? <Button variant="outline" size="sm" onClick={() => {
liveProbeRunRef.current?.ctrl.abort()
setTests(p => p.map(t => (t.status === "running"
? { ...t, status: "done" as const, totalLines: t.lines.length }
: t)))
running.forEach(test => stopTest(test.id))
}}>
<SquareIcon className="size-4" />Остановить все
</Button>
@@ -984,6 +1006,7 @@ export default function ProbesPage() {
</OptionLabel>
<Input value={target} onChange={e => 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() {
<Button
onClick={() => void runTest()}
disabled={runBusy || !srcServer || (tool === "bandwidth" && !bwTunnels.length)}
disabled={runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
className="h-8 shrink-0 gap-1.5 self-end"
>
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />}
@@ -1155,6 +1178,7 @@ export default function ProbesPage() {
{test.status === "done" && test.source === "live" && (
<span className="text-[10px] font-mono text-emerald-600 dark:text-emerald-400">live</span>
)}
{test.status === "cancelled" && <span role="status" className="text-[11px] text-muted-foreground">ожидание отменено</span>}
{test.status === "error" && (
<span className="text-[11px] text-destructive">ошибка</span>
)}