diff --git a/src/lib/components/VersionUpdateModal.svelte b/src/lib/components/VersionUpdateModal.svelte index 781ecf9..06a9486 100644 --- a/src/lib/components/VersionUpdateModal.svelte +++ b/src/lib/components/VersionUpdateModal.svelte @@ -7,6 +7,7 @@ import { copyToClipboard } from '$lib/utils/clipboard'; import { renderMarkdown } from '$lib/utils/markdown'; import { formatDate } from '$lib/stores/settings'; + import { repoBaseOf } from '$lib/utils/pinned-ref'; import type { NewerVersion } from '$lib/types'; // Minimal shape shared by ContainerInfo and StackContainer - all the modal needs. @@ -93,11 +94,10 @@ } } - // Repo without the tag, e.g. `ghcr.io/owner/app` from `ghcr.io/owner/app:1.2`. - const repoBase = $derived.by(() => { - const img = container?.image ?? ''; - return img.slice(0, img.lastIndexOf(':') > img.lastIndexOf('/') ? img.lastIndexOf(':') : img.length); - }); + // Repo without the tag OR digest, e.g. `ghcr.io/owner/app` from + // `ghcr.io/owner/app:1.2@sha256:...` - a running image is often digest-pinned, + // whose `@sha256:` colon must not be mistaken for the tag separator (#1437). + const repoBase = $derived(repoBaseOf(container?.image ?? '')); async function copyTag() { if (!container || !newerVersion) return; diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 9d8994c..4b2db67 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -19,6 +19,7 @@ import { type DeletionSkipReason } from './git-deletions'; import { isAllowedStackFilename } from './stack-filename'; +import { deriveStackStatus } from './stack-status'; import { getEnvironment, getSecretEnvVarsAsRecord, @@ -110,7 +111,7 @@ export interface ComposeStackInfo { name: string; containers: string[]; containerDetails: ContainerDetail[]; - status: 'running' | 'stopped' | 'partial' | 'created'; + status: 'running' | 'stopped' | 'partial' | 'restarting' | 'created'; sourceType?: StackSourceType; hasComposeFile?: boolean; } @@ -1884,12 +1885,14 @@ export async function listComposeStacks(envId?: number | null): Promise { const stackContainers = containers.filter((c) => containerIds.has(c.id)); const runningCount = stackContainers.filter((c) => c.state === 'running').length; + // A container in a restart loop is 'restarting' - it is NOT stopped (it is actively + // trying to come up), so the stack must expose Stop, not Start (#1438). + const restartingCount = stackContainers.filter((c) => c.state === 'restarting').length; // Containers that exited with code 0 are "completed" (e.g., init/migration containers) // and should not count against stack health const completedCount = stackContainers.filter((c) => c.state === 'exited' && c.exitCode === 0 ).length; - const activeTotal = stackContainers.length - completedCount; const containerDetails: ContainerDetail[] = stackContainers .map((c) => { @@ -1947,14 +1950,12 @@ export async function listComposeStacks(envId?: number | null): Promise pendingUpdateIds.has(c.id)).length, // Newer-version-tag (semver) suggestions in this stack - drives the Tag badge. newerVersionCount: stackContainers.filter((c) => newerVersionIds.has(c.id)).length, - status: - activeTotal === 0 - ? 'stopped' - : runningCount >= activeTotal - ? 'running' - : runningCount === 0 - ? 'stopped' - : 'partial' + status: deriveStackStatus({ + total: stackContainers.length, + running: runningCount, + restarting: restartingCount, + completed: completedCount + }) }; }); diff --git a/src/lib/utils/pinned-ref.ts b/src/lib/utils/pinned-ref.ts index df547b5..19e80df 100644 --- a/src/lib/utils/pinned-ref.ts +++ b/src/lib/utils/pinned-ref.ts @@ -11,13 +11,31 @@ */ /** 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. */ + * is part of a registry `host:port` is not a tag separator. A digest suffix + * (`@sha256:...`) is stripped first so its colon is never mistaken for the tag. */ function splitRepoTag(fullRef: string): { repo: string; tag: string | null } { - const lastColon = fullRef.lastIndexOf(':'); - if (lastColon === -1) return { repo: fullRef, tag: null }; + const ref = stripDigest(fullRef); + const lastColon = ref.lastIndexOf(':'); + if (lastColon === -1) return { repo: ref, 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) }; + if (ref.slice(lastColon + 1).includes('/')) return { repo: ref, tag: null }; + return { repo: ref.slice(0, lastColon), tag: ref.slice(lastColon + 1) }; +} + +/** Drop a `@sha256:...` (or any `@digest`) suffix from an image reference. */ +function stripDigest(ref: string): string { + const at = ref.indexOf('@'); + return at === -1 ? ref : ref.slice(0, at); +} + +/** + * The repo part of a running image reference, WITHOUT its tag or digest. + * `ghcr.io/o/app:1.2@sha256:abc` -> `ghcr.io/o/app`, `nginx:1.25` -> `nginx`. + * A digest-pinned running image is why the naive "cut at the last colon" + * approach breaks: that colon lives inside `@sha256:` (#1437). + */ +export function repoBaseOf(image: string): string { + return splitRepoTag(image).repo; } /** The `sha256:...` part of a `repo@sha256:...` digest, or null. */ diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 0a9d97e..956ef87 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -362,6 +362,7 @@ const stackStatusTypes = [ { value: 'running', label: 'Running', icon: Play, color: 'text-emerald-500' }, { value: 'partial', label: 'Partial', icon: CircleDashed, color: 'text-amber-500' }, + { value: 'restarting', label: 'Restarting', icon: RotateCw, color: 'text-orange-500' }, { value: 'stopped', label: 'Stopped', icon: Square, color: 'text-rose-500' }, { value: 'created', label: 'Created', icon: CircleDashed, color: 'text-slate-500' }, { value: 'not deployed', label: 'Not deployed', icon: Rocket, color: 'text-violet-500' } @@ -1131,6 +1132,8 @@ return `${base} bg-red-200 dark:bg-red-800 text-red-900 dark:text-red-100`; case 'partial': return `${base} bg-amber-200 dark:bg-amber-800 text-amber-900 dark:text-amber-100`; + case 'restarting': + return `${base} bg-orange-200 dark:bg-orange-800 text-orange-900 dark:text-orange-100`; case 'created': return `${base} bg-slate-200 dark:bg-slate-700 text-slate-900 dark:text-slate-100`; case 'not deployed':