From 360e7a0af48714db06792896ce302b85c0543213 Mon Sep 17 00:00:00 2001 From: Chris Sherwood Date: Wed, 22 Apr 2026 14:36:05 -0700 Subject: [PATCH] feat(content-updates): show size, surface downloads in Active Downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content Updates had three UX problems that compounded: 1. No size column, so users had to guess how big an update would be before clicking Update All. Upstream /api/v1/resources/check-updates doesn't return size, so CollectionUpdateService now enriches each update with a Content-Length HEAD request in parallel (5s timeout, non-fatal on failure — the row just renders an em-dash). 2. Small ZIM updates (1-8 MB) never appeared in Active Downloads. Two causes, both fixed: handleApply / handleApplyAll didn't invalidate the download-jobs query after dispatching, and useDownloads idled at 30s between polls — enough for a fast job to dispatch, download, and get cleaned up by removeOnComplete before the next refetch. 3. applyUpdate didn't forward title / totalBytes to RunDownloadJob, so any update that did briefly surface in Active Downloads had no label and no byte-count progress, just a filename and a percentage. It now passes both (matching zim_service's dispatch pattern). Also parallelized applyAllUpdates so dispatching five updates doesn't serialize five sequential BullMQ round-trips. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/services/collection_update_service.ts | 49 ++++++++++++++----- admin/app/validators/common.ts | 1 + admin/inertia/hooks/useDownloads.ts | 5 +- admin/inertia/pages/settings/update.tsx | 19 ++++++- admin/types/collections.ts | 1 + 5 files changed, 60 insertions(+), 15 deletions(-) diff --git a/admin/app/services/collection_update_service.ts b/admin/app/services/collection_update_service.ts index fee6c14..2e19cac 100644 --- a/admin/app/services/collection_update_service.ts +++ b/admin/app/services/collection_update_service.ts @@ -53,8 +53,10 @@ export class CollectionUpdateService { `[CollectionUpdateService] Update check complete: ${response.data.length} update(s) available` ) + const updates = await this.enrichWithSizes(response.data) + return { - updates: response.data, + updates, checked_at: new Date().toISOString(), } } catch (error) { @@ -105,6 +107,8 @@ export class CollectionUpdateService { update.resource_type === 'zim' ? ZIM_MIME_TYPES : PMTILES_MIME_TYPES, forceNew: true, filetype: update.resource_type, + title: update.resource_id, + totalBytes: update.size_bytes, resourceMetadata: { resource_id: update.resource_id, version: update.latest_version, @@ -126,21 +130,42 @@ export class CollectionUpdateService { async applyAllUpdates( updates: ResourceUpdateInfo[] ): Promise<{ results: Array<{ resource_id: string; success: boolean; jobId?: string; error?: string }> }> { - const results: Array<{ - resource_id: string - success: boolean - jobId?: string - error?: string - }> = [] - - for (const update of updates) { - const result = await this.applyUpdate(update) - results.push({ resource_id: update.resource_id, ...result }) - } + const results = await Promise.all( + updates.map(async (update) => { + const result = await this.applyUpdate(update) + return { resource_id: update.resource_id, ...result } + }) + ) return { results } } + /** + * Fetch Content-Length for each update URL in parallel. HEAD failures are non-fatal — + * the update row just renders without a size. Bounded to HEAD_TIMEOUT_MS so a slow + * mirror doesn't block the whole check. + */ + private async enrichWithSizes(updates: ResourceUpdateInfo[]): Promise { + const HEAD_TIMEOUT_MS = 5000 + + return await Promise.all( + updates.map(async (update) => { + if (update.size_bytes) return update // Trust upstream if it already gave us one + try { + const head = await axios.head(update.download_url, { + timeout: HEAD_TIMEOUT_MS, + maxRedirects: 5, + validateStatus: (s) => s >= 200 && s < 400, + }) + const len = Number(head.headers['content-length']) + return Number.isFinite(len) && len > 0 ? { ...update, size_bytes: len } : update + } catch { + return update + } + }) + ) + } + private buildFilename(update: ResourceUpdateInfo): string { if (update.resource_type === 'zim') { return `${update.resource_id}_${update.latest_version}.zim` diff --git a/admin/app/validators/common.ts b/admin/app/validators/common.ts index 8fe78bd..7065d4c 100644 --- a/admin/app/validators/common.ts +++ b/admin/app/validators/common.ts @@ -100,6 +100,7 @@ const resourceUpdateInfoBase = vine.object({ installed_version: vine.string().trim(), latest_version: vine.string().trim().minLength(1), download_url: vine.string().url({ require_tld: false }).trim(), + size_bytes: vine.number().positive().optional(), }) export const applyContentUpdateValidator = vine.compile(resourceUpdateInfoBase) diff --git a/admin/inertia/hooks/useDownloads.ts b/admin/inertia/hooks/useDownloads.ts index 3cdb859..b03399d 100644 --- a/admin/inertia/hooks/useDownloads.ts +++ b/admin/inertia/hooks/useDownloads.ts @@ -19,8 +19,9 @@ const useDownloads = (props: useDownloadsProps) => { queryFn: () => api.listDownloadJobs(props.filetype), refetchInterval: (query) => { const data = query.state.data - // Only poll when there are active downloads; otherwise use a slower interval - return data && data.length > 0 ? 2000 : 30000 + // Idle poll is kept tight so newly-dispatched jobs surface quickly — small ZIM + // updates can complete in ~2s, so a 30s idle interval almost always missed them. + return data && data.length > 0 ? 2000 : 3000 }, enabled: props.enabled ?? true, }) diff --git a/admin/inertia/pages/settings/update.tsx b/admin/inertia/pages/settings/update.tsx index 23527fb..348040d 100644 --- a/admin/inertia/pages/settings/update.tsx +++ b/admin/inertia/pages/settings/update.tsx @@ -12,9 +12,10 @@ import type { ContentUpdateCheckResult, ResourceUpdateInfo } from '../../../type import api from '~/lib/api' import Input from '~/components/inputs/Input' import Switch from '~/components/inputs/Switch' -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useNotifications } from '~/context/NotificationContext' import { useSystemSetting } from '~/hooks/useSystemSetting' +import { formatBytes } from '~/lib/util' type Props = { updateAvailable: boolean @@ -25,6 +26,7 @@ type Props = { function ContentUpdatesSection() { const { addNotification } = useNotifications() + const queryClient = useQueryClient() const [checkResult, setCheckResult] = useState(null) const [isChecking, setIsChecking] = useState(false) const [applyingIds, setApplyingIds] = useState>(new Set()) @@ -60,6 +62,9 @@ function ContentUpdatesSection() { ? { ...prev, updates: prev.updates.filter((u) => u.resource_id !== update.resource_id) } : prev ) + // Force Active Downloads to refetch now — small updates finish before the next + // idle poll fires, so without this the user wouldn't see them. + queryClient.invalidateQueries({ queryKey: ['download-jobs'] }) } else { addNotification({ type: 'error', message: result?.error || 'Failed to start update' }) } @@ -95,6 +100,9 @@ function ContentUpdatesSection() { ? { ...prev, updates: prev.updates.filter((u) => !successIds.has(u.resource_id)) } : prev ) + if (successIds.size > 0) { + queryClient.invalidateQueries({ queryKey: ['download-jobs'] }) + } } } catch { addNotification({ type: 'error', message: 'Failed to apply updates' }) @@ -182,6 +190,15 @@ function ContentUpdatesSection() { ), }, + { + accessor: 'size_bytes', + title: 'Size', + render: (record) => ( + + {record.size_bytes ? formatBytes(record.size_bytes, 1) : '—'} + + ), + }, { accessor: 'installed_version', title: 'Version', diff --git a/admin/types/collections.ts b/admin/types/collections.ts index 1ec6d5c..abd47fc 100644 --- a/admin/types/collections.ts +++ b/admin/types/collections.ts @@ -86,6 +86,7 @@ export type ResourceUpdateInfo = { installed_version: string latest_version: string download_url: string + size_bytes?: number } export type ContentUpdateCheckResult = {