feat(registries): add exact-ID tag browser with non-401 failures (#1613)

* feat(resources): show multi-stack usedByStacks on images

Classify images with a deduped sorted stack reverse index, surface chips in the Images table and inspect sheet, and clear node-bound sheet selection on active-node change.

* feat(registries): add exact-ID tag browser with non-401 failures

Add GET /api/registries/:id/tags using credentials for that registry row only, map upstream auth failures to 424, and surface a Registry tags section on the image inspect sheet.

* fix(registries): distinguish unreachable hosts from auth failures

Map auth transport errors to REGISTRY_UPSTREAM (502), surface registry list-load failures in the tag panel, document Used by and Registry tags, and add parser coverage.

* fix(registries): drop unused RegistryTagsPanel __test export

The non-component export tripped react-refresh/only-export-components and failed Frontend lint in CI.
This commit is contained in:
Anso
2026-07-11 13:21:38 -04:00
committed by GitHub
parent 362a18e91a
commit ce699864c1
12 changed files with 977 additions and 76 deletions
@@ -7,6 +7,8 @@ import { apiFetch } from '@/lib/api';
import { formatBytes } from '@/lib/utils';
import { copyToClipboard } from '@/lib/clipboard';
import { formatShortDigest } from '@/lib/formatDigest';
import { useAuth } from '@/context/AuthContext';
import { RegistryTagsPanel } from './RegistryTagsPanel';
import { Copy } from 'lucide-react';
interface ImageInspect {
@@ -76,6 +78,7 @@ function formatRelativeAge(timestampSec: number): string {
export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsSheetProps) {
const imageId = image?.Id ?? null;
const { isAdmin } = useAuth();
const [data, setData] = useState<ImageDetails | null>(null);
const [loading, setLoading] = useState(false);
@@ -113,7 +116,6 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
const inspect = data?.inspect;
const history = data?.history ?? [];
const totalLayers = history.length;
const usedByStacks = image?.usedByStacks ?? [];
const name = image?.RepoTags?.[0]
|| inspect?.RepoTags?.[0]
@@ -145,6 +147,33 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
</div>
)}
{image && (
<SheetSection title="Used by">
{(image.usedByStacks?.length ?? 0) === 0 ? (
<p className="text-xs text-muted-foreground">
{image.managedStatus === 'unused' ? 'No containers' : 'Not used by a Sencho stack'}
</p>
) : (
<div className="flex flex-wrap gap-1">
{image.usedByStacks.map((stack) => (
onOpenStack ? (
<button
key={stack}
type="button"
className="inline-flex items-center px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium hover:bg-success/15 transition-colors"
onClick={() => onOpenStack(stack)}
>
{stack}
</button>
) : (
<Badge key={stack} variant="outline" className="text-[10px] h-5">{stack}</Badge>
)
))}
</div>
)}
</SheetSection>
)}
{!loading && inspect && (
<>
<SheetSection title="Overview">
@@ -191,30 +220,6 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
</div>
</Field>
)}
<Field label="Used by" span={2}>
{usedByStacks.length === 0 ? (
<p className="text-xs mt-0.5 text-muted-foreground">
{image?.managedStatus === 'unused' ? 'No containers' : 'Not used by a Sencho stack'}
</p>
) : (
<div className="flex flex-wrap gap-1 mt-1">
{usedByStacks.map((stack) => (
onOpenStack ? (
<button
key={stack}
type="button"
className="inline-flex items-center px-1.5 py-0.5 rounded border border-success/25 bg-success/8 text-success text-[10px] font-medium hover:bg-success/15 transition-colors"
onClick={() => onOpenStack(stack)}
>
{stack}
</button>
) : (
<Badge key={stack} variant="outline" className="text-[10px] h-5">{stack}</Badge>
)
))}
</div>
)}
</Field>
</div>
</SheetSection>
@@ -279,6 +284,15 @@ export function ImageDetailsSheet({ image, onClose, onOpenStack }: ImageDetailsS
</ol>
)}
</SheetSection>
<SheetSection title="Registry tags">
<RegistryTagsPanel
repoTags={inspect.RepoTags ?? image?.RepoTags ?? []}
repoDigests={inspect.RepoDigests ?? []}
nodeId={image?.nodeId ?? 0}
isAdmin={isAdmin}
/>
</SheetSection>
</>
)}
</SystemSheet>
@@ -0,0 +1,287 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { Loader2 } from 'lucide-react';
interface RegistryRow {
id: number;
name: string;
url: string;
type: 'dockerhub' | 'ghcr' | 'ecr' | 'custom';
has_secret: boolean;
}
interface TagListResponse {
tags: string[];
nextCursor: string | null;
registryId: number;
registryName: string;
repository: string;
}
export interface RegistryTagsPanelProps {
repoTags: string[];
repoDigests: string[];
nodeId: string | number;
isAdmin: boolean;
}
function parseRepoFromTag(tag: string): { host: string; repo: string; tagName: string } | null {
if (!tag || tag === '<none>:<none>') return null;
const at = tag.indexOf('@');
const ref = at === -1 ? tag : tag.slice(0, at);
let rest = ref;
let host = 'docker.io';
const slash = ref.indexOf('/');
if (slash !== -1) {
const first = ref.slice(0, slash);
if (first.includes('.') || first.includes(':') || first === 'localhost') {
host = first.toLowerCase();
rest = ref.slice(slash + 1);
}
}
let tagName = 'latest';
const colon = rest.lastIndexOf(':');
if (colon > 0) {
tagName = rest.slice(colon + 1);
rest = rest.slice(0, colon);
}
if (host === 'docker.io' && !rest.includes('/')) {
rest = `library/${rest}`;
}
return { host, repo: rest, tagName };
}
function registryMatchesHost(reg: RegistryRow, host: string): boolean {
const h = host.toLowerCase();
if (reg.type === 'dockerhub') {
return h === 'docker.io' || h === 'index.docker.io' || h === 'registry-1.docker.io' || h === '';
}
try {
const withProto = reg.url.startsWith('http') ? reg.url : `https://${reg.url}`;
return new URL(withProto).host.toLowerCase() === h;
} catch {
return reg.url.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase() === h;
}
}
export function RegistryTagsPanel({
repoTags,
isAdmin,
}: RegistryTagsPanelProps) {
const candidates = useMemo(() => {
const seen = new Set<string>();
const out: { host: string; repo: string; tagName: string; label: string }[] = [];
for (const t of repoTags) {
const parsed = parseRepoFromTag(t);
if (!parsed) continue;
const key = `${parsed.host}/${parsed.repo}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({ ...parsed, label: t });
}
return out;
}, [repoTags]);
const [registries, setRegistries] = useState<RegistryRow[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [regsError, setRegsError] = useState<string | null>(null);
const [selectedRepoIdx, setSelectedRepoIdx] = useState(0);
const [selectedRegistryId, setSelectedRegistryId] = useState<number | null>(null);
const [tags, setTags] = useState<string[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingTags, setLoadingTags] = useState(false);
const [error, setError] = useState<string | null>(null);
const selected = candidates[selectedRepoIdx] ?? null;
const matchingRegistries = useMemo(() => {
if (!selected) return [];
return registries.filter((r) => registryMatchesHost(r, selected.host));
}, [registries, selected]);
useEffect(() => {
if (!isAdmin) return;
let cancelled = false;
setLoadingRegs(true);
setRegsError(null);
apiFetch('/registries', { localOnly: true })
.then(async (res) => {
if (!res.ok) throw new Error('Failed to load registries');
return res.json() as Promise<RegistryRow[]>;
})
.then((rows) => {
if (!cancelled) setRegistries(Array.isArray(rows) ? rows : []);
})
.catch((err: unknown) => {
if (cancelled) return;
const msg = err instanceof Error ? err.message : 'Failed to load registries';
setRegsError(msg);
setRegistries([]);
toast.error(msg);
})
.finally(() => {
if (!cancelled) setLoadingRegs(false);
});
return () => { cancelled = true; };
}, [isAdmin]);
useEffect(() => {
if (matchingRegistries.length === 0) {
setSelectedRegistryId(null);
return;
}
setSelectedRegistryId((prev) =>
prev && matchingRegistries.some((r) => r.id === prev) ? prev : matchingRegistries[0].id,
);
}, [matchingRegistries]);
const loadTags = async (cursor?: string | null, append = false) => {
if (!selected || selectedRegistryId == null) return;
setLoadingTags(true);
setError(null);
try {
const params = new URLSearchParams({ repository: selected.repo, limit: '50' });
if (cursor) params.set('cursor', cursor);
const res = await apiFetch(`/registries/${selectedRegistryId}/tags?${params}`, { localOnly: true });
const data = await res.json().catch(() => null) as (TagListResponse & { error?: string }) | null;
if (!res.ok) {
throw new Error(data?.error || `Failed to list tags (${res.status})`);
}
const page = Array.isArray(data?.tags) ? data!.tags : [];
setTags((prev) => (append ? [...prev, ...page] : page));
setNextCursor(data?.nextCursor ?? null);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to list tags';
setError(msg);
if (!append) setTags([]);
toast.error(msg);
} finally {
setLoadingTags(false);
}
};
useEffect(() => {
setTags([]);
setNextCursor(null);
setError(null);
if (selected && selectedRegistryId != null) {
void loadTags(null, false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected?.host, selected?.repo, selectedRegistryId]);
if (!isAdmin) {
return (
<p className="text-xs text-muted-foreground">
Registry tag browsing is available to admins with a configured registry.
</p>
);
}
if (candidates.length === 0) {
return <p className="text-xs text-muted-foreground">No repository tags to browse.</p>;
}
let registryBody: ReactNode;
if (loadingRegs) {
registryBody = (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" /> Loading registries
</p>
);
} else if (regsError) {
registryBody = <p className="text-xs text-destructive">{regsError}</p>;
} else if (matchingRegistries.length === 0) {
registryBody = (
<p className="text-xs text-muted-foreground">
No configured registry matches {selected?.host}. Add credentials under Settings Registries.
</p>
);
} else {
registryBody = (
<>
{matchingRegistries.length > 1 && (
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Registry
<select
className="h-8 rounded-md border border-input bg-background px-2 text-foreground text-xs"
value={selectedRegistryId ?? ''}
onChange={(e) => setSelectedRegistryId(Number(e.target.value))}
>
{matchingRegistries.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</label>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{loadingTags && tags.length === 0 ? (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Loader2 className="w-3 h-3 animate-spin" /> Loading tags
</p>
) : tags.length === 0 && !error ? (
<p className="text-xs text-muted-foreground">No tags returned.</p>
) : (
<ul className="max-h-48 overflow-y-auto space-y-1 font-mono text-[11px]">
{tags.map((tag) => {
const isCurrent = selected?.tagName === tag;
return (
<li key={tag} className="flex items-center gap-2 truncate">
<span className={isCurrent ? 'text-foreground font-medium' : 'text-stat-subtitle/90'}>
{tag}
</span>
{isCurrent && (
<Badge variant="outline" className="text-[9px] h-4">current</Badge>
)}
</li>
);
})}
</ul>
)}
{nextCursor && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={loadingTags}
onClick={() => void loadTags(nextCursor, true)}
>
{loadingTags ? 'Loading…' : 'Load more'}
</Button>
)}
</>
);
}
return (
<div className="space-y-3 text-sm">
{candidates.length > 1 && (
<div className="flex flex-wrap gap-1">
{candidates.map((c, i) => (
<button
key={c.label}
type="button"
className={`px-1.5 py-0.5 rounded border text-[10px] font-mono transition-colors ${
i === selectedRepoIdx
? 'border-foreground/30 bg-muted text-foreground'
: 'border-border text-muted-foreground hover:text-foreground'
}`}
onClick={() => setSelectedRepoIdx(i)}
>
{c.label}
</button>
))}
</div>
)}
{registryBody}
</div>
);
}
@@ -5,6 +5,7 @@ import { ImageDetailsSheet } from '../ImageDetailsSheet';
const apiFetch = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ isAdmin: true }) }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import { RegistryTagsPanel } from '../RegistryTagsPanel';
const apiFetch = vi.fn();
const toastError = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: (...args: unknown[]) => toastError(...args), success: vi.fn() },
}));
describe('RegistryTagsPanel', () => {
beforeEach(() => {
apiFetch.mockReset();
toastError.mockReset();
});
const panelProps = {
repoTags: ['ghcr.io/acme/app:latest'],
repoDigests: [] as string[],
nodeId: 1,
isAdmin: true as const,
};
it('surfaces registry list failures instead of a no-match message', async () => {
apiFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: 'boom' }) });
render(<RegistryTagsPanel {...panelProps} />);
expect(await screen.findByText('Failed to load registries')).toBeInTheDocument();
expect(screen.queryByText(/No configured registry matches/i)).toBeNull();
expect(toastError).toHaveBeenCalled();
});
it('shows no-match copy when registries load but none match the host', async () => {
apiFetch.mockResolvedValue({
ok: true,
json: async () => ([{ id: 1, name: 'Hub', url: 'https://index.docker.io/v1/', type: 'dockerhub', has_secret: true }]),
});
render(<RegistryTagsPanel {...panelProps} />);
expect(await screen.findByText(/No configured registry matches ghcr.io/i)).toBeInTheDocument();
});
});