registry browser shows tag size and date for self-hosted registries, loaded on expand
This commit is contained in:
2 files changed
+246
-4
No files matched your search
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user