mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-04 07:46:16 +02:00
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>
This commit is contained in:
parent
08c1076cc9
commit
df4fde16c9
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Job } from 'bullmq'
|
import { Job } 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,100 +17,153 @@ 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
|
||||||
|
|
||||||
await doResumableDownload({
|
// Register abort controller for this job
|
||||||
url,
|
const abortController = new AbortController()
|
||||||
filepath,
|
RunDownloadJob.abortControllers.set(job.id!, abortController)
|
||||||
timeout,
|
|
||||||
allowedMimeTypes,
|
|
||||||
forceNew,
|
|
||||||
onProgress(progress) {
|
|
||||||
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
|
|
||||||
job.updateProgress(Math.floor(progressPercent))
|
|
||||||
},
|
|
||||||
async onComplete(url) {
|
|
||||||
try {
|
|
||||||
// Create InstalledResource entry if metadata was provided
|
|
||||||
if (resourceMetadata) {
|
|
||||||
const { default: InstalledResource } = await import('#models/installed_resource')
|
|
||||||
const { DateTime } = await import('luxon')
|
|
||||||
const { getFileStatsIfExists, deleteFileIfExists } = await import('../utils/fs.js')
|
|
||||||
const stats = await getFileStatsIfExists(filepath)
|
|
||||||
|
|
||||||
// Look up the old entry so we can clean up the previous file after updating
|
// Get Redis client for checking cancel signals from the API process
|
||||||
const oldEntry = await InstalledResource.query()
|
const queueService = new QueueService()
|
||||||
.where('resource_id', resourceMetadata.resource_id)
|
const cancelRedis = await queueService.getQueue(RunDownloadJob.queue).client
|
||||||
.where('resource_type', filetype as 'zim' | 'map')
|
let progressCount = 0
|
||||||
.first()
|
|
||||||
const oldFilePath = oldEntry?.file_path ?? null
|
|
||||||
|
|
||||||
await InstalledResource.updateOrCreate(
|
try {
|
||||||
{ resource_id: resourceMetadata.resource_id, resource_type: filetype as 'zim' | 'map' },
|
await doResumableDownload({
|
||||||
{
|
url,
|
||||||
version: resourceMetadata.version,
|
filepath,
|
||||||
collection_ref: resourceMetadata.collection_ref,
|
timeout,
|
||||||
url: url,
|
allowedMimeTypes,
|
||||||
file_path: filepath,
|
forceNew,
|
||||||
file_size_bytes: stats ? Number(stats.size) : null,
|
signal: abortController.signal,
|
||||||
installed_at: DateTime.now(),
|
onProgress(progress) {
|
||||||
|
const progressPercent = (progress.downloadedBytes / (progress.totalBytes || 1)) * 100
|
||||||
|
const progressData: DownloadProgressData = {
|
||||||
|
percent: Math.floor(progressPercent),
|
||||||
|
downloadedBytes: progress.downloadedBytes,
|
||||||
|
totalBytes: progress.totalBytes,
|
||||||
|
lastProgressTime: Date.now(),
|
||||||
|
}
|
||||||
|
job.updateProgress(progressData)
|
||||||
|
|
||||||
|
// Check for cancel signal every ~10 progress ticks to avoid hammering Redis
|
||||||
|
progressCount++
|
||||||
|
if (progressCount % 10 === 0) {
|
||||||
|
cancelRedis.get(RunDownloadJob.cancelKey(job.id!)).then((val: string | null) => {
|
||||||
|
if (val) {
|
||||||
|
cancelRedis.del(RunDownloadJob.cancelKey(job.id!))
|
||||||
|
abortController.abort()
|
||||||
}
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onComplete(url) {
|
||||||
|
try {
|
||||||
|
// Create InstalledResource entry if metadata was provided
|
||||||
|
if (resourceMetadata) {
|
||||||
|
const { default: InstalledResource } = await import('#models/installed_resource')
|
||||||
|
const { DateTime } = await import('luxon')
|
||||||
|
const { getFileStatsIfExists, deleteFileIfExists } = await import('../utils/fs.js')
|
||||||
|
const stats = await getFileStatsIfExists(filepath)
|
||||||
|
|
||||||
|
// Look up the old entry so we can clean up the previous file after updating
|
||||||
|
const oldEntry = await InstalledResource.query()
|
||||||
|
.where('resource_id', resourceMetadata.resource_id)
|
||||||
|
.where('resource_type', filetype as 'zim' | 'map')
|
||||||
|
.first()
|
||||||
|
const oldFilePath = oldEntry?.file_path ?? null
|
||||||
|
|
||||||
|
await InstalledResource.updateOrCreate(
|
||||||
|
{ resource_id: resourceMetadata.resource_id, resource_type: filetype as 'zim' | 'map' },
|
||||||
|
{
|
||||||
|
version: resourceMetadata.version,
|
||||||
|
collection_ref: resourceMetadata.collection_ref,
|
||||||
|
url: url,
|
||||||
|
file_path: filepath,
|
||||||
|
file_size_bytes: stats ? Number(stats.size) : null,
|
||||||
|
installed_at: DateTime.now(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete the old file if it differs from the new one
|
||||||
|
if (oldFilePath && oldFilePath !== filepath) {
|
||||||
|
try {
|
||||||
|
await deleteFileIfExists(oldFilePath)
|
||||||
|
console.log(`[RunDownloadJob] Deleted old file: ${oldFilePath}`)
|
||||||
|
} catch (deleteError) {
|
||||||
|
console.warn(
|
||||||
|
`[RunDownloadJob] Failed to delete old file ${oldFilePath}:`,
|
||||||
|
deleteError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filetype === 'zim') {
|
||||||
|
const dockerService = new DockerService()
|
||||||
|
const zimService = new ZimService(dockerService)
|
||||||
|
await zimService.downloadRemoteSuccessCallback([url], true)
|
||||||
|
|
||||||
|
// Only dispatch embedding job if AI Assistant (Ollama) is installed
|
||||||
|
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
||||||
|
if (ollamaUrl) {
|
||||||
|
try {
|
||||||
|
await EmbedFileJob.dispatch({
|
||||||
|
fileName: url.split('/').pop() || '',
|
||||||
|
filePath: filepath,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (filetype === 'map') {
|
||||||
|
const mapsService = new MapService()
|
||||||
|
await mapsService.downloadRemoteSuccessCallback([url], false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`[RunDownloadJob] Error in download success callback for URL ${url}:`,
|
||||||
|
error
|
||||||
)
|
)
|
||||||
|
|
||||||
// Delete the old file if it differs from the new one
|
|
||||||
if (oldFilePath && oldFilePath !== filepath) {
|
|
||||||
try {
|
|
||||||
await deleteFileIfExists(oldFilePath)
|
|
||||||
console.log(`[RunDownloadJob] Deleted old file: ${oldFilePath}`)
|
|
||||||
} catch (deleteError) {
|
|
||||||
console.warn(
|
|
||||||
`[RunDownloadJob] Failed to delete old file ${oldFilePath}:`,
|
|
||||||
deleteError
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
job.updateProgress({
|
||||||
|
percent: 100,
|
||||||
|
downloadedBytes: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
lastProgressTime: Date.now(),
|
||||||
|
} as DownloadProgressData)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (filetype === 'zim') {
|
return {
|
||||||
const dockerService = new DockerService()
|
url,
|
||||||
const zimService = new ZimService(dockerService)
|
filepath,
|
||||||
await zimService.downloadRemoteSuccessCallback([url], true)
|
}
|
||||||
|
} finally {
|
||||||
// Only dispatch embedding job if AI Assistant (Ollama) is installed
|
// Clean up abort controller
|
||||||
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
RunDownloadJob.abortControllers.delete(job.id!)
|
||||||
if (ollamaUrl) {
|
|
||||||
try {
|
|
||||||
await EmbedFileJob.dispatch({
|
|
||||||
fileName: url.split('/').pop() || '',
|
|
||||||
filePath: filepath,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (filetype === 'map') {
|
|
||||||
const mapsService = new MapService()
|
|
||||||
await mapsService.downloadRemoteSuccessCallback([url], false)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`[RunDownloadJob] Error in download success callback for URL ${url}:`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
job.updateProgress(100)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
filepath,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,49 @@ 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) {}
|
||||||
|
|
||||||
|
private parseProgress(progress: any): { percent: number; downloadedBytes?: number; totalBytes?: number; lastProgressTime?: number } {
|
||||||
|
if (typeof progress === 'object' && progress !== null && 'percent' in progress) {
|
||||||
|
const p = progress as DownloadProgressData
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
|
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
|
||||||
// Get regular file download jobs (zim, map, etc.)
|
// Get regular file download jobs (zim, map, etc.)
|
||||||
const queue = this.queueService.getQueue(RunDownloadJob.queue)
|
const queue = this.queueService.getQueue(RunDownloadJob.queue)
|
||||||
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed', 'failed'])
|
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed', 'failed'])
|
||||||
|
|
||||||
const fileDownloads = fileJobs.map((job) => ({
|
const fileDownloads = fileJobs.map((job) => {
|
||||||
jobId: job.id!.toString(),
|
const parsed = this.parseProgress(job.progress)
|
||||||
url: job.data.url,
|
return {
|
||||||
progress: parseInt(job.progress.toString(), 10),
|
jobId: job.id!.toString(),
|
||||||
filepath: normalize(job.data.filepath),
|
url: job.data.url,
|
||||||
filetype: job.data.filetype,
|
progress: parsed.percent,
|
||||||
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
|
filepath: normalize(job.data.filepath),
|
||||||
failedReason: job.failedReason || undefined,
|
filetype: job.data.filetype,
|
||||||
}))
|
title: job.data.title || undefined,
|
||||||
|
downloadedBytes: parsed.downloadedBytes,
|
||||||
|
totalBytes: parsed.totalBytes || job.data.totalBytes || undefined,
|
||||||
|
lastProgressTime: parsed.lastProgressTime,
|
||||||
|
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
|
||||||
|
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)
|
||||||
|
|
@ -61,4 +83,46 @@ export class DownloadService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Give the worker a moment to pick up the cancel signal and release the job lock
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
// Remove the BullMQ job
|
||||||
|
try {
|
||||||
|
await job.remove()
|
||||||
|
} catch {
|
||||||
|
// Job may still be locked by worker - it will fail on next progress check
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, message: 'Download cancelled and partial file deleted' }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,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,
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ 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`)
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,62 +10,246 @@ 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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
<div
|
const filename = extractFileName(download.filepath) || download.url
|
||||||
key={download.jobId}
|
const status = getDownloadStatus(download)
|
||||||
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
|
const speed = getSpeed(download.jobId, download.downloadedBytes)
|
||||||
download.status === 'failed'
|
const isCancelling = cancellingJobs.has(download.jobId)
|
||||||
? 'border-red-300'
|
const isConfirming = confirmingCancel === download.jobId
|
||||||
: 'border-desert-stone-light'
|
|
||||||
}`}
|
return (
|
||||||
>
|
<div
|
||||||
{download.status === 'failed' ? (
|
key={download.jobId}
|
||||||
<div className="flex items-center gap-2">
|
className={`rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
|
||||||
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
status === 'failed'
|
||||||
<div className="flex-1 min-w-0">
|
? 'bg-surface-primary border-red-300'
|
||||||
<p className="text-sm font-medium text-gray-900 truncate">
|
: 'bg-surface-primary border-default'
|
||||||
{extractFileName(download.filepath) || download.url}
|
}`}
|
||||||
</p>
|
>
|
||||||
<p className="text-xs text-red-600 mt-0.5">
|
{status === 'failed' ? (
|
||||||
Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
|
<div className="flex items-center gap-2">
|
||||||
</p>
|
<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>
|
||||||
|
<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>
|
||||||
<button
|
) : (
|
||||||
onClick={() => handleDismiss(download.jobId)}
|
<div className="space-y-2">
|
||||||
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
|
{/* Title + Cancel button row */}
|
||||||
title="Dismiss failed download"
|
<div className="flex items-start justify-between gap-2">
|
||||||
>
|
<div className="flex-1 min-w-0">
|
||||||
<IconX className="w-4 h-4 text-red-400 hover:text-red-600" />
|
<p className="font-semibold text-desert-green truncate">
|
||||||
</button>
|
{download.title || filename}
|
||||||
</div>
|
</p>
|
||||||
) : (
|
{download.title && (
|
||||||
<HorizontalBarChart
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
items={[
|
<span className="text-xs text-text-muted truncate font-mono">
|
||||||
{
|
{filename}
|
||||||
label: extractFileName(download.filepath) || download.url,
|
</span>
|
||||||
value: download.progress,
|
<span className="text-xs px-1.5 py-0.5 rounded bg-desert-stone-lighter text-desert-stone-dark font-mono flex-shrink-0">
|
||||||
total: '100%',
|
{download.filetype}
|
||||||
used: `${download.progress}%`,
|
</span>
|
||||||
type: download.filetype,
|
</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}
|
||||||
</div>
|
</span>
|
||||||
))
|
)}
|
||||||
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -563,6 +563,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>(
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,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')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,6 +50,10 @@ export type DownloadJobWithProgress = {
|
||||||
progress: number
|
progress: number
|
||||||
filepath: string
|
filepath: string
|
||||||
filetype: string
|
filetype: string
|
||||||
|
title?: string
|
||||||
|
downloadedBytes?: number
|
||||||
|
totalBytes?: number
|
||||||
|
lastProgressTime?: number
|
||||||
status?: 'active' | 'failed'
|
status?: 'active' | 'failed'
|
||||||
failedReason?: string
|
failedReason?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user