280 行
11 KiB
TypeScript
280 行
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
|
|
import LocalisedText from '@/components/LocalisedText'
|
|
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
|
|
import { countsErrorMessage } from '@/lib/countsDisplay'
|
|
import { useClientMotion } from '@/lib/useClientMotion'
|
|
import { useUITranslation } from '@/lib/useUITranslation'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
|
import type { LocaleCode } from '@/lib/locales'
|
|
|
|
export type CountsExportHandle = { cancel: () => Promise<boolean> }
|
|
type ExportStatus = {
|
|
status: 'starting' | 'queued' | 'running' | 'cancelling' | 'cancelled' | 'ready' | 'failed'
|
|
processed: number; total: number | null; token?: string
|
|
downloadAttempt?: string; downloadStatus?: string; error?: string
|
|
phase?: 'queued' | 'aggregating' | 'writing'; heartbeatAt?: number; updatedAt?: number }
|
|
|
|
|
|
const CountsExportProgress = (
|
|
{ payload, locale, handle, controls }: {
|
|
payload: Record<string, string>; locale: LocaleCode
|
|
handle: CountsExportHandle; controls: DialogueFormControls },
|
|
) => {
|
|
const { label, message } = useUITranslation ('counts')
|
|
const motion = useClientMotion ()
|
|
const [state, setState] = useState<ExportStatus> ({
|
|
status: 'starting', processed: 0, total: null })
|
|
const [cancelError, setCancelError] = useState<unknown> (null)
|
|
const [failure, setFailure] = useState<unknown> (null)
|
|
const [downloadError, setDownloadError] = useState<string | null> (null)
|
|
// One mounted dialogue owns one immutable job, even if its parent rerenders.
|
|
const initial = useRef ({ payload, locale })
|
|
const actions = useRef (controls)
|
|
actions.current = controls
|
|
const terminal = useRef (false)
|
|
const finished = useRef (false)
|
|
const cancelAction = useRef<() => Promise<boolean>> (async () => false)
|
|
const cancelled = useRef (false)
|
|
const generation = useRef (0)
|
|
const job = useRef<string | null> (null)
|
|
const creation = useRef<Promise<{ job: string }> | null> (null)
|
|
const registration = useRef<Promise<ExportStatus> | null> (null)
|
|
const attempt = useRef<string | null> (null)
|
|
const downloadDeadline = useRef (0)
|
|
const releaseDownload = useRef<(() => void) | null> (null)
|
|
const cancellation = useRef<Promise<boolean> | null> (null)
|
|
useEffect (() => { handle.cancel = () => cancelAction.current () }, [handle])
|
|
useEffect (() => {
|
|
const epoch = ++generation.current
|
|
let active = true
|
|
let lastResponse = Date.now ()
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
let acknowledgementTimer: ReturnType<typeof setTimeout> | null = null
|
|
let request: AbortController | null = null
|
|
const current = () => active && generation.current === epoch
|
|
const stop = (reason: string) => {
|
|
if (!current () || terminal.current) return
|
|
terminal.current = true
|
|
if (timer != null) clearTimeout (timer)
|
|
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
|
request?.abort ()
|
|
releaseDownload.current?.()
|
|
releaseDownload.current = null
|
|
setDownloadError (reason)
|
|
}
|
|
const cancel = (): Promise<boolean> => {
|
|
if (finished.current || (terminal.current && job.current == null))
|
|
return Promise.resolve (true)
|
|
cancelled.current = true
|
|
terminal.current = true
|
|
if (timer != null) clearTimeout (timer)
|
|
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
|
request?.abort ()
|
|
releaseDownload.current?.()
|
|
releaseDownload.current = null
|
|
cancellation.current ??= (async () => {
|
|
const controller = new AbortController ()
|
|
const deadline = setTimeout (() => controller.abort (), 15_000)
|
|
try
|
|
{
|
|
const created = await creation.current
|
|
if (created == null) return false
|
|
job.current = created.job
|
|
const result = await apiDelete<ExportStatus> (
|
|
`/counts/export/jobs/${encodeURIComponent (created.job)}`, {
|
|
locale: initial.current.locale, signal: controller.signal })
|
|
if (current ())
|
|
{
|
|
setState (result)
|
|
setCancelError (null)
|
|
finished.current = ['cancelled', 'failed'].includes (result.status)
|
|
terminal.current = finished.current
|
|
if (!terminal.current)
|
|
{
|
|
if (timer != null) clearTimeout (timer)
|
|
timer = setTimeout (() => poll (created.job), 800)
|
|
}
|
|
}
|
|
return ['cancelled', 'failed'].includes (result.status)
|
|
}
|
|
catch (error)
|
|
{
|
|
if (isApiError (error) && error.response?.status === 404) return true
|
|
if (current ()) setCancelError (error)
|
|
return false
|
|
}
|
|
finally
|
|
{
|
|
clearTimeout (deadline)
|
|
cancellation.current = null
|
|
}
|
|
}) ()
|
|
return cancellation.current
|
|
}
|
|
cancelAction.current = cancel
|
|
const armDeadline = () => {
|
|
if (downloadDeadline.current === 0) return
|
|
acknowledgementTimer = setTimeout (() => stop ('timeout'),
|
|
Math.max (0, downloadDeadline.current - Date.now ()))
|
|
}
|
|
const poll = async (token: string) => {
|
|
if (!current () || terminal.current) return
|
|
const controller = new AbortController ()
|
|
request = controller
|
|
const deadline = setTimeout (() => controller.abort (), 15_000)
|
|
try
|
|
{
|
|
const result = await apiGet<ExportStatus> (
|
|
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
|
locale: initial.current.locale, signal: controller.signal })
|
|
if (!current () || terminal.current) return
|
|
lastResponse = Date.now ()
|
|
setState (result)
|
|
if (cancelled.current)
|
|
{
|
|
if (await cancel ()) return
|
|
}
|
|
else if (result.status === 'ready')
|
|
{
|
|
if (result.token == null) { stop ('failed'); return }
|
|
if (attempt.current == null)
|
|
{
|
|
registration.current ??= apiPost<ExportStatus> (
|
|
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
|
|
locale: initial.current.locale, signal: controller.signal })
|
|
const receipt = await registration.current
|
|
if (!current () || terminal.current || cancelled.current) return
|
|
if (receipt.downloadAttempt == null) { stop ('failed'); return }
|
|
attempt.current = receipt.downloadAttempt
|
|
downloadDeadline.current = Date.now () + 30_000
|
|
armDeadline ()
|
|
try
|
|
{
|
|
releaseDownload.current = startAPIDownload ('/counts/export', result.token,
|
|
() => stop ('failed'), new URLSearchParams ({
|
|
job: token, attempt: receipt.downloadAttempt }))
|
|
}
|
|
catch
|
|
{
|
|
stop ('failed')
|
|
return
|
|
}
|
|
}
|
|
if (result.downloadAttempt === attempt.current)
|
|
{
|
|
if (result.downloadStatus === 'started')
|
|
{
|
|
terminal.current = true
|
|
finished.current = true
|
|
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
|
// Rack yielded a chunk; browser receipt and saving are not confirmed.
|
|
releaseDownload.current = null
|
|
actions.current.close ()
|
|
return
|
|
}
|
|
if (['failed', 'unavailable', 'timeout'].includes (result.downloadStatus ?? ''))
|
|
{ stop (result.downloadStatus!); return }
|
|
}
|
|
}
|
|
else if (['failed', 'cancelled'].includes (result.status))
|
|
{
|
|
finished.current = true
|
|
terminal.current = true
|
|
return
|
|
}
|
|
if (timer != null) clearTimeout (timer)
|
|
timer = setTimeout (() => poll (token), 800)
|
|
}
|
|
catch (error)
|
|
{
|
|
if (!current () || terminal.current) return
|
|
const status = isApiError (error) ? error.response?.status : null
|
|
if ([400, 401, 403, 422].includes (status ?? 0)) setFailure (error)
|
|
if (registration.current != null && attempt.current == null)
|
|
{ stop ('failed'); return }
|
|
if (status != null && status >= 400 && status < 500)
|
|
{ stop ('unavailable'); return }
|
|
if (Date.now () - lastResponse >= 30_000)
|
|
{ stop ('timeout'); return }
|
|
timer = setTimeout (() => poll (token), 2000)
|
|
}
|
|
finally
|
|
{
|
|
clearTimeout (deadline)
|
|
if (request === controller) request = null
|
|
}
|
|
}
|
|
if (creation.current == null)
|
|
{
|
|
const controller = new AbortController ()
|
|
const deadline = setTimeout (() => controller.abort (), 15_000)
|
|
creation.current = apiPost<{ job: string }> (
|
|
'/counts/export/jobs', initial.current.payload, {
|
|
locale: initial.current.locale, signal: controller.signal })
|
|
.finally (() => clearTimeout (deadline))
|
|
}
|
|
armDeadline ()
|
|
void creation.current.then (result => {
|
|
if (!current () || terminal.current) return
|
|
job.current = result.job
|
|
void poll (result.job)
|
|
}).catch (error => {
|
|
if (current ()) setFailure (error)
|
|
stop ('failed')
|
|
})
|
|
return () => {
|
|
active = false
|
|
if (timer != null) clearTimeout (timer)
|
|
if (acknowledgementTimer != null) clearTimeout (acknowledgementTimer)
|
|
request?.abort ()
|
|
// StrictMode immediately installs another effect; real unmount releases the frame.
|
|
queueMicrotask (() => {
|
|
if (generation.current !== epoch) return
|
|
releaseDownload.current?.()
|
|
releaseDownload.current = null
|
|
})
|
|
}
|
|
}, [])
|
|
useEffect (() => { controls.setActions ([]) }, [controls])
|
|
const percent = state.total == null ? null : (state.total === 0 ? 100
|
|
: Math.min (100, Math.floor (state.processed / state.total * 100)))
|
|
const busy = downloadError == null && cancelError == null
|
|
&& !['failed', 'cancelled'].includes (state.status)
|
|
const phaseKey = (downloadError != null || cancelError != null) ? 'failed'
|
|
: (state.status === 'running' ? state.phase ?? 'running'
|
|
: (state.status === 'ready' ? 'writing' : state.status))
|
|
const phase = message (`exportProgress.${phaseKey}`)
|
|
const failureMessage = countsErrorMessage (failure ?? state, locale)
|
|
const cancellationMessage = countsErrorMessage (cancelError, locale)
|
|
return <div className="space-y-3 py-2" aria-busy={busy}>
|
|
<p>{label ('exportProgress.format', {
|
|
format: message (`exportFormats.${payload.format}`).text })}</p>
|
|
<p role="status" aria-live="polite" className="flex items-center gap-2">
|
|
{busy && <span aria-hidden="true" className={cn (
|
|
'size-4 shrink-0 rounded-full border-2 border-current border-r-transparent',
|
|
!motion.reduced && 'animate-spin')}
|
|
style={{ animationDuration: `${motion.duration ('collapse') * 4}ms` }}/>}
|
|
<LocalisedText language={phase.language}>{phase.text}</LocalisedText>
|
|
</p>
|
|
<progress className="h-3 w-full" max={100} value={percent ?? undefined}
|
|
aria-label={phase.text} lang={phase.language}/>
|
|
<p aria-live="polite">{percent == null
|
|
? label ('exportProgress.processed', { count: state.processed })
|
|
: label ('exportProgress.measured', {
|
|
processed: state.processed, total: state.total ?? 0, percent })}</p>
|
|
{(downloadError != null || state.status === 'failed') && <p role="alert"
|
|
lang={failureMessage.language}>
|
|
{failure != null || state.status === 'failed'
|
|
? failureMessage.text
|
|
: label (`downloadErrors.${downloadError}`)}
|
|
{downloadError != null && <><br/>{label ('errors.stopped')}</>}</p>}
|
|
{cancelError != null && <p role="alert" lang={cancellationMessage.language}>
|
|
{cancellationMessage.text}</p>}
|
|
</div>
|
|
}
|
|
|
|
export default CountsExportProgress
|