feat(content-updates): show size, surface downloads in Active Downloads

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) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-04-22 14:36:05 -07:00 committed by Jake Turner
parent bb1834a364
commit 360e7a0af4
5 changed files with 60 additions and 15 deletions

View File

@ -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<ResourceUpdateInfo[]> {
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`

View File

@ -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)

View File

@ -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,
})

View File

@ -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<ContentUpdateCheckResult | null>(null)
const [isChecking, setIsChecking] = useState(false)
const [applyingIds, setApplyingIds] = useState<Set<string>>(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() {
</span>
),
},
{
accessor: 'size_bytes',
title: 'Size',
render: (record) => (
<span className="text-desert-stone-dark">
{record.size_bytes ? formatBytes(record.size_bytes, 1) : '—'}
</span>
),
},
{
accessor: 'installed_version',
title: 'Version',

View File

@ -86,6 +86,7 @@ export type ResourceUpdateInfo = {
installed_version: string
latest_version: string
download_url: string
size_bytes?: number
}
export type ContentUpdateCheckResult = {