mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
feat: link container images to registry and source metadata (#1358)
* feat: link container images to registry and source metadata Turn image references on the stack surfaces into actionable links so an operator can review what an image ships before approving an update. A new pure helper maps a reference to its registry page (Docker Hub for official and namespace images, the owner profile for GitHub Container Registry) and never guesses a link for unknown or private registries. A shared dropdown adds copy-image-reference plus source, homepage, documentation, and revision links read from the image OCI labels via a local inspect, so they need no internet access and only appear when the image ships them. Version and non-commit revisions render as plain text. The menu is wired into the stack header image row, each per-container health card, and the update-readiness cards. * fix: invalidate image-source inspect token synchronously on image change The request token that drops a stale image inspect response was bumped only in a passive effect cleanup, which runs after paint. A response for a superseded image could resolve in the gap after the image-id change committed and still pass the token check, writing stale source labels into the new menu. Move the invalidation to a layout effect so the token is bumped during commit, before any network response can interleave. Add a regression test that holds the first inspect open, changes the image id, then resolves the stale response and asserts it is discarded.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Link2, ExternalLink, Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from './ui/dropdown-menu';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildImageLinks, extractImageSourceMeta } from '@/lib/imageLinks';
|
||||
|
||||
interface ImageSourceMenuProps {
|
||||
/** Full image reference, e.g. 'ghcr.io/owner/app:1.2'. Absent/empty renders nothing. */
|
||||
imageRef?: string | null;
|
||||
/** Resolved image id (sha256). When set, OCI source labels are fetched lazily. */
|
||||
imageId?: string;
|
||||
/** Sizing classes for the trigger button so each surface matches its row. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type LabelState = 'idle' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
// A focused quick-action for an image: the deterministic registry page, a copy
|
||||
// action, and (when an image id is available) the image's OCI source/docs/revision
|
||||
// links read lazily from a local inspect. It complements the Resources image
|
||||
// deep-dive sheet rather than duplicating it.
|
||||
export function ImageSourceMenu({ imageRef, imageId, className = 'h-4 w-4' }: ImageSourceMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [labels, setLabels] = useState<Record<string, string> | null>(null);
|
||||
const [labelState, setLabelState] = useState<LabelState>('idle');
|
||||
const [copied, setCopied] = useState(false);
|
||||
// Monotonic id so a response from a superseded image (node switch) or an
|
||||
// unmounted menu is ignored instead of writing stale labels.
|
||||
const requestRef = useRef(0);
|
||||
const [trackedId, setTrackedId] = useState(imageId);
|
||||
|
||||
// A new image id means a different (or node-switched) image: reset so the next
|
||||
// open re-fetches against the active node. Adjusting state during render is the
|
||||
// supported way to react to a changed prop without an extra render pass.
|
||||
if (trackedId !== imageId) {
|
||||
setTrackedId(imageId);
|
||||
setLabelState('idle');
|
||||
setLabels(null);
|
||||
}
|
||||
|
||||
// Invalidate any in-flight fetch when the image id changes or the menu unmounts,
|
||||
// so a late response from a superseded image cannot write stale labels. This runs
|
||||
// in a layout effect (not a passive one) so the token is bumped synchronously
|
||||
// during commit, before a network response could resolve in the gap after the
|
||||
// image-id change re-render.
|
||||
useLayoutEffect(() => () => { requestRef.current += 1; }, [imageId]);
|
||||
|
||||
const loadLabels = (id: string) => {
|
||||
const reqId = (requestRef.current += 1);
|
||||
setLabelState('loading');
|
||||
apiFetch(`/system/images/${encodeURIComponent(id)}`)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error('inspect failed');
|
||||
return res.json() as Promise<{ inspect?: { Config?: { Labels?: Record<string, string> | null } } }>;
|
||||
})
|
||||
.then((data) => {
|
||||
if (requestRef.current !== reqId) return;
|
||||
setLabels(data?.inspect?.Config?.Labels ?? null);
|
||||
setLabelState('loaded');
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestRef.current === reqId) setLabelState('error');
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next && imageId && labelState === 'idle') loadLabels(imageId);
|
||||
};
|
||||
|
||||
const trimmedRef = imageRef?.trim();
|
||||
if (!trimmedRef) return null;
|
||||
const links = buildImageLinks(trimmedRef);
|
||||
if (!links) return null;
|
||||
|
||||
const meta = extractImageSourceMeta(labels);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyToClipboard(trimmedRef);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
toast.success('Image reference copied');
|
||||
} catch {
|
||||
toast.error('Copy failed.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Image source links"
|
||||
title="Image source links"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
<DropdownMenuLabel className="font-mono text-[11px] font-normal text-stat-subtitle truncate">
|
||||
{trimmedRef}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{links.registryUrl ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={links.registryUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" strokeWidth={1.5} />
|
||||
Open on {links.registryLabel}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="truncate">Registry · {links.registryHost}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); void handleCopy(); }}>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 mr-2" strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 mr-2" strokeWidth={1.5} />
|
||||
)}
|
||||
Copy image reference
|
||||
</DropdownMenuItem>
|
||||
|
||||
{imageId && (labelState === 'loading' || meta.links.length > 0 || meta.version || meta.revision) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{labelState === 'loading' ? (
|
||||
<DropdownMenuItem disabled>Loading source…</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
{meta.links.map((link) => (
|
||||
<DropdownMenuItem key={link.id} asChild>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-3.5 w-3.5 mr-2" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{meta.revision && (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="font-mono truncate">Revision · {meta.revision}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.version && (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="font-mono truncate">Version · {meta.version}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user