feat(downloads): rich progress, friendly names, cancel, and live status (#554)

* feat(downloads): rich progress, friendly names, cancel, and live status

Redesign the Active Downloads UI with four improvements:

- Rich progress: BullMQ jobs now report downloadedBytes/totalBytes instead
  of just a percentage, showing "2.3 GB / 5.1 GB" instead of "78% / 100%"
- Friendly names: dispatch title metadata from curated categories, Content
  Explorer library, Wikipedia selector, and map collections
- Cancel button: Redis-based cross-process abort signal lets users cancel
  active downloads with file cleanup. Confirmation step prevents accidents.
- Live status indicator: green pulsing dot with transfer speed for active
  downloads, orange stall warning after 60s of no data, gray dot for queued

Backward compatible with in-flight jobs that have integer-only progress.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(downloads): fix cancel, dismiss, speed, and retry bugs

- Speed indicator: only set prevBytesRef on first observation to prevent
  intermediate re-renders from inflating the calculated speed
- Cancel: throw UnrecoverableError on abort to prevent BullMQ retries
- Dismiss: remove stale BullMQ lock before job.remove() so cancelled
  jobs can actually be dismissed
- Retry: add getActiveByUrl() helper that checks job state before
  blocking re-download, auto-cleans terminal jobs
- Wikipedia: reset selection status to failed on cancel so the
  "downloading" state doesn't persist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(downloads): improve cancellation logic and surface true BullMQ job states

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Jake Turner <jturner@cosmistack.com>
This commit is contained in:
chriscrosstalk 2026-04-01 15:55:13 -07:00 committed by GitHub
parent d03d59a843
commit 4dcf2e9921
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 604 additions and 152 deletions

View File

@ -20,4 +20,8 @@ export default class DownloadsController {
await this.downloadService.removeFailedJob(params.jobId) await this.downloadService.removeFailedJob(params.jobId)
return { success: true } return { success: true }
} }
async cancelJob({ params }: HttpContext) {
return this.downloadService.cancelJob(params.jobId)
}
} }

View File

@ -27,7 +27,7 @@ export default class ZimController {
async downloadRemote({ request }: HttpContext) { async downloadRemote({ request }: HttpContext) {
const payload = await request.validateUsing(remoteDownloadWithMetadataValidator) const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
assertNotPrivateUrl(payload.url) assertNotPrivateUrl(payload.url)
const { filename, jobId } = await this.zimService.downloadRemote(payload.url) const { filename, jobId } = await this.zimService.downloadRemote(payload.url, payload.metadata)
return { return {
message: 'Download started successfully', message: 'Download started successfully',

View File

@ -1,5 +1,5 @@
import { Job } from 'bullmq' import { Job, UnrecoverableError } from 'bullmq'
import { RunDownloadJobParams } from '../../types/downloads.js' import { RunDownloadJobParams, DownloadProgressData } from '../../types/downloads.js'
import { QueueService } from '#services/queue_service' import { QueueService } from '#services/queue_service'
import { doResumableDownload } from '../utils/downloads.js' import { doResumableDownload } from '../utils/downloads.js'
import { createHash } from 'crypto' import { createHash } from 'crypto'
@ -17,23 +17,75 @@ export class RunDownloadJob {
return 'run-download' return 'run-download'
} }
/** In-memory registry of abort controllers for active download jobs */
static abortControllers: Map<string, AbortController> = new Map()
static getJobId(url: string): string { static getJobId(url: string): string {
return createHash('sha256').update(url).digest('hex').slice(0, 16) return createHash('sha256').update(url).digest('hex').slice(0, 16)
} }
/** Redis key used to signal cancellation across processes */
static cancelKey(jobId: string): string {
return `nomad:download:cancel:${jobId}`
}
/** Signal cancellation via Redis so the worker process can pick it up */
static async signalCancel(jobId: string): Promise<void> {
const queueService = new QueueService()
const queue = queueService.getQueue(this.queue)
const client = await queue.client
await client.set(this.cancelKey(jobId), '1', 'EX', 300) // 5 min TTL
}
async handle(job: Job) { async handle(job: Job) {
const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } = const { url, filepath, timeout, allowedMimeTypes, forceNew, filetype, resourceMetadata } =
job.data as RunDownloadJobParams job.data as RunDownloadJobParams
// Register abort controller for this job
const abortController = new AbortController()
RunDownloadJob.abortControllers.set(job.id!, abortController)
// Get Redis client for checking cancel signals from the API process
const queueService = new QueueService()
const cancelRedis = await queueService.getQueue(RunDownloadJob.queue).client
let lastKnownProgress: Pick<DownloadProgressData, 'downloadedBytes' | 'totalBytes'> = {
downloadedBytes: 0,
totalBytes: 0,
}
// Poll Redis for cancel signal every 2s — independent of progress events so cancellation
// works even when the stream is stalled and no onProgress ticks are firing.
let cancelPollInterval: ReturnType<typeof setInterval> | null = setInterval(async () => {
try {
const val = await cancelRedis.get(RunDownloadJob.cancelKey(job.id!))
if (val) {
await cancelRedis.del(RunDownloadJob.cancelKey(job.id!))
abortController.abort()
}
} catch {
// Redis errors are non-fatal; in-process AbortController covers same-process cancels
}
}, 2000)
try {
await doResumableDownload({ await doResumableDownload({
url, url,
filepath, filepath,
timeout, timeout,
allowedMimeTypes, allowedMimeTypes,
forceNew, forceNew,
signal: abortController.signal,
onProgress(progress) { onProgress(progress) {
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100 const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
job.updateProgress(Math.floor(progressPercent)) const progressData: DownloadProgressData = {
percent: Math.floor(progressPercent),
downloadedBytes: progress.downloadedBytes,
totalBytes: progress.totalBytes,
lastProgressTime: Date.now(),
}
job.updateProgress(progressData)
lastKnownProgress = { downloadedBytes: progress.downloadedBytes, totalBytes: progress.totalBytes }
}, },
async onComplete(url) { async onComplete(url) {
try { try {
@ -104,7 +156,12 @@ export class RunDownloadJob {
error error
) )
} }
job.updateProgress(100) job.updateProgress({
percent: 100,
downloadedBytes: lastKnownProgress.downloadedBytes,
totalBytes: lastKnownProgress.totalBytes,
lastProgressTime: Date.now(),
} as DownloadProgressData)
}, },
}) })
@ -112,6 +169,19 @@ export class RunDownloadJob {
url, url,
filepath, filepath,
} }
} catch (error: any) {
// If this was a cancellation abort, don't let BullMQ retry
if (error?.message?.includes('aborted') || error?.message?.includes('cancelled')) {
throw new UnrecoverableError(`Download cancelled: ${error.message}`)
}
throw error
} finally {
if (cancelPollInterval !== null) {
clearInterval(cancelPollInterval)
cancelPollInterval = null
}
RunDownloadJob.abortControllers.delete(job.id!)
}
} }
static async getByUrl(url: string): Promise<Job | undefined> { static async getByUrl(url: string): Promise<Job | undefined> {
@ -121,6 +191,29 @@ export class RunDownloadJob {
return await queue.getJob(jobId) return await queue.getJob(jobId)
} }
/**
* Check if a download is actively in progress for the given URL.
* Returns the job only if it's in an active state (active, waiting, delayed).
* If the job exists in a terminal state (failed, completed), removes it and returns undefined.
*/
static async getActiveByUrl(url: string): Promise<Job | undefined> {
const job = await this.getByUrl(url)
if (!job) return undefined
const state = await job.getState()
if (state === 'active' || state === 'waiting' || state === 'delayed') {
return job
}
// Terminal state -- clean up stale job so it doesn't block re-download
try {
await job.remove()
} catch {
// May already be gone
}
return undefined
}
static async dispatch(params: RunDownloadJobParams) { static async dispatch(params: RunDownloadJobParams) {
const queueService = new QueueService() const queueService = new QueueService()
const queue = queueService.getQueue(this.queue) const queue = queueService.getQueue(this.queue)

View File

@ -2,27 +2,64 @@ import { inject } from '@adonisjs/core'
import { QueueService } from './queue_service.js' import { QueueService } from './queue_service.js'
import { RunDownloadJob } from '#jobs/run_download_job' import { RunDownloadJob } from '#jobs/run_download_job'
import { DownloadModelJob } from '#jobs/download_model_job' import { DownloadModelJob } from '#jobs/download_model_job'
import { DownloadJobWithProgress } from '../../types/downloads.js' import { DownloadJobWithProgress, DownloadProgressData } from '../../types/downloads.js'
import { normalize } from 'path' import { normalize } from 'path'
import { deleteFileIfExists } from '../utils/fs.js'
@inject() @inject()
export class DownloadService { export class DownloadService {
constructor(private queueService: QueueService) {} constructor(private queueService: QueueService) {}
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> { private parseProgress(progress: any): { percent: number; downloadedBytes?: number; totalBytes?: number; lastProgressTime?: number } {
// Get regular file download jobs (zim, map, etc.) if (typeof progress === 'object' && progress !== null && 'percent' in progress) {
const queue = this.queueService.getQueue(RunDownloadJob.queue) const p = progress as DownloadProgressData
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed', 'failed']) return {
percent: p.percent,
downloadedBytes: p.downloadedBytes,
totalBytes: p.totalBytes,
lastProgressTime: p.lastProgressTime,
}
}
// Backward compat: plain integer from in-flight jobs during upgrade
return { percent: parseInt(String(progress), 10) || 0 }
}
const fileDownloads = fileJobs.map((job) => ({ async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
// Get regular file download jobs (zim, map, etc.) — query each state separately so we can
// tag each job with its actual BullMQ state rather than guessing from progress data.
const queue = this.queueService.getQueue(RunDownloadJob.queue)
type FileJobState = 'waiting' | 'active' | 'delayed' | 'failed'
const [waitingJobs, activeJobs, delayedJobs, failedJobs] = await Promise.all([
queue.getJobs(['waiting']),
queue.getJobs(['active']),
queue.getJobs(['delayed']),
queue.getJobs(['failed']),
])
const taggedFileJobs: Array<{ job: (typeof waitingJobs)[0]; state: FileJobState }> = [
...waitingJobs.map((j) => ({ job: j, state: 'waiting' as const })),
...activeJobs.map((j) => ({ job: j, state: 'active' as const })),
...delayedJobs.map((j) => ({ job: j, state: 'delayed' as const })),
...failedJobs.map((j) => ({ job: j, state: 'failed' as const })),
]
const fileDownloads = taggedFileJobs.map(({ job, state }) => {
const parsed = this.parseProgress(job.progress)
return {
jobId: job.id!.toString(), jobId: job.id!.toString(),
url: job.data.url, url: job.data.url,
progress: parseInt(job.progress.toString(), 10), progress: parsed.percent,
filepath: normalize(job.data.filepath), filepath: normalize(job.data.filepath),
filetype: job.data.filetype, filetype: job.data.filetype,
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed', title: job.data.title || undefined,
downloadedBytes: parsed.downloadedBytes,
totalBytes: parsed.totalBytes || job.data.totalBytes || undefined,
lastProgressTime: parsed.lastProgressTime,
status: state,
failedReason: job.failedReason || undefined, failedReason: job.failedReason || undefined,
})) }
})
// Get Ollama model download jobs // Get Ollama model download jobs
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue) const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
@ -56,9 +93,106 @@ export class DownloadService {
const queue = this.queueService.getQueue(queueName) const queue = this.queueService.getQueue(queueName)
const job = await queue.getJob(jobId) const job = await queue.getJob(jobId)
if (job) { if (job) {
try {
await job.remove() await job.remove()
} catch {
// Job may be locked by the worker after cancel. Remove the stale lock and retry.
try {
const client = await queue.client
await client.del(`bull:${queueName}:${jobId}:lock`)
await job.remove()
} catch {
// Last resort: already removed or truly stuck
}
}
return return
} }
} }
} }
async cancelJob(jobId: string): Promise<{ success: boolean; message: string }> {
const queue = this.queueService.getQueue(RunDownloadJob.queue)
const job = await queue.getJob(jobId)
if (!job) {
// Job already completed (removeOnComplete: true) or doesn't exist
return { success: true, message: 'Job not found (may have already completed)' }
}
const filepath = job.data.filepath
// Signal the worker process to abort the download via Redis
await RunDownloadJob.signalCancel(jobId)
// Also try in-memory abort (works if worker is in same process)
RunDownloadJob.abortControllers.get(jobId)?.abort()
RunDownloadJob.abortControllers.delete(jobId)
// Poll for terminal state (up to 4s at 250ms intervals) — cooperates with BullMQ's lifecycle
// instead of force-removing an active job and losing the worker's failure/cleanup path.
const POLL_INTERVAL_MS = 250
const POLL_TIMEOUT_MS = 4000
const deadline = Date.now() + POLL_TIMEOUT_MS
let reachedTerminal = false
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS))
try {
const state = await job.getState()
if (state === 'failed' || state === 'completed' || state === 'unknown') {
reachedTerminal = true
break
}
} catch {
reachedTerminal = true // getState() throws if job is already gone
break
}
}
if (!reachedTerminal) {
console.warn(`[DownloadService] cancelJob: job ${jobId} did not reach terminal state within timeout, removing anyway`)
}
// Remove the BullMQ job
try {
await job.remove()
} catch {
// Lock contention fallback: clear lock and retry once
try {
const client = await queue.client
await client.del(`bull:${RunDownloadJob.queue}:${jobId}:lock`)
const updatedJob = await queue.getJob(jobId)
if (updatedJob) await updatedJob.remove()
} catch {
// Best effort - job will be cleaned up on next dismiss attempt
}
}
// Delete the partial file from disk
if (filepath) {
try {
await deleteFileIfExists(filepath)
// Also try .tmp in case PR #448 staging is merged
await deleteFileIfExists(filepath + '.tmp')
} catch {
// File may not exist yet (waiting job)
}
}
// If this was a Wikipedia download, update selection status to failed
// (the worker's failed event may not fire if we removed the job first)
if (job.data.filetype === 'zim' && job.data.url?.includes('wikipedia_en_')) {
try {
const { DockerService } = await import('#services/docker_service')
const { ZimService } = await import('#services/zim_service')
const dockerService = new DockerService()
const zimService = new ZimService(dockerService)
await zimService.onWikipediaDownloadComplete(job.data.url, false)
} catch {
// Best effort
}
}
return { success: true, message: 'Download cancelled and partial file deleted' }
}
} }

View File

@ -119,7 +119,7 @@ export class MapService implements IMapService {
const downloadFilenames: string[] = [] const downloadFilenames: string[] = []
for (const resource of toDownload) { for (const resource of toDownload) {
const existing = await RunDownloadJob.getByUrl(resource.url) const existing = await RunDownloadJob.getActiveByUrl(resource.url)
if (existing) { if (existing) {
logger.warn(`[MapService] Download already in progress for URL ${resource.url}, skipping.`) logger.warn(`[MapService] Download already in progress for URL ${resource.url}, skipping.`)
continue continue
@ -141,6 +141,7 @@ export class MapService implements IMapService {
allowedMimeTypes: PMTILES_MIME_TYPES, allowedMimeTypes: PMTILES_MIME_TYPES,
forceNew: true, forceNew: true,
filetype: 'map', filetype: 'map',
title: (resource as any).title || undefined,
resourceMetadata: { resourceMetadata: {
resource_id: resource.id, resource_id: resource.id,
version: resource.version, version: resource.version,
@ -189,7 +190,7 @@ export class MapService implements IMapService {
throw new Error(`Invalid PMTiles file URL: ${url}. URL must end with .pmtiles`) throw new Error(`Invalid PMTiles file URL: ${url}. URL must end with .pmtiles`)
} }
const existing = await RunDownloadJob.getByUrl(url) const existing = await RunDownloadJob.getActiveByUrl(url)
if (existing) { if (existing) {
throw new Error(`Download already in progress for URL ${url}`) throw new Error(`Download already in progress for URL ${url}`)
} }

View File

@ -137,13 +137,13 @@ export class ZimService {
} }
} }
async downloadRemote(url: string): Promise<{ filename: string; jobId?: string }> { async downloadRemote(url: string, metadata?: { title?: string; summary?: string; author?: string; size_bytes?: number }): Promise<{ filename: string; jobId?: string }> {
const parsed = new URL(url) const parsed = new URL(url)
if (!parsed.pathname.endsWith('.zim')) { if (!parsed.pathname.endsWith('.zim')) {
throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`) throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`)
} }
const existing = await RunDownloadJob.getByUrl(url) const existing = await RunDownloadJob.getActiveByUrl(url)
if (existing) { if (existing) {
throw new Error('A download for this URL is already in progress') throw new Error('A download for this URL is already in progress')
} }
@ -170,6 +170,8 @@ export class ZimService {
allowedMimeTypes: ZIM_MIME_TYPES, allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true, forceNew: true,
filetype: 'zim', filetype: 'zim',
title: metadata?.title,
totalBytes: metadata?.size_bytes,
resourceMetadata, resourceMetadata,
}) })
@ -219,7 +221,7 @@ export class ZimService {
const downloadFilenames: string[] = [] const downloadFilenames: string[] = []
for (const resource of toDownload) { for (const resource of toDownload) {
const existingJob = await RunDownloadJob.getByUrl(resource.url) const existingJob = await RunDownloadJob.getActiveByUrl(resource.url)
if (existingJob) { if (existingJob) {
logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`) logger.warn(`[ZimService] Download already in progress for ${resource.url}, skipping.`)
continue continue
@ -238,6 +240,8 @@ export class ZimService {
allowedMimeTypes: ZIM_MIME_TYPES, allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true, forceNew: true,
filetype: 'zim', filetype: 'zim',
title: (resource as any).title || undefined,
totalBytes: (resource as any).size_mb ? (resource as any).size_mb * 1024 * 1024 : undefined,
resourceMetadata: { resourceMetadata: {
resource_id: resource.id, resource_id: resource.id,
version: resource.version, version: resource.version,
@ -272,7 +276,9 @@ export class ZimService {
// Filter out completed jobs (progress === 100) to avoid race condition // Filter out completed jobs (progress === 100) to avoid race condition
// where this job itself is still in the active queue // where this job itself is still in the active queue
const activeIncompleteJobs = activeJobs.filter((job) => { const activeIncompleteJobs = activeJobs.filter((job) => {
const progress = typeof job.progress === 'number' ? job.progress : 0 const progress = typeof job.progress === 'object' && job.progress !== null
? (job.progress as any).percent
: typeof job.progress === 'number' ? job.progress : 0
return progress < 100 return progress < 100
}) })
@ -458,7 +464,7 @@ export class ZimService {
} }
// Check if already downloading // Check if already downloading
const existingJob = await RunDownloadJob.getByUrl(selectedOption.url) const existingJob = await RunDownloadJob.getActiveByUrl(selectedOption.url)
if (existingJob) { if (existingJob) {
return { success: false, message: 'Download already in progress' } return { success: false, message: 'Download already in progress' }
} }
@ -497,6 +503,8 @@ export class ZimService {
allowedMimeTypes: ZIM_MIME_TYPES, allowedMimeTypes: ZIM_MIME_TYPES,
forceNew: true, forceNew: true,
filetype: 'zim', filetype: 'zim',
title: selectedOption.name,
totalBytes: selectedOption.size_mb ? selectedOption.size_mb * 1024 * 1024 : undefined,
}) })
if (!result || !result.job) { if (!result || !result.job) {

View File

@ -1,8 +1,8 @@
import { useRef, useState, useCallback } from 'react'
import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads' import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'
import HorizontalBarChart from './HorizontalBarChart' import { extractFileName, formatBytes } from '~/lib/util'
import { extractFileName } from '~/lib/util'
import StyledSectionHeader from './StyledSectionHeader' import StyledSectionHeader from './StyledSectionHeader'
import { IconAlertTriangle, IconX } from '@tabler/icons-react' import { IconAlertTriangle, IconX, IconLoader2 } from '@tabler/icons-react'
import api from '~/lib/api' import api from '~/lib/api'
interface ActiveDownloadProps { interface ActiveDownloadProps {
@ -10,35 +10,128 @@ interface ActiveDownloadProps {
withHeader?: boolean withHeader?: boolean
} }
function formatSpeed(bytesPerSec: number): string {
if (bytesPerSec <= 0) return '0 B/s'
if (bytesPerSec < 1024) return `${Math.round(bytesPerSec)} B/s`
if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`
}
type DownloadStatus = 'queued' | 'active' | 'stalled' | 'failed'
function getDownloadStatus(download: {
progress: number
lastProgressTime?: number
status?: string
}): DownloadStatus {
if (download.status === 'failed') return 'failed'
if (download.status === 'waiting' || download.status === 'delayed') return 'queued'
// Fallback heuristic for model jobs and in-flight jobs from before this deploy
if (download.progress === 0 && !download.lastProgressTime) return 'queued'
if (download.lastProgressTime) {
const elapsed = Date.now() - download.lastProgressTime
if (elapsed > 60_000) return 'stalled'
}
return 'active'
}
const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => { const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {
const { data: downloads, invalidate } = useDownloads({ filetype }) const { data: downloads, invalidate } = useDownloads({ filetype })
const [cancellingJobs, setCancellingJobs] = useState<Set<string>>(new Set())
const [confirmingCancel, setConfirmingCancel] = useState<string | null>(null)
// Track previous downloadedBytes for speed calculation
const prevBytesRef = useRef<Map<string, { bytes: number; time: number }>>(new Map())
const speedRef = useRef<Map<string, number[]>>(new Map())
const getSpeed = useCallback(
(jobId: string, currentBytes?: number): number => {
if (!currentBytes || currentBytes <= 0) return 0
const prev = prevBytesRef.current.get(jobId)
const now = Date.now()
if (prev && prev.bytes > 0 && currentBytes > prev.bytes) {
const deltaBytes = currentBytes - prev.bytes
const deltaSec = (now - prev.time) / 1000
if (deltaSec > 0) {
const instantSpeed = deltaBytes / deltaSec
// Simple moving average (last 5 samples)
const samples = speedRef.current.get(jobId) || []
samples.push(instantSpeed)
if (samples.length > 5) samples.shift()
speedRef.current.set(jobId, samples)
const avg = samples.reduce((a, b) => a + b, 0) / samples.length
prevBytesRef.current.set(jobId, { bytes: currentBytes, time: now })
return avg
}
}
// Only set initial observation; never advance timestamp when bytes unchanged
if (!prev) {
prevBytesRef.current.set(jobId, { bytes: currentBytes, time: now })
}
return speedRef.current.get(jobId)?.at(-1) || 0
},
[]
)
const handleDismiss = async (jobId: string) => { const handleDismiss = async (jobId: string) => {
await api.removeDownloadJob(jobId) await api.removeDownloadJob(jobId)
invalidate() invalidate()
} }
const handleCancel = async (jobId: string) => {
setCancellingJobs((prev) => new Set(prev).add(jobId))
setConfirmingCancel(null)
try {
await api.cancelDownloadJob(jobId)
// Clean up speed tracking refs
prevBytesRef.current.delete(jobId)
speedRef.current.delete(jobId)
} finally {
setCancellingJobs((prev) => {
const next = new Set(prev)
next.delete(jobId)
return next
})
invalidate()
}
}
return ( return (
<> <>
{withHeader && <StyledSectionHeader title="Active Downloads" className="mt-12 mb-4" />} {withHeader && <StyledSectionHeader title="Active Downloads" className="mt-12 mb-4" />}
<div className="space-y-4"> <div className="space-y-4">
{downloads && downloads.length > 0 ? ( {downloads && downloads.length > 0 ? (
downloads.map((download) => ( downloads.map((download) => {
const filename = extractFileName(download.filepath) || download.url
const status = getDownloadStatus(download)
const speed = getSpeed(download.jobId, download.downloadedBytes)
const isCancelling = cancellingJobs.has(download.jobId)
const isConfirming = confirmingCancel === download.jobId
return (
<div <div
key={download.jobId} key={download.jobId}
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${ className={`rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
download.status === 'failed' status === 'failed'
? 'border-red-300' ? 'bg-surface-primary border-red-300'
: 'border-desert-stone-light' : 'bg-surface-primary border-default'
}`} }`}
> >
{download.status === 'failed' ? ( {status === 'failed' ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" /> <IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate"> <p className="text-sm font-medium text-text-primary truncate">
{extractFileName(download.filepath) || download.url} {download.title || filename}
</p> </p>
{download.title && (
<p className="text-xs text-text-muted truncate">{filename}</p>
)}
<p className="text-xs text-red-600 mt-0.5"> <p className="text-xs text-red-600 mt-0.5">
Download failed{download.failedReason ? `: ${download.failedReason}` : ''} Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
</p> </p>
@ -52,20 +145,116 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
</button> </button>
</div> </div>
) : ( ) : (
<HorizontalBarChart <div className="space-y-2">
items={[ {/* Title + Cancel button row */}
{ <div className="flex items-start justify-between gap-2">
label: extractFileName(download.filepath) || download.url, <div className="flex-1 min-w-0">
value: download.progress, <p className="font-semibold text-desert-green truncate">
total: '100%', {download.title || filename}
used: `${download.progress}%`, </p>
type: download.filetype, {download.title && (
}, <div className="flex items-center gap-2 mt-0.5">
]} <span className="text-xs text-text-muted truncate font-mono">
/> {filename}
</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-desert-stone-lighter text-desert-stone-dark font-mono flex-shrink-0">
{download.filetype}
</span>
</div>
)}
{!download.title && download.filetype && (
<span className="text-xs px-1.5 py-0.5 rounded bg-desert-stone-lighter text-desert-stone-dark font-mono">
{download.filetype}
</span>
)} )}
</div> </div>
)) {isConfirming ? (
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => handleCancel(download.jobId)}
className="text-xs px-2 py-1 rounded bg-red-100 text-red-700 hover:bg-red-200 transition-colors"
>
Confirm
</button>
<button
onClick={() => setConfirmingCancel(null)}
className="text-xs px-2 py-1 rounded bg-desert-stone-lighter text-text-muted hover:bg-desert-stone-light transition-colors"
>
Keep
</button>
</div>
) : isCancelling ? (
<IconLoader2 className="w-4 h-4 text-text-muted animate-spin flex-shrink-0" />
) : (
<button
onClick={() => setConfirmingCancel(download.jobId)}
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
title="Cancel download"
>
<IconX className="w-4 h-4 text-text-muted hover:text-red-500" />
</button>
)}
</div>
{/* Size info */}
<div className="flex justify-between items-baseline text-sm text-text-muted font-mono">
<span>
{download.downloadedBytes && download.totalBytes
? `${formatBytes(download.downloadedBytes, 1)} / ${formatBytes(download.totalBytes, 1)}`
: `${download.progress}% / 100%`}
</span>
</div>
{/* Progress bar */}
<div className="relative">
<div className="h-6 bg-desert-green-lighter bg-opacity-20 rounded-lg border border-default overflow-hidden">
<div
className="h-full rounded-lg transition-all duration-1000 ease-out bg-desert-green"
style={{ width: `${download.progress}%` }}
/>
</div>
<div
className={`absolute top-1/2 -translate-y-1/2 font-bold text-xs ${
download.progress > 15
? 'left-2 text-white drop-shadow-md'
: 'right-2 text-desert-green'
}`}
>
{Math.round(download.progress)}%
</div>
</div>
{/* Status indicator */}
<div className="flex items-center gap-2">
{status === 'queued' && (
<>
<div className="w-2 h-2 rounded-full bg-desert-stone" />
<span className="text-xs text-text-muted">Waiting...</span>
</>
)}
{status === 'active' && (
<>
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs text-text-muted">
Downloading...{speed > 0 ? ` ${formatSpeed(speed)}` : ''}
</span>
</>
)}
{status === 'stalled' && download.lastProgressTime && (
<>
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse" />
<span className="text-xs text-orange-600">
No data received for{' '}
{Math.floor((Date.now() - download.lastProgressTime) / 60_000)}m...
</span>
</>
)}
</div>
</div>
)}
</div>
)
})
) : ( ) : (
<p className="text-text-muted">No active downloads</p> <p className="text-text-muted">No active downloads</p>
)} )}

View File

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

View File

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

View File

@ -23,11 +23,20 @@ export type DoResumableDownloadProgress = {
url: string url: string
} }
export type DownloadProgressData = {
percent: number
downloadedBytes: number
totalBytes: number
lastProgressTime: number
}
export type RunDownloadJobParams = Omit< export type RunDownloadJobParams = Omit<
DoResumableDownloadParams, DoResumableDownloadParams,
'onProgress' | 'onComplete' | 'signal' 'onProgress' | 'onComplete' | 'signal'
> & { > & {
filetype: string filetype: string
title?: string
totalBytes?: number
resourceMetadata?: { resourceMetadata?: {
resource_id: string resource_id: string
version: string version: string
@ -41,7 +50,11 @@ export type DownloadJobWithProgress = {
progress: number progress: number
filepath: string filepath: string
filetype: string filetype: string
status?: 'active' | 'failed' title?: string
downloadedBytes?: number
totalBytes?: number
lastProgressTime?: number
status?: 'active' | 'waiting' | 'delayed' | 'failed'
failedReason?: string failedReason?: string
} }