|
|
import { useState, useCallback, useRef, useEffect } from "react"; |
|
|
import { apiClient, ApiError } from "../api/client"; |
|
|
import type { SegmentationResult, JobStatus } from "../types"; |
|
|
|
|
|
|
|
|
const POLLING_INTERVAL = 2000; |
|
|
|
|
|
|
|
|
const MAX_COLD_START_RETRIES = 5; |
|
|
const INITIAL_RETRY_DELAY = 2000; |
|
|
const MAX_RETRY_DELAY = 30000; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const sleep = (ms: number): Promise<void> => |
|
|
new Promise((resolve) => setTimeout(resolve, ms)); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function useSegmentation() { |
|
|
|
|
|
const [result, setResult] = useState<SegmentationResult | null>(null); |
|
|
const [error, setError] = useState<string | null>(null); |
|
|
|
|
|
|
|
|
const [jobId, setJobId] = useState<string | null>(null); |
|
|
const [jobStatus, setJobStatus] = useState<JobStatus | null>(null); |
|
|
const [progress, setProgress] = useState(0); |
|
|
const [progressMessage, setProgressMessage] = useState(""); |
|
|
const [elapsedSeconds, setElapsedSeconds] = useState<number | undefined>( |
|
|
undefined, |
|
|
); |
|
|
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false); |
|
|
|
|
|
|
|
|
const currentJobRef = useRef<string | null>(null); |
|
|
const pollingIntervalRef = useRef<number | null>(null); |
|
|
const abortControllerRef = useRef<AbortController | null>(null); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const stopPolling = useCallback(() => { |
|
|
if (pollingIntervalRef.current) { |
|
|
clearInterval(pollingIntervalRef.current); |
|
|
pollingIntervalRef.current = null; |
|
|
} |
|
|
}, []); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const pollJobStatus = useCallback( |
|
|
async (id: string, signal: AbortSignal) => { |
|
|
|
|
|
if (id !== currentJobRef.current) { |
|
|
stopPolling(); |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
const response = await apiClient.getJobStatus(id, signal); |
|
|
|
|
|
|
|
|
if (id !== currentJobRef.current) return; |
|
|
|
|
|
|
|
|
setJobStatus(response.status); |
|
|
setProgress(response.progress); |
|
|
setProgressMessage(response.progressMessage); |
|
|
setElapsedSeconds(response.elapsedSeconds); |
|
|
|
|
|
|
|
|
if (response.status === "completed" && response.result) { |
|
|
stopPolling(); |
|
|
setIsLoading(false); |
|
|
setResult({ |
|
|
dwiUrl: response.result.dwiUrl, |
|
|
predictionUrl: response.result.predictionUrl, |
|
|
metrics: { |
|
|
caseId: response.result.caseId, |
|
|
diceScore: response.result.diceScore, |
|
|
volumeMl: response.result.volumeMl, |
|
|
elapsedSeconds: response.result.elapsedSeconds, |
|
|
warning: response.result.warning, |
|
|
}, |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
if (response.status === "failed") { |
|
|
stopPolling(); |
|
|
setIsLoading(false); |
|
|
setError(response.error || "Job failed"); |
|
|
setResult(null); |
|
|
} |
|
|
} catch (err) { |
|
|
|
|
|
if (err instanceof Error && err.name === "AbortError") return; |
|
|
|
|
|
|
|
|
if (err instanceof ApiError && err.status === 404) { |
|
|
stopPolling(); |
|
|
setIsLoading(false); |
|
|
setJobStatus("failed"); |
|
|
setError( |
|
|
"Job expired or lost. This can happen if the backend restarted. Please try again.", |
|
|
); |
|
|
setResult(null); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
console.warn("Polling error (will retry):", err); |
|
|
} |
|
|
}, |
|
|
[stopPolling], |
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const runSegmentation = useCallback( |
|
|
async (caseId: string, fastMode = true) => { |
|
|
|
|
|
stopPolling(); |
|
|
abortControllerRef.current?.abort(); |
|
|
|
|
|
const abortController = new AbortController(); |
|
|
abortControllerRef.current = abortController; |
|
|
|
|
|
|
|
|
setError(null); |
|
|
setResult(null); |
|
|
setProgress(0); |
|
|
setProgressMessage("Creating job..."); |
|
|
setJobStatus("pending"); |
|
|
setElapsedSeconds(undefined); |
|
|
setIsLoading(true); |
|
|
|
|
|
|
|
|
let retryCount = 0; |
|
|
while (retryCount <= MAX_COLD_START_RETRIES) { |
|
|
try { |
|
|
|
|
|
const response = await apiClient.createSegmentJob( |
|
|
caseId, |
|
|
fastMode, |
|
|
abortController.signal, |
|
|
); |
|
|
|
|
|
|
|
|
const newJobId = response.jobId; |
|
|
setJobId(newJobId); |
|
|
currentJobRef.current = newJobId; |
|
|
setJobStatus(response.status); |
|
|
setProgressMessage(response.message); |
|
|
|
|
|
|
|
|
pollingIntervalRef.current = window.setInterval(() => { |
|
|
pollJobStatus(newJobId, abortController.signal); |
|
|
}, POLLING_INTERVAL); |
|
|
|
|
|
|
|
|
await pollJobStatus(newJobId, abortController.signal); |
|
|
|
|
|
|
|
|
return; |
|
|
} catch (err) { |
|
|
|
|
|
if (err instanceof Error && err.name === "AbortError") return; |
|
|
|
|
|
|
|
|
const is503 = err instanceof ApiError && err.status === 503; |
|
|
const isNetworkError = |
|
|
err instanceof TypeError && |
|
|
err.message.toLowerCase().includes("fetch"); |
|
|
|
|
|
|
|
|
if ( |
|
|
(is503 || isNetworkError) && |
|
|
retryCount < MAX_COLD_START_RETRIES |
|
|
) { |
|
|
retryCount++; |
|
|
setJobStatus("waking_up"); |
|
|
setProgressMessage( |
|
|
`Backend is waking up... Please wait (~30-60s). Retry ${retryCount}/${MAX_COLD_START_RETRIES}`, |
|
|
); |
|
|
setProgress(0); |
|
|
|
|
|
|
|
|
const delay = Math.min( |
|
|
INITIAL_RETRY_DELAY * Math.pow(2, retryCount - 1), |
|
|
MAX_RETRY_DELAY, |
|
|
); |
|
|
await sleep(delay); |
|
|
|
|
|
|
|
|
continue; |
|
|
} |
|
|
|
|
|
|
|
|
const message = |
|
|
is503 || isNetworkError |
|
|
? "Backend failed to wake up. Please try again later." |
|
|
: err instanceof Error |
|
|
? err.message |
|
|
: "Failed to start job"; |
|
|
setError(message); |
|
|
setIsLoading(false); |
|
|
setJobStatus("failed"); |
|
|
return; |
|
|
} |
|
|
} |
|
|
}, |
|
|
[pollJobStatus, stopPolling], |
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const cancelJob = useCallback(() => { |
|
|
stopPolling(); |
|
|
abortControllerRef.current?.abort(); |
|
|
currentJobRef.current = null; |
|
|
setIsLoading(false); |
|
|
setJobStatus(null); |
|
|
setProgress(0); |
|
|
setProgressMessage(""); |
|
|
}, [stopPolling]); |
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
return () => { |
|
|
stopPolling(); |
|
|
abortControllerRef.current?.abort(); |
|
|
}; |
|
|
}, [stopPolling]); |
|
|
|
|
|
return { |
|
|
|
|
|
result, |
|
|
error, |
|
|
|
|
|
|
|
|
jobId, |
|
|
jobStatus, |
|
|
progress, |
|
|
progressMessage, |
|
|
elapsedSeconds, |
|
|
|
|
|
|
|
|
isLoading, |
|
|
|
|
|
|
|
|
runSegmentation, |
|
|
cancelJob, |
|
|
}; |
|
|
} |
|
|
|