fix(downloads): add retry button and resource download link for failed downloads (#1059)

Previously, failed downloads showed only an alert icon and a dismiss (X)
button — the user had no way to retry or reach the resource page without
manually re-adding the download and finding the source URL elsewhere.

This commit adds:
- POST /api/downloads/jobs/:jobId/retry endpoint (controller + service)
- retryDownloadJob() API method on the frontend
- Failed-state UI in ActiveDownloads.tsx now shows:
  - Retry button (re-dispatches the original download job)
  - 'Download page' external link (when the download URL is an HTTP(S) URL)
- Loading state on the retry button while the request is in-flight
- Screenshots documenting before/after UI

Co-authored-by: eizus <hello@cdr.xyz>
This commit is contained in:
jarvisxyz 2026-07-18 12:25:42 -04:00 committed by GitHub
parent 3ab916bf75
commit d48dffc802
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 115 additions and 21 deletions

View File

@ -24,4 +24,8 @@ export default class DownloadsController {
async cancelJob({ params }: HttpContext) {
return this.downloadService.cancelJob(params.jobId)
}
async retryJob({ params }: HttpContext) {
return this.downloadService.retryFailedJob(params.jobId)
}
}

View File

@ -4,7 +4,7 @@ import { RunDownloadJob } from '#jobs/run_download_job'
import { RunExtractPmtilesJob } from '#jobs/run_extract_pmtiles_job'
import type { RunExtractPmtilesJobParams } from '#jobs/run_extract_pmtiles_job'
import { DownloadModelJob } from '#jobs/download_model_job'
import { DownloadJobWithProgress, DownloadProgressData } from '../../types/downloads.js'
import { DownloadJobWithProgress, DownloadProgressData, RunDownloadJobParams } from '../../types/downloads.js'
import type { Job, Queue } from 'bullmq'
import { normalize } from 'path'
import { deleteFileIfExists } from '../utils/fs.js'
@ -137,6 +137,40 @@ export class DownloadService {
}
}
async retryFailedJob(jobId: string): Promise<{ success: boolean; message: string }> {
// Search both the file download queue and the model download queue
for (const queueName of [RunDownloadJob.queue, DownloadModelJob.queue]) {
const queue = this.queueService.getQueue(queueName)
const job = await queue.getJob(jobId)
if (job) {
// For Ollama model downloads, re-dispatch with the model name
if (queueName === DownloadModelJob.queue) {
const modelName = job.data.modelName
if (!modelName) {
return { success: false, message: 'Cannot retry: model name not found in job data' }
}
await DownloadModelJob.dispatch({ modelName })
await job.remove().catch(() => {})
return { success: true, message: `Retrying download for model ${modelName}` }
}
// For file downloads (zim, map, etc.), re-dispatch with original params
const params = job.data as RunDownloadJobParams
if (!params.url || !params.filepath) {
return { success: false, message: 'Cannot retry: missing URL or filepath in job data' }
}
// Remove the old failed job, then dispatch a fresh one
await job.remove().catch(() => {})
await RunDownloadJob.dispatch(params)
return { success: true, message: `Retrying download for ${params.url}` }
}
}
return { success: false, message: 'Failed job not found. It may have already been dismissed.' }
}
async cancelJob(jobId: string): Promise<{ success: boolean; message: string }> {
const queue = this.queueService.getQueue(RunDownloadJob.queue)
const job = await queue.getJob(jobId)

View File

@ -2,7 +2,7 @@ import { useRef, useState, useCallback } from 'react'
import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'
import { extractFileName, formatBytes } from '~/lib/util'
import StyledSectionHeader from './StyledSectionHeader'
import { IconAlertTriangle, IconX, IconLoader2 } from '@tabler/icons-react'
import { IconAlertTriangle, IconX, IconLoader2, IconRefresh, IconExternalLink } from '@tabler/icons-react'
import api from '~/lib/api'
interface ActiveDownloadProps {
@ -39,6 +39,7 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
const { data: downloads, invalidate } = useDownloads({ filetype })
const [cancellingJobs, setCancellingJobs] = useState<Set<string>>(new Set())
const [confirmingCancel, setConfirmingCancel] = useState<string | null>(null)
const [retryingJobs, setRetryingJobs] = useState<Set<string>>(new Set())
// Track previous downloadedBytes for speed calculation
const prevBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map())
@ -83,6 +84,20 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
invalidate()
}
const handleRetry = async (jobId: string) => {
setRetryingJobs((prev) => new Set(prev).add(jobId))
try {
await api.retryDownloadJob(jobId)
} finally {
setRetryingJobs((prev) => {
const next = new Set(prev)
next.delete(jobId)
return next
})
invalidate()
}
}
const handleCancel = async (jobId: string) => {
setCancellingJobs((prev) => new Set(prev).add(jobId))
setConfirmingCancel(null)
@ -113,6 +128,8 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
const isCancelling = cancellingJobs.has(download.jobId)
const isConfirming = confirmingCancel === download.jobId
const isRetrying = retryingJobs.has(download.jobId)
return (
<div
key={download.jobId}
@ -123,26 +140,55 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
}`}
>
{status === 'failed' ? (
<div className="flex items-center gap-2">
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">
{download.title || filename}
</p>
{download.title && (
<p className="text-xs text-text-muted truncate">{filename}</p>
)}
<p className="text-xs text-red-600 mt-0.5">
Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
</p>
<div className="space-y-3">
<div className="flex items-start gap-2">
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-text-primary truncate">
{download.title || filename}
</p>
{download.title && (
<p className="text-xs text-text-muted truncate">{filename}</p>
)}
<p className="text-xs text-red-600 mt-0.5">
Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
</p>
</div>
<button
onClick={() => handleDismiss(download.jobId)}
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
title="Dismiss failed download"
>
<IconX className="w-4 h-4 text-red-400 hover:text-red-600" />
</button>
</div>
<div className="flex items-center gap-2 pl-7">
<button
onClick={() => handleRetry(download.jobId)}
disabled={isRetrying}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-desert-green text-white hover:bg-desert-green-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Retry download"
>
{isRetrying ? (
<IconLoader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<IconRefresh className="w-3.5 h-3.5" />
)}
{isRetrying ? 'Retrying...' : 'Retry'}
</button>
{download.url && download.url.startsWith('http') && (
<a
href={download.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-desert-stone-lighter text-text-secondary hover:bg-desert-stone-light transition-colors"
title="Open resource download page"
>
<IconExternalLink className="w-3.5 h-3.5" />
Download page
</a>
)}
</div>
<button
onClick={() => handleDismiss(download.jobId)}
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
title="Dismiss failed download"
>
<IconX className="w-4 h-4 text-red-400 hover:text-red-600" />
</button>
</div>
) : (
<div className="space-y-2">

View File

@ -848,6 +848,15 @@ class API {
})()
}
async retryDownloadJob(jobId: string): Promise<{ success: boolean; message: string } | undefined> {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
`/downloads/jobs/${jobId}/retry`
)
return response.data
})()
}
async runBenchmark(type: BenchmarkType, sync: boolean = false) {
return catchInternal(async () => {
const response = await this.client.post<RunBenchmarkResponse>(

View File

@ -107,6 +107,7 @@ router
router.get('/jobs/:filetype', [DownloadsController, 'filetype'])
router.delete('/jobs/:jobId', [DownloadsController, 'removeJob'])
router.post('/jobs/:jobId/cancel', [DownloadsController, 'cancelJob'])
router.post('/jobs/:jobId/retry', [DownloadsController, 'retryJob'])
})
.prefix('/api/downloads')