- A newer version shows as a badge on the container — it is never auto-applied. Bump the tag in your compose yourself.
+ A newer version shows as a badge on the container — it is never auto-applied.
{/if}
diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json
index 8fbf1e7..23a1a81 100644
--- a/src/lib/openapi.generated.json
+++ b/src/lib/openapi.generated.json
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "Dockhand API",
- "version": "1.0.42",
+ "version": "1.0.43",
"description": "Auto-generated from src/routes/**/+server.ts by scripts/generate-openapi.ts. Path, HTTP method, tag, and auth requirement are derived automatically from the route tree and hooks.server.ts PUBLIC_PATHS. Parameters, response status codes, and request body fields are additionally auto-detected from each handler’s own source (query params via url.searchParams, status codes via status:/error(), body fields via destructuring) — adding a new endpoint therefore requires ZERO manual spec edits to show up with real params/responses. An optional, additive `@openapi` JSDoc annotation on a handler replaces the generic auto-descriptions with hand-written summaries, exact types, and examples."
},
"servers": [
@@ -11946,18 +11946,6 @@
},
"vulnerabilityCriteria": {
"type": "string"
- },
- "checkPinnedVersions": {
- "type": "boolean"
- },
- "semverMaxBump": {
- "type": "string"
- },
- "semverMatchFlavor": {
- "type": "boolean"
- },
- "semverIncludePrerelease": {
- "type": "boolean"
}
}
},
@@ -11965,11 +11953,7 @@
"enabled": true,
"cron": "0 4 * * *",
"autoUpdate": false,
- "vulnerabilityCriteria": "never",
- "checkPinnedVersions": true,
- "semverMaxBump": "minor",
- "semverMatchFlavor": true,
- "semverIncludePrerelease": false
+ "vulnerabilityCriteria": "never"
}
}
}
@@ -23482,6 +23466,134 @@
]
}
},
+ "/api/settings/semver": {
+ "get": {
+ "operationId": "get_api_settings_semver",
+ "tags": [
+ "settings"
+ ],
+ "summary": "Get the global newer-version-tag (semver) detection config",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "maxBump": {
+ "type": "string"
+ },
+ "matchFlavor": {
+ "type": "boolean"
+ },
+ "includePrerelease": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "enabled",
+ "maxBump",
+ "matchFlavor",
+ "includePrerelease"
+ ]
+ },
+ "example": {
+ "enabled": true,
+ "maxBump": "minor",
+ "matchFlavor": true,
+ "includePrerelease": false
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ]
+ },
+ "post": {
+ "operationId": "post_api_settings_semver",
+ "tags": [
+ "settings"
+ ],
+ "summary": "Set the global newer-version-tag (semver) detection config",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "success": {
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "success"
+ ]
+ },
+ "example": {
+ "success": true
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Permission denied (needs settings:edit)"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "maxBump": {
+ "type": "string"
+ },
+ "matchFlavor": {
+ "type": "boolean"
+ },
+ "includePrerelease": {
+ "type": "boolean"
+ }
+ }
+ },
+ "example": {
+ "enabled": true,
+ "maxBump": "minor",
+ "matchFlavor": true,
+ "includePrerelease": false
+ }
+ }
+ }
+ }
+ }
+ },
"/api/settings/theme": {
"get": {
"operationId": "get_api_settings_theme",
@@ -25184,6 +25296,9 @@
"type": "string"
}
}
+ },
+ "envVars": {
+ "type": "string"
}
},
"required": [
diff --git a/src/lib/server/container-labels.ts b/src/lib/server/container-labels.ts
index fcfa0a7..65addd2 100644
--- a/src/lib/server/container-labels.ts
+++ b/src/lib/server/container-labels.ts
@@ -9,11 +9,15 @@
* - dockhand.port..url= — Override the click URL for a specific published port
* - dockhand.order= — Controls display order within a stack (lower = first, default 0)
* - dockhand.adopt=false — Prevent this stack from being adopted (any container in the stack)
+ * - dockhand.version.pattern=regex:... — Override how the newer-version check reads
+ * this image's tags, for non-standard tag schemes (named groups major/minor/patch)
*
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
* The opt-out model means labels override DB settings (label wins).
*/
+import { compileVersionPattern } from './semver/tag-parser';
+
/** Recognized Dockhand label keys */
export const DOCKHAND_LABELS = {
UPDATE: 'dockhand.update',
@@ -22,6 +26,7 @@ export const DOCKHAND_LABELS = {
URL: 'dockhand.url',
ORDER: 'dockhand.order',
ADOPT: 'dockhand.adopt',
+ VERSION_PATTERN: 'dockhand.version.pattern',
} as const;
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
@@ -69,6 +74,17 @@ export function isHiddenByLabel(labels: Record | undefined | nul
return value === true; // explicitly hidden
}
+/**
+ * Compile this container's `dockhand.version.pattern` label into a RegExp for the
+ * newer-version check's override, or null when absent/invalid. A bad pattern
+ * degrades to null (default parser), so a typo never breaks the update check.
+ */
+export function getVersionPatternOverride(
+ labels: Record | undefined | null
+): RegExp | null {
+ return compileVersionPattern(getLabel(labels, DOCKHAND_LABELS.VERSION_PATTERN));
+}
+
/**
* Check if notifications should be suppressed for this container.
* Returns true if dockhand.notify is explicitly set to false/no/0.
diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts
index e08842c..fe4ceb5 100644
--- a/src/lib/server/db.ts
+++ b/src/lib/server/db.ts
@@ -4868,12 +4868,48 @@ 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;
+}
+
+/**
+ * Global newer-version-tag (semver) detection config. Per-env settings decide
+ * WHEN/whether to check on a schedule; this decides HOW versions are read, and
+ * applies to every check - scheduled and manual alike.
+ */
+export interface GlobalSemverConfig {
+ enabled: boolean;
+ maxBump: 'patch' | 'minor' | 'major';
+ matchFlavor: boolean;
+ includePrerelease: boolean;
+}
+
+const GLOBAL_SEMVER_KEY = 'global_semver_check';
+const DEFAULT_SEMVER_CONFIG: GlobalSemverConfig = {
+ enabled: false,
+ maxBump: 'major',
+ matchFlavor: true,
+ includePrerelease: false
+};
+
+export async function getGlobalSemverConfig(): Promise {
+ const result = await db.select().from(settings).where(eq(settings.key, GLOBAL_SEMVER_KEY));
+ if (!result[0]) return { ...DEFAULT_SEMVER_CONFIG };
+ try {
+ return { ...DEFAULT_SEMVER_CONFIG, ...JSON.parse(result[0].value) };
+ } catch {
+ return { ...DEFAULT_SEMVER_CONFIG };
+ }
+}
+
+export async function setGlobalSemverConfig(config: GlobalSemverConfig): Promise {
+ const value = JSON.stringify(config);
+ const existing = await db.select().from(settings).where(eq(settings.key, GLOBAL_SEMVER_KEY));
+ if (existing.length > 0) {
+ await db.update(settings)
+ .set({ value, updatedAt: new Date().toISOString() })
+ .where(eq(settings.key, GLOBAL_SEMVER_KEY));
+ } else {
+ await db.insert(settings).values({ key: GLOBAL_SEMVER_KEY, value });
+ }
}
export async function getEnvUpdateCheckSettings(envId: number): Promise {
diff --git a/src/lib/server/scheduler/tasks/env-update-check.ts b/src/lib/server/scheduler/tasks/env-update-check.ts
index b1b64ea..047eae1 100644
--- a/src/lib/server/scheduler/tasks/env-update-check.ts
+++ b/src/lib/server/scheduler/tasks/env-update-check.ts
@@ -8,6 +8,7 @@
import type { ScheduleTrigger, VulnerabilityCriteria } from '../../db';
import {
getEnvUpdateCheckSettings,
+ getGlobalSemverConfig,
getEnvironment,
createScheduleExecution,
updateScheduleExecution,
@@ -37,7 +38,7 @@ import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { resolveBlockDecision } from './block-decision';
-import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
+import { isUpdateDisabledByLabel, isHiddenByLabel, getVersionPatternOverride } from '../../container-labels';
import { recreateContainer } from './container-update';
interface UpdateInfo {
@@ -110,12 +111,14 @@ 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;
+ // Semver "newer version tag" detection is a global setting - it rides this
+ // same pass when enabled, and the manual check reads the same config.
+ const semverConfig = await getGlobalSemverConfig();
+ const semverEnabled = semverConfig.enabled;
const semverOptions = {
- maxBump: config.semverMaxBump ?? 'major',
- matchFlavor: config.semverMatchFlavor ?? true,
- includePrerelease: config.semverIncludePrerelease ?? false
+ maxBump: semverConfig.maxBump,
+ matchFlavor: semverConfig.matchFlavor,
+ includePrerelease: semverConfig.includePrerelease
} 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.
@@ -221,7 +224,10 @@ export async function runEnvUpdateCheckJob(
// 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);
+ // A `dockhand.version.pattern` label lets a container teach the check
+ // how to read its own non-standard tags (CalVer+hash, etc.).
+ const versionPattern = getVersionPatternOverride(inspectData.Config?.Labels);
+ const newer = await checkNewerVersion(imageName, { ...semverOptions, versionPattern }).catch(() => null);
if (newer) {
semverByContainer.set(container.id, newer);
await log(` NEWER VERSION: ${newer.tag} (${newer.bump})`);
diff --git a/src/lib/server/semver/check.ts b/src/lib/server/semver/check.ts
index 29eea14..0de744b 100644
--- a/src/lib/server/semver/check.ts
+++ b/src/lib/server/semver/check.ts
@@ -20,7 +20,9 @@ export async function checkNewerVersion(
const { tag } = parseImageReference(imageRef);
// Floating tag -> nothing to compare, and we skip the registry call entirely.
- if (!parseTag(tag)) return null;
+ // Parse with the same override find-newer will use, so a custom-scheme tag
+ // isn't wrongly dismissed as floating before the registry fetch.
+ if (!parseTag(tag, options.versionPattern ?? null)) return null;
const tags = await listVersionTags(imageRef);
return findNewerVersionTag(tag, tags, options);
diff --git a/src/lib/server/semver/find-newer.ts b/src/lib/server/semver/find-newer.ts
index 37708c2..4b986bc 100644
--- a/src/lib/server/semver/find-newer.ts
+++ b/src/lib/server/semver/find-newer.ts
@@ -25,6 +25,12 @@ export interface FindNewerOptions {
includePrerelease?: boolean;
/** Require the same flavor/suffix (`-alpine`). Default true — the #1 noise-killer. */
matchFlavor?: boolean;
+ /**
+ * A compiled `dockhand.version.pattern` override (from compileVersionPattern).
+ * When set, both the current tag and every candidate are parsed with it, so an
+ * image with a non-standard tag scheme can still be compared. Absent = default.
+ */
+ versionPattern?: RegExp | null;
}
export interface NewerVersion {
@@ -65,15 +71,16 @@ export function findNewerVersionTag(
allTags: string[],
options: FindNewerOptions = {}
): NewerVersion | null {
- const current = parseTag(currentTag);
+ const { maxBump = 'major', includePrerelease = false, matchFlavor = true, versionPattern = null } = options;
+
+ const current = parseTag(currentTag, versionPattern);
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) }))
+ .map((tag) => ({ tag, parsed: parseTag(tag, versionPattern) }))
.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))
diff --git a/src/lib/server/semver/tag-parser.ts b/src/lib/server/semver/tag-parser.ts
index 8ca4092..49849f6 100644
--- a/src/lib/server/semver/tag-parser.ts
+++ b/src/lib/server/semver/tag-parser.ts
@@ -23,8 +23,18 @@ export interface ParsedTag {
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 {
+/**
+ * Parse a tag into its version parts, or null when it has no version (`latest`,
+ * `stable`, a sha). An optional `override` regex (from a `dockhand.version.pattern`
+ * label) replaces the default detection for images whose tags don't match the
+ * generic dotted-number shape (e.g. a CalVer-plus-hash like `2024.12.5-a1b2c3d`).
+ * The override must capture numeric named groups `major`/`minor`/`patch` (patch
+ * optional, plus any further `p4`/`p5`); those become `parts`. A tag the override
+ * doesn't match is treated as a non-version (null) - same as a floating tag.
+ */
+export function parseTag(tag: string, override?: RegExp | null): ParsedTag | null {
+ if (override) return parseWithOverride(tag, override);
+
const match = VERSION_RE.exec(tag);
if (!match) return null;
@@ -37,6 +47,36 @@ export function parseTag(tag: string): ParsedTag | null {
};
}
+// Numeric named groups the override may capture, in version order. Only `major`
+// is required; the rest fill in as present.
+const OVERRIDE_PART_GROUPS = ['major', 'minor', 'patch', 'p4', 'p5'] as const;
+
+function parseWithOverride(tag: string, override: RegExp): ParsedTag | null {
+ const match = override.exec(tag);
+ if (!match || !match.groups) return null;
+
+ const parts: number[] = [];
+ for (const name of OVERRIDE_PART_GROUPS) {
+ const raw = match.groups[name];
+ if (raw === undefined) break; // stop at the first absent segment
+ const n = Number(raw);
+ if (!Number.isFinite(n)) return null; // a captured non-number is not a version
+ parts.push(n);
+ }
+ if (parts.length === 0) return null; // no `major` captured -> not a version
+
+ // The whole match is the "version"; prefix/suffix are the text around it so
+ // flavor matching (e.g. `-alpine`) keeps working under an override too.
+ const version = match[0];
+ const index = match.index;
+ return {
+ version,
+ prefix: tag.slice(0, index),
+ suffix: tag.slice(index + version.length),
+ parts
+ };
+}
+
/**
* 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`).
@@ -93,3 +133,37 @@ export function compareParts(a: ParsedTag, b: ParsedTag): number {
}
return 0;
}
+
+// The `dockhand.version.pattern` label value must start with this scheme, leaving
+// room for future non-regex strategies (e.g. `calver:`) under the same label.
+const VERSION_PATTERN_SCHEME = 'regex:';
+const MAX_PATTERN_LENGTH = 300;
+// Nested quantifiers (`(a+)+`, `(a*)*`, `(a+)*`) are the classic catastrophic-
+// backtracking shape. Refuse them rather than run an attacker-supplied ReDoS on
+// every tag. A conservative reject: a group closed with a quantifier immediately
+// followed by another quantifier.
+const NESTED_QUANTIFIER_RE = /[+*}]\s*\)[+*?]|\([^)]*[+*][^)]*\)[+*]/;
+
+/**
+ * Compile a `dockhand.version.pattern` label value into a RegExp for parseTag's
+ * override, or null when absent/invalid. Never throws - a bad pattern degrades to
+ * the default parser (null), so a typo can't break the update check. Requires the
+ * `regex:` scheme, a `major` named group, a sane length, and rejects obvious
+ * catastrophic-backtracking shapes (the value comes from an untrusted image label).
+ */
+export function compileVersionPattern(value: string | undefined | null): RegExp | null {
+ if (!value) return null;
+ const trimmed = value.trim();
+ if (!trimmed.startsWith(VERSION_PATTERN_SCHEME)) return null;
+
+ const source = trimmed.slice(VERSION_PATTERN_SCHEME.length);
+ if (source.length === 0 || source.length > MAX_PATTERN_LENGTH) return null;
+ if (!source.includes('(?')) return null; // must yield at least a major
+ if (NESTED_QUANTIFIER_RE.test(source)) return null; // ReDoS guard
+
+ try {
+ return new RegExp(source);
+ } catch {
+ return null; // invalid regex -> fall back to the default parser
+ }
+}
diff --git a/src/routes/api/containers/check-updates/+server.ts b/src/routes/api/containers/check-updates/+server.ts
index 6c17ed4..7077826 100644
--- a/src/routes/api/containers/check-updates/+server.ts
+++ b/src/routes/api/containers/check-updates/+server.ts
@@ -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 {
diff --git a/src/routes/api/environments/[id]/update-check/+server.ts b/src/routes/api/environments/[id]/update-check/+server.ts
index daf13e4..a18cafe 100644
--- a/src/routes/api/environments/[id]/update-check/+server.ts
+++ b/src/routes/api/environments/[id]/update-check/+server.ts
@@ -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
diff --git a/src/routes/api/settings/semver/+server.ts b/src/routes/api/settings/semver/+server.ts
new file mode 100644
index 0000000..f30b7f8
--- /dev/null
+++ b/src/routes/api/settings/semver/+server.ts
@@ -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 });
+};
diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte
index e0eeb2d..1e30a83 100644
--- a/src/routes/containers/+page.svelte
+++ b/src/routes/containers/+page.svelte
@@ -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([]);
// 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 @@
>
Update all ({updatableContainersCount})
-
{/snippet}
{/if}
- {#if hasNewerVersions}
-
-
- {/if}
+
{#if $canAccess('containers', 'remove')}
('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}
diff --git a/src/routes/settings/environments/tabs/UpdatesTab.svelte b/src/routes/settings/environments/tabs/UpdatesTab.svelte
index a8f5234..059b22b 100644
--- a/src/routes/settings/environments/tabs/UpdatesTab.svelte
+++ b/src/routes/settings/environments/tabs/UpdatesTab.svelte
@@ -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 @@
You'll receive notifications when updates are available. Containers won't be modified.
{/if}
-
-
{/if}
{/if}
diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte
index b4e786c..e83f073 100644
--- a/src/routes/settings/general/GeneralTab.svelte
+++ b/src/routes/settings/general/GeneralTab.svelte
@@ -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();
+ });
@@ -829,6 +876,28 @@ services:
+
+
+
+
+ Newer version tags
+
+
+ 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.
+
+
+
+
+
+
+
diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte
index 29e7bf9..0a9d97e 100644
--- a/src/routes/stacks/+page.svelte
+++ b/src/routes/stacks/+page.svelte
@@ -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}
-
- {/if}
+