mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-05-12 16:10:11 +02:00
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:
parent
bb1834a364
commit
360e7a0af4
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export type ResourceUpdateInfo = {
|
|||
installed_version: string
|
||||
latest_version: string
|
||||
download_url: string
|
||||
size_bytes?: number
|
||||
}
|
||||
|
||||
export type ContentUpdateCheckResult = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user