ファイル
btrc-hub/frontend/src/components/counts/CountsExportProgress.tsx
T
2026-10-31 13:50:10 +09:00

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