|
|
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 |
|
|
|
|
|
|
|
|
console.warn('Polling error (will retry):', err) |
|
|
} |
|
|
}, |
|
|
[stopPolling] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const runSegmentation = useCallback( |
|
|
async (caseId: string, fastMode = true, retryCount = 0) => { |
|
|
|
|
|
if (retryCount === 0) { |
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
const abortController = abortControllerRef.current |
|
|
if (!abortController) return |
|
|
|
|
|
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) |
|
|
} 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) { |
|
|
setJobStatus('waking_up') |
|
|
setProgressMessage( |
|
|
`Backend is waking up... Please wait (~30-60s). Retry ${retryCount + 1}/${MAX_COLD_START_RETRIES}` |
|
|
) |
|
|
setProgress(0) |
|
|
|
|
|
|
|
|
const delay = Math.min( |
|
|
INITIAL_RETRY_DELAY * Math.pow(2, retryCount), |
|
|
MAX_RETRY_DELAY |
|
|
) |
|
|
await sleep(delay) |
|
|
|
|
|
|
|
|
return runSegmentation(caseId, fastMode, retryCount + 1) |
|
|
} |
|
|
|
|
|
|
|
|
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') |
|
|
} |
|
|
}, |
|
|
[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, |
|
|
} |
|
|
} |
|
|
|