このコミットが含まれているのは:
@@ -2,7 +2,9 @@ import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { apiDelete, apiGet, apiPost, isApiError, startAPIDownload } from '@/lib/api'
|
||||
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'
|
||||
@@ -10,7 +12,8 @@ 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 }
|
||||
processed: number; total: number | null; token?: string
|
||||
phase?: 'queued' | 'aggregating' | 'writing'; heartbeatAt?: number; updatedAt?: number }
|
||||
|
||||
|
||||
const CountsExportProgress = (
|
||||
@@ -19,6 +22,7 @@ const CountsExportProgress = (
|
||||
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 (false)
|
||||
@@ -27,40 +31,55 @@ const CountsExportProgress = (
|
||||
const job = useRef<string | null> (null)
|
||||
const creation = useRef<Promise<{ job: string }> | null> (null)
|
||||
const downloaded = useRef (false)
|
||||
const pollRequest = useRef<AbortController | null> (null)
|
||||
useEffect (() => {
|
||||
handle.cancel = async () => {
|
||||
if (terminal.current) return true
|
||||
cancelled.current = true
|
||||
if (job.current != null)
|
||||
{
|
||||
const controller = new AbortController ()
|
||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||
try
|
||||
{
|
||||
setCancelError (false)
|
||||
const result = await apiDelete<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (job.current)}`, { locale })
|
||||
`/counts/export/jobs/${encodeURIComponent (job.current)}`, {
|
||||
locale, signal: controller.signal })
|
||||
setState (result)
|
||||
}
|
||||
catch
|
||||
{
|
||||
setCancelError (true)
|
||||
}
|
||||
finally
|
||||
{
|
||||
clearTimeout (deadline)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}, [handle, locale])
|
||||
useEffect (() => {
|
||||
let active = true
|
||||
let lastResponse = Date.now ()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const poll = async (token: string) => {
|
||||
const controller = new AbortController ()
|
||||
pollRequest.current = controller
|
||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||
try
|
||||
{
|
||||
const response = await apiGet<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, { locale })
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||
locale, signal: controller.signal })
|
||||
if (!active) return
|
||||
const result = (cancelled.current && response.status === 'ready')
|
||||
? await apiDelete<ExportStatus> (
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, { locale }) : response
|
||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||
locale, signal: controller.signal }) : response
|
||||
if (!active) return
|
||||
lastResponse = Date.now ()
|
||||
setState (result)
|
||||
terminal.current = ['ready', 'failed', 'cancelled'].includes (result.status)
|
||||
if (result.status === 'ready' && result.token != null && !downloaded.current)
|
||||
@@ -76,7 +95,8 @@ const CountsExportProgress = (
|
||||
{
|
||||
if (active)
|
||||
{
|
||||
if (isApiError (error) && error.response?.status === 404)
|
||||
if ((isApiError (error) && error.response?.status === 404)
|
||||
|| Date.now () - lastResponse > 30_000)
|
||||
{
|
||||
terminal.current = true
|
||||
setState (previous => ({ ...previous, status: 'failed' }))
|
||||
@@ -86,8 +106,19 @@ const CountsExportProgress = (
|
||||
timer = setTimeout (() => poll (token), 2000)
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
clearTimeout (deadline)
|
||||
if (pollRequest.current === controller) pollRequest.current = null
|
||||
}
|
||||
}
|
||||
if (creation.current == null)
|
||||
{
|
||||
const controller = new AbortController ()
|
||||
const deadline = setTimeout (() => controller.abort (), 15_000)
|
||||
creation.current = apiPost<{ job: string }> ('/counts/export/jobs', payload, {
|
||||
locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
|
||||
}
|
||||
creation.current ??= apiPost<{ job: string }> ('/counts/export/jobs', payload, { locale })
|
||||
creation.current.then (async result => {
|
||||
if (!active) return
|
||||
job.current = result.job
|
||||
@@ -103,6 +134,7 @@ const CountsExportProgress = (
|
||||
return () => {
|
||||
active = false
|
||||
if (timer != null) clearTimeout (timer)
|
||||
pollRequest.current?.abort ()
|
||||
}
|
||||
}, [handle, locale, payload])
|
||||
useEffect (() => {
|
||||
@@ -110,11 +142,17 @@ const CountsExportProgress = (
|
||||
}, [controls])
|
||||
const percent = state.total == null ? null : (state.total === 0 ? 100
|
||||
: Math.min (100, Math.floor (state.processed / state.total * 100)))
|
||||
const phase = message (`exportProgress.${state.status}`)
|
||||
return <div className="space-y-3 py-2">
|
||||
const busy = !['ready', 'failed', 'cancelled'].includes (state.status)
|
||||
const phase = message (`exportProgress.${state.status === 'running'
|
||||
? state.phase ?? 'running' : state.status}`)
|
||||
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">
|
||||
<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}
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする