registry browser shows tag size and date for self-hosted registries, loaded on expand

This commit is contained in:
jarek committed 2026-08-09 10:31:27 +02:00
1 parent 5f8fc71c73
commit b287cee31f
2 files changed
+246 -4

No files matched your search

+139
View File
@@ -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: '<why>' } 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<string, string> = { 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<TagInfoResult> {
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);
}
};
+107 -4
View File
@@ -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<TagInfo>) {
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 @@
});
</script>
<!--
Size/Modified cell for a registry tag (#876). Shows a mini spinner while the
per-tag manifest is being fetched, the formatted value once resolved, or a '?'
with a tooltip explaining why it's unavailable (multi-arch, no date, auth, etc.).
-->
{#snippet enrichCell(tag: TagInfo, formatted: string, hasValue: boolean)}
{#if tag.enrichState === 'loading'}
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground/60" />
{:else if hasValue}
{formatted}
{:else if tag.enrichState === 'unavailable'}
<Tooltip.Provider delayDuration={150}>
<Tooltip.Root>
<Tooltip.Trigger class="cursor-help">
<HelpCircle class="w-3 h-3 text-muted-foreground/50" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="top" sideOffset={6}>
{tag.enrichReason || 'Unavailable'}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
{:else}
-
{/if}
{/snippet}
<div class="h-full flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Download} title="Registry" showConnection={false} />
@@ -681,10 +784,10 @@
</div>
</td>
<td class="py-1 px-2 pr-4 text-muted-foreground whitespace-nowrap">
{formatRegistryBytes(tag.size)}
{@render enrichCell(tag, formatRegistryBytes(tag.size), tag.size != null)}
</td>
<td class="py-1 px-2 pr-4 text-muted-foreground whitespace-nowrap">
{formatDate(tag.lastUpdated)}
{@render enrichCell(tag, formatDate(tag.lastUpdated), tag.lastUpdated != null)}
</td>
<td class="py-1 px-2">
<div class="flex items-center gap-1">