mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 03:06:57 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user