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 { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import * as Select from '$lib/components/ui/select';
|
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 ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { whale } from '@lucide/lab';
|
import { whale } from '@lucide/lab';
|
||||||
@@ -44,6 +45,14 @@
|
|||||||
size?: number;
|
size?: number;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
digest?: 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 {
|
interface ExpandedImageState {
|
||||||
@@ -177,7 +186,17 @@
|
|||||||
}
|
}
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (response.ok) {
|
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 {
|
} else {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
errorMessage = data.error || 'Search failed';
|
errorMessage = data.error || 'Search failed';
|
||||||
@@ -336,6 +355,11 @@
|
|||||||
hasNext: data.hasNext
|
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 {
|
} else {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expandedImages = {
|
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) {
|
function handleTagsWheel(event: WheelEvent, imageName: string) {
|
||||||
const target = event.currentTarget as HTMLElement;
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
|
||||||
@@ -482,6 +557,34 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</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="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">
|
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
|
||||||
<PageHeader icon={Download} title="Registry" showConnection={false} />
|
<PageHeader icon={Download} title="Registry" showConnection={false} />
|
||||||
@@ -681,10 +784,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-1 px-2 pr-4 text-muted-foreground whitespace-nowrap">
|
<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>
|
||||||
<td class="py-1 px-2 pr-4 text-muted-foreground whitespace-nowrap">
|
<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>
|
||||||
<td class="py-1 px-2">
|
<td class="py-1 px-2">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
Reference in new issue
Block a user