detect newer version tags for pinned images and show them as a badge with release notes
This commit is contained in:
17 files changed
+529
-144
No files matched your search
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { CircleArrowUp, Tag } from 'lucide-svelte';
|
||||
import { scale } from 'svelte/transition';
|
||||
import { backOut } from 'svelte/easing';
|
||||
|
||||
interface Props {
|
||||
/** Whether any update indicator is present (digest, newer-version, or failed check). */
|
||||
show: boolean;
|
||||
/** Number of items with a digest update - shows the up-arrow icon + count. */
|
||||
digestCount: number;
|
||||
/** Number of items with a newer-version tag - shows the tag icon + count. */
|
||||
newerVersionCount: number;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
let { show, digestCount, newerVersionCount, onDismiss }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div transition:scale={{ duration: 220, start: 0.85, opacity: 0, easing: backOut }} class="flex">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={onDismiss}
|
||||
class="gap-1.5 border-amber-500/40 text-amber-600 hover:bg-amber-500/10 hover:border-amber-500"
|
||||
title="Dismiss all update indicators"
|
||||
>
|
||||
{#if digestCount > 0}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<CircleArrowUp class="w-3.5 h-3.5" />
|
||||
<span class="text-xs font-medium tabular-nums">{digestCount}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if newerVersionCount > 0}
|
||||
<span class="flex items-center gap-0.5">
|
||||
<Tag class="w-2.5 h-2.5" />
|
||||
<span class="text-xs font-medium tabular-nums">{newerVersionCount}</span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if digestCount === 0 && newerVersionCount === 0}
|
||||
<!-- only failed checks remain - fall back to the update icon -->
|
||||
<CircleArrowUp class="w-3.5 h-3.5" />
|
||||
{/if}
|
||||
<span class="text-base leading-none opacity-50">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -118,7 +118,7 @@
|
||||
{#if enabled && !isFloating}
|
||||
<div class="ml-6 flex items-start gap-2 rounded-md bg-muted/50 p-2 text-xs text-muted-foreground">
|
||||
<Info class="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<span>A newer version shows as a badge on the container — it is <strong class="text-foreground font-semibold">never auto-applied</strong>. Bump the tag in your compose yourself.</span>
|
||||
<span>A newer version shows as a badge on the container — it is <strong class="text-foreground font-semibold">never auto-applied</strong>.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+133
-18
@@ -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": [
|
||||
|
||||
@@ -9,11 +9,15 @@
|
||||
* - dockhand.port.<hostPort>.url=<url> — Override the click URL for a specific published port
|
||||
* - dockhand.order=<int> — 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<string, string> | 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<string, string> | 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.
|
||||
|
||||
+42
-6
@@ -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<GlobalSemverConfig> {
|
||||
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<void> {
|
||||
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<EnvUpdateCheckSettings | null> {
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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('(?<major>')) 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user