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({
+ + + + + {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 }; +}