From b287cee31f47ee4601b3e649780c07eba20e62d7 Mon Sep 17 00:00:00 2001 From: jarek Date: Sun, 9 Aug 2026 10:31:27 +0200 Subject: [PATCH] registry browser shows tag size and date for self-hosted registries, loaded on expand --- src/routes/api/registry/tag-info/+server.ts | 139 ++++++++++++++++++++ src/routes/registry/+page.svelte | 111 +++++++++++++++- 2 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 src/routes/api/registry/tag-info/+server.ts diff --git a/src/routes/api/registry/tag-info/+server.ts b/src/routes/api/registry/tag-info/+server.ts new file mode 100644 index 0000000..32ec08e --- /dev/null +++ b/src/routes/api/registry/tag-info/+server.ts @@ -0,0 +1,139 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getRegistry } from '$lib/server/db'; +import { getRegistryAuth } from '$lib/server/docker'; + +function isDockerHub(rawUrl: string): boolean { + const lower = rawUrl.toLowerCase(); + return lower.includes('docker.io') || lower.includes('hub.docker.com') || lower.includes('registry.hub.docker.com'); +} + +// Per-tag size + created date for the registry browser (#876). The V2 tags/list +// endpoint returns only names, so size/date live in the per-tag manifest. This is +// fetched lazily by the UI (one call per visible tag on expand), NOT in tags/list - +// a 125-tag repo must not eat 125 manifest fetches up front. +// +// FOOLPROOF: every failure path returns HTTP 200 with { size: null, lastUpdated: null, +// reason: '' } so a slow/dead/auth-gated/multi-arch tag NEVER breaks the row or +// the tag list. Only a genuinely-missing registry/image param is a 4xx. + +interface TagInfoResult { + size: number | null; + lastUpdated: string | null; + reason?: string; // shown in the UI tooltip when size/date are unavailable +} + +// Accept both OCI (distribution 3.0 default) and legacy Docker manifests + their index +// forms. Without the OCI types, distribution 3.0 answers MANIFEST_UNKNOWN. +const MANIFEST_ACCEPT = [ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.docker.distribution.manifest.v2+json' +].join(', '); + +const INDEX_MEDIA_TYPES = new Set([ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json' +]); + +async function fetchManifest( + baseUrl: string, + image: string, + ref: string, + authHeader: string | null +): Promise<{ ok: true; body: any; contentType: string } | { ok: false; reason: string }> { + const headers: Record = { Accept: MANIFEST_ACCEPT }; + if (authHeader) headers['Authorization'] = authHeader; + + const res = await fetch(`${baseUrl}/v2/${image}/manifests/${encodeURIComponent(ref)}`, { + method: 'GET', + headers, + // short per-request timeout so one slow tag doesn't stall the row + signal: AbortSignal.timeout(8000) + }); + + if (!res.ok) { + if (res.status === 401 || res.status === 403) return { ok: false, reason: 'Authentication required' }; + if (res.status === 404) return { ok: false, reason: 'Manifest not found' }; + return { ok: false, reason: `Registry error ${res.status}` }; + } + + const contentType = res.headers.get('content-type') || ''; + const body = await res.json(); + return { ok: true, body, contentType }; +} + +// Sum a single-arch manifest's compressed size: config blob + all layer blobs. +function sizeFromManifest(m: any): number | null { + if (!m || typeof m !== 'object') return null; + const layers = Array.isArray(m.layers) ? m.layers : []; + const configSize = typeof m.config?.size === 'number' ? m.config.size : 0; + const layersSize = layers.reduce((acc: number, l: any) => acc + (typeof l?.size === 'number' ? l.size : 0), 0); + const total = configSize + layersSize; + return total > 0 ? total : null; +} + +// OCI/Docker images may carry a created date in annotations. +function createdFromManifest(m: any): string | null { + const created = m?.annotations?.['org.opencontainers.image.created']; + return typeof created === 'string' && created.length > 0 ? created : null; +} + +async function resolveTagInfo(registry: any, image: string, tag: string): Promise { + const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${image}:pull`); + + const first = await fetchManifest(baseUrl, image, tag, authHeader); + if (!first.ok) return { size: null, lastUpdated: null, reason: first.reason }; + + // Multi-arch index: follow the first child manifest to compute a representative size. + if (INDEX_MEDIA_TYPES.has(first.body?.mediaType) || Array.isArray(first.body?.manifests)) { + const child = (first.body?.manifests || [])[0]; + if (!child?.digest) return { size: null, lastUpdated: null, reason: 'Multi-arch (no child manifest)' }; + const childRes = await fetchManifest(baseUrl, image, child.digest, authHeader); + if (!childRes.ok) return { size: null, lastUpdated: null, reason: childRes.reason }; + return { + size: sizeFromManifest(childRes.body), + lastUpdated: createdFromManifest(childRes.body) || createdFromManifest(first.body), + reason: undefined + }; + } + + const size = sizeFromManifest(first.body); + const lastUpdated = createdFromManifest(first.body); + const result: TagInfoResult = { size, lastUpdated }; + if (size === null && lastUpdated === null) result.reason = 'No size/date in manifest'; + return result; +} + +export const GET: RequestHandler = async ({ url }) => { + const registryId = url.searchParams.get('registry'); + const image = url.searchParams.get('image'); + const tag = url.searchParams.get('tag'); + + if (!image || !tag) { + return json({ error: 'image and tag are required' }, { status: 400 }); + } + + // Docker Hub tags already carry size/date from the hub API (see registry/tags), + // so this per-manifest path is only for self-hosted V2 registries. + if (!registryId) { + return json({ size: null, lastUpdated: null, reason: 'Not supported without a registry' } satisfies TagInfoResult); + } + + const registry = await getRegistry(parseInt(registryId)); + if (!registry) { + return json({ error: 'Registry not found' }, { status: 404 }); + } + if (isDockerHub(registry.url)) { + return json({ size: null, lastUpdated: null, reason: 'Docker Hub tags carry size/date already' } satisfies TagInfoResult); + } + + try { + return json(await resolveTagInfo(registry, image, tag) satisfies TagInfoResult); + } catch (error: any) { + // Never fail the row: fold any error into a graceful "unavailable" result. + const reason = error?.name === 'TimeoutError' ? 'Timed out' : (error?.message || 'Unavailable'); + return json({ size: null, lastUpdated: null, reason } satisfies TagInfoResult); + } +}; diff --git a/src/routes/registry/+page.svelte b/src/routes/registry/+page.svelte index af1dbf2..a6f944e 100644 --- a/src/routes/registry/+page.svelte +++ b/src/routes/registry/+page.svelte @@ -7,7 +7,8 @@ import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import * as Select from '$lib/components/ui/select'; - import { Search, Download, Star, RefreshCw, Settings2, List, Play, Copy, Clipboard, Check, Server, Icon, ChevronRight, ChevronDown, Loader2, Tag, Calendar, HardDrive, Trash2 } from 'lucide-svelte'; + import { Search, Download, Star, RefreshCw, Settings2, List, Play, Copy, Clipboard, Check, Server, Icon, ChevronRight, ChevronDown, Loader2, Tag, Calendar, HardDrive, Trash2, HelpCircle } from 'lucide-svelte'; + import * as Tooltip from '$lib/components/ui/tooltip'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import { toast } from 'svelte-sonner'; import { whale } from '@lucide/lab'; @@ -44,6 +45,14 @@ size?: number; lastUpdated?: string; digest?: string; + // Lazy per-tag enrichment (#876). V2 tags/list gives only names; size/date are + // fetched per visible tag from the manifest on expand. + // 'idle' - not a self-hosted V2 tag (Docker Hub) or not yet queued + // 'loading' - manifest fetch in flight (show a mini spinner) + // 'done' - size/date resolved (or legitimately absent) + // 'unavailable' - manifest gave nothing usable; show '?' + reason tooltip + enrichState?: 'idle' | 'loading' | 'done' | 'unavailable'; + enrichReason?: string; } interface ExpandedImageState { @@ -177,7 +186,17 @@ } const response = await fetch(url); if (response.ok) { - results = await response.json(); + const data = await response.json(); + // Search returns a bare array normally, or { results, notSupported, message } + // when the registry won't allow catalog listing (GitLab/Harbor, #873). + if (Array.isArray(data)) { + results = data; + } else { + results = data.results || []; + if (data.notSupported && results.length === 0) { + errorMessage = data.message || 'This registry does not support searching all images.'; + } + } } else { const data = await response.json(); errorMessage = data.error || 'Search failed'; @@ -336,6 +355,11 @@ hasNext: data.hasNext } }; + // Lazily enrich the newly-loaded tags with size/date (#876). Docker Hub + // tags already carry both, so only enrich self-hosted V2 registries. + if (selectedRegistryId) { + enrichTags(imageName, data.tags); + } } else { const data = await response.json(); expandedImages = { @@ -361,6 +385,57 @@ } } + // Fetch size/date for a batch of tags, a few at a time, updating each row as it + // resolves (#876). Fully best-effort: any failure leaves the row's enrichState + // 'unavailable' with a reason; it never throws or blocks the tag list. + async function enrichTags(imageName: string, tags: TagInfo[]) { + const CONCURRENCY = 4; + const queue = tags.filter((t) => !t.enrichState || t.enrichState === 'idle'); + + // Mark queued tags as loading so the spinner shows immediately. + for (const t of queue) setTagEnrich(imageName, t.name, { enrichState: 'loading' }); + + let idx = 0; + const worker = async () => { + while (idx < queue.length) { + const tag = queue[idx++]; + try { + const params = new URLSearchParams({ + image: imageName, + tag: tag.name, + registry: String(selectedRegistryId) + }); + const res = await fetch(`/api/registry/tag-info?${params}`); + const info = res.ok ? await res.json() : {}; + const hasData = info?.size != null || info?.lastUpdated != null; + setTagEnrich(imageName, tag.name, { + size: info?.size ?? undefined, + lastUpdated: info?.lastUpdated ?? undefined, + enrichState: hasData ? 'done' : 'unavailable', + enrichReason: hasData ? undefined : (info?.reason || 'Unavailable') + }); + } catch { + setTagEnrich(imageName, tag.name, { enrichState: 'unavailable', enrichReason: 'Request failed' }); + } + } + }; + + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker)); + } + + // Merge enrichment fields into a single tag row (matched by name) immutably. + function setTagEnrich(imageName: string, tagName: string, patch: Partial) { + const state = expandedImages[imageName]; + if (!state) return; + expandedImages = { + ...expandedImages, + [imageName]: { + ...state, + tags: state.tags.map((t) => (t.name === tagName ? { ...t, ...patch } : t)) + } + }; + } + function handleTagsWheel(event: WheelEvent, imageName: string) { const target = event.currentTarget as HTMLElement; @@ -482,6 +557,34 @@ }); + +{#snippet enrichCell(tag: TagInfo, formatted: string, hasValue: boolean)} + {#if tag.enrichState === 'loading'} + + {:else if hasValue} + {formatted} + {:else if tag.enrichState === 'unavailable'} + + + + + + + + {tag.enrichReason || 'Unavailable'} + + + + + {:else} + - + {/if} +{/snippet} +
@@ -681,10 +784,10 @@
- {formatRegistryBytes(tag.size)} + {@render enrichCell(tag, formatRegistryBytes(tag.size), tag.size != null)} - {formatDate(tag.lastUpdated)} + {@render enrichCell(tag, formatDate(tag.lastUpdated), tag.lastUpdated != null)}