From 156b1dac8ffe8845e356b01adb1bb2fe65f0556e Mon Sep 17 00:00:00 2001 From: eizus Date: Wed, 1 Jul 2026 15:28:14 -0400 Subject: [PATCH] fix(downloads): add retry button and resource download link for failed downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- admin/app/controllers/downloads_controller.ts | 4 + admin/app/services/download_service.ts | 36 +++++++- admin/inertia/components/ActiveDownloads.tsx | 86 ++++++++++++++----- admin/inertia/lib/api.ts | 9 ++ admin/start/routes.ts | 1 + 5 files changed, 115 insertions(+), 21 deletions(-) diff --git a/admin/app/controllers/downloads_controller.ts b/admin/app/controllers/downloads_controller.ts index 0a9b1e3..88c11c2 100644 --- a/admin/app/controllers/downloads_controller.ts +++ b/admin/app/controllers/downloads_controller.ts @@ -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) + } } diff --git a/admin/app/services/download_service.ts b/admin/app/services/download_service.ts index ac9d02d..8d0f489 100644 --- a/admin/app/services/download_service.ts +++ b/admin/app/services/download_service.ts @@ -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) diff --git a/admin/inertia/components/ActiveDownloads.tsx b/admin/inertia/components/ActiveDownloads.tsx index b4a53ca..dd43c16 100644 --- a/admin/inertia/components/ActiveDownloads.tsx +++ b/admin/inertia/components/ActiveDownloads.tsx @@ -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>(new Set()) const [confirmingCancel, setConfirmingCancel] = useState(null) + const [retryingJobs, setRetryingJobs] = useState>(new Set()) // Track previous downloadedBytes for speed calculation const prevBytesRef = useRef>(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 (
{status === 'failed' ? ( -
- -
-

- {download.title || filename} -

- {download.title && ( -

{filename}

- )} -

- Download failed{download.failedReason ? `: ${download.failedReason}` : ''} -

+
+
+ +
+

+ {download.title || filename} +

+ {download.title && ( +

{filename}

+ )} +

+ Download failed{download.failedReason ? `: ${download.failedReason}` : ''} +

+
+ +
+
+ + {download.url && download.url.startsWith('http') && ( + + + Download page + + )}
-
) : (
diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index dc1c7ed..ab608ed 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -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( diff --git a/admin/start/routes.ts b/admin/start/routes.ts index d201174..bfc835f 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -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')