From 99b1672be5cbc508d8befa973cc6139ac2f2c3ae Mon Sep 17 00:00:00 2001 From: jarek Date: Wed, 19 Aug 2026 14:26:45 +0200 Subject: [PATCH] detect newer version tags for pinned images and show them as a badge with release notes --- .../components/DismissUpdatesButton.svelte | 48 ++++++ src/lib/components/SemverCheckConfig.svelte | 2 +- src/lib/openapi.generated.json | 151 +++++++++++++++--- src/lib/server/container-labels.ts | 16 ++ src/lib/server/db.ts | 48 +++++- .../scheduler/tasks/env-update-check.ts | 20 ++- src/lib/server/semver/check.ts | 4 +- src/lib/server/semver/find-newer.ts | 13 +- src/lib/server/semver/tag-parser.ts | 78 ++++++++- .../api/containers/check-updates/+server.ts | 23 +-- .../environments/[id]/update-check/+server.ts | 10 +- src/routes/api/settings/semver/+server.ts | 41 +++++ src/routes/containers/+page.svelte | 85 +++++----- .../environments/EnvironmentModal.svelte | 23 +-- .../environments/tabs/UpdatesTab.svelte | 17 -- src/routes/settings/general/GeneralTab.svelte | 69 ++++++++ src/routes/stacks/+page.svelte | 25 +-- 17 files changed, 529 insertions(+), 144 deletions(-) create mode 100644 src/lib/components/DismissUpdatesButton.svelte create mode 100644 src/routes/api/settings/semver/+server.ts diff --git a/src/lib/components/DismissUpdatesButton.svelte b/src/lib/components/DismissUpdatesButton.svelte new file mode 100644 index 0000000..4c9692a --- /dev/null +++ b/src/lib/components/DismissUpdatesButton.svelte @@ -0,0 +1,48 @@ + + +{#if show} +
+ +
+{/if} diff --git a/src/lib/components/SemverCheckConfig.svelte b/src/lib/components/SemverCheckConfig.svelte index f953d7c..cebbf47 100644 --- a/src/lib/components/SemverCheckConfig.svelte +++ b/src/lib/components/SemverCheckConfig.svelte @@ -118,7 +118,7 @@ {#if enabled && !isFloating}
- 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} +