fix(downloads): add retry button and resource download link for failed downloads
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
This commit is contained in:
parent
8dcbf7dbcf
commit
156b1dac8f
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { inject } from '@adonisjs/core'
|
|||
import { QueueService } from './queue_service.js'
|
||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import { DownloadModelJob } from '#jobs/download_model_job'
|
||||
import { DownloadJobWithProgress, DownloadProgressData } from '../../types/downloads.js'
|
||||
import { DownloadJobWithProgress, DownloadProgressData, RunDownloadJobParams } from '../../types/downloads.js'
|
||||
import { normalize } from 'path'
|
||||
import { deleteFileIfExists } from '../utils/fs.js'
|
||||
|
||||
|
|
@ -110,6 +110,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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -660,6 +660,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>(
|
||||
|
|
|
|||
|
|
@ -100,6 +100,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')
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue