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

This commit is contained in:
jarek committed 2026-08-19 14:26:45 +02:00
1 parent 29795c074c
commit 99b1672be5
17 files changed
+529 -144

No files matched your search

@@ -2,9 +2,9 @@ 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, getEnvUpdateCheckSettings } from '$lib/server/db';
import { clearPendingContainerUpdates, addPendingContainerUpdate, getPendingContainerUpdates, getGlobalSemverConfig } from '$lib/server/db';
import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
import { isUpdateDisabledByLabel, isHiddenByLabel, getVersionPatternOverride } 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';
@@ -106,15 +106,15 @@ 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
// Semver "newer version tag" detection is a global setting; when enabled, each
// pinned-version container is also compared against the registry's tag list.
const envSettings = envIdNum ? await getEnvUpdateCheckSettings(envIdNum) : null;
const semverEnabled = !!envSettings?.checkPinnedVersions;
// The scheduled check reads the same config.
const semverConfig = await getGlobalSemverConfig();
const semverEnabled = semverConfig.enabled;
const semverOptions = {
maxBump: envSettings?.semverMaxBump ?? 'major',
matchFlavor: envSettings?.semverMatchFlavor ?? true,
includePrerelease: envSettings?.semverIncludePrerelease ?? false
maxBump: semverConfig.maxBump,
matchFlavor: semverConfig.matchFlavor,
includePrerelease: semverConfig.includePrerelease
} as const;
// Check container for updates
@@ -153,7 +153,10 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
// 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)
? await checkNewerVersion(imageName, {
...semverOptions,
versionPattern: getVersionPatternOverride(inspectData.Config?.Labels)
}).catch(() => null)
: null;
return {
@@ -57,8 +57,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
* @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}
* body: {enabled:boolean, cron:string, autoUpdate:boolean, vulnerabilityCriteria:string}
* body-example: {"enabled":true,"cron":"0 4 * * *","autoUpdate":false,"vulnerabilityCriteria":"never"}
* 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
@@ -85,11 +85,7 @@ 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',
checkPinnedVersions: data.checkPinnedVersions ?? false,
semverMaxBump: data.semverMaxBump || 'major',
semverMatchFlavor: data.semverMatchFlavor ?? true,
semverIncludePrerelease: data.semverIncludePrerelease ?? false
vulnerabilityCriteria: data.vulnerabilityCriteria || 'never'
};
// Save settings to database
+41
View File
@@ -0,0 +1,41 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getGlobalSemverConfig, setGlobalSemverConfig, type GlobalSemverConfig } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
/**
* @openapi
* summary: Get the global newer-version-tag (semver) detection config
* resp-200: {enabled:boolean!, maxBump:string!, matchFlavor:boolean!, includePrerelease:boolean!}
* resp-200-example: {"enabled":true,"maxBump":"minor","matchFlavor":true,"includePrerelease":false}
*/
export const GET: RequestHandler = async ({ cookies }) => {
await authorize(cookies);
return json(await getGlobalSemverConfig());
};
const MAX_BUMPS = new Set(['patch', 'minor', 'major']);
/**
* @openapi
* summary: Set the global newer-version-tag (semver) detection config
* body: {enabled:boolean, maxBump:string, matchFlavor:boolean, includePrerelease:boolean}
* body-example: {"enabled":true,"maxBump":"minor","matchFlavor":true,"includePrerelease":false}
* resp-200: {success:boolean!}
* resp-403: Permission denied (needs settings:edit)
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const data = await request.json().catch(() => ({}));
const config: GlobalSemverConfig = {
enabled: data.enabled === true,
maxBump: MAX_BUMPS.has(data.maxBump) ? data.maxBump : 'major',
matchFlavor: data.matchFlavor ?? true,
includePrerelease: data.includePrerelease === true
};
await setGlobalSemverConfig(config);
return json({ success: true });
};
+47 -38
View File
@@ -78,6 +78,7 @@
import FileBrowserModal from './FileBrowserModal.svelte';
import BatchUpdateModal from './BatchUpdateModal.svelte';
import CheckUpdatesButton from '$lib/components/CheckUpdatesButton.svelte';
import DismissUpdatesButton from '$lib/components/DismissUpdatesButton.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.svelte';
import VersionUpdateModal from '$lib/components/VersionUpdateModal.svelte';
@@ -131,6 +132,7 @@
// pseudo-status (#1063), which ANDs with actual Docker states.
const STATUS_FILTER_STORAGE_KEY = 'dockhand-containers-status-filter';
const UPDATE_AVAILABLE_FILTER_VALUE = 'update-available';
const NEWER_VERSION_FILTER_VALUE = 'newer-version';
let statusFilter = $state<string[]>([]);
// Status types with icons for filter and table
@@ -325,25 +327,39 @@
const containersWithFailedCheckSet = $derived(new Set($containerStore.failedUpdateIds));
const failedUpdateErrors = $derived($containerStore.failedUpdateErrors);
// Any update indicator on the page - digest updates, newer-version tags, or failed
// checks. Drives the compact "dismiss all" (×) button.
const hasUpdateIndicators = $derived(
$containerStore.pendingUpdateIds.length > 0 ||
hasNewerVersions ||
$containerStore.failedUpdateIds.length > 0
);
// 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).
const filterOptions = $derived(
containersWithUpdatesSet.size > 0
? [
...statusTypes,
{
value: UPDATE_AVAILABLE_FILTER_VALUE,
label: 'Update available',
icon: CircleArrowUp,
color: 'text-amber-500'
}
]
: statusTypes
);
const filterOptions = $derived([
...statusTypes,
...(containersWithUpdatesSet.size > 0
? [{
value: UPDATE_AVAILABLE_FILTER_VALUE,
label: 'Update available',
icon: CircleArrowUp,
color: 'text-amber-500'
}]
: []),
...(hasNewerVersions
? [{
value: NEWER_VERSION_FILTER_VALUE,
label: 'Newer version',
icon: Tag,
color: 'text-amber-500'
}]
: [])
]);
// Drop the 'update-available' filter when no pending updates remain —
// otherwise the user has no way to deselect it (dropdown hides the
@@ -355,6 +371,11 @@
) {
statusFilter = statusFilter.filter((v) => v !== UPDATE_AVAILABLE_FILTER_VALUE);
}
// Same guard for the newer-version filter: drop it once no container has a
// newer version, or the dropdown hides the entry and the list stays empty.
if (statusFilter.includes(NEWER_VERSION_FILTER_VALUE) && !hasNewerVersions) {
statusFilter = statusFilter.filter((v) => v !== NEWER_VERSION_FILTER_VALUE);
}
});
// Count of updatable containers (excluding system containers like Dockhand/Hawser)
@@ -745,14 +766,20 @@
// Filter by status. The synthetic 'update-available' value (#1063)
// is split off so it ANDs with real-state selections instead of
// being treated like another Docker state.
const stateValues = statusFilter.filter((v) => v !== UPDATE_AVAILABLE_FILTER_VALUE);
const stateValues = statusFilter.filter(
(v) => v !== UPDATE_AVAILABLE_FILTER_VALUE && v !== NEWER_VERSION_FILTER_VALUE
);
const updatesOnly = statusFilter.includes(UPDATE_AVAILABLE_FILTER_VALUE);
const newerVersionOnly = statusFilter.includes(NEWER_VERSION_FILTER_VALUE);
if (stateValues.length > 0) {
result = result.filter((c) => stateValues.includes(c.state.toLowerCase()));
}
if (updatesOnly) {
result = result.filter((c) => containersWithUpdatesSet.has(c.id));
}
if (newerVersionOnly) {
result = result.filter((c) => newerVersionsMap.has(c.id));
}
// Filter by search query
if (searchQuery.trim()) {
@@ -1445,34 +1472,16 @@
>
<CircleArrowUp class="w-3.5 h-3.5" />
Update all ({updatableContainersCount})
<button
type="button"
onclick={(e) => { e.stopPropagation(); dismissPendingUpdates(); }}
class="-mr-1 text-[12px] leading-none rounded-full hover:bg-destructive/20 hover:text-destructive transition-colors opacity-40 hover:opacity-100"
title="Dismiss all update indicators"
>×</button>
</Button>
{/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}
<DismissUpdatesButton
show={hasUpdateIndicators}
digestCount={updatableContainersCount}
newerVersionCount={$containerStore.newerVersions.size}
onDismiss={dismissPendingUpdates}
/>
{#if $canAccess('containers', 'remove')}
<ConfirmPopover
open={confirmPrune}
@@ -533,11 +533,6 @@
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);
@@ -1228,20 +1223,12 @@
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) {
@@ -1260,11 +1247,7 @@
enabled: updateCheckEnabled,
cron: updateCheckCron,
autoUpdate: updateCheckAutoUpdate,
vulnerabilityCriteria: updateCheckVulnerabilityCriteria,
checkPinnedVersions: semverEnabled,
semverMaxBump,
semverMatchFlavor,
semverIncludePrerelease
vulnerabilityCriteria: updateCheckVulnerabilityCriteria
})
});
} catch (error) {
@@ -2597,10 +2580,6 @@
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,7 +5,6 @@
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';
@@ -18,11 +17,6 @@
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;
@@ -41,10 +35,6 @@
updateCheckAutoUpdate = $bindable(),
updateCheckVulnerabilityCriteria = $bindable(),
scannerEnabled,
semverEnabled = $bindable(),
semverMaxBump = $bindable(),
semverMatchFlavor = $bindable(),
semverIncludePrerelease = $bindable(),
imagePruneLoading,
imagePruneEnabled = $bindable(),
imagePruneCron = $bindable(),
@@ -128,13 +118,6 @@
<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>
@@ -18,6 +18,8 @@
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
import IndentGuidesToggle from '$lib/components/IndentGuidesToggle.svelte';
import ColoredActionsToggle from '$lib/components/ColoredActionsToggle.svelte';
import SemverCheckConfig from '$lib/components/SemverCheckConfig.svelte';
import { onMount } from 'svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
// General settings state - these derive from the store
@@ -281,6 +283,51 @@ services:
toast.success('Backup image updated');
}
}
// Global newer-version-tag (semver) detection - one setting every update check
// (scheduled and manual) reads.
let semverEnabled = $state(false);
let semverMaxBump = $state<'patch' | 'minor' | 'major'>('major');
let semverMatchFlavor = $state(true);
let semverIncludePrerelease = $state(false);
let semverLoaded = $state(false);
onMount(async () => {
try {
const res = await fetch('/api/settings/semver');
if (res.ok) {
const c = await res.json();
semverEnabled = c.enabled ?? false;
semverMaxBump = c.maxBump ?? 'major';
semverMatchFlavor = c.matchFlavor ?? true;
semverIncludePrerelease = c.includePrerelease ?? false;
}
} catch { /* keep defaults */ }
semverLoaded = true;
});
async function saveSemverConfig() {
try {
await fetch('/api/settings/semver', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: semverEnabled,
maxBump: semverMaxBump,
matchFlavor: semverMatchFlavor,
includePrerelease: semverIncludePrerelease
})
});
} catch {
toast.error('Failed to save version-check settings');
}
}
// Persist on any change once the initial load is done (skip the load itself).
$effect(() => {
semverEnabled; semverMaxBump; semverMatchFlavor; semverIncludePrerelease;
if (semverLoaded) saveSemverConfig();
});
</script>
<div class="flex-1 min-h-0 overflow-y-auto">
@@ -829,6 +876,28 @@ services:
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<Tags class="w-4 h-4" />
Newer version tags
</Card.Title>
<Card.Description>
How the update check looks for newer version tags on pinned images. Applies to every
check - scheduled and manual. Per-environment settings decide whether to check on a
schedule; this decides how versions are compared.
</Card.Description>
</Card.Header>
<Card.Content>
<SemverCheckConfig
bind:enabled={semverEnabled}
bind:maxBump={semverMaxBump}
bind:matchFlavor={semverMatchFlavor}
bind:includePrerelease={semverIncludePrerelease}
/>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
+13 -12
View File
@@ -36,6 +36,7 @@
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
import BatchUpdateModal from '../containers/BatchUpdateModal.svelte';
import CheckUpdatesButton from '$lib/components/CheckUpdatesButton.svelte';
import DismissUpdatesButton from '$lib/components/DismissUpdatesButton.svelte';
import VersionUpdateBadge from '$lib/components/VersionUpdateBadge.svelte';
import VersionUpdateModal from '$lib/components/VersionUpdateModal.svelte';
import LogsPanel from '../logs/LogsPanel.svelte';
@@ -783,6 +784,12 @@
stacks.some((s) => s.updatesAvailable || (s.newerVersionCount ?? 0) > 0) ||
failedUpdateCheckIds.size > 0
);
// Counts for the compact dismiss: stacks with a digest update, and total containers
// with a newer version tag - each shown with its icon, mirroring the containers page.
const stackDigestCount = $derived(stacks.filter((s) => s.updatesAvailable).length);
const stackNewerVersionCount = $derived(
stacks.reduce((sum, s) => sum + (s.newerVersionCount ?? 0), 0)
);
// Dismiss both the amber "update available" and red "check failed" indicators.
// Update-available lives in the shared pending_container_updates table (same
@@ -1482,18 +1489,12 @@
fetchStacks();
}}
/>
{#if hasUpdateIndicators}
<Button
size="sm"
variant="ghost"
onclick={dismissStackUpdates}
class="h-8 gap-1 text-muted-foreground hover:text-destructive"
title="Dismiss all update indicators"
>
<X class="w-3.5 h-3.5" />
Clear
</Button>
{/if}
<DismissUpdatesButton
show={hasUpdateIndicators}
digestCount={stackDigestCount}
newerVersionCount={stackNewerVersionCount}
onDismiss={dismissStackUpdates}
/>
<Button
size="sm"
variant="outline"