detect newer version tags for pinned images and show them as a badge with release notes
This commit is contained in:
36 files changed
+8820
-98
No files matched your search
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "pending_container_updates" ADD COLUMN "has_image_update" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "pending_container_updates" ADD COLUMN "newer_version" text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -78,6 +78,13 @@
|
||||
"when": 1783800723008,
|
||||
"tag": "0010_add_secret_providers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1786892847678,
|
||||
"tag": "0011_pending_updates_semver",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `pending_container_updates` ADD `has_image_update` integer DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `pending_container_updates` ADD `newer_version` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -78,6 +78,13 @@
|
||||
"when": 1783800723008,
|
||||
"tag": "0010_add_secret_providers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "6",
|
||||
"when": 1786892843482,
|
||||
"tag": "0011_pending_updates_semver",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import type { NewerVersion } from '$lib/types';
|
||||
|
||||
export interface UpdateCheckResultItem {
|
||||
containerId: string;
|
||||
@@ -12,6 +13,12 @@
|
||||
imageName: string;
|
||||
}
|
||||
|
||||
/** A container that has a newer VERSION tag (semver) — independent of digest updates. */
|
||||
export interface NewerVersionItem {
|
||||
containerId: string;
|
||||
newerVersion: NewerVersion;
|
||||
}
|
||||
|
||||
export interface FailedCheckItem {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
@@ -24,7 +31,11 @@
|
||||
/** When true and no check is running, the button reflects the "updates found" (green) state — e.g. from persisted pending updates loaded on mount. */
|
||||
hasPendingUpdates?: boolean;
|
||||
/** Called when a check finishes with the containers that have updates and any failures. */
|
||||
onComplete?: (result: { withUpdates: UpdateCheckResultItem[]; failed: FailedCheckItem[] }) => void;
|
||||
onComplete?: (result: {
|
||||
withUpdates: UpdateCheckResultItem[];
|
||||
failed: FailedCheckItem[];
|
||||
newerVersions: NewerVersionItem[];
|
||||
}) => void;
|
||||
}
|
||||
|
||||
let { envId, hasPendingUpdates = false, onComplete }: Props = $props();
|
||||
@@ -119,8 +130,19 @@
|
||||
const failed: FailedCheckItem[] = data.results
|
||||
.filter((r: any) => r.error && !r.hasUpdate)
|
||||
.map((r: any) => ({ containerId: r.containerId, containerName: r.containerName, imageName: r.imageName, error: r.error }));
|
||||
// Semver suggestions are independent of digest updates: a container can be
|
||||
// digest-current yet run an outdated version tag.
|
||||
const newerVersions: NewerVersionItem[] = data.results
|
||||
.filter((r: any) => r.newerVersion)
|
||||
.map((r: any) => ({ containerId: r.containerId, newerVersion: r.newerVersion }));
|
||||
|
||||
if (withUpdates.length === 0) {
|
||||
// A newer version tag is a real result even when no digest update exists,
|
||||
// so it counts toward the "found" state and the summary message.
|
||||
const semverNote = newerVersions.length > 0
|
||||
? `${newerVersions.length} newer version tag${newerVersions.length !== 1 ? 's' : ''}`
|
||||
: '';
|
||||
|
||||
if (withUpdates.length === 0 && newerVersions.length === 0) {
|
||||
// Keep the "Latest" status until re-check / env-switch — don't auto-revert (#1019)
|
||||
status = 'none';
|
||||
if (failed.length > 0) {
|
||||
@@ -130,14 +152,19 @@
|
||||
}
|
||||
} else {
|
||||
status = 'found';
|
||||
const parts = [
|
||||
withUpdates.length > 0 ? `${withUpdates.length} update${withUpdates.length !== 1 ? 's' : ''} available` : '',
|
||||
semverNote
|
||||
].filter(Boolean);
|
||||
const summary = parts.join(', ');
|
||||
if (failed.length > 0) {
|
||||
showFailedChecksToast(failed, `${withUpdates.length} update(s) available`);
|
||||
showFailedChecksToast(failed, summary);
|
||||
} else {
|
||||
toast.info(`${withUpdates.length} update(s) available`);
|
||||
toast.info(summary);
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.({ withUpdates, failed });
|
||||
onComplete?.({ withUpdates, failed, newerVersions });
|
||||
} catch {
|
||||
failError();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Tag, Info } from 'lucide-svelte';
|
||||
|
||||
type Bump = 'patch' | 'minor' | 'major';
|
||||
|
||||
interface Props {
|
||||
/** Master toggle: also detect newer version tags for pinned images. */
|
||||
enabled: boolean;
|
||||
/** Cap the surfaced bump. */
|
||||
maxBump: Bump;
|
||||
/** Require the same tag suffix (flavor) as the running tag. */
|
||||
matchFlavor: boolean;
|
||||
/** Consider -rc/-beta prerelease tags. */
|
||||
includePrerelease: boolean;
|
||||
/**
|
||||
* When set, the current tag is floating (latest/stable/sha) so there is no
|
||||
* version to compare — the toggle is disabled and this tag is shown in the hint.
|
||||
* Container scope passes it; env scope leaves it undefined.
|
||||
*/
|
||||
floatingTag?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
enabled = $bindable(),
|
||||
maxBump = $bindable(),
|
||||
matchFlavor = $bindable(),
|
||||
includePrerelease = $bindable(),
|
||||
floatingTag
|
||||
}: Props = $props();
|
||||
|
||||
const isFloating = $derived(!!floatingTag);
|
||||
const bumps: Bump[] = ['patch', 'minor', 'major'];
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-2" class:opacity-55={isFloating}>
|
||||
<Tag class="w-4 h-4 text-green-500 glow-green mt-0.5 shrink-0" />
|
||||
<div class="flex-1">
|
||||
<Label>Check for newer version tags</Label>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
For a container pinned to a version tag (e.g. <code>16.2</code>, <code>v3.0</code>), also detect
|
||||
when a newer version is published. Advisory only.
|
||||
</p>
|
||||
</div>
|
||||
<TogglePill bind:checked={enabled} disabled={isFloating} />
|
||||
</div>
|
||||
|
||||
{#if isFloating}
|
||||
<div class="ml-6 flex items-start gap-2 rounded-md bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
<Info class="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span><code>{floatingTag}</code> is a floating tag — pin a version tag (e.g. <code>1.26</code>) to enable newer-version detection.</span>
|
||||
</div>
|
||||
{:else if enabled}
|
||||
<!-- Indented under the header so it reads as "all this belongs to the toggle
|
||||
above"; the boxes inside are full-width of THIS container so they line up. -->
|
||||
<div class="ml-6 space-y-4">
|
||||
<!-- max bump -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-4">
|
||||
<Label class="flex-1">Which updates to show</Label>
|
||||
<div class="inline-flex shrink-0 gap-0.5 rounded-lg border border-border bg-muted/60 p-0.5">
|
||||
{#each bumps as bump}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (maxBump = bump)}
|
||||
class="rounded-md px-3.5 py-1.5 text-xs font-semibold capitalize transition-colors
|
||||
{maxBump === bump
|
||||
? 'bg-green-500/15 text-green-500 shadow-[inset_0_0_0_1px_rgba(34,197,94,0.3)]'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
>
|
||||
{bump}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border bg-muted/40 p-2.5 text-[11.5px] leading-relaxed text-muted-foreground">
|
||||
Running <code>1.4.2</code>, you'll be told about:<br />
|
||||
<span class="font-bold text-foreground">Patch</span> bug-fix only → <code>1.4.3</code><br />
|
||||
<span class="font-bold text-foreground">Minor</span> new features too → <code>1.4.3</code>, <code>1.5.0</code><br />
|
||||
<span class="font-bold text-foreground">Major</span> everything, incl. breaking → <code>1.4.3</code>, <code>1.5.0</code>, <code>2.0.0</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- match flavor -->
|
||||
<div class="space-y-2 border-t border-border/60 pt-3">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex-1">
|
||||
<Label>Match the tag flavor</Label>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
Only suggest tags with the same suffix as the one you run — the flavor, like
|
||||
<code>-alpine</code> or <code>-ls123</code>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0"><TogglePill bind:checked={matchFlavor} /></div>
|
||||
</div>
|
||||
<div class="rounded-md border border-border bg-muted/40 p-2.5 text-[11.5px] leading-relaxed text-muted-foreground">
|
||||
<span class="font-bold text-foreground">On</span> <code>1.2-alpine</code> → <code>1.5-alpine</code>, never bare <code>1.5</code><br />
|
||||
<span class="font-bold text-foreground">Off</span> any newer version, even a different flavor — noisier
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- include prereleases -->
|
||||
<div class="flex items-start gap-4 border-t border-border/60 pt-3">
|
||||
<div class="flex-1">
|
||||
<Label>Include prereleases</Label>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
Consider <code>-rc</code> / <code>-beta</code> tags. Off keeps a stable deployment on
|
||||
stable releases.
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0"><TogglePill bind:checked={includePrerelease} /></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if enabled && !isFloating}
|
||||
<div class="ml-6 flex items-start gap-2 rounded-md bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
<Info class="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span>A newer version shows as a badge on the container — it is <strong class="text-foreground font-semibold">never auto-applied</strong>. Bump the tag in your compose yourself.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
code {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
background: hsl(var(--muted));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { Tag } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import type { NewerVersion } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** The newer-version suggestion for this container (target tag + how many were skipped). */
|
||||
newerVersion: NewerVersion;
|
||||
/** Called when the badge is clicked — opens the release-notes modal. */
|
||||
onclick?: () => void;
|
||||
/** Compact icon-only marker for the image icon-row (default), or a full pill with the tag. */
|
||||
variant?: 'icon' | 'pill';
|
||||
}
|
||||
|
||||
let { newerVersion, onclick, variant = 'icon' }: Props = $props();
|
||||
|
||||
// How many versions behind the running tag is (skipped is inclusive of the target).
|
||||
const behind = $derived(newerVersion.skipped.length);
|
||||
const bumpColor = $derived(
|
||||
newerVersion.bump === 'major'
|
||||
? 'text-red-400'
|
||||
: newerVersion.bump === 'minor'
|
||||
? 'text-amber-400'
|
||||
: 'text-indigo-400'
|
||||
);
|
||||
</script>
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onclick?.();
|
||||
}}
|
||||
class="shrink-0 inline-flex items-center gap-1 self-center text-amber-500 hover:text-amber-400 transition-colors cursor-pointer
|
||||
{variant === 'pill'
|
||||
? 'rounded-full bg-amber-500/10 hover:bg-amber-500/20 px-1.5 py-0.5 text-2xs font-medium'
|
||||
: ''}"
|
||||
>
|
||||
<Tag class="w-3 h-3 shrink-0" />
|
||||
{#if variant === 'pill'}
|
||||
<span>{newerVersion.tag}</span>
|
||||
{/if}
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="right" class="w-64 p-3">
|
||||
<div class="space-y-1.5">
|
||||
<p class="font-medium text-sm flex items-center gap-1.5">
|
||||
<Tag class="w-4 h-4 text-amber-500 shrink-0" />
|
||||
Newer version available
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
<span class="text-muted-foreground">Move to</span>
|
||||
<code class="mx-0.5 rounded bg-muted px-1 py-0.5 font-mono">{newerVersion.tag}</code>
|
||||
<span class="font-semibold uppercase {bumpColor}">{newerVersion.bump}</span>
|
||||
</p>
|
||||
{#if behind > 1}
|
||||
<p class="text-xs text-muted-foreground">{behind} versions ahead of the tag you run.</p>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">Click for release notes. Never auto-applied.</p>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Box, ArrowRight, ExternalLink, Copy, Check, RefreshCw, ChevronDown, BookOpen } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import { renderMarkdown } from '$lib/utils/markdown';
|
||||
import { formatDate } from '$lib/stores/settings';
|
||||
import type { NewerVersion } from '$lib/types';
|
||||
|
||||
// Minimal shape shared by ContainerInfo and StackContainer - all the modal needs.
|
||||
type ModalContainer = { id: string; name: string; image: string };
|
||||
|
||||
interface ReleaseNote {
|
||||
version: string;
|
||||
name: string;
|
||||
githubTag: string;
|
||||
body: string;
|
||||
publishedAt: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The container this modal is about; null closes the modal. */
|
||||
container: ModalContainer | null;
|
||||
/** The newer-version suggestion (target tag + skipped versions). */
|
||||
newerVersion: NewerVersion | null;
|
||||
envId: number | null;
|
||||
}
|
||||
|
||||
let { container = $bindable(), newerVersion, envId }: Props = $props();
|
||||
|
||||
const open = $derived(!!container && !!newerVersion);
|
||||
|
||||
// The current tag = the part after the last ':' of the image reference.
|
||||
const currentTag = $derived.by(() => {
|
||||
const img = container?.image ?? '';
|
||||
const colon = img.lastIndexOf(':');
|
||||
return colon > img.lastIndexOf('/') ? img.slice(colon + 1) : 'latest';
|
||||
});
|
||||
|
||||
const bumpColor = $derived(
|
||||
newerVersion?.bump === 'major'
|
||||
? 'text-red-400 border-red-400/40 bg-red-400/10'
|
||||
: newerVersion?.bump === 'minor'
|
||||
? 'text-amber-400 border-amber-400/40 bg-amber-400/10'
|
||||
: 'text-amber-400 border-amber-400/40 bg-amber-400/10'
|
||||
);
|
||||
|
||||
// skipped is ordered current-exclusive .. target-inclusive.
|
||||
const versionPath = $derived(newerVersion?.skipped ?? []);
|
||||
|
||||
let loading = $state(false);
|
||||
let notes = $state<ReleaseNote[]>([]);
|
||||
let changelogUrl = $state<string | null>(null);
|
||||
let source = $state<string | null>(null);
|
||||
let loadedFor = $state<string | null>(null);
|
||||
let copied = $state(false);
|
||||
|
||||
// Fetch notes when the modal opens for a (container, target) pair we haven't loaded.
|
||||
$effect(() => {
|
||||
if (!open || !container || !newerVersion) return;
|
||||
const key = `${container.id}:${newerVersion.tag}`;
|
||||
if (loadedFor === key) return;
|
||||
loadedFor = key;
|
||||
void loadNotes(container.id, versionPath);
|
||||
});
|
||||
|
||||
async function loadNotes(containerId: string, versions: string[]) {
|
||||
loading = true;
|
||||
notes = [];
|
||||
changelogUrl = null;
|
||||
source = null;
|
||||
try {
|
||||
const qs = `versions=${encodeURIComponent(versions.join(','))}`;
|
||||
const res = await fetch(
|
||||
appendEnvParam(`/api/containers/${containerId}/version-notes?${qs}`, envId)
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
notes = data.notes ?? [];
|
||||
changelogUrl = data.changelogUrl ?? null;
|
||||
source = data.source ?? null;
|
||||
}
|
||||
} catch {
|
||||
// leave notes empty -> the "no notes" fallback renders
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTag() {
|
||||
if (!container || !newerVersion) return;
|
||||
const img = container.image;
|
||||
const base = img.slice(0, img.lastIndexOf(':') > img.lastIndexOf('/') ? img.lastIndexOf(':') : img.length);
|
||||
const ok = await copyToClipboard(`${base}:${newerVersion.tag}`);
|
||||
if (ok) {
|
||||
copied = true;
|
||||
toast.success('New image tag copied');
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
container = null;
|
||||
}
|
||||
|
||||
// Note for a given version path entry, if GitHub had one.
|
||||
function noteFor(version: string): ReleaseNote | undefined {
|
||||
return notes.find((n) => n.version === version);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root {open} onOpenChange={(v) => { if (!v) close(); }}>
|
||||
<Dialog.Content class="max-w-3xl max-h-[90vh] flex flex-col !animate-none">
|
||||
{#if container && newerVersion}
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2.5 flex-wrap pr-8">
|
||||
<Box class="w-5 h-5 text-muted-foreground shrink-0" />
|
||||
<span class="font-semibold">{container.name}</span>
|
||||
<span class="flex items-center gap-2 font-mono text-sm">
|
||||
<span class="text-muted-foreground font-normal">{currentTag}</span>
|
||||
<ArrowRight class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span class="font-semibold text-amber-400">{newerVersion.tag}</span>
|
||||
</span>
|
||||
<span class="ml-auto flex items-center gap-2">
|
||||
<span class="rounded-full border px-2 py-0.5 text-2xs font-semibold uppercase {bumpColor}">
|
||||
{newerVersion.bump}
|
||||
</span>
|
||||
{#if versionPath.length > 1}
|
||||
<span class="text-xs font-normal text-muted-foreground">{versionPath.length} versions ahead</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Advisory only. Dockhand never changes the tag in your compose - bump it yourself.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Version path -->
|
||||
{#if versionPath.length > 1}
|
||||
<div class="flex items-center gap-1.5 flex-wrap text-xs px-1">
|
||||
<span class="text-muted-foreground">Path:</span>
|
||||
<span class="font-mono text-muted-foreground">{currentTag}</span>
|
||||
{#each versionPath as v, i}
|
||||
<ArrowRight class="w-3 h-3 text-muted-foreground/60 shrink-0" />
|
||||
<span class="font-mono {i === versionPath.length - 1 ? 'text-amber-400 font-semibold' : 'text-foreground'}">{v}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Release notes -->
|
||||
<div class="flex-1 overflow-auto pr-1 space-y-2 min-h-[120px]">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<RefreshCw class="w-5 h-5 animate-spin" />
|
||||
<span class="ml-2 text-sm">Loading release notes...</span>
|
||||
</div>
|
||||
{:else if notes.length > 0}
|
||||
{#each versionPath.slice().reverse() as version}
|
||||
{@const note = noteFor(version)}
|
||||
{#if note}
|
||||
<details class="group rounded-md border border-border bg-muted/30 [&_summary::-webkit-details-marker]:hidden" open={version === newerVersion.tag || versionPath.length <= 3}>
|
||||
<summary class="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left hover:bg-muted/50 transition-colors rounded-md list-none">
|
||||
<ChevronDown class="w-4 h-4 text-muted-foreground shrink-0 transition-transform group-open:rotate-0 -rotate-90" />
|
||||
<span class="font-mono text-sm font-semibold">{note.name || note.githubTag}</span>
|
||||
{#if note.publishedAt}
|
||||
<span class="ml-auto text-xs text-muted-foreground">{formatDate(note.publishedAt)}</span>
|
||||
{/if}
|
||||
<a
|
||||
href={note.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="text-muted-foreground hover:text-foreground shrink-0 {note.publishedAt ? '' : 'ml-auto'}"
|
||||
title="Open on GitHub"
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</summary>
|
||||
<div class="markdown-body border-t border-border/60 px-3 py-3 text-sm">
|
||||
{@html renderMarkdown(note.body)}
|
||||
</div>
|
||||
</details>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Fallback: no per-version notes found -->
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-10 text-center">
|
||||
<BookOpen class="w-8 h-8 text-muted-foreground/50" />
|
||||
<p class="text-sm text-muted-foreground max-w-sm">
|
||||
{#if changelogUrl}
|
||||
Release notes for these versions aren't available inline.
|
||||
{:else}
|
||||
This image doesn't publish release notes Dockhand can read
|
||||
(no <code class="text-xs">org.opencontainers.image.source</code> label pointing at a GitHub or Gitea/Forgejo repo).
|
||||
{/if}
|
||||
</p>
|
||||
{#if changelogUrl}
|
||||
<Button variant="outline" size="sm" href={changelogUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink class="w-3.5 h-3.5 mr-1.5" /> View changelog
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="flex-row items-center gap-2 sm:justify-between">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{#if source}
|
||||
Notes from <code class="text-xs">{source}</code>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" onclick={copyTag}>
|
||||
{#if copied}
|
||||
<Check class="w-3.5 h-3.5 mr-1.5 text-green-500" /> Copied
|
||||
{:else}
|
||||
<Copy class="w-3.5 h-3.5 mr-1.5" /> Copy new tag
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onclick={close}>Close</Button>
|
||||
</div>
|
||||
</Dialog.Footer>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
/* Scoped typography for rendered release-note markdown. */
|
||||
.markdown-body :global(h1),
|
||||
.markdown-body :global(h2),
|
||||
.markdown-body :global(h3) {
|
||||
font-weight: 600;
|
||||
margin: 0.75rem 0 0.4rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.markdown-body :global(h1) { font-size: 1.05rem; }
|
||||
.markdown-body :global(h2) { font-size: 1rem; }
|
||||
.markdown-body :global(h3) { font-size: 0.9rem; }
|
||||
.markdown-body :global(p) { margin: 0.4rem 0; }
|
||||
.markdown-body :global(ul),
|
||||
.markdown-body :global(ol) { margin: 0.4rem 0; padding-left: 1.4rem; }
|
||||
.markdown-body :global(li) { margin: 0.15rem 0; }
|
||||
.markdown-body :global(ul) { list-style: disc; }
|
||||
.markdown-body :global(ol) { list-style: decimal; }
|
||||
.markdown-body :global(a) { color: hsl(var(--primary)); text-decoration: underline; }
|
||||
.markdown-body :global(code) {
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
}
|
||||
.markdown-body :global(pre) {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.markdown-body :global(pre code) { background: none; padding: 0; }
|
||||
.markdown-body :global(blockquote) {
|
||||
border-left: 3px solid hsl(var(--border));
|
||||
padding-left: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.markdown-body :global(hr) { border: none; border-top: 1px solid hsl(var(--border)); margin: 0.75rem 0; }
|
||||
.markdown-body :global(img) { max-width: 100%; }
|
||||
</style>
|
||||
+21
-3
@@ -973,6 +973,7 @@ export const NOTIFICATION_EVENT_TYPES = [
|
||||
{ id: 'auto_update_failed', label: 'Auto-update failed', description: 'Container auto-update failed (pull error, start error)', group: 'auto_update', scope: 'environment' },
|
||||
{ id: 'auto_update_blocked', label: 'Auto-update blocked', description: 'Update blocked due to vulnerability criteria', group: 'auto_update', scope: 'environment' },
|
||||
{ id: 'updates_detected', label: 'Updates detected', description: 'Container image updates are available (scheduled check)', group: 'auto_update', scope: 'environment' },
|
||||
{ id: 'newer_version_available', label: 'Newer version tag', description: 'A newer version tag is published for a pinned image (semver, advisory)', group: 'auto_update', scope: 'environment' },
|
||||
{ id: 'batch_update_success', label: 'Batch update completed', description: 'Scheduled container updates completed successfully', group: 'auto_update', scope: 'environment' },
|
||||
|
||||
// Git stack events (environment-scoped)
|
||||
@@ -4867,6 +4868,12 @@ export interface EnvUpdateCheckSettings {
|
||||
cron: string;
|
||||
autoUpdate: boolean;
|
||||
vulnerabilityCriteria: VulnerabilityCriteria;
|
||||
// Semver (newer-version-tag) detection — optional so pre-existing rows (which
|
||||
// never had these) parse as "off" without any migration.
|
||||
checkPinnedVersions?: boolean;
|
||||
semverMaxBump?: 'patch' | 'minor' | 'major';
|
||||
semverMatchFlavor?: boolean;
|
||||
semverIncludePrerelease?: boolean;
|
||||
}
|
||||
|
||||
export async function getEnvUpdateCheckSettings(envId: number): Promise<EnvUpdateCheckSettings | null> {
|
||||
@@ -5443,8 +5450,15 @@ export async function addPendingContainerUpdate(
|
||||
environmentId: number,
|
||||
containerId: string,
|
||||
containerName: string,
|
||||
currentImage: string
|
||||
currentImage: string,
|
||||
// A row can exist for a digest update, a newer-version-tag (semver) suggestion,
|
||||
// or both. Both flags default to the classic "digest update only" shape so
|
||||
// existing callers keep working unchanged.
|
||||
options: { hasImageUpdate?: boolean; newerVersion?: unknown | null } = {}
|
||||
): Promise<void> {
|
||||
const hasImageUpdate = options.hasImageUpdate ?? true;
|
||||
const newerVersion = options.newerVersion != null ? JSON.stringify(options.newerVersion) : null;
|
||||
const now = new Date().toISOString();
|
||||
// Use insert with onConflictDoUpdate for upsert behavior
|
||||
await db.insert(pendingContainerUpdates)
|
||||
.values({
|
||||
@@ -5452,14 +5466,18 @@ export async function addPendingContainerUpdate(
|
||||
containerId,
|
||||
containerName,
|
||||
currentImage,
|
||||
checkedAt: new Date().toISOString()
|
||||
hasImageUpdate,
|
||||
newerVersion,
|
||||
checkedAt: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [pendingContainerUpdates.environmentId, pendingContainerUpdates.containerId],
|
||||
set: {
|
||||
containerName,
|
||||
currentImage,
|
||||
checkedAt: new Date().toISOString()
|
||||
hasImageUpdate,
|
||||
newerVersion,
|
||||
checkedAt: now
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -481,6 +481,12 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates',
|
||||
containerId: text('container_id').notNull(),
|
||||
containerName: text('container_name').notNull(),
|
||||
currentImage: text('current_image').notNull(),
|
||||
// True when a digest image update is pending (the classic amber icon). Defaults
|
||||
// true so pre-existing rows keep behaving exactly as before.
|
||||
hasImageUpdate: integer('has_image_update', { mode: 'boolean' }).notNull().default(true),
|
||||
// A newer VERSION tag (semver) for a pinned image, as JSON {tag,bump,skipped}.
|
||||
// Null when there's no semver suggestion. Advisory - never auto-applied.
|
||||
newerVersion: text('newer_version'),
|
||||
checkedAt: text('checked_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`)
|
||||
}, (table) => ({
|
||||
|
||||
@@ -484,6 +484,12 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', {
|
||||
containerId: text('container_id').notNull(),
|
||||
containerName: text('container_name').notNull(),
|
||||
currentImage: text('current_image').notNull(),
|
||||
// True when a digest image update is pending (the classic amber icon). Defaults
|
||||
// true so pre-existing rows keep behaving exactly as before.
|
||||
hasImageUpdate: boolean('has_image_update').notNull().default(true),
|
||||
// A newer VERSION tag (semver) for a pinned image, as JSON {tag,bump,skipped}.
|
||||
// Null when there's no semver suggestion. Advisory - never auto-applied.
|
||||
newerVersion: text('newer_version'),
|
||||
checkedAt: timestamp('checked_at', { mode: 'string' }).defaultNow(),
|
||||
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow()
|
||||
}, (table) => ({
|
||||
|
||||
@@ -19,6 +19,9 @@ import type { Environment } from './db';
|
||||
import { getSetting } from './db';
|
||||
import { getAdditionalVolumeBinds, dedupeVolumesForRecreate } from './mount-dedupe';
|
||||
import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-recreate';
|
||||
// Import-light image parsing shared with the semver layer; re-exported below for callers.
|
||||
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 } from './registry-auth';
|
||||
import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils';
|
||||
@@ -3023,60 +3026,6 @@ export async function inspectImage(id: string, envId?: number | null) {
|
||||
return dockerJsonRequest(`/images/${encodeURIComponent(id)}/json`, {}, envId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an image reference into registry, repository, and tag components.
|
||||
* Follows Docker's reference parsing rules.
|
||||
* Examples:
|
||||
* nginx:latest -> { registry: 'index.docker.io', repo: 'library/nginx', tag: 'latest' }
|
||||
* ghcr.io/user/image:v1 -> { registry: 'ghcr.io', repo: 'user/image', tag: 'v1' }
|
||||
* registry.example.com:5000/repo:tag -> { registry: 'registry.example.com:5000', repo: 'repo', tag: 'tag' }
|
||||
*/
|
||||
function parseImageReference(imageName: string): { registry: string; repo: string; tag: string } {
|
||||
let registry = 'index.docker.io'; // Docker Hub's actual host
|
||||
let repo = imageName;
|
||||
let tag = 'latest';
|
||||
|
||||
// Handle digest references (remove digest part for manifest lookup)
|
||||
if (repo.includes('@')) {
|
||||
const [repoWithoutDigest] = repo.split('@');
|
||||
repo = repoWithoutDigest;
|
||||
}
|
||||
|
||||
// Extract tag
|
||||
const lastColon = repo.lastIndexOf(':');
|
||||
if (lastColon > -1) {
|
||||
const potentialTag = repo.substring(lastColon + 1);
|
||||
// Make sure it's not a port number (no slashes in tags)
|
||||
if (!potentialTag.includes('/')) {
|
||||
tag = potentialTag;
|
||||
repo = repo.substring(0, lastColon);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract registry if present
|
||||
const firstSlash = repo.indexOf('/');
|
||||
if (firstSlash > -1) {
|
||||
const firstPart = repo.substring(0, firstSlash);
|
||||
// If the first part contains a dot, colon, or is "localhost", it's a registry
|
||||
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
|
||||
registry = firstPart;
|
||||
repo = repo.substring(firstSlash + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize docker.io to index.docker.io (Docker Hub's actual registry host)
|
||||
// docker.io redirects to www.docker.com, while index.docker.io is the real API
|
||||
if (registry === 'docker.io') {
|
||||
registry = 'index.docker.io';
|
||||
}
|
||||
|
||||
// Docker Hub requires library/ prefix for official images
|
||||
if (registry === 'index.docker.io' && !repo.includes('/')) {
|
||||
repo = `library/${repo}`;
|
||||
}
|
||||
|
||||
return { registry, repo, tag };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a registry URL into host and path components.
|
||||
@@ -3113,7 +3062,7 @@ export function parseRegistryUrl(url: string): { host: string; path: string; ful
|
||||
* - Host-only stored: stored 'registry.example.com' matches requested 'registry.example.com/org'
|
||||
* (allows a single credential entry to work for all org paths)
|
||||
*/
|
||||
async function findRegistryCredentials(registryHost: string): Promise<{ username: string; password: string } | null> {
|
||||
export async function findRegistryCredentials(registryHost: string): Promise<{ username: string; password: string } | null> {
|
||||
try {
|
||||
// Import here to avoid circular dependency
|
||||
const { getRegistries } = await import('./db.js');
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Parse a Docker image reference into its registry host, repository, and tag,
|
||||
* following Docker's own reference rules. Pure and dependency-free so it can be
|
||||
* unit-tested (and imported by the light semver layer) without pulling in the
|
||||
* native modules that live in docker.ts.
|
||||
*
|
||||
* nginx:latest -> { registry: 'index.docker.io', repo: 'library/nginx', tag: 'latest' }
|
||||
* ghcr.io/user/image:v1 -> { registry: 'ghcr.io', repo: 'user/image', tag: 'v1' }
|
||||
* codeberg.org/forgejo/forgejo:15.0.2 -> { registry: 'codeberg.org', repo: 'forgejo/forgejo', tag: '15.0.2' }
|
||||
* registry.example.com:5000/repo:tag -> { registry: 'registry.example.com:5000', repo: 'repo', tag: 'tag' }
|
||||
*/
|
||||
export interface ImageReference {
|
||||
registry: string;
|
||||
repo: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export function parseImageReference(imageName: string): ImageReference {
|
||||
let registry = 'index.docker.io'; // Docker Hub's actual API host.
|
||||
let repo = imageName;
|
||||
let tag = 'latest';
|
||||
|
||||
// Drop a digest — irrelevant to tag/version lookups.
|
||||
if (repo.includes('@')) {
|
||||
repo = repo.split('@')[0];
|
||||
}
|
||||
|
||||
// Split off the tag, but a colon before a `/` is a registry port, not a tag.
|
||||
const lastColon = repo.lastIndexOf(':');
|
||||
if (lastColon > -1) {
|
||||
const potentialTag = repo.slice(lastColon + 1);
|
||||
if (!potentialTag.includes('/')) {
|
||||
tag = potentialTag;
|
||||
repo = repo.slice(0, lastColon);
|
||||
}
|
||||
}
|
||||
|
||||
// The first path segment is the registry only if it looks like a host
|
||||
// (has a dot or port, or is localhost) — otherwise it's part of the repo.
|
||||
const firstSlash = repo.indexOf('/');
|
||||
if (firstSlash > -1) {
|
||||
const firstPart = repo.slice(0, firstSlash);
|
||||
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
|
||||
registry = firstPart;
|
||||
repo = repo.slice(firstSlash + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (registry === 'docker.io') registry = 'index.docker.io';
|
||||
|
||||
// Docker Hub official images are namespaced under library/.
|
||||
if (registry === 'index.docker.io' && !repo.includes('/')) {
|
||||
repo = `library/${repo}`;
|
||||
}
|
||||
|
||||
return { registry, repo, tag };
|
||||
}
|
||||
|
||||
/** Every hostname that means "Docker Hub". */
|
||||
const DOCKER_HUB_HOSTS = new Set([
|
||||
'index.docker.io',
|
||||
'docker.io',
|
||||
'registry-1.docker.io',
|
||||
'registry.docker.io',
|
||||
'hub.docker.com',
|
||||
'registry.hub.docker.com'
|
||||
]);
|
||||
|
||||
export function isDockerHub(registry: string): boolean {
|
||||
return DOCKER_HUB_HOSTS.has(registry.toLowerCase());
|
||||
}
|
||||
|
||||
export function isGhcr(registry: string): boolean {
|
||||
return registry.toLowerCase() === 'ghcr.io';
|
||||
}
|
||||
@@ -15,8 +15,11 @@ import {
|
||||
saveVulnerabilityScan,
|
||||
clearPendingContainerUpdates,
|
||||
addPendingContainerUpdate,
|
||||
removePendingContainerUpdate
|
||||
removePendingContainerUpdate,
|
||||
getPendingContainerUpdates
|
||||
} from '../../db';
|
||||
import { checkNewerVersion } from '../../semver/check';
|
||||
import type { NewerVersion } from '../../semver/find-newer';
|
||||
import {
|
||||
listContainers,
|
||||
inspectContainer,
|
||||
@@ -107,6 +110,32 @@ export async function runEnvUpdateCheckJob(
|
||||
await log(`Starting update check for environment: ${env.name}`);
|
||||
await log(`Auto-update mode: ${config.autoUpdate ? 'ON' : 'OFF'}`);
|
||||
|
||||
// Semver "newer version tag" detection rides this same pass when enabled.
|
||||
const semverEnabled = !!config.checkPinnedVersions;
|
||||
const semverOptions = {
|
||||
maxBump: config.semverMaxBump ?? 'major',
|
||||
matchFlavor: config.semverMatchFlavor ?? true,
|
||||
includePrerelease: config.semverIncludePrerelease ?? false
|
||||
} as const;
|
||||
// Remember the target we last surfaced per container, so we only notify when
|
||||
// a NEW newer version appears - not on every scheduled run. Read before clear.
|
||||
const previousSemverTargets = new Map<string, string>();
|
||||
if (semverEnabled) {
|
||||
try {
|
||||
for (const row of await getPendingContainerUpdates(environmentId)) {
|
||||
if (row.newerVersion) {
|
||||
try {
|
||||
const nv = JSON.parse(row.newerVersion) as NewerVersion;
|
||||
previousSemverTargets.set(row.containerId, nv.tag);
|
||||
} catch { /* ignore malformed */ }
|
||||
}
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
// Collected here so a single notification can summarise all newly-found versions.
|
||||
const newSemverFindings: { containerName: string; imageName: string; newerVersion: NewerVersion }[] = [];
|
||||
const semverByContainer = new Map<string, NewerVersion>();
|
||||
|
||||
// Clear pending updates at the start - we'll re-add as we discover updates
|
||||
await clearPendingContainerUpdates(environmentId);
|
||||
|
||||
@@ -182,20 +211,66 @@ export async function runEnvUpdateCheckJob(
|
||||
newDigest: result.registryDigest,
|
||||
oldImageConfig
|
||||
});
|
||||
// Add to pending table immediately - will be removed on successful update
|
||||
await addPendingContainerUpdate(environmentId, container.id, container.name, imageName);
|
||||
await log(` UPDATE AVAILABLE`);
|
||||
await log(` Current: ${result.currentDigest?.substring(0, 24) || 'unknown'}...`);
|
||||
await log(` New: ${result.registryDigest?.substring(0, 24) || 'unknown'}...`);
|
||||
} else {
|
||||
await log(` Up to date`);
|
||||
}
|
||||
|
||||
// Newer-version-tag (semver) detection - independent of the digest check.
|
||||
// Skips floating tags without a registry call. Never throws.
|
||||
if (semverEnabled) {
|
||||
const newer = await checkNewerVersion(imageName, semverOptions).catch(() => null);
|
||||
if (newer) {
|
||||
semverByContainer.set(container.id, newer);
|
||||
await log(` NEWER VERSION: ${newer.tag} (${newer.bump})`);
|
||||
if (previousSemverTargets.get(container.id) !== newer.tag) {
|
||||
newSemverFindings.push({ containerName: container.name, imageName, newerVersion: newer });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
await log(` [${container.name}] Error: ${err.message}`);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist pending rows once per container, merging the digest update and the
|
||||
// semver suggestion so a pure-semver container still gets a (badge) row.
|
||||
const pendingContainerIds = new Set<string>([
|
||||
...updatesAvailable.map((u) => u.containerId),
|
||||
...semverByContainer.keys()
|
||||
]);
|
||||
for (const cid of pendingContainerIds) {
|
||||
const digest = updatesAvailable.find((u) => u.containerId === cid);
|
||||
const semver = semverByContainer.get(cid) ?? null;
|
||||
const container = containers.find((c) => c.id === cid);
|
||||
await addPendingContainerUpdate(
|
||||
environmentId,
|
||||
cid,
|
||||
digest?.containerName ?? container?.name ?? cid,
|
||||
digest?.imageName ?? container?.image ?? '',
|
||||
{ hasImageUpdate: !!digest, newerVersion: semver }
|
||||
);
|
||||
}
|
||||
|
||||
// Notify about NEW newer-version tags (advisory, never auto-applied). Fires
|
||||
// independently of digest updates, and only for versions not surfaced last
|
||||
// run - so a daily cron won't re-notify the same suggestion.
|
||||
if (newSemverFindings.length > 0) {
|
||||
const lines = newSemverFindings
|
||||
.map((f) => `- ${f.containerName} (${f.imageName}): newer version ${f.newerVersion.tag} (${f.newerVersion.bump})`)
|
||||
.join('\n');
|
||||
await log('');
|
||||
await log(`Newer version tags: ${newSemverFindings.length} new`);
|
||||
await sendEventNotification('newer_version_available', {
|
||||
title: `Newer version tag${newSemverFindings.length !== 1 ? 's' : ''} available on ${env.name}`,
|
||||
message: `${newSemverFindings.length} container${newSemverFindings.length !== 1 ? 's have' : ' has'} a newer version tag published (advisory - not auto-applied):\n${lines}`,
|
||||
type: 'info'
|
||||
}, environmentId);
|
||||
}
|
||||
|
||||
// Summary
|
||||
await log('');
|
||||
await log('=== SUMMARY ===');
|
||||
@@ -205,8 +280,9 @@ export async function runEnvUpdateCheckJob(
|
||||
await log(`Errors: ${errorCount}`);
|
||||
|
||||
if (updatesAvailable.length === 0) {
|
||||
await log('All containers are up to date');
|
||||
// Pending updates already cleared at start, nothing to add
|
||||
await log('No digest updates');
|
||||
// Any semver (newer-version) rows were already persisted + notified above;
|
||||
// there's just no digest work to do, so skip the auto-update path.
|
||||
await updateScheduleExecution(execution.id, {
|
||||
status: 'success',
|
||||
completedAt: new Date().toISOString(),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Decide whether a running image has a newer VERSION tag available. Glues the
|
||||
* registry tag list (I/O) to the pure version comparison, and short-circuits for
|
||||
* floating tags so `latest`/`stable`/sha never trigger a registry fetch.
|
||||
*/
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function checkNewerVersion(
|
||||
imageRef: string,
|
||||
options: FindNewerOptions = {}
|
||||
): Promise<NewerVersion | null> {
|
||||
const { tag } = parseImageReference(imageRef);
|
||||
|
||||
// Floating tag -> nothing to compare, and we skip the registry call entirely.
|
||||
if (!parseTag(tag)) return null;
|
||||
|
||||
const tags = await listVersionTags(imageRef);
|
||||
return findNewerVersionTag(tag, tags, options);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Given the tag a container currently runs and every tag the registry offers,
|
||||
* decide whether a newer VERSION tag exists — and which versions were skipped
|
||||
* along the way. Pure: no network, no registry knowledge, fully unit-testable.
|
||||
*
|
||||
* This is advisory only. We never auto-apply it: the tag lives in the user's
|
||||
* compose, which we don't own. We just tell them "a newer version is out".
|
||||
*/
|
||||
|
||||
import {
|
||||
parseTag,
|
||||
prefixMatches,
|
||||
flavorMatches,
|
||||
compareParts,
|
||||
isPrerelease,
|
||||
type ParsedTag
|
||||
} from './tag-parser';
|
||||
|
||||
export type VersionBump = 'major' | 'minor' | 'patch';
|
||||
|
||||
export interface FindNewerOptions {
|
||||
/** Cap the offered bump: `patch` stays quiet, `major` offers everything. Default `major`. */
|
||||
maxBump?: VersionBump;
|
||||
/** Consider `-rc`/`-beta`/... tags even when the current tag is stable. Default false. */
|
||||
includePrerelease?: boolean;
|
||||
/** Require the same flavor/suffix (`-alpine`). Default true — the #1 noise-killer. */
|
||||
matchFlavor?: boolean;
|
||||
}
|
||||
|
||||
export interface NewerVersion {
|
||||
/** The recommended tag to move to, e.g. `16.4-alpine`. */
|
||||
tag: string;
|
||||
/** How big the jump is, relative to the current version. */
|
||||
bump: VersionBump;
|
||||
/** Every newer version between current and target (exclusive of current, inclusive of target), ordered. */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
/** Which segment first differs decides the bump: [0]=major, [1]=minor, else patch. */
|
||||
export function classifyBump(current: ParsedTag, candidate: ParsedTag): VersionBump {
|
||||
if ((candidate.parts[0] ?? 0) !== (current.parts[0] ?? 0)) return 'major';
|
||||
if ((candidate.parts[1] ?? 0) !== (current.parts[1] ?? 0)) return 'minor';
|
||||
return 'patch';
|
||||
}
|
||||
|
||||
const BUMP_RANK: Record<VersionBump, number> = { patch: 1, minor: 2, major: 3 };
|
||||
|
||||
/** Canonical key for a numeric version: trailing zero segments trimmed, so `1.2`
|
||||
* and `1.2.0` (equal under compareParts) map to the same key. Keeps at least one
|
||||
* segment (`0.0.0` -> `0`). */
|
||||
function versionKey(parts: number[]): string {
|
||||
let end = parts.length;
|
||||
while (end > 1 && parts[end - 1] === 0) end--;
|
||||
return parts.slice(0, end).join('.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the newest version tag strictly greater than `currentTag`, or null.
|
||||
*
|
||||
* Returns null when the current tag isn't a version at all (`latest`, `stable`,
|
||||
* a sha) — there is nothing to compare, so no suggestion is ever made.
|
||||
*/
|
||||
export function findNewerVersionTag(
|
||||
currentTag: string,
|
||||
allTags: string[],
|
||||
options: FindNewerOptions = {}
|
||||
): NewerVersion | null {
|
||||
const current = parseTag(currentTag);
|
||||
if (!current) return null; // floating tag — not a version, never suggest.
|
||||
|
||||
const { maxBump = 'major', includePrerelease = false, matchFlavor = true } = options;
|
||||
const maxRank = BUMP_RANK[maxBump];
|
||||
const currentIsPrerelease = isPrerelease(current);
|
||||
|
||||
const candidates = allTags
|
||||
.map((tag) => ({ tag, parsed: parseTag(tag) }))
|
||||
.filter((c): c is { tag: string; parsed: ParsedTag } => c.parsed !== null)
|
||||
.filter((c) => prefixMatches(c.parsed.prefix, current.prefix))
|
||||
.filter((c) => !matchFlavor || flavorMatches(c.parsed, current))
|
||||
.filter((c) => includePrerelease || currentIsPrerelease || !isPrerelease(c.parsed))
|
||||
.filter((c) => compareParts(c.parsed, current) > 0)
|
||||
.filter((c) => BUMP_RANK[classifyBump(current, c.parsed)] <= maxRank);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// A registry commonly publishes the same version both with and without the
|
||||
// `v` prefix (`v3.6` and `3.6`). They are the SAME version, so collapse them
|
||||
// to one entry, keeping the variant whose prefix matches the running tag (so
|
||||
// `v3.0` suggests `v3.6`, not `3.6`). Keyed by the numeric tuple.
|
||||
const byVersion = new Map<string, { tag: string; parsed: ParsedTag }>();
|
||||
for (const c of candidates) {
|
||||
// Key on the numeric value with trailing zero segments trimmed, so `1.2` and
|
||||
// `1.2.0` (equal under compareParts) collapse to one entry instead of both
|
||||
// landing in `skipped` and racing for `target`.
|
||||
const key = versionKey(c.parsed.parts);
|
||||
const existing = byVersion.get(key);
|
||||
if (!existing) {
|
||||
byVersion.set(key, c);
|
||||
continue;
|
||||
}
|
||||
// Prefer the variant that shares the current tag's exact prefix.
|
||||
const cMatches = c.parsed.prefix === current.prefix;
|
||||
const existingMatches = existing.parsed.prefix === current.prefix;
|
||||
if (cMatches && !existingMatches) byVersion.set(key, c);
|
||||
}
|
||||
|
||||
const deduped = Array.from(byVersion.values());
|
||||
// Highest version wins; sort ascending so `skipped` reads oldest -> newest.
|
||||
deduped.sort((a, b) => compareParts(a.parsed, b.parsed));
|
||||
const target = deduped[deduped.length - 1];
|
||||
|
||||
return {
|
||||
tag: target.tag,
|
||||
bump: classifyBump(current, target.parsed),
|
||||
skipped: deduped.map((c) => c.tag)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Fetch per-version release notes from GitHub for the versions a semver check
|
||||
* surfaced. The match core (release-tag <-> wanted-version) is pure and unit-
|
||||
* tested; only the fetch touches the network. Degrades to an empty list on any
|
||||
* failure, so the modal always renders (it falls back to a plain link).
|
||||
*/
|
||||
|
||||
import { parseTag, compareParts } from './tag-parser';
|
||||
import type { ReleaseSource } from './release-source';
|
||||
|
||||
export interface ReleaseNote {
|
||||
/** The requested version tag this note is for, e.g. `16.4-alpine`. */
|
||||
version: string;
|
||||
/** The GitHub release name/title (may be empty). */
|
||||
name: string;
|
||||
/** The GitHub release tag, e.g. `v16.4`. */
|
||||
githubTag: string;
|
||||
/** Markdown body of the release. */
|
||||
body: string;
|
||||
/** ISO date the release was published. */
|
||||
publishedAt: string | null;
|
||||
/** Direct link to the release page. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
name: string | null;
|
||||
body: string | null;
|
||||
published_at: string | null;
|
||||
html_url: string;
|
||||
draft: boolean;
|
||||
prerelease: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick, for each wanted version tag, the GitHub release whose tag parses to the
|
||||
* SAME numeric version. Pure: `releases` is whatever the API returned, `wanted`
|
||||
* is the list of image tags (target + skipped). Flavor/prefix noise is ignored —
|
||||
* only the numeric tuple has to match, so `v16.4` matches `16.4-alpine`.
|
||||
*/
|
||||
export function matchReleasesToVersions(
|
||||
releases: Pick<GithubRelease, 'tag_name' | 'name' | 'body' | 'published_at' | 'html_url'>[],
|
||||
wanted: string[]
|
||||
): ReleaseNote[] {
|
||||
const notes: ReleaseNote[] = [];
|
||||
for (const version of wanted) {
|
||||
const parsedWanted = parseTag(version);
|
||||
if (!parsedWanted) continue;
|
||||
const match = releases.find((r) => {
|
||||
const parsedRelease = parseTag(r.tag_name);
|
||||
return parsedRelease && compareParts(parsedRelease, parsedWanted) === 0;
|
||||
});
|
||||
if (match) {
|
||||
notes.push({
|
||||
version,
|
||||
name: match.name ?? '',
|
||||
githubTag: match.tag_name,
|
||||
body: match.body ?? '',
|
||||
publishedAt: match.published_at,
|
||||
url: match.html_url
|
||||
});
|
||||
}
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
const PER_PAGE = 100;
|
||||
const MAX_PAGES = 3; // 300 recent releases is plenty to cover a handful of skipped versions.
|
||||
|
||||
/**
|
||||
* Fetch release notes for `wanted` versions from a GitHub or Gitea/Forgejo forge.
|
||||
* Both APIs return the same fields and page newest-first; only the page-size
|
||||
* query param differs (`per_page` vs `limit`). Walks pages until every wanted
|
||||
* version is matched or the page budget is spent. Never throws.
|
||||
*/
|
||||
export async function fetchReleaseNotes(
|
||||
src: ReleaseSource,
|
||||
wanted: string[],
|
||||
fetchImpl: typeof fetch = fetch
|
||||
): Promise<ReleaseNote[]> {
|
||||
if (wanted.length === 0) return [];
|
||||
|
||||
const pageSizeParam = src.kind === 'github' ? 'per_page' : 'limit';
|
||||
const collected: GithubRelease[] = [];
|
||||
try {
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const url = `${src.apiBase}?${pageSizeParam}=${PER_PAGE}&page=${page}`;
|
||||
const res = await fetchImpl(url, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'dockhand' }
|
||||
});
|
||||
if (!res.ok) break;
|
||||
const batch = (await res.json()) as GithubRelease[];
|
||||
if (!Array.isArray(batch) || batch.length === 0) break;
|
||||
collected.push(...batch.filter((r) => !r.draft));
|
||||
|
||||
// Stop early once we can match everything we're after.
|
||||
const matched = matchReleasesToVersions(collected, wanted);
|
||||
if (matched.length >= wanted.length) break;
|
||||
if (batch.length < PER_PAGE) break;
|
||||
}
|
||||
} catch {
|
||||
return matchReleasesToVersions(collected, wanted);
|
||||
}
|
||||
|
||||
return matchReleasesToVersions(collected, wanted);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Resolve WHERE to fetch a container image's release notes from. Two forge
|
||||
* families share one release-notes shape and are handled here:
|
||||
*
|
||||
* - GitHub -> https://api.github.com/repos/<slug>/releases
|
||||
* - Gitea / Forgejo -> https://<host>/api/v1/repos/<slug>/releases
|
||||
* (Codeberg is a Forgejo instance; self-hosted Gitea/Forgejo work too.)
|
||||
*
|
||||
* Both return the same fields (`tag_name`, `name`, `body`, `published_at`,
|
||||
* `html_url`), so the fetch + version-match logic is identical - only the base
|
||||
* URL differs. Priority mirrors resolveChangelogUrl: the
|
||||
* `org.opencontainers.image.source` label wins, then a `ghcr.io/<owner>/<repo>`
|
||||
* image name (always GitHub).
|
||||
*
|
||||
* Pure, no I/O, unit-tested. Images with no recognizable source resolve to null
|
||||
* (we never guess a repo - a wrong repo is worse than no notes).
|
||||
*/
|
||||
|
||||
const GHCR_PREFIX = 'ghcr.io/';
|
||||
|
||||
export type ForgeKind = 'github' | 'gitea';
|
||||
|
||||
export interface ReleaseSource {
|
||||
kind: ForgeKind;
|
||||
/** `owner/repo`. */
|
||||
slug: string;
|
||||
/** Fully-formed releases API base, ready for `?per_page`/`?limit` + `&page`. */
|
||||
apiBase: string;
|
||||
/** Human-facing releases page (for the "View releases" fallback link). */
|
||||
releasesUrl: string;
|
||||
}
|
||||
|
||||
/** Pull `owner/repo` out of a forge URL, tolerating trailing slash / `.git`. */
|
||||
function slugFromUrl(url: string, host: string): string | null {
|
||||
const idx = url.indexOf(host);
|
||||
if (idx === -1) return null;
|
||||
const after = url
|
||||
.slice(idx + host.length)
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\.git$/, '');
|
||||
const parts = after.split('/');
|
||||
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
|
||||
function hostOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function stripImageTag(image: string): string {
|
||||
const atIdx = image.indexOf('@');
|
||||
const withoutDigest = atIdx >= 0 ? image.slice(0, atIdx) : image;
|
||||
const colonIdx = withoutDigest.lastIndexOf(':');
|
||||
if (colonIdx > withoutDigest.lastIndexOf('/')) {
|
||||
return withoutDigest.slice(0, colonIdx);
|
||||
}
|
||||
return withoutDigest;
|
||||
}
|
||||
|
||||
export function resolveReleaseSource(
|
||||
imageName: string | null | undefined,
|
||||
labels?: Record<string, string> | null
|
||||
): ReleaseSource | null {
|
||||
const source = labels?.['org.opencontainers.image.source'];
|
||||
if (source) {
|
||||
const host = hostOf(source);
|
||||
if (host === 'github.com') {
|
||||
const slug = slugFromUrl(source, 'github.com');
|
||||
if (slug) return githubSource(slug);
|
||||
} else if (host) {
|
||||
// Any other host is treated as a Gitea/Forgejo instance. Its releases
|
||||
// API is host-relative, so the notes come from the SAME host the image
|
||||
// declares (codeberg.org, gitea.com, a self-hosted forge, ...).
|
||||
const slug = slugFromUrl(source, host);
|
||||
if (slug) {
|
||||
return {
|
||||
kind: 'gitea',
|
||||
slug,
|
||||
apiBase: `https://${host}/api/v1/repos/${slug}/releases`,
|
||||
releasesUrl: `https://${host}/${slug}/releases`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageName && imageName.startsWith(GHCR_PREFIX)) {
|
||||
const repo = stripImageTag(imageName.slice(GHCR_PREFIX.length));
|
||||
const parts = repo.split('/');
|
||||
if (parts.length >= 2 && parts[0] && parts[1]) {
|
||||
return githubSource(`${parts[0]}/${parts[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function githubSource(slug: string): ReleaseSource {
|
||||
return {
|
||||
kind: 'github',
|
||||
slug,
|
||||
apiBase: `https://api.github.com/repos/${slug}/releases`,
|
||||
releasesUrl: `https://github.com/${slug}/releases`
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* List tags from Docker Hub via its own tags API (not the Registry v2
|
||||
* `/tags/list`), ordered newest-first, so recent version tags land on the first
|
||||
* page or two.
|
||||
*
|
||||
* No server-side `name=` filter: Hub's `name` param is a SUBSTRING match, so
|
||||
* narrowing by the current major digit (e.g. `1` for `1.25`) would exclude a
|
||||
* clean newer major like `2.0` (no `1` in the name) and silently hide the single
|
||||
* most important upgrade. We page newest-first instead and let the pure
|
||||
* comparator pick the target.
|
||||
*/
|
||||
|
||||
/** Newest-first pages; a few pages cover recent version tags across major lines. */
|
||||
const MAX_PAGES = 3;
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface HubTag {
|
||||
name: string;
|
||||
}
|
||||
interface HubTagsResponse {
|
||||
results?: HubTag[];
|
||||
next?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param repo Full Hub repo path, e.g. `library/nginx` or `grafana/grafana`.
|
||||
*/
|
||||
export async function fetchDockerHubTags(repo: string): Promise<string[]> {
|
||||
const tags: string[] = [];
|
||||
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const url = `https://hub.docker.com/v2/repositories/${repo}/tags?page_size=${PAGE_SIZE}&page=${page}&ordering=last_updated`;
|
||||
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!response.ok) break;
|
||||
|
||||
const data = (await response.json()) as HubTagsResponse;
|
||||
tags.push(...(data.results ?? []).map((t) => t.name));
|
||||
if (!data.next) break;
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* List tags from any Registry HTTP API v2 host (Codeberg, GitLab, Harbor,
|
||||
* self-hosted, ...) via `/v2/<repo>/tags/list`, following the `Link: rel="next"`
|
||||
* header across pages so version tags aren't lost behind a first page full of
|
||||
* git-hash tags. Auth uses our shared WWW-Authenticate token flow, which also
|
||||
* works anonymously for public repos.
|
||||
*/
|
||||
import { getRegistryAuthHeader } from '../../docker';
|
||||
|
||||
/** Stop after this many pages — a guard against pathological repos, not a real limit. */
|
||||
const MAX_PAGES = 50;
|
||||
const PAGE_SIZE = 1000;
|
||||
|
||||
interface TagsListResponse {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/** Parse `</v2/repo/tags/list?last=x&n=1000>; rel="next"` into the next path. */
|
||||
function nextPagePath(linkHeader: string | null): string | null {
|
||||
const match = linkHeader?.match(/<([^>]+)>;\s*rel="next"/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export async function fetchGenericV2Tags(
|
||||
registry: string,
|
||||
repo: string,
|
||||
credentials?: { username: string; password: string } | null
|
||||
): Promise<string[]> {
|
||||
const base = `https://${registry}`;
|
||||
const authHeader = await getRegistryAuthHeader(base, `repository:${repo}:pull`, credentials ?? undefined);
|
||||
const headers: Record<string, string> = { Accept: 'application/json', 'User-Agent': 'Dockhand/1.0' };
|
||||
if (authHeader) headers['Authorization'] = authHeader;
|
||||
|
||||
const tags: string[] = [];
|
||||
let path: string | null = `/v2/${repo}/tags/list?n=${PAGE_SIZE}`;
|
||||
|
||||
for (let page = 0; page < MAX_PAGES && path; page++) {
|
||||
const response = await fetch(`${base}${path}`, { headers });
|
||||
if (!response.ok) break; // 4xx/5xx: stop with whatever we have.
|
||||
|
||||
const data = (await response.json()) as TagsListResponse;
|
||||
tags.push(...(data.tags ?? []));
|
||||
path = nextPagePath(response.headers.get('link'));
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Parse a Docker image tag into its numeric version and the text around it, so
|
||||
* two tags of the SAME image can be compared as versions.
|
||||
*
|
||||
* A tag is `<prefix><version><suffix>` where version is the first dotted-number
|
||||
* run: `v1.25-alpine` -> prefix `v`, version `1.25`, suffix `-alpine`. The
|
||||
* version becomes a plain numeric tuple (`[1, 25]`), which compares CalVer
|
||||
* (`2024.1.3`) and SemVer (`1.2.3`) alike with no semver library and no special
|
||||
* cases. Pure functions, no I/O.
|
||||
*/
|
||||
|
||||
/** The first dotted-number run in a tag: 2 to 4 numeric segments. */
|
||||
const VERSION_RE = /\d+\.\d+(?:\.\d+)?(?:\.\d+)?/;
|
||||
|
||||
export interface ParsedTag {
|
||||
/** The matched version text, e.g. `1.25` or `2024.1.3`. */
|
||||
version: string;
|
||||
/** Text before the version, e.g. `v` in `v1.25`. */
|
||||
prefix: string;
|
||||
/** Text after the version, e.g. `-alpine` in `1.25-alpine`. This is the "flavor". */
|
||||
suffix: string;
|
||||
/** The version as a numeric tuple, e.g. `[1, 25]`. */
|
||||
parts: number[];
|
||||
}
|
||||
|
||||
/** Parse a tag into its version parts, or null when it has no version (`latest`, `stable`, a sha). */
|
||||
export function parseTag(tag: string): ParsedTag | null {
|
||||
const match = VERSION_RE.exec(tag);
|
||||
if (!match) return null;
|
||||
|
||||
const version = match[0];
|
||||
return {
|
||||
version,
|
||||
prefix: tag.slice(0, match.index),
|
||||
suffix: tag.slice(match.index + version.length),
|
||||
parts: version.split('.').map(Number)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Two prefixes are equivalent, treating `""` and `"v"` as the same — projects
|
||||
* routinely add or drop the leading `v` between releases (`1.2.3` vs `v1.2.3`).
|
||||
*/
|
||||
export function prefixMatches(a: string, b: string): boolean {
|
||||
const normalize = (p: string) => (p === 'v' ? '' : p);
|
||||
return normalize(a) === normalize(b);
|
||||
}
|
||||
|
||||
// A channel marker delimited by start/`-`/`.`, optionally followed by a number
|
||||
// (`rc1`, `beta.2`) or another delimiter. `\b` won't do: `rc1` has no boundary
|
||||
// between `c` and `1`.
|
||||
const PRERELEASE_RE = /(^|[-.])(rc|beta|alpha|nightly|dev|pre|snapshot)(\d|[-.]|$)/i;
|
||||
|
||||
// A trailing commit hash, e.g. `-b2da6b90f` in searxng's `2026.8.16-b2da6b90f`.
|
||||
// Requiring 7+ pure-hex chars keeps real build variants (`-alpine`, `-ls123`,
|
||||
// `-ubuntu22.04`) intact - they aren't long hex runs - while collapsing the
|
||||
// per-release hash so every hashed tag of one image shares a flavor and can be
|
||||
// compared. Guarded by at least one letter so a plain numeric build (`-12345678`)
|
||||
// isn't mistaken for a hash.
|
||||
const COMMIT_HASH_RE = /[-.](?=[0-9a-f]*[a-f])[0-9a-f]{7,}$/i;
|
||||
|
||||
export function isPrerelease(tag: ParsedTag): boolean {
|
||||
return PRERELEASE_RE.test(tag.suffix);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "flavor" is the stable part of the suffix — the build variant like
|
||||
* `-alpine` or `-ls123` — with any prerelease channel and any trailing commit
|
||||
* hash stripped off. So `1.0-alpine` and `1.1-alpine` share the flavor `-alpine`,
|
||||
* `1.0-rc1` / `1.0-rc2` share the flavor `` (they only differ in the prerelease),
|
||||
* and `2026.8.15-a1b2c3d` / `2026.8.16-b2da6b9` share the flavor `` (they only
|
||||
* differ in the per-release hash). This lets one hashed tag suggest the next
|
||||
* while still never mixing `-alpine` with bare.
|
||||
*/
|
||||
export function flavorOf(tag: ParsedTag): string {
|
||||
return tag.suffix
|
||||
.replace(COMMIT_HASH_RE, '')
|
||||
.replace(PRERELEASE_RE, (_m, delim: string) => (delim === '.' ? '' : delim))
|
||||
.replace(/[-.]+$/, ''); // drop the delimiter the prerelease left dangling
|
||||
}
|
||||
|
||||
/** A newer version is only offered within the SAME flavor. Exact match, prerelease-agnostic. */
|
||||
export function flavorMatches(a: ParsedTag, b: ParsedTag): boolean {
|
||||
return flavorOf(a) === flavorOf(b);
|
||||
}
|
||||
|
||||
/** Compare two version tuples pairwise (missing segments count as 0). <0, 0, or >0. */
|
||||
export function compareParts(a: ParsedTag, b: ParsedTag): number {
|
||||
const length = Math.max(a.parts.length, b.parts.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
const diff = (a.parts[i] ?? 0) - (b.parts[i] ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* List every version-looking tag an image's registry publishes, so the pure
|
||||
* `findNewerVersionTag` can pick the newest. Routes to the right fetch strategy
|
||||
* by host (Docker Hub API vs generic Registry v2), and caches per repo for 6h —
|
||||
* the set of published tags changes slowly and this keeps registries un-hammered.
|
||||
*
|
||||
* GHCR note: ghcr.io works through the generic v2 path here. GitHub's Packages
|
||||
* API returns versions newest-first (nicer for repos buried under git-hash tags),
|
||||
* but it needs a token we don't yet store — a phase-2 optimization, not a gap.
|
||||
*/
|
||||
import { parseImageReference, isDockerHub } from '../registry/image-ref';
|
||||
import { findRegistryCredentials } from '../docker';
|
||||
import { fetchDockerHubTags } from './strategies/docker-hub';
|
||||
import { fetchGenericV2Tags } from './strategies/generic-v2';
|
||||
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
interface CacheEntry {
|
||||
tags: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
// Keyed by `registry/repo`. Registry credentials are stored per-HOST (not per
|
||||
// environment) in Dockhand's registries table, so the tag list for a given
|
||||
// registry/repo is the same regardless of which env asked - the key needs no env
|
||||
// dimension. A generic V2 registry that needs auth uses the stored credentials
|
||||
// for that host (same lookup the digest check uses); an unauthenticated repo just
|
||||
// works anonymously.
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
// In-flight fetches keyed the same way, so a burst of same-image containers in
|
||||
// one check pass (CONCURRENCY=20) collapses to ONE registry request per repo
|
||||
// instead of one per container.
|
||||
const inflight = new Map<string, Promise<string[]>>();
|
||||
|
||||
/**
|
||||
* All tags published for the image behind `imageRef`, cached per repo. Returns
|
||||
* `[]` on any registry failure — the caller treats "no tags" as "nothing newer".
|
||||
*/
|
||||
export async function listVersionTags(imageRef: string): Promise<string[]> {
|
||||
const { registry, repo } = parseImageReference(imageRef);
|
||||
const cacheKey = `${registry}/${repo}`;
|
||||
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.tags;
|
||||
|
||||
const pending = inflight.get(cacheKey);
|
||||
if (pending) return pending;
|
||||
|
||||
const promise = (async () => {
|
||||
let tags: string[] = [];
|
||||
try {
|
||||
if (isDockerHub(registry)) {
|
||||
tags = await fetchDockerHubTags(repo);
|
||||
} else {
|
||||
const credentials = await findRegistryCredentials(registry).catch(() => null);
|
||||
tags = await fetchGenericV2Tags(registry, repo, credentials);
|
||||
}
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
cache.set(cacheKey, { tags, expiresAt: Date.now() + CACHE_TTL_MS });
|
||||
return tags;
|
||||
})();
|
||||
|
||||
inflight.set(cacheKey, promise);
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: clear the per-repo tag cache. */
|
||||
export function __resetTagSourceCacheForTests(): void {
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
}
|
||||
@@ -1845,13 +1845,27 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
|
||||
const containers = await listContainers(true, envId);
|
||||
const stacks = new Map<string, Set<string>>();
|
||||
|
||||
// Container IDs with pending image updates (populated by manual/scheduled update checks).
|
||||
// Used to flag stacks that contain at least one outdated container.
|
||||
// Container IDs with a pending DIGEST image update (the classic amber icon).
|
||||
// A persisted row can also be a pure newer-version-tag (semver) suggestion with
|
||||
// no digest update - those must NOT count as an "update available", so filter
|
||||
// on hasImageUpdate. `newerVersionIds` drives the separate semver stack badge.
|
||||
const pendingUpdateIds = new Set<string>();
|
||||
const newerVersionIds = new Set<string>();
|
||||
const newerVersionById = new Map<string, unknown>();
|
||||
if (typeof envId === 'number') {
|
||||
try {
|
||||
const pending = await getPendingContainerUpdates(envId);
|
||||
pending.forEach((p) => pendingUpdateIds.add(p.containerId));
|
||||
pending.forEach((p) => {
|
||||
if (p.hasImageUpdate) pendingUpdateIds.add(p.containerId);
|
||||
if (p.newerVersion) {
|
||||
newerVersionIds.add(p.containerId);
|
||||
try {
|
||||
newerVersionById.set(p.containerId, JSON.parse(p.newerVersion));
|
||||
} catch {
|
||||
// malformed row - skip the badge for this one
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal: stacks just won't show update markers
|
||||
}
|
||||
@@ -1914,7 +1928,8 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
|
||||
exitCode: c.exitCode,
|
||||
created: c.created,
|
||||
labels: c.labels || {},
|
||||
updateAvailable: pendingUpdateIds.has(c.id)
|
||||
updateAvailable: pendingUpdateIds.has(c.id),
|
||||
newerVersion: newerVersionById.get(c.id) ?? null
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
@@ -1930,6 +1945,8 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
|
||||
containerDetails,
|
||||
updatesAvailable: stackContainers.some((c) => pendingUpdateIds.has(c.id)),
|
||||
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'
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ContainerInfo, ContainerStats } from '$lib/types';
|
||||
import { appendEnvParam, clearStaleEnvironment, environments } from '$lib/stores/environment';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { NewerVersion } from '$lib/types';
|
||||
|
||||
export interface AutoUpdateSetting {
|
||||
enabled: boolean;
|
||||
@@ -29,6 +30,8 @@ export interface ContainerStoreState {
|
||||
failedUpdateIds: string[];
|
||||
/** Update-check error message keyed by container ID (for tooltip) */
|
||||
failedUpdateErrors: Map<string, string>;
|
||||
/** Newer VERSION tag (semver) suggestions from the last check, keyed by container ID. Advisory. */
|
||||
newerVersions: Map<string, NewerVersion>;
|
||||
/** Whether the current environment has vulnerability scanning */
|
||||
envHasScanning: boolean;
|
||||
/** Environment-level vulnerability criteria */
|
||||
@@ -48,6 +51,7 @@ const INITIAL_STATE: ContainerStoreState = {
|
||||
pendingUpdateNames: new Map(),
|
||||
failedUpdateIds: [],
|
||||
failedUpdateErrors: new Map(),
|
||||
newerVersions: new Map(),
|
||||
envHasScanning: false,
|
||||
envVulnerabilityCriteria: 'never',
|
||||
loading: true,
|
||||
@@ -290,14 +294,20 @@ function createContainerStore() {
|
||||
const response = await fetch(appendEnvParam('/api/containers/pending-updates', envId));
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
if (data.pendingUpdates && data.pendingUpdates.length > 0) {
|
||||
patch({
|
||||
pendingUpdateIds: data.pendingUpdates.map((u: any) => u.containerId),
|
||||
pendingUpdateNames: new Map(
|
||||
data.pendingUpdates.map((u: any) => [u.containerId, u.containerName])
|
||||
)
|
||||
});
|
||||
}
|
||||
// A persisted row can carry a digest update, a newer-version (semver)
|
||||
// suggestion, or both. Split them: only hasImageUpdate rows drive the amber
|
||||
// "update available" state; newerVersion rows drive the Tag badge. Always
|
||||
// patch (even on an empty list) so a cleared server state clears the store -
|
||||
// otherwise stale badges/counters linger after the last update is applied.
|
||||
const rows: any[] = data.pendingUpdates ?? [];
|
||||
const withImageUpdate = rows.filter((u) => u.hasImageUpdate);
|
||||
patch({
|
||||
pendingUpdateIds: withImageUpdate.map((u) => u.containerId),
|
||||
pendingUpdateNames: new Map(withImageUpdate.map((u) => [u.containerId, u.containerName])),
|
||||
newerVersions: new Map(
|
||||
rows.filter((u) => u.newerVersion).map((u) => [u.containerId, u.newerVersion as NewerVersion])
|
||||
)
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors - background load
|
||||
}
|
||||
@@ -357,6 +367,11 @@ function createContainerStore() {
|
||||
patch({ failedUpdateIds: ids, failedUpdateErrors: errors });
|
||||
},
|
||||
|
||||
/** Record newer-version-tag (semver) suggestions from the last check. Session-only, advisory. */
|
||||
setNewerVersions(map: Map<string, NewerVersion>) {
|
||||
patch({ newerVersions: map });
|
||||
},
|
||||
|
||||
/** Patch arbitrary fields */
|
||||
patch
|
||||
};
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
*/
|
||||
export type SystemContainerType = 'dockhand' | 'hawser';
|
||||
|
||||
/** A newer VERSION tag (semver) suggestion. Mirrors the server's find-newer result. */
|
||||
export interface NewerVersion {
|
||||
tag: string;
|
||||
bump: 'major' | 'minor' | 'patch';
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -135,6 +142,8 @@ export interface StackContainer {
|
||||
created: number;
|
||||
labels: Record<string, string>;
|
||||
updateAvailable?: boolean;
|
||||
/** A newer VERSION tag (semver) for this pinned image, or null. Advisory. */
|
||||
newerVersion?: NewerVersion | null;
|
||||
}
|
||||
|
||||
export interface ComposeStackInfo {
|
||||
@@ -144,6 +153,8 @@ export interface ComposeStackInfo {
|
||||
status: string;
|
||||
updatesAvailable?: boolean;
|
||||
updateCount?: number;
|
||||
/** How many containers in this stack have a newer version tag (semver). */
|
||||
newerVersionCount?: number;
|
||||
sourceType?: 'external' | 'internal' | 'git';
|
||||
repository?: {
|
||||
id: number;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Render untrusted markdown (GitHub release bodies) to sanitized HTML. The
|
||||
* source is arbitrary internet content, so the output is always run through
|
||||
* DOMPurify before it reaches an {@html} block.
|
||||
*/
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
marked.setOptions({ gfm: true, breaks: false });
|
||||
|
||||
export function renderMarkdown(md: string): string {
|
||||
if (!md) return '';
|
||||
const raw = marked.parse(md, { async: false }) as string;
|
||||
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target', 'rel'] });
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { inspectContainer } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||
import { resolveReleaseSource } from '$lib/server/semver/release-source';
|
||||
import { resolveChangelogUrl } from '$lib/utils/changelog-url';
|
||||
import { fetchReleaseNotes } from '$lib/server/semver/release-notes';
|
||||
|
||||
/**
|
||||
* GET /api/containers/{id}/version-notes - Release notes for a newer version tag
|
||||
*
|
||||
* @openapi
|
||||
* summary: Resolve GitHub release notes for the versions a semver update-check surfaced (requires the 'view' permission)
|
||||
* path: id:string! Container ID or name (from GET /api/containers)
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* query: versions:string! Comma-separated version tags to fetch notes for (target + skipped), e.g. "16.3-alpine,16.4-alpine"
|
||||
* resp-200: {changelogUrl:string, source:string, notes:array<{version:string!, name:string, githubTag:string, body:string, publishedAt:string, url:string!}>!}
|
||||
* resp-200-example: {"changelogUrl":"https://github.com/go-gitea/gitea/releases","source":"go-gitea/gitea","notes":[{"version":"1.22.0","name":"v1.22.0","githubTag":"v1.22.0","body":"## Changes...","publishedAt":"2024-05-01T00:00:00Z","url":"https://github.com/go-gitea/gitea/releases/tag/v1.22.0"}]}
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to resolve release notes
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
const invalid = validateDockerIdParam(params.id, 'container');
|
||||
if (invalid) return invalid;
|
||||
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
if (auth.authEnabled && !(await auth.can('containers', 'view', envIdNum))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const versions = (url.searchParams.get('versions') ?? '')
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
try {
|
||||
const inspectData = (await inspectContainer(params.id, envIdNum)) as any;
|
||||
const imageName: string | undefined = inspectData.Config?.Image;
|
||||
const labels: Record<string, string> = inspectData.Config?.Labels ?? {};
|
||||
|
||||
const src = resolveReleaseSource(imageName, labels);
|
||||
// Prefer the label-based changelog URL; fall back to the forge's releases page.
|
||||
const changelogUrl = resolveChangelogUrl(imageName, labels) ?? src?.releasesUrl ?? null;
|
||||
|
||||
// No recognizable forge -> no per-version notes, just the changelog link (if any).
|
||||
const notes = src ? await fetchReleaseNotes(src, versions) : [];
|
||||
|
||||
return json({ changelogUrl, source: src?.slug ?? null, notes });
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve version notes:', error);
|
||||
return json({ error: 'Failed to resolve release notes' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -2,10 +2,12 @@ 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 { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates } from '$lib/server/db';
|
||||
import { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates, getEnvUpdateCheckSettings } from '$lib/server/db';
|
||||
import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
|
||||
import { createJobResponse } from '$lib/server/sse';
|
||||
import { checkNewerVersion } from '$lib/server/semver/check';
|
||||
import type { NewerVersion } from '$lib/server/semver/find-newer';
|
||||
|
||||
export interface UpdateCheckResult {
|
||||
containerId: string;
|
||||
@@ -18,12 +20,23 @@ export interface UpdateCheckResult {
|
||||
isLocalImage?: boolean;
|
||||
systemContainer?: 'dockhand' | 'hawser' | null;
|
||||
updateDisabled?: boolean;
|
||||
/** A newer VERSION tag is published for a pinned image (advisory, never auto-applied). */
|
||||
newerVersion?: NewerVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET = READ the cached result of the last update check (no side effects, does NOT
|
||||
* trigger a new check). Returns the containers currently flagged as having a pending
|
||||
* image update. Use POST (below) to actually run a fresh check. (issue #1266)
|
||||
*
|
||||
* @openapi
|
||||
* summary: Read the cached pending image-update records for an environment (no fresh check; requires the 'view' permission)
|
||||
* description: Returns the containers currently flagged as having a pending image update from the last check. Does not trigger a new check — use POST to run a fresh check.
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: {environmentId:integer!, pendingUpdates:array<{containerId:string!, containerName:string!, currentImage:string!, checkedAt:string}>!}
|
||||
* resp-400: Environment ID required
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to get pending updates
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -59,6 +72,12 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
/**
|
||||
* POST = TRIGGER a fresh update check across all containers (job-based).
|
||||
* Returns progress events during checking, final result when done.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Trigger a fresh image-update check across all (non-hidden, non-podman-infra) containers in an environment
|
||||
* query: env:integer The target environment ID (omit for the local/default Docker host) (from GET /api/environments)
|
||||
* resp-200: text/event-stream job stream ("progress" events with {checked,total}, final "result" event with {total,updatesFound,results}) — or, with "Accept: application/json", the final result as plain JSON
|
||||
* resp-403: Permission denied
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -87,6 +106,17 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
|
||||
send('progress', { checked: 0, total: containers.length });
|
||||
|
||||
// Semver "newer version tag" detection is an opt-in branch on this same pass.
|
||||
// Read the env's update-check config once; if checkPinnedVersions is on, each
|
||||
// pinned-version container is also compared against the registry's tag list.
|
||||
const envSettings = envIdNum ? await getEnvUpdateCheckSettings(envIdNum) : null;
|
||||
const semverEnabled = !!envSettings?.checkPinnedVersions;
|
||||
const semverOptions = {
|
||||
maxBump: envSettings?.semverMaxBump ?? 'major',
|
||||
matchFlavor: envSettings?.semverMatchFlavor ?? true,
|
||||
includePrerelease: envSettings?.semverIncludePrerelease ?? false
|
||||
} as const;
|
||||
|
||||
// Check container for updates
|
||||
let checked = 0;
|
||||
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
|
||||
@@ -120,6 +150,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
|
||||
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
|
||||
|
||||
// Newer-version-tag detection (opt-in). Skips instantly for floating tags,
|
||||
// so it only hits the registry for pinned versions. Never throws.
|
||||
const newerVersion = semverEnabled
|
||||
? await checkNewerVersion(imageName, semverOptions).catch(() => null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
@@ -130,7 +166,8 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
error: result.error,
|
||||
isLocalImage: result.isLocalImage,
|
||||
systemContainer: isSystemContainer(imageName) || null,
|
||||
updateDisabled
|
||||
updateDisabled,
|
||||
newerVersion: newerVersion ?? undefined
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
@@ -160,17 +197,21 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
|
||||
|
||||
const updatesFound = results.filter(r => r.hasUpdate && !r.systemContainer && !r.updateDisabled).length;
|
||||
|
||||
// Save containers with updates to the database for persistence
|
||||
// Persist a row for anything worth showing on reload: a digest update, a
|
||||
// newer-version-tag (semver) suggestion, or both. A pure-semver row (no
|
||||
// digest update) still persists so the badge survives a page reload.
|
||||
if (envIdNum) {
|
||||
for (const result of results) {
|
||||
if (result.hasUpdate && !result.systemContainer && !result.updateDisabled) {
|
||||
await addPendingContainerUpdate(
|
||||
envIdNum,
|
||||
result.containerId,
|
||||
result.containerName,
|
||||
result.imageName
|
||||
);
|
||||
}
|
||||
if (result.systemContainer || result.updateDisabled) continue;
|
||||
const hasImageUpdate = result.hasUpdate;
|
||||
if (!hasImageUpdate && !result.newerVersion) continue;
|
||||
await addPendingContainerUpdate(
|
||||
envIdNum,
|
||||
result.containerId,
|
||||
result.containerName,
|
||||
result.imageName,
|
||||
{ hasImageUpdate, newerVersion: result.newerVersion ?? null }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,24 @@ import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getPendingContainerUpdates, removePendingContainerUpdate, clearPendingContainerUpdates } from '$lib/server/db';
|
||||
|
||||
function safeParse(json: string): unknown {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending container updates for an environment.
|
||||
*
|
||||
* @openapi
|
||||
* summary: List the containers in an environment that have a pending image update recorded (requires the 'view' permission)
|
||||
* query: env:integer! The target environment ID (required) (from GET /api/environments)
|
||||
* resp-200: {environmentId:integer!, pendingUpdates:array<{containerId:string!, containerName:string!, currentImage:string!, checkedAt:string!, hasImageUpdate:boolean!, newerVersion:object}>!}
|
||||
* resp-400: Environment ID is required
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to get the pending updates
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -29,7 +45,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
containerId: u.containerId,
|
||||
containerName: u.containerName,
|
||||
currentImage: u.currentImage,
|
||||
checkedAt: u.checkedAt
|
||||
checkedAt: u.checkedAt,
|
||||
hasImageUpdate: u.hasImageUpdate,
|
||||
newerVersion: u.newerVersion ? safeParse(u.newerVersion) : null
|
||||
}))
|
||||
});
|
||||
} catch (error: any) {
|
||||
@@ -40,6 +58,16 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
|
||||
/**
|
||||
* Remove a pending container update (e.g., after manual update).
|
||||
*
|
||||
* @openapi
|
||||
* summary: Clear pending-update records for an environment, or for a single container when containerId is given
|
||||
* query: env:integer! The target environment ID (required) (from GET /api/environments)
|
||||
* query: containerId:string The container to clear; omit to clear ALL pending updates in the environment (from GET /api/containers)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-200-example: {"success":true}
|
||||
* resp-400: Environment ID is required
|
||||
* resp-403: Permission denied
|
||||
* resp-500: Failed to remove the pending update
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
@@ -10,6 +10,15 @@ import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
|
||||
/**
|
||||
* Get update check settings for an environment.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Get the automatic container-image update-check schedule for an environment
|
||||
* path: id:integer! Environment id (from GET /api/environments)
|
||||
* resp-200: {settings:{enabled:boolean!, cron:string!, autoUpdate:boolean!, vulnerabilityCriteria:string!}!}
|
||||
* resp-200-example: {"settings":{"enabled":false,"cron":"0 4 * * *","autoUpdate":false,"vulnerabilityCriteria":"never"}}
|
||||
* resp-403: Permission denied (RBAC 'environments:view' missing)
|
||||
* resp-404: Environment not found
|
||||
* resp-500: Unexpected error while loading the settings
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -44,6 +53,16 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
|
||||
/**
|
||||
* Save update check settings for an environment.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Save the automatic image update-check schedule for an environment (registers/unregisters the croner job)
|
||||
* path: id:integer! Environment id (from GET /api/environments)
|
||||
* body: {enabled:boolean, cron:string, autoUpdate:boolean, vulnerabilityCriteria:string, checkPinnedVersions:boolean, semverMaxBump:string, semverMatchFlavor:boolean, semverIncludePrerelease:boolean}
|
||||
* body-example: {"enabled":true,"cron":"0 4 * * *","autoUpdate":false,"vulnerabilityCriteria":"never","checkPinnedVersions":true,"semverMaxBump":"minor","semverMatchFlavor":true,"semverIncludePrerelease":false}
|
||||
* resp-200: {success:boolean!, settings:{enabled:boolean!, cron:string!, autoUpdate:boolean!, vulnerabilityCriteria:string!}!}
|
||||
* resp-403: Permission denied (RBAC 'environments:edit' missing)
|
||||
* resp-404: Environment not found
|
||||
* resp-500: Unexpected error while saving the settings
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
@@ -66,7 +85,11 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
|
||||
enabled: data.enabled ?? false,
|
||||
cron: data.cron || '0 4 * * *',
|
||||
autoUpdate: data.autoUpdate ?? false,
|
||||
vulnerabilityCriteria: data.vulnerabilityCriteria || 'never'
|
||||
vulnerabilityCriteria: data.vulnerabilityCriteria || 'never',
|
||||
checkPinnedVersions: data.checkPinnedVersions ?? false,
|
||||
semverMaxBump: data.semverMaxBump || 'major',
|
||||
semverMatchFlavor: data.semverMatchFlavor ?? true,
|
||||
semverIncludePrerelease: data.semverIncludePrerelease ?? false
|
||||
};
|
||||
|
||||
// Save settings to database
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
Cable,
|
||||
Copy,
|
||||
Loader2,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Tag
|
||||
} from 'lucide-svelte';
|
||||
import { broom } from '@lucide/lab';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
@@ -78,6 +79,8 @@
|
||||
import BatchUpdateModal from './BatchUpdateModal.svelte';
|
||||
import CheckUpdatesButton from '$lib/components/CheckUpdatesButton.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.svelte';
|
||||
import VersionUpdateModal from '$lib/components/VersionUpdateModal.svelte';
|
||||
import type { ContainerInfo } from '$lib/types';
|
||||
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
|
||||
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
|
||||
@@ -274,6 +277,14 @@
|
||||
const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds);
|
||||
const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames);
|
||||
|
||||
// Version-update (semver) release-notes modal — opened from the Tag badge.
|
||||
let versionModalContainer = $state<ContainerInfo | null>(null);
|
||||
let versionModalNewer = $state<import('$lib/server/semver/find-newer').NewerVersion | null>(null);
|
||||
function openVersionModal(container: ContainerInfo, newer: import('$lib/server/semver/find-newer').NewerVersion) {
|
||||
versionModalContainer = container;
|
||||
versionModalNewer = newer;
|
||||
}
|
||||
|
||||
// Single container update mode (doesn't overwrite batch list)
|
||||
let singleUpdateContainerId = $state<string | null>(null);
|
||||
let singleUpdateContainerName = $state<string | null>(null);
|
||||
@@ -307,10 +318,16 @@
|
||||
// Set of container IDs with updates available (for O(1) lookup)
|
||||
const containersWithUpdatesSet = $derived(new Set(batchUpdateContainerIds));
|
||||
|
||||
// Whether any semver badges are showing (may be true with zero digest updates).
|
||||
const hasNewerVersions = $derived($containerStore.newerVersions.size > 0);
|
||||
|
||||
// Container IDs whose last update check failed (e.g. registry rate-limited) — #1255
|
||||
const containersWithFailedCheckSet = $derived(new Set($containerStore.failedUpdateIds));
|
||||
const failedUpdateErrors = $derived($containerStore.failedUpdateErrors);
|
||||
|
||||
// Newer-version-tag (semver) suggestions from the last check, keyed by container ID.
|
||||
const newerVersionsMap = $derived($containerStore.newerVersions);
|
||||
|
||||
// Filter dropdown entries: real statuses plus the synthetic
|
||||
// "update-available" entry, only offered once we know about a pending
|
||||
// update — picking it on an empty set would just empty the list (#1063).
|
||||
@@ -466,6 +483,7 @@
|
||||
function handleUpdateCheckComplete(result: {
|
||||
withUpdates: Array<{ containerId: string; containerName: string }>;
|
||||
failed?: Array<{ containerId: string; error: string }>;
|
||||
newerVersions?: Array<{ containerId: string; newerVersion: import('$lib/server/semver/find-newer').NewerVersion }>;
|
||||
}) {
|
||||
if (result.withUpdates.length === 0) {
|
||||
containerStore.setPendingUpdates([], new Map());
|
||||
@@ -483,6 +501,9 @@
|
||||
failed.map((f) => f.containerId),
|
||||
new Map(failed.map((f) => [f.containerId, f.error]))
|
||||
);
|
||||
// Newer-version-tag (semver) suggestions — advisory badge, session-only.
|
||||
const newer = result.newerVersions ?? [];
|
||||
containerStore.setNewerVersions(new Map(newer.map((n) => [n.containerId, n.newerVersion])));
|
||||
}
|
||||
|
||||
// Load pending updates from database (persisted from check-updates or scheduled jobs)
|
||||
@@ -521,6 +542,8 @@
|
||||
// Failed-check state is session-only — clear it here too so "Clear"
|
||||
// dismisses the red "check failed" icons alongside the amber ones.
|
||||
containerStore.setFailedUpdates([], new Map());
|
||||
// Newer-version (semver) badges are session-only too — dismiss them alongside.
|
||||
containerStore.setNewerVersions(new Map());
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to clear update indicators');
|
||||
@@ -1432,6 +1455,24 @@
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
{#if hasNewerVersions}
|
||||
<!-- Newer version tags. Shown whether or not digest updates also exist.
|
||||
Only carries its own dismiss (×) when there's no "Update all" button
|
||||
(whose × already clears both) - so we never show two dismiss controls. -->
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="border-amber-500/40 text-amber-600 hover:bg-amber-500/10 hover:border-amber-500"
|
||||
onclick={updatableContainersCount === 0 ? dismissPendingUpdates : undefined}
|
||||
title={updatableContainersCount === 0 ? 'Dismiss version indicators' : 'Containers running a pinned tag with a newer version published'}
|
||||
>
|
||||
<Tag class="w-3.5 h-3.5" />
|
||||
{$containerStore.newerVersions.size} newer version{$containerStore.newerVersions.size !== 1 ? 's' : ''}
|
||||
{#if updatableContainersCount === 0}
|
||||
<span class="ml-1 text-[12px] leading-none opacity-40">×</span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if $canAccess('containers', 'remove')}
|
||||
<ConfirmPopover
|
||||
open={confirmPrune}
|
||||
@@ -1779,6 +1820,13 @@
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{#if newerVersionsMap.has(container.id)}
|
||||
<VersionUpdateBadge
|
||||
newerVersion={newerVersionsMap.get(container.id)!}
|
||||
variant="pill"
|
||||
onclick={() => openVersionModal(container, newerVersionsMap.get(container.id)!)}
|
||||
/>
|
||||
{/if}
|
||||
<span class="text-xs text-muted-foreground truncate" title={container.image}>{container.image}</span>
|
||||
</div>
|
||||
{:else if column.id === 'state'}
|
||||
@@ -2442,6 +2490,12 @@
|
||||
onComplete={handleBatchUpdateComplete}
|
||||
/>
|
||||
|
||||
<VersionUpdateModal
|
||||
bind:container={versionModalContainer}
|
||||
newerVersion={versionModalNewer}
|
||||
{envId}
|
||||
/>
|
||||
|
||||
<BatchOperationModal
|
||||
bind:open={showBatchOpModal}
|
||||
title={batchOpTitle}
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
{ id: 'auto_update_failed', label: 'Update failed', description: 'Container auto-update failed' },
|
||||
{ id: 'auto_update_blocked', label: 'Update blocked by vulns', description: 'Update blocked due to vulnerability criteria' },
|
||||
{ id: 'updates_detected', label: 'Updates detected', description: 'Container image updates are available (scheduled check)' },
|
||||
{ id: 'newer_version_available', label: 'Newer version tag', description: 'A newer version tag is published for a pinned image (semver, advisory)' },
|
||||
{ id: 'batch_update_success', label: 'Batch update completed', description: 'Scheduled container updates completed successfully' }
|
||||
]
|
||||
},
|
||||
@@ -532,6 +533,11 @@
|
||||
let updateCheckAutoUpdate = $state(false);
|
||||
let updateCheckVulnerabilityCriteria = $state<VulnerabilityCriteria>('never');
|
||||
let updateCheckLoading = $state(false);
|
||||
// Semver (newer-version-tag) detection — persisted in the same update-check JSON blob.
|
||||
let semverEnabled = $state(false);
|
||||
let semverMaxBump = $state<'patch' | 'minor' | 'major'>('major');
|
||||
let semverMatchFlavor = $state(true);
|
||||
let semverIncludePrerelease = $state(false);
|
||||
|
||||
// Image prune settings state
|
||||
let imagePruneEnabled = $state(false);
|
||||
@@ -1222,12 +1228,20 @@
|
||||
updateCheckCron = data.settings.cron || '0 4 * * *';
|
||||
updateCheckAutoUpdate = data.settings.autoUpdate ?? false;
|
||||
updateCheckVulnerabilityCriteria = data.settings.vulnerabilityCriteria || 'never';
|
||||
semverEnabled = data.settings.checkPinnedVersions ?? false;
|
||||
semverMaxBump = data.settings.semverMaxBump || 'major';
|
||||
semverMatchFlavor = data.settings.semverMatchFlavor ?? true;
|
||||
semverIncludePrerelease = data.settings.semverIncludePrerelease ?? false;
|
||||
} else {
|
||||
// No settings found - use defaults
|
||||
updateCheckEnabled = false;
|
||||
updateCheckCron = '0 4 * * *';
|
||||
updateCheckAutoUpdate = false;
|
||||
updateCheckVulnerabilityCriteria = 'never';
|
||||
semverEnabled = false;
|
||||
semverMaxBump = 'major';
|
||||
semverMatchFlavor = true;
|
||||
semverIncludePrerelease = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1246,7 +1260,11 @@
|
||||
enabled: updateCheckEnabled,
|
||||
cron: updateCheckCron,
|
||||
autoUpdate: updateCheckAutoUpdate,
|
||||
vulnerabilityCriteria: updateCheckVulnerabilityCriteria
|
||||
vulnerabilityCriteria: updateCheckVulnerabilityCriteria,
|
||||
checkPinnedVersions: semverEnabled,
|
||||
semverMaxBump,
|
||||
semverMatchFlavor,
|
||||
semverIncludePrerelease
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -2579,6 +2597,10 @@
|
||||
bind:updateCheckAutoUpdate={updateCheckAutoUpdate}
|
||||
bind:updateCheckVulnerabilityCriteria={updateCheckVulnerabilityCriteria}
|
||||
scannerEnabled={scannerEnabled}
|
||||
bind:semverEnabled={semverEnabled}
|
||||
bind:semverMaxBump={semverMaxBump}
|
||||
bind:semverMatchFlavor={semverMatchFlavor}
|
||||
bind:semverIncludePrerelease={semverIncludePrerelease}
|
||||
imagePruneLoading={imagePruneLoading}
|
||||
bind:imagePruneEnabled={imagePruneEnabled}
|
||||
bind:imagePruneCron={imagePruneCron}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
|
||||
import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
|
||||
import SemverCheckConfig from '$lib/components/SemverCheckConfig.svelte';
|
||||
import { CircleFadingArrowUp, CircleArrowUp, RefreshCw, Info, Trash2 } from 'lucide-svelte';
|
||||
import { formatDateTime } from '$lib/stores/settings';
|
||||
import { formatBytes } from '$lib/utils/format';
|
||||
@@ -17,6 +18,11 @@
|
||||
updateCheckAutoUpdate: boolean;
|
||||
updateCheckVulnerabilityCriteria: VulnerabilityCriteria;
|
||||
scannerEnabled: boolean;
|
||||
// Semver (newer-version-tag) detection — rides the same update check
|
||||
semverEnabled: boolean;
|
||||
semverMaxBump: 'patch' | 'minor' | 'major';
|
||||
semverMatchFlavor: boolean;
|
||||
semverIncludePrerelease: boolean;
|
||||
// Image prune settings
|
||||
imagePruneLoading: boolean;
|
||||
imagePruneEnabled: boolean;
|
||||
@@ -35,6 +41,10 @@
|
||||
updateCheckAutoUpdate = $bindable(),
|
||||
updateCheckVulnerabilityCriteria = $bindable(),
|
||||
scannerEnabled,
|
||||
semverEnabled = $bindable(),
|
||||
semverMaxBump = $bindable(),
|
||||
semverMatchFlavor = $bindable(),
|
||||
semverIncludePrerelease = $bindable(),
|
||||
imagePruneLoading,
|
||||
imagePruneEnabled = $bindable(),
|
||||
imagePruneCron = $bindable(),
|
||||
@@ -118,6 +128,13 @@
|
||||
<span>You'll receive notifications when updates are available. Containers won't be modified.</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<SemverCheckConfig
|
||||
bind:enabled={semverEnabled}
|
||||
bind:maxBump={semverMaxBump}
|
||||
bind:matchFlavor={semverMatchFlavor}
|
||||
bind:includePrerelease={semverIncludePrerelease}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
|
||||
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical, Globe, CircleArrowUp, NotepadText } from 'lucide-svelte';
|
||||
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical, Globe, CircleArrowUp, NotepadText, Tag } from 'lucide-svelte';
|
||||
import { formatPorts } from '$lib/utils/port-format';
|
||||
import { parseCustomUrl } from '$lib/utils/custom-url';
|
||||
import { extractTraefikUrls } from '$lib/utils/traefik-urls';
|
||||
@@ -24,7 +24,7 @@
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
|
||||
import type { ComposeStackInfo, ContainerStats, StackContainer } from '$lib/types';
|
||||
import StackModal from './StackModal.svelte';
|
||||
import DeleteStackModal from './DeleteStackModal.svelte';
|
||||
import GitSourceBadge from './GitSourceBadge.svelte';
|
||||
@@ -36,6 +36,8 @@
|
||||
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
|
||||
import BatchUpdateModal from '../containers/BatchUpdateModal.svelte';
|
||||
import CheckUpdatesButton from '$lib/components/CheckUpdatesButton.svelte';
|
||||
import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.svelte';
|
||||
import VersionUpdateModal from '$lib/components/VersionUpdateModal.svelte';
|
||||
import LogsPanel from '../logs/LogsPanel.svelte';
|
||||
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
|
||||
import { onDockerEvent, isContainerListChange } from '$lib/stores/events';
|
||||
@@ -81,6 +83,12 @@
|
||||
let showBatchUpdateModal = $state(false);
|
||||
let singleUpdateContainerId = $state<string | null>(null);
|
||||
let singleUpdateContainerName = $state<string | null>(null);
|
||||
|
||||
// Version-update (semver) release-notes modal — opened from a container's Tag badge.
|
||||
let versionModalContainer = $state<StackContainer | null>(null);
|
||||
function openVersionModal(container: StackContainer) {
|
||||
versionModalContainer = container;
|
||||
}
|
||||
let envHasScanning = $state(false);
|
||||
let envVulnerabilityCriteria = $state<'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current'>('never');
|
||||
|
||||
@@ -772,7 +780,8 @@
|
||||
// True when any stack container shows an update-available (amber) or
|
||||
// check-failed (red) indicator — gates the "dismiss indicators" button.
|
||||
const hasUpdateIndicators = $derived(
|
||||
stacks.some((s) => s.updatesAvailable) || failedUpdateCheckIds.size > 0
|
||||
stacks.some((s) => s.updatesAvailable || (s.newerVersionCount ?? 0) > 0) ||
|
||||
failedUpdateCheckIds.size > 0
|
||||
);
|
||||
|
||||
// Dismiss both the amber "update available" and red "check failed" indicators.
|
||||
@@ -1766,6 +1775,17 @@
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if (stack.newerVersionCount ?? 0) > 0}
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="inline-flex items-center gap-0.5 self-center shrink-0 text-amber-500">
|
||||
<Tag class="w-3.5 h-3.5" />
|
||||
<span class="text-2xs font-medium leading-none">{stack.newerVersionCount}</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
{stack.newerVersionCount} container{(stack.newerVersionCount ?? 0) > 1 ? 's have' : ' has'} a newer version tag.
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
</span>
|
||||
{:else if column.id === 'source'}
|
||||
{#if source.sourceType === 'git'}
|
||||
@@ -2202,6 +2222,13 @@
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
{/if}
|
||||
{#if container.newerVersion}
|
||||
<VersionUpdateBadge
|
||||
newerVersion={container.newerVersion}
|
||||
variant="pill"
|
||||
onclick={() => openVersionModal(container)}
|
||||
/>
|
||||
{/if}
|
||||
<span class="flex-1"></span>
|
||||
{#if container.health}
|
||||
<span title={container.health}>
|
||||
@@ -2700,6 +2727,12 @@
|
||||
onComplete={handleSingleUpdateComplete}
|
||||
/>
|
||||
|
||||
<VersionUpdateModal
|
||||
bind:container={versionModalContainer}
|
||||
newerVersion={versionModalContainer?.newerVersion ?? null}
|
||||
{envId}
|
||||
/>
|
||||
|
||||
<BatchOperationModal
|
||||
bind:open={showBatchOpModal}
|
||||
title={batchOpTitle}
|
||||
|
||||
Reference in new issue
Block a user