diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx
index e4841fbb..55ded9ce 100644
--- a/docs/features/stack-management.mdx
+++ b/docs/features/stack-management.mdx
@@ -186,6 +186,16 @@ The header answers three questions at a glance:
The action row to the right of the header keeps everyday lifecycle controls visible. The **More actions** overflow holds Rollback, Scan config, and Delete; the next sections cover both surfaces.
+### Image source links
+
+Next to the image line is a links button that turns the image reference into somewhere useful to go. It opens a small menu with:
+
+- **Open on the registry.** Docker Hub images link to their Docker Hub page; GitHub Container Registry (`ghcr.io`) images link to the owner on GitHub. Images from a private or unrecognized registry show the registry host instead of a link, so you never land on a broken guessed URL.
+- **Copy image reference.** Grabs the full reference for use elsewhere.
+- **Source, homepage, documentation, and revision links** when the image declares them. These come from the image's own [OCI labels](https://github.com/opencontainers/image-spec/blob/main/annotations.md) (`org.opencontainers.image.source`, `.url`, `.documentation`, `.revision`, `.version`) and are read locally from the image, so they need no internet access and appear only when the image ships them. A revision links to its commit when the source is a GitHub repository; the version is shown as plain text.
+
+The same links button appears on each container row and on the update cards in [Auto-Update Policies](/features/auto-update-policies), so you can review what an image ships and where it comes from before approving an update.
+
## Container health strip
Below the header, each container in the stack gets a single row that answers "is this piece working, and how do I reach it?" without expanding anything.
@@ -201,7 +211,7 @@ Each row includes:
- **Meta line.** Uptime (`up 12 hours`) and the primary port mapping (`8989 → 8989/tcp`).
- **Open link.** When the container publishes a port, an `open ↗` link launches the app in a new tab using the active node's hostname.
- **Live stat tiles.** Three tiles show CPU, memory, and network I/O with a rolling sparkline. The sparkline uses the cyan data color and refreshes roughly every 1.5 seconds.
-- **Action icons.** Shortcuts to **View logs**, open a bash shell, and the per-service kebab.
+- **Action icons.** The image source links button (see above), plus shortcuts to **View logs**, open a bash shell, and the per-service kebab.
## Logs viewer
diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx
index 2cc2c459..dd7c89ee 100644
--- a/frontend/src/components/AutoUpdateReadinessView.tsx
+++ b/frontend/src/components/AutoUpdateReadinessView.tsx
@@ -6,6 +6,7 @@ import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play
import { toast } from '@/components/ui/toast-store';
import { apiFetch, fetchForNode } from '@/lib/api';
import { useNodes } from '@/context/NodeContext';
+import { ImageSourceMenu } from './ImageSourceMenu';
import type { ScheduledTask } from '@/types/scheduling';
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
@@ -208,6 +209,7 @@ function StackReadinessCard({
· {updatingImageCount} services
)}
+
diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx
index 44215b25..45d71e73 100644
--- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx
+++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx
@@ -27,6 +27,7 @@ import {
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Sparkline } from '../ui/sparkline';
+import { ImageSourceMenu } from '../ImageSourceMenu';
import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/lib/clipboard';
import ErrorBoundary from '../ErrorBoundary';
@@ -212,6 +213,7 @@ export function StackIdentityHeader({
>
)}
+
);
})()}
@@ -409,6 +411,11 @@ export function ContainersHealth({
+
({ apiFetch: vi.fn() }));
+vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
+vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+import { apiFetch } from '@/lib/api';
+import { copyToClipboard } from '@/lib/clipboard';
+import { toast } from '@/components/ui/toast-store';
+import { ImageSourceMenu } from './ImageSourceMenu';
+
+function inspectRes(labels: Record | null, ok = true) {
+ return {
+ ok,
+ status: ok ? 200 : 404,
+ json: async () => ({ inspect: { Config: { Labels: labels } } }),
+ } as unknown as Response;
+}
+
+const trigger = () => screen.getByRole('button', { name: 'Image source links' });
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(apiFetch).mockResolvedValue(inspectRes({
+ 'org.opencontainers.image.source': 'https://github.com/owner/repo',
+ }));
+});
+
+describe('ImageSourceMenu', () => {
+ it('renders nothing when the image ref is absent', () => {
+ const { container } = render( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('shows the deterministic registry link with safe external-link attributes', async () => {
+ render( );
+ await userEvent.click(trigger());
+ const link = await screen.findByRole('menuitem', { name: /Open on GitHub Container Registry/ });
+ expect(link).toHaveAttribute('href', 'https://github.com/owner');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ });
+
+ it('does not fetch labels when no imageId is provided', async () => {
+ render( );
+ await userEvent.click(trigger());
+ await screen.findByRole('menuitem', { name: /Open on/ });
+ expect(apiFetch).not.toHaveBeenCalled();
+ });
+
+ it('fetches OCI labels once on first open and not again on reopen', async () => {
+ render( );
+ await userEvent.click(trigger());
+ expect(await screen.findByRole('menuitem', { name: 'Source repository' })).toHaveAttribute(
+ 'href', 'https://github.com/owner/repo',
+ );
+ expect(apiFetch).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(apiFetch).mock.calls[0][0]).toContain('/system/images/');
+
+ await userEvent.keyboard('{Escape}');
+ await waitFor(() => expect(screen.queryByRole('menuitem', { name: 'Source repository' })).toBeNull());
+ await userEvent.click(trigger());
+ await screen.findByRole('menuitem', { name: 'Source repository' });
+ expect(apiFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('re-fetches and clears stale labels when the image id changes', async () => {
+ vi.mocked(apiFetch).mockResolvedValueOnce(inspectRes({
+ 'org.opencontainers.image.source': 'https://github.com/owner/old',
+ }));
+ const { rerender } = render( );
+ await userEvent.click(trigger());
+ expect(await screen.findByRole('menuitem', { name: 'Source repository' })).toHaveAttribute(
+ 'href', 'https://github.com/owner/old',
+ );
+ await userEvent.keyboard('{Escape}');
+
+ vi.mocked(apiFetch).mockResolvedValueOnce(inspectRes({
+ 'org.opencontainers.image.source': 'https://github.com/owner/new',
+ }));
+ rerender( );
+ await userEvent.click(trigger());
+ expect(await screen.findByRole('menuitem', { name: 'Source repository' })).toHaveAttribute(
+ 'href', 'https://github.com/owner/new',
+ );
+ expect(apiFetch).toHaveBeenCalledTimes(2);
+ });
+
+ it('drops a late inspect response from a superseded image after the id changes', async () => {
+ // First open: an inspect that stays in flight until we resolve it by hand.
+ let resolveOld!: (v: Response) => void;
+ vi.mocked(apiFetch).mockReturnValueOnce(new Promise((res) => { resolveOld = res; }));
+
+ const { rerender } = render( );
+ await userEvent.click(trigger());
+ await screen.findByRole('menuitem', { name: /Loading source/ });
+
+ // The image id changes while the old inspect is still pending (e.g. node switch).
+ rerender( );
+
+ // The stale response now resolves; the request-token guard must discard it.
+ // Drain the full response chain (.then -> res.json() -> .then) inside act so a
+ // missing guard would have written the OLD label before we assert.
+ await act(async () => {
+ resolveOld(inspectRes({ 'org.opencontainers.image.source': 'https://github.com/owner/OLD' }));
+ await new Promise((r) => setTimeout(r, 0));
+ });
+
+ expect(screen.queryByRole('menuitem', { name: 'Source repository' })).toBeNull();
+ });
+
+ it('surfaces a toast when the copy fails', async () => {
+ vi.mocked(copyToClipboard).mockRejectedValueOnce(new Error('no clipboard'));
+ render( );
+ await userEvent.click(trigger());
+ await userEvent.click(await screen.findByRole('menuitem', { name: /Copy image reference/ }));
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Copy failed.'));
+ });
+
+ it('still renders deterministic links when the inspect fetch fails', async () => {
+ vi.mocked(apiFetch).mockRejectedValue(new Error('boom'));
+ render( );
+ await userEvent.click(trigger());
+ await screen.findByRole('menuitem', { name: /Open on GitHub Container Registry/ });
+ await waitFor(() => expect(apiFetch).toHaveBeenCalled());
+ expect(screen.queryByRole('menuitem', { name: 'Source repository' })).toBeNull();
+ });
+
+ it('copies the image reference on the copy action', async () => {
+ render( );
+ await userEvent.click(trigger());
+ await userEvent.click(await screen.findByRole('menuitem', { name: /Copy image reference/ }));
+ expect(copyToClipboard).toHaveBeenCalledWith('ghcr.io/owner/app:1.2');
+ });
+
+ it('shows the host with no Open link for an unknown private registry', async () => {
+ render( );
+ await userEvent.click(trigger());
+ await screen.findByText(/Registry · registry.example.com:5000/);
+ expect(screen.queryByRole('menuitem', { name: /Open on/ })).toBeNull();
+ });
+});
diff --git a/frontend/src/components/ImageSourceMenu.tsx b/frontend/src/components/ImageSourceMenu.tsx
new file mode 100644
index 00000000..af21092b
--- /dev/null
+++ b/frontend/src/components/ImageSourceMenu.tsx
@@ -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 | null>(null);
+ const [labelState, setLabelState] = useState('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 | 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 (
+
+
+
+
+
+
+
+
+ {trimmedRef}
+
+
+
+ {links.registryUrl ? (
+
+
+
+ Open on {links.registryLabel}
+
+
+ ) : (
+
+ Registry · {links.registryHost}
+
+ )}
+
+ { e.preventDefault(); void handleCopy(); }}>
+ {copied ? (
+
+ ) : (
+
+ )}
+ Copy image reference
+
+
+ {imageId && (labelState === 'loading' || meta.links.length > 0 || meta.version || meta.revision) && (
+ <>
+
+ {labelState === 'loading' ? (
+ Loading source…
+ ) : (
+ <>
+ {meta.links.map((link) => (
+
+
+
+ {link.label}
+
+
+ ))}
+ {meta.revision && (
+
+ Revision · {meta.revision}
+
+ )}
+ {meta.version && (
+
+ Version · {meta.version}
+
+ )}
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/lib/imageLinks.test.ts b/frontend/src/lib/imageLinks.test.ts
new file mode 100644
index 00000000..6c07779a
--- /dev/null
+++ b/frontend/src/lib/imageLinks.test.ts
@@ -0,0 +1,184 @@
+import { describe, it, expect } from 'vitest';
+import { parseImageRef, buildImageLinks, extractImageSourceMeta } from './imageLinks';
+
+describe('parseImageRef', () => {
+ it('parses an official Docker Hub image with a tag', () => {
+ expect(parseImageRef('nginx:latest')).toEqual({
+ registry: 'docker.io', namespace: null, repo: 'nginx', tag: 'latest', digest: null,
+ });
+ });
+
+ it('parses a bare image name with no tag', () => {
+ expect(parseImageRef('nginx')).toEqual({
+ registry: 'docker.io', namespace: null, repo: 'nginx', tag: null, digest: null,
+ });
+ });
+
+ it('parses a Docker Hub namespace image', () => {
+ expect(parseImageRef('linuxserver/sonarr:latest')).toEqual({
+ registry: 'docker.io', namespace: 'linuxserver', repo: 'sonarr', tag: 'latest', digest: null,
+ });
+ });
+
+ it('parses a GHCR image', () => {
+ expect(parseImageRef('ghcr.io/owner/image:tag')).toEqual({
+ registry: 'ghcr.io', namespace: 'owner', repo: 'image', tag: 'tag', digest: null,
+ });
+ });
+
+ it('parses a private registry with a port', () => {
+ expect(parseImageRef('registry.example.com:5000/team/app:1.2')).toEqual({
+ registry: 'registry.example.com:5000', namespace: 'team', repo: 'app', tag: '1.2', digest: null,
+ });
+ });
+
+ it('captures the digest of a digest-pinned namespace image', () => {
+ const digest = 'sha256:0000000000000000000000000000000000000000000000000000000000000000';
+ expect(parseImageRef(`myrepo/app@${digest}`)).toEqual({
+ registry: 'docker.io', namespace: 'myrepo', repo: 'app', tag: null, digest,
+ });
+ });
+
+ it('captures the digest of a digest-pinned official image', () => {
+ const digest = 'sha256:1111111111111111111111111111111111111111111111111111111111111111';
+ expect(parseImageRef(`nginx@${digest}`)).toEqual({
+ registry: 'docker.io', namespace: null, repo: 'nginx', tag: null, digest,
+ });
+ });
+
+ it('keeps both the tag and the digest of a tag-and-digest pinned ref', () => {
+ const digest = 'sha256:3333333333333333333333333333333333333333333333333333333333333333';
+ expect(parseImageRef(`nginx:1.25@${digest}`)).toEqual({
+ registry: 'docker.io', namespace: null, repo: 'nginx', tag: '1.25', digest,
+ });
+ });
+
+ it('does not mistake a registry port for a tag when no tag is present', () => {
+ expect(parseImageRef('registry.example.com:5000/team/app')).toEqual({
+ registry: 'registry.example.com:5000', namespace: 'team', repo: 'app', tag: null, digest: null,
+ });
+ });
+
+ it('returns null for a bare digest and for empty input', () => {
+ expect(parseImageRef('sha256:abc')).toBeNull();
+ expect(parseImageRef(' ')).toBeNull();
+ });
+});
+
+describe('buildImageLinks', () => {
+ it('links official Docker Hub images to the _/ page', () => {
+ const links = buildImageLinks('nginx:latest');
+ expect(links?.kind).toBe('dockerhub-official');
+ expect(links?.registryUrl).toBe('https://hub.docker.com/_/nginx');
+ });
+
+ it('treats library/ as an official image', () => {
+ expect(buildImageLinks('library/nginx')?.registryUrl).toBe('https://hub.docker.com/_/nginx');
+ });
+
+ it('treats docker.io / registry-1 / index aliases as Docker Hub', () => {
+ for (const ref of [
+ 'docker.io/library/nginx',
+ 'registry-1.docker.io/library/nginx:latest',
+ 'index.docker.io/library/nginx',
+ ]) {
+ const links = buildImageLinks(ref);
+ expect(links?.kind, ref).toBe('dockerhub-official');
+ expect(links?.registryUrl, ref).toBe('https://hub.docker.com/_/nginx');
+ }
+ });
+
+ it('links Docker Hub namespace images to the r/ page', () => {
+ const links = buildImageLinks('linuxserver/sonarr:latest');
+ expect(links?.kind).toBe('dockerhub-namespace');
+ expect(links?.registryUrl).toBe('https://hub.docker.com/r/linuxserver/sonarr');
+ });
+
+ it('links GHCR images to the owner profile', () => {
+ const links = buildImageLinks('ghcr.io/owner/image:tag');
+ expect(links?.kind).toBe('ghcr');
+ expect(links?.registryHost).toBe('ghcr.io');
+ expect(links?.registryUrl).toBe('https://github.com/owner');
+ });
+
+ it('never guesses a link for an unknown/private registry', () => {
+ const links = buildImageLinks('registry.example.com:5000/team/app:1.2');
+ expect(links?.kind).toBe('other');
+ expect(links?.registryHost).toBe('registry.example.com:5000');
+ expect(links?.registryUrl).toBeNull();
+ });
+
+ it('still resolves the registry page for a digest-pinned ref', () => {
+ const links = buildImageLinks('linuxserver/sonarr@sha256:2222222222222222222222222222222222222222222222222222222222222222');
+ expect(links?.registryUrl).toBe('https://hub.docker.com/r/linuxserver/sonarr');
+ });
+
+ it('returns null for an unparseable ref', () => {
+ expect(buildImageLinks('sha256:abc')).toBeNull();
+ });
+});
+
+describe('extractImageSourceMeta', () => {
+ it('returns nothing for empty or missing labels', () => {
+ expect(extractImageSourceMeta(null)).toEqual({ links: [], version: null, revision: null });
+ expect(extractImageSourceMeta({})).toEqual({ links: [], version: null, revision: null });
+ });
+
+ it('emits links only for valid absolute http(s) label values', () => {
+ const meta = extractImageSourceMeta({
+ 'org.opencontainers.image.source': 'https://github.com/owner/repo',
+ 'org.opencontainers.image.url': 'https://example.com',
+ 'org.opencontainers.image.documentation': 'http://docs.example.com',
+ });
+ expect(meta.links.map(l => l.id)).toEqual(['source', 'url', 'documentation']);
+ });
+
+ it('rejects relative, javascript:, and non-URL label values', () => {
+ const meta = extractImageSourceMeta({
+ 'org.opencontainers.image.source': '/owner/repo',
+ 'org.opencontainers.image.url': 'javascript:alert(1)',
+ 'org.opencontainers.image.documentation': 'not a url',
+ });
+ expect(meta.links).toEqual([]);
+ });
+
+ it('renders version as text only, never a derived release link', () => {
+ const meta = extractImageSourceMeta({ 'org.opencontainers.image.version': '1.2.3' });
+ expect(meta.version).toBe('1.2.3');
+ expect(meta.links).toEqual([]);
+ });
+
+ it('links a SHA-like revision to a GitHub commit when source is a github repo', () => {
+ const meta = extractImageSourceMeta({
+ 'org.opencontainers.image.source': 'https://github.com/owner/repo.git',
+ 'org.opencontainers.image.revision': 'abcdef1234567890',
+ });
+ const rev = meta.links.find(l => l.id === 'revision');
+ expect(rev?.url).toBe('https://github.com/owner/repo/commit/abcdef1234567890');
+ expect(meta.revision).toBeNull();
+ });
+
+ it('keeps a SHA-like revision as text when there is no source label to anchor it', () => {
+ const meta = extractImageSourceMeta({
+ 'org.opencontainers.image.revision': 'abcdef1234567890',
+ });
+ expect(meta.links).toEqual([]);
+ expect(meta.revision).toBe('abcdef1234567890');
+ });
+
+ it('keeps revision as text when it is not SHA-like or source is not github', () => {
+ const notSha = extractImageSourceMeta({
+ 'org.opencontainers.image.source': 'https://github.com/owner/repo',
+ 'org.opencontainers.image.revision': 'v1.2.3',
+ });
+ expect(notSha.links.find(l => l.id === 'revision')).toBeUndefined();
+ expect(notSha.revision).toBe('v1.2.3');
+
+ const notGithub = extractImageSourceMeta({
+ 'org.opencontainers.image.source': 'https://gitlab.com/owner/repo',
+ 'org.opencontainers.image.revision': 'abcdef1234567890',
+ });
+ expect(notGithub.links.find(l => l.id === 'revision')).toBeUndefined();
+ expect(notGithub.revision).toBe('abcdef1234567890');
+ });
+});
diff --git a/frontend/src/lib/imageLinks.ts b/frontend/src/lib/imageLinks.ts
new file mode 100644
index 00000000..8aa72267
--- /dev/null
+++ b/frontend/src/lib/imageLinks.ts
@@ -0,0 +1,217 @@
+// Turns a Docker image reference into actionable external links so operators can
+// review an image upstream before approving an update. Pure and offline: parsing
+// and URL building never touch the network. OCI label handling is deliberately
+// conservative so untrusted image metadata can never produce a broken or unsafe
+// link (only absolute http(s) values become links; everything else stays text).
+
+export interface ParsedImageRef {
+ /** Normalized registry host actually present in the ref ('docker.io' when implicit). */
+ registry: string;
+ /** First path segment (Docker Hub namespace / GHCR owner); null for official Hub images. */
+ namespace: string | null;
+ /** Repository name without the namespace, e.g. 'nginx', 'sonarr'. */
+ repo: string;
+ /** Tag if present, else null. */
+ tag: string | null;
+ /** Full digest including algorithm (e.g. 'sha256:abc…') if pinned, else null. */
+ digest: string | null;
+}
+
+export type RegistryKind =
+ | 'dockerhub-official'
+ | 'dockerhub-namespace'
+ | 'ghcr'
+ | 'other';
+
+interface ImageLinksBase {
+ /** Host to display, e.g. 'docker.io', 'ghcr.io', 'registry.example.com:5000'. */
+ registryHost: string;
+ /** Human label for the registry, e.g. 'Docker Hub'. */
+ registryLabel: string;
+}
+
+// A recognized registry always yields a link; an unknown/private registry never
+// guesses one. Modeling that as a discriminated union makes the "no link for
+// 'other'" rule provable at the type level instead of a convention.
+export type ImageLinks =
+ | (ImageLinksBase & { kind: Exclude; registryUrl: string })
+ | (ImageLinksBase & { kind: 'other'; registryUrl: null });
+
+export interface ImageSourceLink {
+ id: 'source' | 'url' | 'documentation' | 'revision';
+ label: string;
+ url: string;
+}
+
+export interface ImageSourceMeta {
+ links: ImageSourceLink[];
+ /** org.opencontainers.image.version, shown as plain text (never a derived link). */
+ version: string | null;
+ /** Short revision text, only when it could not be turned into a commit link. */
+ revision: string | null;
+}
+
+// docker.io is the public alias; registry-1.docker.io / index.docker.io are the
+// API/login hosts an explicit ref may carry. All three are Docker Hub for linking.
+const DOCKER_HUB_HOSTS = new Set(['docker.io', 'registry-1.docker.io', 'index.docker.io']);
+
+function isRegistryHost(segment: string): boolean {
+ return segment.includes('.') || segment.includes(':') || segment === 'localhost';
+}
+
+export function parseImageRef(ref: string): ParsedImageRef | null {
+ const trimmed = ref?.trim();
+ if (!trimmed) return null;
+ // A bare digest carries no repository, so there is nothing to link to.
+ if (trimmed.startsWith('sha256:')) return null;
+
+ let rest = trimmed;
+
+ let digest: string | null = null;
+ const atIdx = rest.indexOf('@');
+ if (atIdx !== -1) {
+ digest = rest.slice(atIdx + 1) || null;
+ rest = rest.slice(0, atIdx);
+ }
+
+ let registry = 'docker.io';
+ const slashIdx = rest.indexOf('/');
+ if (slashIdx !== -1) {
+ const firstPart = rest.slice(0, slashIdx);
+ if (isRegistryHost(firstPart)) {
+ registry = firstPart;
+ rest = rest.slice(slashIdx + 1);
+ }
+ }
+
+ let tag: string | null = null;
+ const colonIdx = rest.lastIndexOf(':');
+ if (colonIdx > 0) {
+ tag = rest.slice(colonIdx + 1);
+ rest = rest.slice(0, colonIdx);
+ }
+
+ if (!rest) return null;
+
+ const segments = rest.split('/').filter(Boolean);
+ if (segments.length === 0) return null;
+
+ let namespace: string | null;
+ let repo: string;
+ if (segments.length === 1) {
+ namespace = null;
+ repo = segments[0];
+ } else {
+ namespace = segments[0];
+ repo = segments.slice(1).join('/');
+ }
+
+ return { registry, namespace, repo, tag, digest };
+}
+
+export function buildImageLinks(ref: string): ImageLinks | null {
+ const parsed = parseImageRef(ref);
+ if (!parsed) return null;
+ const { registry, namespace, repo } = parsed;
+
+ if (DOCKER_HUB_HOSTS.has(registry)) {
+ const isOfficial = namespace === null || namespace === 'library';
+ return {
+ registryHost: 'docker.io',
+ registryLabel: 'Docker Hub',
+ kind: isOfficial ? 'dockerhub-official' : 'dockerhub-namespace',
+ registryUrl: isOfficial
+ ? `https://hub.docker.com/_/${repo}`
+ : `https://hub.docker.com/r/${namespace}/${repo}`,
+ };
+ }
+
+ if (registry === 'ghcr.io') {
+ // GitHub does not expose a reliable package URL from the ref alone (user vs org,
+ // image name may differ from the repo). The owner profile always resolves; the
+ // exact source repo is filled in from the OCI source label when available.
+ const owner = namespace ?? repo;
+ return {
+ registryHost: 'ghcr.io',
+ registryLabel: 'GitHub Container Registry',
+ kind: 'ghcr',
+ registryUrl: `https://github.com/${owner}`,
+ };
+ }
+
+ // Unknown / private registry: never guess a link, just expose the host.
+ return {
+ registryHost: registry,
+ registryLabel: registry,
+ kind: 'other',
+ registryUrl: null,
+ };
+}
+
+function asHttpUrl(value: string | undefined | null): string | null {
+ if (!value) return null;
+ try {
+ const u = new URL(value);
+ return u.protocol === 'http:' || u.protocol === 'https:' ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+function githubRepoRoot(sourceUrl: string): string | null {
+ try {
+ const u = new URL(sourceUrl);
+ if (u.hostname !== 'github.com' && u.hostname !== 'www.github.com') return null;
+ const parts = u.pathname.split('/').filter(Boolean);
+ if (parts.length < 2) return null;
+ const owner = parts[0];
+ const repo = parts[1].replace(/\.git$/, '');
+ return `https://github.com/${owner}/${repo}`;
+ } catch {
+ return null;
+ }
+}
+
+const OCI = {
+ source: 'org.opencontainers.image.source',
+ url: 'org.opencontainers.image.url',
+ documentation: 'org.opencontainers.image.documentation',
+ revision: 'org.opencontainers.image.revision',
+ version: 'org.opencontainers.image.version',
+} as const;
+
+const SHA_LIKE = /^[0-9a-f]{7,40}$/i;
+
+export function extractImageSourceMeta(
+ labels: Record | null | undefined,
+): ImageSourceMeta {
+ const links: ImageSourceLink[] = [];
+ if (!labels) return { links, version: null, revision: null };
+
+ const sourceUrl = asHttpUrl(labels[OCI.source]);
+ if (sourceUrl) links.push({ id: 'source', label: 'Source repository', url: sourceUrl });
+
+ const homepage = asHttpUrl(labels[OCI.url]);
+ if (homepage) links.push({ id: 'url', label: 'Project homepage', url: homepage });
+
+ const docs = asHttpUrl(labels[OCI.documentation]);
+ if (docs) links.push({ id: 'documentation', label: 'Documentation', url: docs });
+
+ const rawRevision = labels[OCI.revision]?.trim() || null;
+ let revisionText: string | null = rawRevision;
+ if (rawRevision && SHA_LIKE.test(rawRevision) && sourceUrl) {
+ const repoRoot = githubRepoRoot(sourceUrl);
+ if (repoRoot) {
+ links.push({
+ id: 'revision',
+ label: `Revision ${rawRevision.slice(0, 12)}`,
+ url: `${repoRoot}/commit/${rawRevision}`,
+ });
+ revisionText = null;
+ }
+ }
+
+ const version = labels[OCI.version]?.trim() || null;
+
+ return { links, version, revision: revisionText };
+}