diff --git a/src/lib/components/VersionUpdateModal.svelte b/src/lib/components/VersionUpdateModal.svelte index 27744ee..781ecf9 100644 --- a/src/lib/components/VersionUpdateModal.svelte +++ b/src/lib/components/VersionUpdateModal.svelte @@ -1,7 +1,7 @@ { if (!v) close(); }}> @@ -123,6 +156,18 @@ {currentTag} {newerVersion.tag} + @@ -133,19 +178,44 @@ {/if} - - Advisory only. Dockhand never changes the tag in your compose - bump it yourself. - - + + {#if newerVersion.digest} + + {/if} + + {#if versionPath.length > 1}
Path: {currentTag} {#each versionPath as v, i} + {@const isTarget = i === versionPath.length - 1} - {v} + {#if noteFor(v)} + + {:else} + {v} + {/if} {/each}
{/if} @@ -161,7 +231,7 @@ {#each versionPath.slice().reverse() as version} {@const note = noteFor(version)} {#if note} -
+
{note.name || note.githubTag} @@ -187,22 +257,40 @@ {/each} {:else} -
- -

+ {#if rateLimited} +

+
+ +

+ GitHub's rate limit was hit while fetching release notes. Set a + DOCKHAND_GITHUB_TOKEN (a personal access token, no scopes needed) + to raise the limit from 60 to 5000 requests/hour. +

+
{#if changelogUrl} - Release notes for these versions aren't available inline. - {:else} - This image doesn't publish release notes Dockhand can read - (no org.opencontainers.image.source label pointing at a GitHub or Gitea/Forgejo repo). + {/if} -

- {#if changelogUrl} - - {/if} -
+
+ {:else} +
+ +

+ {#if changelogUrl} + Release notes for these versions aren't available inline. + {:else} + This image doesn't publish release notes Dockhand can read + (no org.opencontainers.image.source label pointing at a GitHub or Gitea/Forgejo repo). + {/if} +

+ {#if changelogUrl} + + {/if} +
+ {/if} {/if} @@ -213,14 +301,7 @@ {/if}
- - +
{/if} diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index 23a1a81..b7cfe74 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -8651,6 +8651,9 @@ "source": { "type": "string" }, + "rateLimited": { + "type": "boolean" + }, "notes": { "type": "array", "items": { diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 15707b6..c44ad06 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -24,7 +24,8 @@ import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-r import { parseImageReference } from './registry/image-ref'; export { parseImageReference } from './registry/image-ref'; import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge'; -import { encodeRegistryAuth, fetchRegistryToken } from './registry-auth'; +import { encodeRegistryAuth, fetchRegistryToken, isSafeRegistryHost } from './registry-auth'; +import { classifyManifest, type ArtifactKind } from './semver/manifest-artifact'; import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils'; import { deepDiff } from '../utils/diff.js'; import { getInstanceId } from './backups/identity'; @@ -3129,15 +3130,22 @@ export async function findRegistryCredentials(registryHost: string): Promise<{ u */ async function getRegistryBearerToken(registry: string, repo: string): Promise { try { + const hostSafety = isSafeRegistryHost(registry); + if (!hostSafety.ok) { + console.error(`[Registry] Refusing token request for ${registry}: ${hostSafety.reason}`); + return null; + } const registryUrl = `https://${registry}`; // Look up stored credentials for this registry const credentials = await findRegistryCredentials(registry); // Step 1: Challenge request to /v2/ + // Do not follow redirects on the challenge; a 3xx is treated as a non-401 below. const challengeResponse = await fetch(`${registryUrl}/v2/`, { method: 'GET', - headers: { 'User-Agent': 'Dockhand/1.0' } + headers: { 'User-Agent': 'Dockhand/1.0' }, + redirect: 'manual' }); // If 200, no auth needed @@ -3258,12 +3266,19 @@ export async function getRegistryAuthHeader( try { // Parse URL to extract host (V2 API is always at the host root) const parsed = parseRegistryUrl(registryUrl); + const hostSafety = isSafeRegistryHost(parsed.host); + if (!hostSafety.ok) { + console.error(`[Registry] Refusing auth challenge for ${parsed.host}: ${hostSafety.reason}`); + return null; + } const apiBaseUrl = `${parsed.protocol}://${parsed.host}`; - // Step 1: Challenge request to /v2/ (always at registry root, not under org path) + // Step 1: Challenge request to /v2/ (always at registry root, not under org path). + // Do not follow redirects; a 3xx is treated as a non-401 below. const challengeResponse = await fetch(`${apiBaseUrl}/v2/`, { method: 'GET', - headers: { 'User-Agent': 'Dockhand/1.0' } + headers: { 'User-Agent': 'Dockhand/1.0' }, + redirect: 'manual' }); // If 200, no auth needed @@ -3638,6 +3653,7 @@ export async function harborSearchRepositories( export async function getRegistryManifestDigest(imageName: string): Promise { try { const { registry, repo, tag } = parseImageReference(imageName); + if (!isSafeRegistryHost(registry).ok) return null; const token = await getRegistryBearerToken(registry, repo); const manifestUrl = `https://${registry}/v2/${repo}/manifests/${tag}`; @@ -3679,6 +3695,54 @@ export async function getRegistryManifestDigest(imageName: string): Promise { + try { + if (!isSafeRegistryHost(registry).ok) return { kind: 'image', digest: null }; + const token = await getRegistryBearerToken(registry, repo); + const headers: Record = { + 'User-Agent': 'Dockhand/1.0', + 'Accept': [ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.v2+json' + ].join(', ') + }; + if (token) headers['Authorization'] = token; + + const res = await fetch(`https://${registry}/v2/${repo}/manifests/${tag}`, { + method: 'GET', + headers, + signal: AbortSignal.timeout(8000) + }); + if (!res.ok) { + await drainResponse(res); + return { kind: 'image', digest: null }; + } + const digest = res.headers.get('Docker-Content-Digest'); + const topMediaType = res.headers.get('Content-Type'); + const body = (await res.json().catch(() => null)) as + | { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] } + | null; + return { kind: classifyManifest(body, topMediaType), digest }; + } catch { + return { kind: 'image', digest: null }; + } +} + /** * A multi-arch tag is a manifest list / OCI image index: the list has one digest, * and each per-architecture child manifest has its own. `docker pull` usually records @@ -3699,6 +3763,7 @@ async function localDigestMatchesRegistryChild( ): Promise { try { const { registry, repo, tag } = parseImageReference(imageName); + if (!isSafeRegistryHost(registry).ok) return false; const token = await getRegistryBearerToken(registry, repo); const headers: Record = { 'User-Agent': 'Dockhand/1.0', diff --git a/src/lib/server/scheduler/tasks/env-update-check.ts b/src/lib/server/scheduler/tasks/env-update-check.ts index 047eae1..05da2c4 100644 --- a/src/lib/server/scheduler/tasks/env-update-check.ts +++ b/src/lib/server/scheduler/tasks/env-update-check.ts @@ -32,6 +32,7 @@ import { removeTempImage, tagImage, inspectImage, + getTagArtifactKind, } from '../../docker'; import type { ImageEnvLabels } from '../../container-env-merge'; import { sendEventNotification } from '../../notifications'; @@ -227,7 +228,7 @@ export async function runEnvUpdateCheckJob( // A `dockhand.version.pattern` label lets a container teach the check // how to read its own non-standard tags (CalVer+hash, etc.). const versionPattern = getVersionPatternOverride(inspectData.Config?.Labels); - const newer = await checkNewerVersion(imageName, { ...semverOptions, versionPattern }).catch(() => null); + const newer = await checkNewerVersion(imageName, { ...semverOptions, versionPattern }, getTagArtifactKind).catch(() => null); if (newer) { semverByContainer.set(container.id, newer); await log(` NEWER VERSION: ${newer.tag} (${newer.bump})`); diff --git a/src/lib/server/semver/check.ts b/src/lib/server/semver/check.ts index 0de744b..ecc0f76 100644 --- a/src/lib/server/semver/check.ts +++ b/src/lib/server/semver/check.ts @@ -6,18 +6,33 @@ import { parseImageReference } from '../registry/image-ref'; import { parseTag } from './tag-parser'; import { listVersionTags } from './tag-source'; -import { findNewerVersionTag, type FindNewerOptions, type NewerVersion } from './find-newer'; +import { findNewerVersionTag, findNewerImageTag, type FindNewerOptions, type NewerVersion } from './find-newer'; +import type { ArtifactKind } from './manifest-artifact'; + +/** Probe a single tag's artifact kind + manifest digest. Injected so check.ts stays unit-testable. */ +export type TagKindProbe = ( + registry: string, + repo: string, + tag: string +) => Promise<{ kind: ArtifactKind; digest: string | null }>; /** * Returns the newer-version suggestion for `imageRef`, or null when the current * tag isn't a version (floating) or nothing newer is published. Never throws — * a registry failure yields null, so the update check degrades gracefully. + * + * When `probeTagKind` is supplied, the chosen candidate is verified to be a real + * container image, not a Helm chart / other OCI artifact published into the same + * repo (monorepos do this). A non-image target is dropped and the next-highest + * version is tried. Only the candidate is probed. A probe failure is treated as + * "is an image" (fail-open) so a registry hiccup never hides a real update. */ export async function checkNewerVersion( imageRef: string, - options: FindNewerOptions = {} + options: FindNewerOptions = {}, + probeTagKind?: TagKindProbe ): Promise { - const { tag } = parseImageReference(imageRef); + const { registry, repo, tag } = parseImageReference(imageRef); // Floating tag -> nothing to compare, and we skip the registry call entirely. // Parse with the same override find-newer will use, so a custom-scheme tag @@ -25,5 +40,20 @@ export async function checkNewerVersion( if (!parseTag(tag, options.versionPattern ?? null)) return null; const tags = await listVersionTags(imageRef); - return findNewerVersionTag(tag, tags, options); + + if (!probeTagKind) return findNewerVersionTag(tag, tags, options); + + return findNewerImageTag( + tag, + tags, + async (candidate) => { + try { + const { kind, digest } = await probeTagKind(registry, repo, candidate); + return { ok: kind === 'image', digest }; + } catch { + return { ok: true }; // fail-open: never hide a real update on a probe error. + } + }, + options + ); } diff --git a/src/lib/server/semver/find-newer.ts b/src/lib/server/semver/find-newer.ts index 4b986bc..d8bc671 100644 --- a/src/lib/server/semver/find-newer.ts +++ b/src/lib/server/semver/find-newer.ts @@ -40,6 +40,8 @@ export interface NewerVersion { bump: VersionBump; /** Every newer version between current and target (exclusive of current, inclusive of target), ordered. */ skipped: string[]; + /** The target tag's manifest digest (`sha256:...`), when probed. Lets the UI show/copy the new tag digest-pinned. */ + digest?: string; } /** Which segment first differs decides the bump: [0]=major, [1]=minor, else patch. */ @@ -122,3 +124,35 @@ export function findNewerVersionTag( skipped: deduped.map((c) => c.tag) }; } + +/** + * Like findNewerVersionTag, but verifies the chosen target is a real container + * image before offering it - dropping any tag that turns out to be a Helm chart or + * other non-image OCI artifact published into the same repo, and re-picking the + * next-highest version. `probe` returns `{ ok, digest }` where ok=true means a + * runnable image; the digest (when present) is attached to the result so the UI can + * offer the new tag digest-pinned. Async + injected so this stays pure/testable. + * + * Only the chosen candidate is probed, not every tag. `maxSkips` bounds the extra + * probes so a repo full of artifacts can't fan out unbounded. `probe` failures + * should resolve to `{ ok: true }` (fail-open) at the call site so a probe error + * never hides a real update. + */ +export async function findNewerImageTag( + currentTag: string, + allTags: string[], + probe: (tag: string) => Promise<{ ok: boolean; digest?: string | null }>, + options: FindNewerOptions = {}, + maxSkips = 5 +): Promise { + const excluded = new Set(); + for (let i = 0; i <= maxSkips; i++) { + const pool = excluded.size ? allTags.filter((t) => !excluded.has(t)) : allTags; + const newer = findNewerVersionTag(currentTag, pool, options); + if (!newer) return null; + const { ok, digest } = await probe(newer.tag); + if (ok) return digest ? { ...newer, digest } : newer; + excluded.add(newer.tag); + } + return null; +} diff --git a/src/lib/server/semver/manifest-artifact.ts b/src/lib/server/semver/manifest-artifact.ts new file mode 100644 index 0000000..629f43c --- /dev/null +++ b/src/lib/server/semver/manifest-artifact.ts @@ -0,0 +1,75 @@ +/** + * Classify a registry manifest as a runnable container image vs a non-image OCI + * artifact (Helm chart, SBOM, signature, ...). Monorepos increasingly publish + * Helm charts into the SAME repo as their images (e.g. `maximhq/bifrost` ships + * chart tag `2.1.29` alongside image tag `v1.6.11`), so the semver check must not + * offer a chart tag as a "newer version" of an image. + * + * Pure: takes the already-parsed manifest JSON, no I/O. The fetch lives in + * docker.ts; this just reads the media types. + */ + +export type ArtifactKind = 'image' | 'chart' | 'other'; + +/** config.mediaType values that mean "this is a real container image config". */ +const IMAGE_CONFIG_TYPES = new Set([ + 'application/vnd.oci.image.config.v1+json', + 'application/vnd.docker.container.image.v1+json' +]); + +/** Helm's OCI chart config media type. */ +const HELM_CONFIG_TYPE = 'application/vnd.cncf.helm.config.v1+json'; + +/** Manifest LIST / image INDEX media types - a multi-arch image points here. */ +const INDEX_TYPES = new Set([ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json' +]); + +/** Single-image manifest media types. */ +const IMAGE_MANIFEST_TYPES = new Set([ + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.v2+json' +]); + +/** + * Classify a manifest body (parsed JSON). `topMediaType` is the manifest's own + * media type when known (from the response Content-Type or a `mediaType` field); + * an image index has no `config`, so it is recognized by that type alone. + * + * - A manifest INDEX / manifest LIST is always a multi-arch IMAGE. + * - Otherwise the `config.mediaType` decides: Helm config -> chart, image config + * -> image, anything else -> other (SBOM, signature, unknown artifact). + * - Unknown / missing info defaults to 'image' (fail-open: never hide a real + * update just because a registry omitted a field). + */ +export function classifyManifest( + manifest: { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] } | null | undefined, + topMediaType?: string | null +): ArtifactKind { + if (!manifest) return 'image'; + + const top = manifest.mediaType || topMediaType || ''; + if (INDEX_TYPES.has(top) || Array.isArray(manifest.manifests)) return 'image'; + + const cfg = manifest.config?.mediaType; + if (cfg === HELM_CONFIG_TYPE) return 'chart'; + if (cfg && IMAGE_CONFIG_TYPES.has(cfg)) return 'image'; + + // A recognized single-image manifest type with no/other config is still an image. + if (IMAGE_MANIFEST_TYPES.has(top)) return 'image'; + + // A config media type we don't recognize, on a non-index manifest, is a + // non-image artifact (chart variants, SBOM, cosign signature, ...). + if (cfg) return 'other'; + + return 'image'; +} + +/** True only for a runnable container image (multi-arch index or single image). */ +export function isRunnableImage( + manifest: { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] } | null | undefined, + topMediaType?: string | null +): boolean { + return classifyManifest(manifest, topMediaType) === 'image'; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 2dbf7c3..85da203 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -10,6 +10,8 @@ export interface NewerVersion { tag: string; bump: 'major' | 'minor' | 'patch'; skipped: string[]; + /** The target tag's manifest digest (`sha256:...`), when known. Lets the UI copy the new tag digest-pinned. */ + digest?: string; } export interface ContainerInfo { diff --git a/src/lib/utils/changelog-url.ts b/src/lib/utils/changelog-url.ts index c284c5f..fe63e58 100644 --- a/src/lib/utils/changelog-url.ts +++ b/src/lib/utils/changelog-url.ts @@ -5,6 +5,10 @@ * 1. `dockhand.changelog.url` label — explicit override set by the image * author or at runtime via `--label dockhand.changelog.url=…`. Wins over * everything. Pattern matches the existing dockhand.* label convention. + * May contain `{{version}}` / `{{tag}}` placeholders (see interpolation + * below) to build a per-version link (e.g. a monorepo release stream like + * `.../releases/tag/transports/{{version}}`); a plain URL with no + * placeholders is returned verbatim, unchanged from before. * 2. `org.opencontainers.image.source` label — the OCI standard. When it * points at github.com the canonical changelog page is `/releases`. * 3. GHCR images — `ghcr.io//` is always the same as @@ -20,6 +24,18 @@ const GITHUB_HOST = 'github.com'; const GHCR_PREFIX = 'ghcr.io/'; +/** + * Substitute `{{version}}` / `{{tag}}` (case-insensitive, whitespace-tolerant) + * in a changelog-url override. Backward-compatible: a URL with NO placeholder is + * returned unchanged, and if `version` is not supplied (e.g. the generic + * "changelog" link on the container/stack list, which has no target version) the + * template is left as-is so callers can decide - the plain-URL case is unaffected. + */ +export function interpolateChangelogUrl(url: string, version?: string | null): string { + if (!version || !url.includes('{{')) return url; + return url.replace(/\{\{\s*(version|tag)\s*\}\}/gi, version); +} + function stripTrailingSlash(s: string): string { return s.endsWith('/') ? s.slice(0, -1) : s; } @@ -38,12 +54,13 @@ function stripImageTag(image: string): string { export function resolveChangelogUrl( imageName: string | null | undefined, - labels?: Record | null + labels?: Record | null, + version?: string | null ): string | null { if (!imageName) return null; const override = labels?.['dockhand.changelog.url']; - if (override && override.trim()) return override.trim(); + if (override && override.trim()) return interpolateChangelogUrl(override.trim(), version); const source = labels?.['org.opencontainers.image.source']; if (source && source.includes(GITHUB_HOST)) { diff --git a/src/lib/utils/pinned-ref.ts b/src/lib/utils/pinned-ref.ts new file mode 100644 index 0000000..df547b5 --- /dev/null +++ b/src/lib/utils/pinned-ref.ts @@ -0,0 +1,68 @@ +/** + * Build a digest-pinned image reference from a `repo:tag` and the image's + * RepoDigests, for the "copy pinned reference" action. A supply-chain-hardened + * reference keeps the human-readable tag AND pins the immutable digest: + * + * nginx:1.25 + ["nginx@sha256:abc..."] -> nginx:1.25@sha256:abc... + * + * Pure, no I/O - unit-tested. Returns null when no digest matches the repo (an + * image built locally and never pushed has no RepoDigests), so the caller can + * hide/disable the button instead of copying a broken ref. + */ + +/** Split `repo:tag` (or a bare `repo`) into its repo and tag parts. A colon that + * is part of a registry `host:port` is not a tag separator. */ +function splitRepoTag(fullRef: string): { repo: string; tag: string | null } { + const lastColon = fullRef.lastIndexOf(':'); + if (lastColon === -1) return { repo: fullRef, tag: null }; + // A tag never contains a slash; `host:port/repo` has the slash AFTER the colon. + if (fullRef.slice(lastColon + 1).includes('/')) return { repo: fullRef, tag: null }; + return { repo: fullRef.slice(0, lastColon), tag: fullRef.slice(lastColon + 1) }; +} + +/** The `sha256:...` part of a `repo@sha256:...` digest, or null. */ +function digestOf(repoDigest: string): string | null { + const at = repoDigest.indexOf('@'); + return at > 0 ? repoDigest.slice(at + 1) : null; +} + +/** + * Return `repo:tag@sha256:...` for `fullRef`, picking the digest whose repo + * matches. Falls back to the single available digest when there is exactly one + * (common case: one repo, one digest). Returns null if no usable digest exists. + */ +export function buildPinnedRef(fullRef: string, repoDigests: string[] | undefined | null): string | null { + if (!repoDigests || repoDigests.length === 0) return null; + const { repo, tag } = splitRepoTag(fullRef); + + // Prefer the digest published for the SAME repo as this tag (an image can carry + // digests for several repo names when it was tagged/pushed under more than one). + const match = + repoDigests.find((d) => d.slice(0, d.indexOf('@')) === repo) ?? + (repoDigests.length === 1 ? repoDigests[0] : null); + if (!match) return null; + + const digest = digestOf(match); + if (!digest) return null; + + return tag ? `${repo}:${tag}@${digest}` : `${repo}@${digest}`; +} + +/** + * The manifest digest (`sha256:...`) for `fullRef`, shortened for display, or null. + * Same matching rules as buildPinnedRef. `chars` counts hex digits shown after the + * `sha256:` prefix. + */ +export function shortDigest( + fullRef: string, + repoDigests: string[] | undefined | null, + chars = 12 +): string | null { + const pinned = buildPinnedRef(fullRef, repoDigests); + if (!pinned) return null; + const at = pinned.lastIndexOf('@'); + const digest = pinned.slice(at + 1); // sha256:.... + const colon = digest.indexOf(':'); + if (colon === -1) return digest.slice(0, chars); + return `${digest.slice(0, colon + 1)}${digest.slice(colon + 1, colon + 1 + chars)}`; +} diff --git a/src/routes/api/containers/check-updates/+server.ts b/src/routes/api/containers/check-updates/+server.ts index 7077826..c61ffd7 100644 --- a/src/routes/api/containers/check-updates/+server.ts +++ b/src/routes/api/containers/check-updates/+server.ts @@ -1,7 +1,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { authorize } from '$lib/server/authorize'; -import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker'; +import { listContainers, inspectContainer, checkImageUpdateAvailable, getTagArtifactKind } from '$lib/server/docker'; import { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates, getGlobalSemverConfig } from '$lib/server/db'; import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils'; import { isUpdateDisabledByLabel, isHiddenByLabel, getVersionPatternOverride } from '$lib/server/container-labels'; @@ -156,7 +156,7 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => { ? await checkNewerVersion(imageName, { ...semverOptions, versionPattern: getVersionPatternOverride(inspectData.Config?.Labels) - }).catch(() => null) + }, getTagArtifactKind).catch(() => null) : null; return { diff --git a/src/routes/images/+page.svelte b/src/routes/images/+page.svelte index ea87655..ecfdb94 100644 --- a/src/routes/images/+page.svelte +++ b/src/routes/images/+page.svelte @@ -25,6 +25,7 @@ import { formatBytes } from '$lib/utils/format'; import * as Tooltip from '$lib/components/ui/tooltip'; import { copyToClipboard } from '$lib/utils/clipboard'; + import { buildPinnedRef, shortDigest } from '$lib/utils/pinned-ref'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import BatchOperationModal from '$lib/components/BatchOperationModal.svelte'; import ImageHistoryModal from './ImageHistoryModal.svelte'; @@ -63,6 +64,10 @@ tag: string; fullRef: string; imageId: string; + /** `repo:tag@sha256:...` for the "copy pinned reference" action, or null if the image has no digest. */ + pinnedRef: string | null; + /** Shortened `sha256:...` shown next to the tag, or null. */ + digestShort: string | null; size: number; created: number; containers: number; @@ -354,6 +359,16 @@ } } + // Copy digest-pinned reference (repo:tag@sha256:...). + let copiedPinned = $state(null); + async function copyPinnedRef(pinnedRef: string) { + const ok = await copyToClipboard(pinnedRef); + if (ok) { + copiedPinned = pinnedRef; + pendingTimeouts.push(setTimeout(() => copiedPinned = null, 2000)); + } + } + // Export state let exportingId = $state(null); @@ -414,6 +429,8 @@ tag: repoName === '' ? image.id.slice(7, 19) : '', fullRef: image.id, imageId: image.id, + pinnedRef: buildPinnedRef(repoName, image.repoDigests), + digestShort: shortDigest(repoName, image.repoDigests), size: image.size, created: image.created, containers: image.containers @@ -446,6 +463,8 @@ tag: tagPart, fullRef: fullTag, imageId: image.id, + pinnedRef: buildPinnedRef(fullTag, image.repoDigests), + digestShort: shortDigest(fullTag, image.repoDigests), size: image.size, created: image.created, containers: image.containers @@ -1388,6 +1407,22 @@
{tagInfo.tag} + {#if tagInfo.digestShort && tagInfo.pinnedRef} + + {/if}
{:else if column.id === 'id'}