detect newer version tags for pinned images and show them as a badge with release notes

This commit is contained in:
Jarek Krochmalski committed 2026-08-22 09:09:41 +02:00
1 parent 9977476b15
commit a760680b8d
4 files changed
+42 -20

No files matched your search

+5 -5
View File
@@ -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;
+11 -10
View File
@@ -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<ComposeS
const result: ComposeStackInfo[] = Array.from(stacks.entries()).map(([name, containerIds]) => {
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<ComposeS
updateCount: stackContainers.filter((c) => 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
})
};
});
+23 -5
View File
@@ -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. */
+3
View File
@@ -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':