fix(ui): различать результат и отмену диагностики
This commit is contained in:
1 parent
8bda85129f
commit
6fa8e5bfd1
9 files changed
+132
-20
No files matched your search
+42
-18
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user