-
-
-
- {appVersion}
-
-
-
-
-
fnsys/dockhand:{appVersion}
- {#if buildCommit}
-
Commit: {buildCommit.slice(0, 7)}
- {/if}
-
-
-
+
+ {#if $selfUpdate.updateAvailable}
+
+
+
+ {@render versionTooltip()}
+
+
+ {/if}
+
+
+
+
+ {appVersion}
+ {#if $selfUpdate.updateAvailable}
+
+
+
+ {/if}
+
+
+ {@render versionTooltip()}
+
diff --git a/src/lib/components/cron-editor.svelte b/src/lib/components/cron-editor.svelte
index 961593d..45e939d 100644
--- a/src/lib/components/cron-editor.svelte
+++ b/src/lib/components/cron-editor.svelte
@@ -172,22 +172,24 @@
}
});
- // Generate hours array based on time format preference
+ // Generate hours array based on time format preference. 24h shows just the
+ // 2-digit hour so the combined picker reads "at 03 :15" rather than
+ // the confusing "at 03:00 :15" (#1198).
const hours = $derived(
Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: is12Hour
? i === 0 ? '12 AM' : i < 12 ? `${i} AM` : i === 12 ? '12 PM' : `${i - 12} PM`
- : i.toString().padStart(2, '0') + ':00'
+ : i.toString().padStart(2, '0')
}))
);
- const minutes = [
- { value: '0', label: ':00' },
- { value: '15', label: ':15' },
- { value: '30', label: ':30' },
- { value: '45', label: ':45' }
- ];
+ // 5-minute granularity — fine enough to stagger notifications without
+ // drowning the dropdown in 60 entries (#1198).
+ const minutes = Array.from({ length: 12 }, (_, i) => ({
+ value: String(i * 5),
+ label: ':' + (i * 5).toString().padStart(2, '0')
+ }));
const daysOfWeek = [
{ value: '1', label: 'Monday' },
diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json
index 944158c..bde1c15 100644
--- a/src/lib/data/changelog.json
+++ b/src/lib/data/changelog.json
@@ -1,4 +1,26 @@
[
+ {
+ "version": "1.0.36",
+ "date": "2026-06-27",
+ "changes": [
+ { "type": "feature", "text": "sidebar shows an amber update indicator when a newer Dockhand image is available (#1146)" },
+ { "type": "fix", "text": "Tag image modal too narrow for images identified only by SHA (#1205)" },
+ { "type": "fix", "text": "self-hosted ntfy: accept raw `tk_...` access tokens in `?auth=` (#1209)" },
+ { "type": "fix", "text": "dashboard: a single failed DB stats query no longer poisons the whole environment tile (#1210)" },
+ { "type": "fix", "text": "UI dates and times now honor the configured default timezone instead of the browser's timezone (#1183)" },
+ { "type": "fix", "text": "add missing `reset-mfa.sh` emergency script referenced by the manual (#1214)" },
+ { "type": "fix", "text": "registry browser: copy between registries no longer duplicates the host (#1220)" },
+ { "type": "improvement", "text": "published image carries standard OCI annotations (source, url, title, description, vendor, licenses) (#1217)" },
+ { "type": "feature", "text": "scanner: configurable network mode and DNS servers for vulnerability scans (#1219)" },
+ { "type": "feature", "text": "template tiles now show a `Project` link that opens the upstream project page (#1211)" },
+ { "type": "improvement", "text": "cron picker: hour shows just `HH` (was `HH:00`), minute granularity bumped from 15 to 5 (#1198)" },
+ { "type": "feature", "text": "grid buttons: configurable size and optional semantic colors (#1072)" },
+ { "type": "fix", "text": "copy buttons (git deploy logs, API tokens) now work over plain HTTP (#1222)" },
+ { "type": "fix", "text": "Podman pod-infra containers no longer trigger update-check warnings (#1221)" },
+ { "type": "fix", "text": "terminal exec works even when the browser sends a cookie with stray `%` characters (#1224)" }
+ ],
+ "imageTag": "fnsys/dockhand:v1.0.36"
+ },
{
"version": "1.0.35",
"date": "2026-06-19",
diff --git a/src/lib/server/api-tokens.ts b/src/lib/server/api-tokens.ts
index 1416c59..44b2a1f 100644
--- a/src/lib/server/api-tokens.ts
+++ b/src/lib/server/api-tokens.ts
@@ -46,12 +46,11 @@ function cacheKey(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}
-// Pre-computed dummy hash for timing protection on invalid prefixes
let dummyHash: string | null = null;
async function getDummyHash(): Promise
{
if (!dummyHash) {
- dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
+ dummyHash = await hashPassword('dh_init_seed');
}
return dummyHash;
}
diff --git a/src/lib/server/authorize.ts b/src/lib/server/authorize.ts
index 968b198..197c68e 100644
--- a/src/lib/server/authorize.ts
+++ b/src/lib/server/authorize.ts
@@ -36,6 +36,7 @@
*/
import type { Cookies } from '@sveltejs/kit';
+import { json } from '@sveltejs/kit';
import type { Permissions } from './db';
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
@@ -105,6 +106,27 @@ export interface AuthorizationContext {
* - (User is admin OR has audit_logs view permission)
*/
canViewAuditLog: () => Promise;
+
+ /**
+ * Gate handler: require a permission. Returns a 403 Response on denial,
+ * null when allowed. Use at the top of a handler with `??`-chaining for
+ * one-line auth/env checks:
+ *
+ * const denied = (await auth.requirePermission('schedules', 'edit'))
+ * ?? (await auth.requireEnvAccess(envId));
+ * if (denied) return denied;
+ *
+ * No-op when authEnabled is false.
+ */
+ requirePermission: (resource: keyof Permissions, action: string, environmentId?: number) => Promise;
+
+ /**
+ * Gate handler: require access to the given environment. Returns a 403
+ * Response on denial, null when allowed. Pass null/undefined to no-op
+ * (e.g. system-scoped operations that don't target an env). No-op when
+ * authEnabled is false.
+ */
+ requireEnvAccess: (environmentId: number | null | undefined) => Promise;
}
/**
@@ -232,6 +254,25 @@ export async function authorize(cookies: Cookies): Promise
// Check for audit_logs permission
return checkPermission(user, 'audit_logs' as keyof Permissions, 'view');
+ },
+
+ async requirePermission(
+ resource: keyof Permissions,
+ action: string,
+ environmentId?: number
+ ): Promise {
+ if (!authEnabled) return null;
+ return (await ctx.can(resource, action, environmentId))
+ ? null
+ : json({ error: 'Permission denied' }, { status: 403 });
+ },
+
+ async requireEnvAccess(environmentId: number | null | undefined): Promise {
+ if (!authEnabled) return null;
+ if (environmentId === null || environmentId === undefined) return null;
+ return (await ctx.canAccessEnvironment(environmentId))
+ ? null
+ : json({ error: 'Access denied to this environment' }, { status: 403 });
}
};
diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts
index 91ccad2..03596ad 100644
--- a/src/lib/server/db.ts
+++ b/src/lib/server/db.ts
@@ -389,8 +389,10 @@ export async function getUserThemePreferences(userId: number): Promise<{
terminalFont: string;
editorFont: string;
animateIcons: boolean;
+ coloredActionButtons: boolean;
+ actionIconSize: string;
}> {
- const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
+ const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons, coloredActionButtons, actionIconSize] = await Promise.all([
getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'),
@@ -398,7 +400,9 @@ export async function getUserThemePreferences(userId: number): Promise<{
getUserSetting(userId, 'grid_font_size'),
getUserSetting(userId, 'terminal_font'),
getUserSetting(userId, 'editor_font'),
- getUserSetting(userId, 'animate_icons')
+ getUserSetting(userId, 'animate_icons'),
+ getUserSetting(userId, 'colored_action_buttons'),
+ getUserSetting(userId, 'action_icon_size')
]);
return {
lightTheme: lightTheme || 'default',
@@ -409,13 +413,16 @@ export async function getUserThemePreferences(userId: number): Promise<{
terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono',
// Default ON — only false when explicitly stored
- animateIcons: animateIcons === 'false' ? false : true
+ animateIcons: animateIcons === 'false' ? false : true,
+ // Default OFF — only true when explicitly stored
+ coloredActionButtons: coloredActionButtons === 'true',
+ actionIconSize: actionIconSize || 'normal'
};
}
export async function setUserThemePreferences(
userId: number,
- prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
+ prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string }
): Promise {
const updates: Promise[] = [];
if (prefs.lightTheme !== undefined) {
@@ -442,6 +449,12 @@ export async function setUserThemePreferences(
if (prefs.animateIcons !== undefined) {
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
}
+ if (prefs.coloredActionButtons !== undefined) {
+ updates.push(setUserSetting(userId, 'colored_action_buttons', prefs.coloredActionButtons ? 'true' : 'false'));
+ }
+ if (prefs.actionIconSize !== undefined) {
+ updates.push(setUserSetting(userId, 'action_icon_size', prefs.actionIconSize));
+ }
await Promise.all(updates);
}
diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts
index 3a9bf28..187ea12 100644
--- a/src/lib/server/docker.ts
+++ b/src/lib/server/docker.ts
@@ -6,14 +6,14 @@
*/
import { homedir } from 'node:os';
-import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
-import { join, resolve } from 'node:path';
+import { existsSync } from 'node:fs';
+import { join } from 'node:path';
import * as http from 'node:http';
import * as https from 'node:https';
import * as tls from 'node:tls';
import { createHash } from 'node:crypto';
import type { Environment } from './db';
-import { getStackEnvVarsAsRecord, getSetting } from './db';
+import { getSetting } from './db';
import { getAdditionalVolumeBinds } from './mount-dedupe';
import { encodeRegistryAuth } from './registry-auth';
import { isSystemContainer } from './scheduler/tasks/update-utils';
@@ -4501,6 +4501,8 @@ export async function runContainer(options: {
extraHosts?: string[];
name?: string;
envId?: number | null;
+ networkMode?: string; // Docker network mode (e.g., 'host', 'bridge', custom network name)
+ dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
}): Promise<{ stdout: string; stderr: string }> {
// Add random suffix to avoid naming conflicts
const baseName = options.name || `dockhand-temp-${Date.now()}`;
@@ -4524,6 +4526,14 @@ export async function runContainer(options: {
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
}
+ if (options.networkMode) {
+ containerConfig.HostConfig.NetworkMode = options.networkMode;
+ }
+
+ if (options.dns && options.dns.length > 0) {
+ containerConfig.HostConfig.Dns = options.dns;
+ }
+
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{
@@ -4591,6 +4601,7 @@ export async function runContainerWithStreaming(options: {
onStderr?: (data: string) => void;
timeout?: number; // Overall timeout in ms (0 or undefined = no timeout)
networkMode?: string; // Docker network mode (e.g., network name for TCP access)
+ dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
}): Promise {
const baseName = options.name || `dockhand-stream-${Date.now()}`;
const containerName = `${baseName}-${randomSuffix()}`;
@@ -4625,6 +4636,10 @@ export async function runContainerWithStreaming(options: {
containerConfig.HostConfig.NetworkMode = options.networkMode;
}
+ if (options.dns && options.dns.length > 0) {
+ containerConfig.HostConfig.Dns = options.dns;
+ }
+
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{ method: 'POST', body: JSON.stringify(containerConfig) },
diff --git a/src/lib/server/notifications/ntfy.ts b/src/lib/server/notifications/ntfy.ts
index 89b47eb..e6c35ce 100644
--- a/src/lib/server/notifications/ntfy.ts
+++ b/src/lib/server/notifications/ntfy.ts
@@ -1,13 +1,40 @@
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
+// HTTP header values must be ByteString (every char ≤ 0xFF). Reject anything
+// else so we never blow up fetch() with a 65533 (U+FFFD) replacement char.
+function isHeaderSafe(value: string): boolean {
+ for (let i = 0; i < value.length; i++) {
+ if (value.charCodeAt(i) > 0xff) return false;
+ }
+ return true;
+}
+
+// ?auth= accepts either a raw ntfy access token (typically `tk_...`) or a
+// base64-encoded `Bearer ` / `Basic ` string. We try base64 first
+// only when the input looks plausibly base64; if decoding produces non-ASCII
+// garbage we fall back to treating the value as a raw token.
+export function resolveQueryAuth(queryAuth: string): string {
+ if (queryAuth.startsWith('tk_')) {
+ return `Bearer ${queryAuth}`;
+ }
+ const looksBase64 = /^[A-Za-z0-9+/=_-]+$/.test(queryAuth);
+ if (looksBase64) {
+ const decoded = Buffer.from(queryAuth, 'base64').toString();
+ if (isHeaderSafe(decoded) && (decoded.startsWith('Bearer ') || decoded.startsWith('Basic '))) {
+ return decoded;
+ }
+ }
+ return `Bearer ${queryAuth}`;
+}
+
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
- // ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
+ // ntfy://host/topic?auth=TOKEN (raw token, e.g. tk_..., or base64-encoded "Bearer ")
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
@@ -53,8 +80,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
}
if (!authHeader && queryAuth) {
- const decoded = Buffer.from(queryAuth, 'base64').toString();
- authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
+ authHeader = resolveQueryAuth(queryAuth);
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts
index d020862..65e85cd 100644
--- a/src/lib/server/scanner.ts
+++ b/src/lib/server/scanner.ts
@@ -28,6 +28,7 @@ import {
} from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown, rm } from 'node:fs/promises';
+import { resolveScannerOverrides } from '$lib/utils/scanner-overrides';
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
@@ -164,24 +165,47 @@ export interface ScanProgress {
output?: string; // Line of scanner output
}
+function parseDnsSetting(raw: string | null | undefined): string[] {
+ if (!raw) return [];
+ const trimmed = raw.trim();
+ if (!trimmed) return [];
+ // Tolerate either JSON-array storage (preferred) or comma-separated string
+ // (defensive — UI submits JSON, but legacy / hand-edited values may exist).
+ if (trimmed.startsWith('[')) {
+ try {
+ const parsed = JSON.parse(trimmed);
+ return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
+ } catch {
+ return [];
+ }
+ }
+ return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
+}
+
// Get global default scanner CLI args and images from general settings (or fallback to hardcoded defaults)
export async function getGlobalScannerDefaults(): Promise<{
grypeArgs: string;
trivyArgs: string;
grypeImage: string;
trivyImage: string;
+ networkMode: string;
+ dns: string[];
}> {
- const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([
+ const [grypeArgs, trivyArgs, grypeImage, trivyImage, networkMode, dns] = await Promise.all([
getSetting('default_grype_args'),
getSetting('default_trivy_args'),
getSetting('default_grype_image'),
- getSetting('default_trivy_image')
+ getSetting('default_trivy_image'),
+ getSetting('default_scanner_network_mode'),
+ getSetting('default_scanner_dns')
]);
return {
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS,
grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE,
- trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE
+ trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE,
+ networkMode: networkMode ?? '',
+ dns: parseDnsSetting(dns)
};
}
@@ -747,6 +771,19 @@ async function runScannerContainerCore(
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
}
+ // Apply user-configured overrides on top of auto-detection (#1219).
+ // Empty user settings → resolver returns the auto-detected values unchanged.
+ const userScannerSettings = await getGlobalScannerDefaults();
+ const overrides = resolveScannerOverrides(
+ { networkMode: scannerNetworkMode, extraHosts: scannerExtraHosts },
+ { networkMode: userScannerSettings.networkMode, dns: userScannerSettings.dns }
+ );
+ if (userScannerSettings.networkMode || userScannerSettings.dns.length > 0) {
+ console.log(
+ `[Scanner] User overrides applied — network=${overrides.networkMode ?? 'default'}, dns=${overrides.dns?.join(',') ?? 'default'}`
+ );
+ }
+
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
// Run the scanner container with a 10-minute timeout to prevent indefinite hangs
@@ -755,10 +792,11 @@ async function runScannerContainerCore(
cmd,
binds,
env: envVars,
- extraHosts: scannerExtraHosts,
+ extraHosts: overrides.extraHosts,
name: `dockhand-${scannerType}-${Date.now()}`,
envId,
- networkMode: scannerNetworkMode,
+ networkMode: overrides.networkMode,
+ dns: overrides.dns,
timeout: 600_000, // 10 minutes
onStderr: (data) => {
// Stream stderr lines for real-time progress output
@@ -1076,14 +1114,22 @@ async function getScannerVersion(
);
if (!hasImage) return null;
- // Create temporary container to get version
+ // Create temporary container to get version. Apply user overrides so
+ // version probe works in environments where default networking fails
+ // (#1219 — host needs --network host for scanner images to resolve DNS).
const versionCmd = scannerType === 'grype' ? ['version'] : ['--version'];
+ const overrides = resolveScannerOverrides(
+ {},
+ { networkMode: defaults.networkMode, dns: defaults.dns }
+ );
console.log(`[Scanner] Getting ${scannerType} version with cmd:`, versionCmd);
const { stdout, stderr } = await runContainer({
image: scannerImage,
cmd: versionCmd,
name: `dockhand-${scannerType}-version-${Date.now()}`,
- envId
+ envId,
+ networkMode: overrides.networkMode,
+ dns: overrides.dns
});
console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`);
diff --git a/src/lib/server/scheduler/tasks/container-update.ts b/src/lib/server/scheduler/tasks/container-update.ts
index 5e0cdd3..50547c2 100644
--- a/src/lib/server/scheduler/tasks/container-update.ts
+++ b/src/lib/server/scheduler/tasks/container-update.ts
@@ -37,7 +37,7 @@ import {
} from '../../docker';
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
-import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
+import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
// =============================================================================
@@ -343,6 +343,19 @@ export async function runContainerUpdate(
const inspectData = await inspectContainer(container.id, envId) as any;
const imageNameFromConfig = inspectData.Config?.Image;
+ // Podman pod-infra containers may have an empty Config.Image — silently
+ // skip them before the "Could not determine image name" failure (#1221).
+ if (isPodmanInfraContainer(imageNameFromConfig, inspectData.Config?.Labels)) {
+ log(`Skipping Podman pod-infra container`);
+ await updateScheduleExecution(execution.id, {
+ status: 'skipped',
+ completedAt: new Date().toISOString(),
+ duration: Date.now() - startTime,
+ details: { reason: 'Podman pod-infra container' }
+ });
+ return;
+ }
+
if (!imageNameFromConfig) {
log(`Could not determine image name from container config`);
await updateScheduleExecution(execution.id, {
diff --git a/src/lib/server/scheduler/tasks/env-update-check.ts b/src/lib/server/scheduler/tasks/env-update-check.ts
index a5a3b60..0199893 100644
--- a/src/lib/server/scheduler/tasks/env-update-check.ts
+++ b/src/lib/server/scheduler/tasks/env-update-check.ts
@@ -30,7 +30,7 @@ import {
} from '../../docker';
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
-import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
+import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
import { recreateContainer } from './container-update';
@@ -108,9 +108,12 @@ export async function runEnvUpdateCheckJob(
// Get all containers in this environment, excluding ones hidden via
// dockhand.hidden=true (consistent with manual check-updates, #1083).
const allContainers = await listContainers(true, environmentId);
- const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
+ // Skip hidden + Podman pod-infra (#1083, #1221)
+ const containers = allContainers.filter(
+ (c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
+ );
const hiddenCount = allContainers.length - containers.length;
- await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
+ await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden/infra)` : ''}`);
const updatesAvailable: UpdateInfo[] = [];
let checkedCount = 0;
diff --git a/src/lib/server/scheduler/tasks/update-utils.ts b/src/lib/server/scheduler/tasks/update-utils.ts
index ab7582e..16e8f96 100644
--- a/src/lib/server/scheduler/tasks/update-utils.ts
+++ b/src/lib/server/scheduler/tasks/update-utils.ts
@@ -127,6 +127,22 @@ export function isSystemContainer(imageName: string): SystemContainerType | null
return null;
}
+/**
+ * Podman creates a hidden "pod-infra" container for every pod it runs (#1221).
+ * The image (`localhost/podman-pause`) is generated locally and never published
+ * to any registry, so update checks always fail noisily. The label is the
+ * authoritative signal — but Podman may set Config.Image empty for these
+ * containers, so the name fallback catches that too.
+ */
+export function isPodmanInfraContainer(
+ imageName: string | undefined,
+ labels: Record | undefined
+): boolean {
+ if (labels?.['io.podman.annotations.infra'] === 'true') return true;
+ if (imageName && /podman-pause/i.test(imageName)) return true;
+ return false;
+}
+
/**
* Combine multiple scan summaries by taking the maximum of each severity level.
*/
diff --git a/src/lib/server/ws-auth.ts b/src/lib/server/ws-auth.ts
index c4c77e2..524170c 100644
--- a/src/lib/server/ws-auth.ts
+++ b/src/lib/server/ws-auth.ts
@@ -2,6 +2,7 @@ import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth';
import { validateApiToken } from './api-tokens';
import { isEnterprise } from './license';
import { userHasAdminRole, userCanAccessEnvironment } from './db';
+import { parseCookieHeader } from '$lib/utils/cookie-parse';
export interface WsUpgradeAuth {
userId: number;
@@ -10,20 +11,6 @@ export interface WsUpgradeAuth {
authDisabled: boolean;
}
-function parseCookieHeader(header: string | undefined): Record {
- if (!header) return {};
- const out: Record = {};
- for (const part of header.split(';')) {
- const eq = part.indexOf('=');
- if (eq < 0) continue;
- const k = part.slice(0, eq).trim();
- let v = part.slice(eq + 1).trim();
- if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
- if (k) out[k] = decodeURIComponent(v);
- }
- return out;
-}
-
type LowercasedHeaders = Record;
function pickHeader(headers: LowercasedHeaders, name: string): string | undefined {
diff --git a/src/lib/stores/self-update.ts b/src/lib/stores/self-update.ts
new file mode 100644
index 0000000..99b73f3
--- /dev/null
+++ b/src/lib/stores/self-update.ts
@@ -0,0 +1,98 @@
+/**
+ * Session-scoped Dockhand self-update check.
+ *
+ * Fires once per browser session (first sidebar mount). The result is shared
+ * across every consumer (sidebar indicator, Settings → About) so we never
+ * hit the registry more than once unless the user clicks "Check now."
+ *
+ * Endpoint requires admin; non-admin users see {error, updateAvailable: false}
+ * and the consumers render nothing. No retry, no polling.
+ */
+import { writable } from 'svelte/store';
+import { browser } from '$app/environment';
+
+export interface SelfUpdateState {
+ checked: boolean;
+ updateAvailable: boolean;
+ currentImage?: string;
+ currentDigest?: string;
+ registryDigest?: string;
+ /** Parsed semver like "1.0.36" — present when latestVersion came back from the server. */
+ latestVersion?: string;
+ /** Resolved newer image tag (registry/repo:newTag). */
+ newImage?: string;
+}
+
+const initial: SelfUpdateState = {
+ checked: false,
+ updateAvailable: false
+};
+
+function createStore() {
+ const { subscribe, set } = writable(initial);
+ let inFlight: Promise | null = null;
+ let didCheck = false;
+
+ async function checkOnce(force = false): Promise {
+ if (!browser) return;
+ if (inFlight) return inFlight;
+ if (didCheck && !force) return;
+
+ inFlight = (async () => {
+ try {
+ const res = await fetch('/api/self-update/check', {
+ headers: force ? { 'Cache-Control': 'no-cache' } : undefined
+ });
+ if (!res.ok) {
+ set({ ...initial, checked: true });
+ return;
+ }
+ const data = await res.json();
+ set({
+ checked: true,
+ updateAvailable: !!data.updateAvailable,
+ currentImage: data.currentImage,
+ currentDigest: data.currentDigest,
+ registryDigest: data.registryDigest,
+ latestVersion: data.latestVersion,
+ newImage: data.newImage
+ });
+ } catch {
+ set({ ...initial, checked: true });
+ } finally {
+ didCheck = true;
+ inFlight = null;
+ }
+ })();
+
+ return inFlight;
+ }
+
+ return {
+ subscribe,
+ checkOnce,
+ /** Force a fresh check (e.g. user clicked "Check now" in About). */
+ refresh: () => checkOnce(true),
+ /** Hand-set the result when a caller already fetched it (avoids
+ * a duplicate /api/self-update/check on the wire). */
+ setFromResponse: (data: {
+ updateAvailable?: boolean;
+ currentImage?: string;
+ currentDigest?: string;
+ registryDigest?: string;
+ latestVersion?: string;
+ newImage?: string;
+ }) =>
+ set({
+ checked: true,
+ updateAvailable: !!data.updateAvailable,
+ currentImage: data.currentImage,
+ currentDigest: data.currentDigest,
+ registryDigest: data.registryDigest,
+ latestVersion: data.latestVersion,
+ newImage: data.newImage
+ })
+ };
+}
+
+export const selfUpdate = createStore();
diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts
index 120ee08..2303880 100644
--- a/src/lib/stores/settings.ts
+++ b/src/lib/stores/settings.ts
@@ -1,5 +1,11 @@
import { writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
+import {
+ buildFormatters,
+ formatDatePartWith,
+ formatTimePartWith,
+ type DateTimeFormatters
+} from '$lib/utils/date-format';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
@@ -42,6 +48,9 @@ export interface AppSettings {
honorProxyLabels: boolean;
showImageChangelogLinks: boolean;
protectScannerImages: boolean;
+ // Scanner Advanced settings (#1219). Empty values = use auto-detection.
+ defaultScannerNetworkMode: string; // '' | 'host' | 'bridge' | 'none' |
+ defaultScannerDns: string[]; // ['1.1.1.1', '8.8.8.8']; empty = inherit
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -78,6 +87,8 @@ const DEFAULT_SETTINGS: AppSettings = {
honorProxyLabels: true,
showImageChangelogLinks: true,
protectScannerImages: true,
+ defaultScannerNetworkMode: '',
+ defaultScannerDns: [],
defaultComposeTemplate: `version: "3.8"
services:
@@ -160,7 +171,9 @@ function createSettingsStore() {
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
- protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages
+ protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
+ defaultScannerNetworkMode: settings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
+ defaultScannerDns: Array.isArray(settings.defaultScannerDns) ? settings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns
});
}
} catch {
@@ -214,7 +227,9 @@ function createSettingsStore() {
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
- protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages
+ protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
+ defaultScannerNetworkMode: updatedSettings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
+ defaultScannerDns: Array.isArray(updatedSettings.defaultScannerDns) ? updatedSettings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns
});
}
} catch (error) {
@@ -297,6 +312,20 @@ function createSettingsStore() {
return newSettings;
});
},
+ setDefaultScannerNetworkMode: (value: string) => {
+ update((current) => {
+ const newSettings = { ...current, defaultScannerNetworkMode: value };
+ saveSettings({ defaultScannerNetworkMode: value });
+ return newSettings;
+ });
+ },
+ setDefaultScannerDns: (value: string[]) => {
+ update((current) => {
+ const newSettings = { ...current, defaultScannerDns: value };
+ saveSettings({ defaultScannerDns: value });
+ return newSettings;
+ });
+ },
setScheduleRetentionDays: (value: number) => {
update((current) => {
const newSettings = { ...current, scheduleRetentionDays: value };
@@ -492,12 +521,30 @@ export const appSettings = createSettingsStore();
// Cache current settings for synchronous access (updated reactively)
let cachedTimeFormat: TimeFormat = DEFAULT_SETTINGS.timeFormat;
let cachedDateFormat: DateFormat = DEFAULT_SETTINGS.dateFormat;
+let cachedDefaultTimezone: string = DEFAULT_SETTINGS.defaultTimezone;
+
+// Intl.DateTimeFormat construction is expensive; build once per timezone
+// change and reuse for every format call (activity log can render hundreds of
+// rows). Built lazily on first format call so the heavy work runs only when
+// needed and after the store subscription has populated cachedDefaultTimezone.
+let formatters: DateTimeFormatters | null = null;
+
+function getFormatters(): DateTimeFormatters {
+ if (formatters === null) {
+ formatters = buildFormatters(cachedDefaultTimezone);
+ }
+ return formatters;
+}
// Subscribe once to keep cache updated
if (browser) {
appSettings.subscribe((s) => {
cachedTimeFormat = s.timeFormat;
cachedDateFormat = s.dateFormat;
+ if (s.defaultTimezone !== cachedDefaultTimezone) {
+ cachedDefaultTimezone = s.defaultTimezone;
+ formatters = buildFormatters(cachedDefaultTimezone);
+ }
});
}
@@ -506,21 +553,7 @@ if (browser) {
* This is a low-level helper - prefer formatDateTime for most uses.
*/
function formatDatePart(d: Date): string {
- const day = d.getDate().toString().padStart(2, '0');
- const month = (d.getMonth() + 1).toString().padStart(2, '0');
- const year = d.getFullYear();
-
- switch (cachedDateFormat) {
- case 'MM/DD/YYYY':
- return `${month}/${day}/${year}`;
- case 'DD/MM/YYYY':
- return `${day}/${month}/${year}`;
- case 'YYYY-MM-DD':
- return `${year}-${month}-${day}`;
- case 'DD.MM.YYYY':
- default:
- return `${day}.${month}.${year}`;
- }
+ return formatDatePartWith(d, getFormatters().date, cachedDateFormat);
}
/**
@@ -528,22 +561,7 @@ function formatDatePart(d: Date): string {
* This is a low-level helper - prefer formatDateTime for most uses.
*/
function formatTimePart(d: Date, includeSeconds = false): string {
- const hours = d.getHours();
- const minutes = d.getMinutes().toString().padStart(2, '0');
- const seconds = d.getSeconds().toString().padStart(2, '0');
-
- if (cachedTimeFormat === '12h') {
- const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
- const ampm = hours >= 12 ? 'PM' : 'AM';
- return includeSeconds
- ? `${hour12}:${minutes}:${seconds} ${ampm}`
- : `${hour12}:${minutes} ${ampm}`;
- } else {
- const hour24 = hours.toString().padStart(2, '0');
- return includeSeconds
- ? `${hour24}:${minutes}:${seconds}`
- : `${hour24}:${minutes}`;
- }
+ return formatTimePartWith(d, getFormatters().time, cachedTimeFormat, includeSeconds);
}
/**
diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts
index 0f06db8..ebc3a76 100644
--- a/src/lib/stores/theme.ts
+++ b/src/lib/stores/theme.ts
@@ -11,6 +11,17 @@ import { writable, get } from 'svelte/store';
import { getFont, getMonospaceFont, type FontMeta } from '$lib/themes';
export type FontSize = 'xsmall' | 'small' | 'normal' | 'medium' | 'large' | 'xlarge';
+export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge';
+
+// Pixel values for each action icon size (#1072).
+// Normal = 12px matches the historical Tailwind w-3 default — picking
+// Normal is a no-op for existing users.
+export const ACTION_ICON_SIZE_PX: Record = {
+ small: 10,
+ normal: 12,
+ large: 16,
+ xlarge: 20
+};
export interface ThemePreferences {
lightTheme: string;
@@ -21,6 +32,8 @@ export interface ThemePreferences {
terminalFont: string;
editorFont: string;
animateIcons: boolean;
+ coloredActionButtons: boolean;
+ actionIconSize: ActionIconSize;
}
const STORAGE_KEY = 'dockhand-theme';
@@ -33,7 +46,9 @@ const defaultPrefs: ThemePreferences = {
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono',
- animateIcons: true
+ animateIcons: true,
+ coloredActionButtons: false,
+ actionIconSize: 'normal'
};
// Font size scale mapping
@@ -107,7 +122,10 @@ function createThemeStore() {
animateIcons:
data.animateIcons === undefined && data.animate_icons === undefined
? true
- : !!(data.animateIcons ?? data.animate_icons)
+ : !!(data.animateIcons ?? data.animate_icons),
+ // Default OFF (#1072)
+ coloredActionButtons: !!(data.coloredActionButtons ?? data.colored_action_buttons ?? false),
+ actionIconSize: (data.actionIconSize || data.action_icon_size || 'normal') as ActionIconSize
};
set(prefs);
saveToStorage(prefs);
@@ -208,6 +226,17 @@ export function applyTheme(prefs: ThemePreferences) {
// Apply icon animation toggle (#1169) — single class on drives a CSS rule in app.css
document.documentElement.classList.toggle('no-icon-animation', !prefs.animateIcons);
+
+ // Apply colored grid action buttons + action icon size (#1072)
+ applyActionButtonStyles(prefs.coloredActionButtons, prefs.actionIconSize);
+}
+
+// Apply colored-action class and action icon size CSS var
+function applyActionButtonStyles(colored: boolean, size: ActionIconSize) {
+ if (typeof document === 'undefined') return;
+ document.documentElement.classList.toggle('colored-actions', colored);
+ const px = ACTION_ICON_SIZE_PX[size] ?? ACTION_ICON_SIZE_PX.small;
+ document.documentElement.style.setProperty('--action-icon-size', `${px}px`);
}
// Apply font to document
diff --git a/src/lib/utils/cookie-parse.ts b/src/lib/utils/cookie-parse.ts
new file mode 100644
index 0000000..0cde08b
--- /dev/null
+++ b/src/lib/utils/cookie-parse.ts
@@ -0,0 +1,32 @@
+/**
+ * Safe cookie header parsing for WebSocket upgrades.
+ *
+ * Extracted from ws-auth so it can be unit-tested without pulling in the
+ * DB/SvelteKit graph (#1224).
+ */
+
+export function safeDecode(v: string): string {
+ // Browsers freely send cookies with stray `%` from third-party trackers and
+ // legacy code. decodeURIComponent throws URIError on `%` not followed by two
+ // hex digits, which would crash the entire WS upgrade — including the
+ // session cookie we actually care about. Fall back to the raw value (#1224).
+ try {
+ return decodeURIComponent(v);
+ } catch {
+ return v;
+ }
+}
+
+export function parseCookieHeader(header: string | undefined): Record {
+ if (!header) return {};
+ const out: Record = {};
+ for (const part of header.split(';')) {
+ const eq = part.indexOf('=');
+ if (eq < 0) continue;
+ const k = part.slice(0, eq).trim();
+ let v = part.slice(eq + 1).trim();
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
+ if (k) out[k] = safeDecode(v);
+ }
+ return out;
+}
diff --git a/src/lib/utils/date-format.ts b/src/lib/utils/date-format.ts
new file mode 100644
index 0000000..9ce7d68
--- /dev/null
+++ b/src/lib/utils/date-format.ts
@@ -0,0 +1,84 @@
+/**
+ * Pure date/time formatting helpers — no SvelteKit / store imports.
+ *
+ * settings.ts wraps these with cached preferences and a reactive subscription;
+ * keeping the implementation here lets the formatters be unit-tested without
+ * dragging in $app/environment or svelte/store.
+ */
+
+export type TimeFormat = '12h' | '24h';
+export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
+
+export interface DateTimeFormatters {
+ date: Intl.DateTimeFormat;
+ time: Intl.DateTimeFormat;
+}
+
+// Intl.DateTimeFormat construction is expensive; the caller caches one pair per
+// timezone and rebuilds only when the timezone setting changes.
+export function buildFormatters(timeZone: string | undefined): DateTimeFormatters {
+ const tz = timeZone || undefined;
+ return {
+ date: new Intl.DateTimeFormat('en-GB', {
+ timeZone: tz,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ }),
+ time: new Intl.DateTimeFormat('en-GB', {
+ timeZone: tz,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ })
+ };
+}
+
+function pickPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
+ return parts.find((p) => p.type === type)?.value ?? '';
+}
+
+export function formatDatePartWith(d: Date, dateFormatter: Intl.DateTimeFormat, dateFormat: DateFormat): string {
+ const parts = dateFormatter.formatToParts(d);
+ const day = pickPart(parts, 'day');
+ const month = pickPart(parts, 'month');
+ const year = pickPart(parts, 'year');
+
+ switch (dateFormat) {
+ case 'MM/DD/YYYY':
+ return `${month}/${day}/${year}`;
+ case 'DD/MM/YYYY':
+ return `${day}/${month}/${year}`;
+ case 'YYYY-MM-DD':
+ return `${year}-${month}-${day}`;
+ case 'DD.MM.YYYY':
+ default:
+ return `${day}.${month}.${year}`;
+ }
+}
+
+export function formatTimePartWith(
+ d: Date,
+ timeFormatter: Intl.DateTimeFormat,
+ timeFormat: TimeFormat,
+ includeSeconds = false
+): string {
+ const parts = timeFormatter.formatToParts(d);
+ const hours = parseInt(pickPart(parts, 'hour'), 10);
+ const minutes = pickPart(parts, 'minute');
+ const seconds = pickPart(parts, 'second');
+
+ if (timeFormat === '12h') {
+ const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
+ const ampm = hours >= 12 ? 'PM' : 'AM';
+ return includeSeconds
+ ? `${hour12}:${minutes}:${seconds} ${ampm}`
+ : `${hour12}:${minutes} ${ampm}`;
+ } else {
+ const hour24 = hours.toString().padStart(2, '0');
+ return includeSeconds
+ ? `${hour24}:${minutes}:${seconds}`
+ : `${hour24}:${minutes}`;
+ }
+}
diff --git a/src/lib/utils/env-name.ts b/src/lib/utils/env-name.ts
index 330773c..6e1544c 100644
--- a/src/lib/utils/env-name.ts
+++ b/src/lib/utils/env-name.ts
@@ -11,12 +11,14 @@
* cause breakage are rejected.
*/
-// Allowed characters: ASCII letters/digits + _ . - ( ) space + @
+// Allowed characters: ASCII letters/digits + _ . - ( ) space + @ '
// First char: any allowed except space (no leading whitespace).
// Last char: any allowed except space and dot (no trailing whitespace,
// no trailing dot to avoid Windows-style "hidden" trailing-dot issues).
// Length: 1..64.
-export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-() +@](?:[A-Za-z0-9_.\-() +@]{0,62}[A-Za-z0-9_\-)+@])?$/;
+// Apostrophe (#1228): safe — env.name only lands in path.join() and
+// array-form spawn(), never in a shell-interpolated string.
+export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-()' +@](?:[A-Za-z0-9_.\-()' +@]{0,62}[A-Za-z0-9_\-)'+@])?$/;
export const ENV_NAME_MAX_LENGTH = 64;
@@ -39,7 +41,7 @@ export function validateEnvName(name: unknown): ValidationResult {
return {
ok: false,
reason:
- 'Name may contain letters, digits, spaces, and any of - _ . ( ) + @ (no leading/trailing whitespace, no trailing dot, no slashes, no wildcards)'
+ 'Name can\'t contain slashes, wildcards, or other shell-special characters, and can\'t start with whitespace or end with whitespace or a dot'
};
}
return { ok: true };
diff --git a/src/lib/utils/pangolin-urls.ts b/src/lib/utils/pangolin-urls.ts
index 4c171e1..264d6db 100644
--- a/src/lib/utils/pangolin-urls.ts
+++ b/src/lib/utils/pangolin-urls.ts
@@ -23,6 +23,9 @@
*
* dockhand.url labels override this — Pangolin extraction is a fallback,
* never a winner over an explicit user-provided URL.
+ *
+ * Earlier v1.0.34 used `pangolin.proxy-resources.*` — that label was never
+ * recognised by Pangolin itself and is no longer accepted here.
*/
export interface PangolinUrl {
url: string;
diff --git a/src/lib/utils/safe-redirect.ts b/src/lib/utils/safe-redirect.ts
new file mode 100644
index 0000000..e1e01eb
--- /dev/null
+++ b/src/lib/utils/safe-redirect.ts
@@ -0,0 +1,22 @@
+/**
+ * Path-relative redirect target validation.
+ *
+ * Returns true only for targets that are path-relative URLs ('/path?query#hash').
+ * Rejects absolute URLs ('https://...', 'http://...'), protocol-relative URLs
+ * ('//example.com'), backslash-prefixed forms ('\\example.com' — some browsers
+ * normalize these), and non-string inputs.
+ */
+export function isSafeRedirect(target: unknown): target is string {
+ if (typeof target !== 'string' || target.length === 0) return false;
+ if (!target.startsWith('/')) return false;
+ if (target.startsWith('//') || target.startsWith('/\\')) return false;
+ return true;
+}
+
+/**
+ * Return the input when it passes isSafeRedirect, otherwise '/'. Use this on
+ * any post-login / post-callback redirect target derived from user input.
+ */
+export function safeRedirectOrRoot(target: unknown): string {
+ return isSafeRedirect(target) ? target : '/';
+}
diff --git a/src/lib/utils/scanner-overrides.ts b/src/lib/utils/scanner-overrides.ts
new file mode 100644
index 0000000..f8ac687
--- /dev/null
+++ b/src/lib/utils/scanner-overrides.ts
@@ -0,0 +1,27 @@
+/**
+ * Pure scanner override resolver (#1219).
+ *
+ * Decides which networkMode + DNS values to pass to the scanner container,
+ * given (auto-detected, user-settings) inputs. Kept SvelteKit/server-free so
+ * the unit tests can import it without triggering DB initialisation.
+ */
+
+export function resolveScannerOverrides(
+ autoDetected: { networkMode?: string; extraHosts?: string[] },
+ userSettings: { networkMode?: string; dns?: string[] }
+): { networkMode?: string; dns?: string[]; extraHosts?: string[] } {
+ const userNet = userSettings.networkMode?.trim();
+ const networkMode = userNet ? userNet : autoDetected.networkMode || undefined;
+
+ const cleanedDns = (userSettings.dns ?? [])
+ .map((d) => d.trim())
+ .filter((d) => d.length > 0);
+ const dns = cleanedDns.length > 0 ? cleanedDns : undefined;
+
+ const extraHosts =
+ autoDetected.extraHosts && autoDetected.extraHosts.length > 0
+ ? autoDetected.extraHosts
+ : undefined;
+
+ return { networkMode, dns, extraHosts };
+}
diff --git a/src/routes/activity/+page.svelte b/src/routes/activity/+page.svelte
index aa89f40..43d3a0d 100644
--- a/src/routes/activity/+page.svelte
+++ b/src/routes/activity/+page.svelte
@@ -852,7 +852,7 @@
{:else if column.id === 'actions'}
{ e.stopPropagation(); showDetails(event); }}>
-
+
{/if}
diff --git a/src/routes/api/auth/oidc/[id]/initiate/+server.ts b/src/routes/api/auth/oidc/[id]/initiate/+server.ts
index c36d4e3..a837e40 100644
--- a/src/routes/api/auth/oidc/[id]/initiate/+server.ts
+++ b/src/routes/api/auth/oidc/[id]/initiate/+server.ts
@@ -2,6 +2,7 @@ import { json, redirect } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { buildOidcAuthorizationUrl, isAuthEnabled } from '$lib/server/auth';
import { getOidcConfig } from '$lib/server/db';
+import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
export const GET: RequestHandler = async ({ params, url }) => {
@@ -15,8 +16,8 @@ export const GET: RequestHandler = async ({ params, url }) => {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
- // Get redirect URL from query params
- const redirectUrl = url.searchParams.get('redirect') || '/';
+ // Get redirect URL from query params (validated path-relative only)
+ const redirectUrl = safeRedirectOrRoot(url.searchParams.get('redirect'));
try {
const config = await getOidcConfig(id);
@@ -56,7 +57,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
try {
const body = await request.json().catch(() => ({}));
- const redirectUrl = body.redirect || '/';
+ const redirectUrl = safeRedirectOrRoot(body.redirect);
const config = await getOidcConfig(id);
if (!config || !config.enabled) {
diff --git a/src/routes/api/auth/oidc/callback/+server.ts b/src/routes/api/auth/oidc/callback/+server.ts
index 23ff4af..8625e38 100644
--- a/src/routes/api/auth/oidc/callback/+server.ts
+++ b/src/routes/api/auth/oidc/callback/+server.ts
@@ -3,6 +3,7 @@ import type { RequestHandler } from '@sveltejs/kit';
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
import { auditAuth } from '$lib/server/audit';
import { getClientIp } from '$lib/server/client-ip';
+import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
export const GET: RequestHandler = async (event) => {
@@ -54,8 +55,8 @@ export const GET: RequestHandler = async (event) => {
providerName: result.providerName
});
- // Redirect to the original destination or home
- const redirectUrl = result.redirectUrl || '/';
+ // Redirect to the original destination or home (validated path-relative)
+ const redirectUrl = safeRedirectOrRoot(result.redirectUrl);
throw redirect(302, redirectUrl);
} catch (error: any) {
// Re-throw redirect
diff --git a/src/routes/api/auto-update/[containerName]/+server.ts b/src/routes/api/auto-update/[containerName]/+server.ts
index 5694ac7..9dda7ee 100644
--- a/src/routes/api/auto-update/[containerName]/+server.ts
+++ b/src/routes/api/auto-update/[containerName]/+server.ts
@@ -9,12 +9,20 @@ import {
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
-export const GET: RequestHandler = async ({ params, url }) => {
+export const GET: RequestHandler = async ({ params, url, cookies }) => {
+ const auth = await authorize(cookies);
+
+ const permDenied = await auth.requirePermission('schedules', 'view');
+ if (permDenied) return permDenied;
+
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
+ const envDenied = await auth.requireEnvAccess(envId);
+ if (envDenied) return envDenied;
+
const setting = await getAutoUpdateSetting(containerName, envId);
if (!setting) {
@@ -41,15 +49,18 @@ export const GET: RequestHandler = async ({ params, url }) => {
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const auth = await authorize(cookies);
- if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
- return json({ error: 'Permission denied' }, { status: 403 });
- }
+
+ const permDenied = await auth.requirePermission('schedules', 'edit');
+ if (permDenied) return permDenied;
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
+ const envDenied = await auth.requireEnvAccess(envId);
+ if (envDenied) return envDenied;
+
const body = await request.json();
// Accept both camelCase and snake_case for backward compatibility
const enabled = body.enabled;
@@ -109,15 +120,18 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
- if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
- return json({ error: 'Permission denied' }, { status: 403 });
- }
+
+ const permDenied = await auth.requirePermission('schedules', 'edit');
+ if (permDenied) return permDenied;
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
+ const envDenied = await auth.requireEnvAccess(envId);
+ if (envDenied) return envDenied;
+
// Get the setting ID before deleting
const setting = await getAutoUpdateSetting(containerName, envId);
const settingId = setting?.id;
diff --git a/src/routes/api/containers/check-updates/+server.ts b/src/routes/api/containers/check-updates/+server.ts
index 341dde6..9d66ae8 100644
--- a/src/routes/api/containers/check-updates/+server.ts
+++ b/src/routes/api/containers/check-updates/+server.ts
@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
-import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
+import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
import { createJobResponse } from '$lib/server/sse';
@@ -42,9 +42,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
const allContainers = await listContainers(true, envIdNum);
- // Containers labeled dockhand.hidden=true are excluded from update checks —
- // they're invisible to the user, so we won't alert on updates for them either (#1083).
- const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
+ // Skip:
+ // - dockhand.hidden=true (invisible to user, so no update alerts) (#1083)
+ // - Podman pod-infra containers (localhost/podman-pause, never published) (#1221)
+ const containers = allContainers.filter(
+ (c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
+ );
send('progress', { checked: 0, total: containers.length });
diff --git a/src/routes/api/dashboard/stats/+server.ts b/src/routes/api/dashboard/stats/+server.ts
index 732bf0f..aa757b6 100644
--- a/src/routes/api/dashboard/stats/+server.ts
+++ b/src/routes/api/dashboard/stats/+server.ts
@@ -258,11 +258,13 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
- // Get latest metrics, event stats, and pending updates in parallel
+ // Get latest metrics, event stats, and pending updates in parallel.
+ // Each call has its own catch so one failed DB query (e.g. a corrupt
+ // container_events index, #1210) doesn't poison the whole tile.
const [latestMetrics, eventStats, pendingUpdates] = await Promise.all([
- getLatestHostMetrics(env.id),
- getContainerEventStats(env.id),
- getPendingContainerUpdates(env.id)
+ getLatestHostMetrics(env.id).catch(() => null),
+ getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })),
+ getPendingContainerUpdates(env.id).catch(() => [])
]);
if (latestMetrics) {
diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts
index 48c73c7..bdc2c0a 100644
--- a/src/routes/api/dashboard/stats/stream/+server.ts
+++ b/src/routes/api/dashboard/stats/stream/+server.ts
@@ -195,13 +195,15 @@ async function getEnvironmentStatsProgressive(
// Get all database stats in parallel for better performance
// NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others
- // Instead, we determine online status from whether listContainers succeeds
+ // Instead, we determine online status from whether listContainers succeeds.
+ // Each call has its own catch so one failed DB query (e.g. a corrupt
+ // container_events index, #1210) doesn't poison the whole tile.
const [latestMetrics, eventStats, recentEventsResult, metricsHistory, pendingUpdates] = await Promise.all([
- getLatestHostMetrics(env.id),
- getContainerEventStats(env.id),
- getContainerEvents({ environmentId: env.id, limit: 10 }),
- getHostMetrics(metricsPointCount, env.id),
- getPendingContainerUpdates(env.id)
+ getLatestHostMetrics(env.id).catch(() => null),
+ getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })),
+ getContainerEvents({ environmentId: env.id, limit: 10 }).catch(() => ({ events: [], total: 0, limit: 10, offset: 0 })),
+ getHostMetrics(metricsPointCount, env.id).catch(() => []),
+ getPendingContainerUpdates(env.id).catch(() => [])
]);
if (latestMetrics) {
diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts
index dc5d5b8..3854ae0 100644
--- a/src/routes/api/events/+server.ts
+++ b/src/routes/api/events/+server.ts
@@ -1,11 +1,20 @@
import type { RequestHandler } from './$types';
import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker';
import { getEnvironment } from '$lib/server/db';
+import { authorize } from '$lib/server/authorize';
+
+export const GET: RequestHandler = async ({ url, cookies }) => {
+ const auth = await authorize(cookies);
+
+ const permDenied = await auth.requirePermission('activity', 'view');
+ if (permDenied) return permDenied;
-export const GET: RequestHandler = async ({ url }) => {
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
+ const envDenied = await auth.requireEnvAccess(envIdNum);
+ if (envDenied) return envDenied;
+
// Early return if no environment specified
if (!envIdNum) {
return new Response(
diff --git a/src/routes/api/health/database/+server.ts b/src/routes/api/health/database/+server.ts
index 72497a3..b3c7b5d 100644
--- a/src/routes/api/health/database/+server.ts
+++ b/src/routes/api/health/database/+server.ts
@@ -1,38 +1,43 @@
/**
* Database Health Check Endpoint
*
- * Returns detailed information about the database schema state,
- * including migration status, table existence, and connection info.
+ * Public endpoint suitable for external monitoring. The public payload reports
+ * enough detail to detect schema drift and table loss without exposing
+ * connection details (host, port, db name, user) or the running migration tag.
+ *
+ * Authenticated callers with settings:view get the full payload — connection
+ * string (password masked) and schema version included — which is useful for
+ * operators debugging from the admin UI.
*
* GET /api/health/database
- *
- * Response:
- * {
- * healthy: boolean,
- * database: 'sqlite' | 'postgresql',
- * connection: string,
- * migrationsTable: boolean,
- * appliedMigrations: number,
- * pendingMigrations: number,
- * schemaVersion: string | null,
- * tables: {
- * expected: number,
- * found: number,
- * missing: string[]
- * },
- * timestamp: string
- * }
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { checkSchemaHealth } from '$lib/server/db/drizzle';
+import { authorize } from '$lib/server/authorize';
-export const GET: RequestHandler = async () => {
+export const GET: RequestHandler = async ({ cookies }) => {
try {
const health = await checkSchemaHealth();
- return json(health, {
+ const auth = await authorize(cookies);
+ const showFullDetail = !auth.authEnabled
+ || (auth.isAuthenticated && await auth.can('settings', 'view'));
+
+ const payload = showFullDetail
+ ? health
+ : {
+ healthy: health.healthy,
+ database: health.database,
+ migrationsTable: health.migrationsTable,
+ appliedMigrations: health.appliedMigrations,
+ pendingMigrations: health.pendingMigrations,
+ tables: health.tables,
+ timestamp: health.timestamp
+ };
+
+ return json(payload, {
status: health.healthy ? 200 : 503,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate'
diff --git a/src/routes/api/profile/preferences/+server.ts b/src/routes/api/profile/preferences/+server.ts
index 493c51f..0359233 100644
--- a/src/routes/api/profile/preferences/+server.ts
+++ b/src/routes/api/profile/preferences/+server.ts
@@ -45,7 +45,9 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
const validTerminalFontIds = monospaceFonts.map(f => f.id);
const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
- const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean } = {};
+ const validActionIconSizes = ['small', 'normal', 'large', 'xlarge'];
+
+ const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string } = {};
if (data.lightTheme !== undefined) {
if (!validLightThemeIds.includes(data.lightTheme)) {
@@ -103,6 +105,20 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
updates.animateIcons = data.animateIcons;
}
+ if (data.coloredActionButtons !== undefined) {
+ if (typeof data.coloredActionButtons !== 'boolean') {
+ return json({ error: 'Invalid coloredActionButtons' }, { status: 400 });
+ }
+ updates.coloredActionButtons = data.coloredActionButtons;
+ }
+
+ if (data.actionIconSize !== undefined) {
+ if (!validActionIconSizes.includes(data.actionIconSize)) {
+ return json({ error: 'Invalid actionIconSize' }, { status: 400 });
+ }
+ updates.actionIconSize = data.actionIconSize;
+ }
+
await setUserThemePreferences(currentUser.id, updates);
// Return updated preferences
diff --git a/src/routes/api/registries/[id]/default/+server.ts b/src/routes/api/registries/[id]/default/+server.ts
index 464f1c6..11dc02f 100644
--- a/src/routes/api/registries/[id]/default/+server.ts
+++ b/src/routes/api/registries/[id]/default/+server.ts
@@ -1,8 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { setDefaultRegistry, getRegistry } from '$lib/server/db';
+import { authorize } from '$lib/server/authorize';
+
+export const POST: RequestHandler = async ({ params, cookies }) => {
+ const auth = await authorize(cookies);
+ if (auth.authEnabled && !await auth.can('settings', 'edit')) {
+ return json({ error: 'Permission denied' }, { status: 403 });
+ }
-export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
diff --git a/src/routes/api/registry/image/+server.ts b/src/routes/api/registry/image/+server.ts
index 98ae38f..b20e794 100644
--- a/src/routes/api/registry/image/+server.ts
+++ b/src/routes/api/registry/image/+server.ts
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
+import { authorize } from '$lib/server/authorize';
function isDockerHub(url: string): boolean {
const lower = url.toLowerCase();
@@ -18,7 +19,12 @@ const MANIFEST_TYPES = [
'application/vnd.oci.image.index.v1+json'
];
-export const DELETE: RequestHandler = async ({ url }) => {
+export const DELETE: RequestHandler = async ({ url, cookies }) => {
+ const auth = await authorize(cookies);
+ if (auth.authEnabled && !await auth.can('settings', 'edit')) {
+ return json({ error: 'Permission denied' }, { status: 403 });
+ }
+
try {
const registryId = url.searchParams.get('registry');
const imageName = url.searchParams.get('image');
diff --git a/src/routes/api/schedules/+server.ts b/src/routes/api/schedules/+server.ts
index b732920..75a0d03 100644
--- a/src/routes/api/schedules/+server.ts
+++ b/src/routes/api/schedules/+server.ts
@@ -22,6 +22,7 @@ import {
} from '$lib/server/db';
import { getNextRun, getSystemSchedules } from '$lib/server/scheduler';
import { getGlobalScannerDefaults, getScannerSettingsWithDefaults } from '$lib/server/scanner';
+import { authorize } from '$lib/server/authorize';
export interface ScheduleInfo {
id: number;
@@ -43,7 +44,12 @@ export interface ScheduleInfo {
vulnerabilityCriteria?: VulnerabilityCriteria | null;
}
-export const GET: RequestHandler = async () => {
+export const GET: RequestHandler = async ({ cookies }) => {
+ const auth = await authorize(cookies);
+
+ const permDenied = await auth.requirePermission('schedules', 'view');
+ if (permDenied) return permDenied;
+
try {
const schedules: ScheduleInfo[] = [];
@@ -233,13 +239,23 @@ export const GET: RequestHandler = async () => {
);
schedules.push(...sysSchedules);
+ // Filter by per-env access (enterprise): caller sees only schedules
+ // for envs they can access, plus all system schedules (env null).
+ // Free edition / admin: getAccessibleEnvironmentIds returns null,
+ // no filtering applied.
+ const accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
+ const filtered = accessibleEnvIds === null
+ ? schedules
+ : schedules.filter(s =>
+ s.environmentId === null || accessibleEnvIds.includes(s.environmentId));
+
// Sort: system jobs last, then by name
- schedules.sort((a, b) => {
+ filtered.sort((a, b) => {
if (a.isSystem !== b.isSystem) return a.isSystem ? 1 : -1;
return a.name.localeCompare(b.name);
});
- return json({ schedules });
+ return json({ schedules: filtered });
} catch (error: any) {
console.error('Failed to get schedules:', error);
return json({ error: error.message }, { status: 500 });
diff --git a/src/routes/api/schedules/[type]/[id]/+server.ts b/src/routes/api/schedules/[type]/[id]/+server.ts
index 055c443..fabc57f 100644
--- a/src/routes/api/schedules/[type]/[id]/+server.ts
+++ b/src/routes/api/schedules/[type]/[id]/+server.ts
@@ -8,6 +8,7 @@ import type { RequestHandler } from './$types';
import {
getAutoUpdateSettingById,
deleteAutoUpdateSchedule,
+ getGitStack,
updateGitStack,
deleteEnvUpdateCheckSettings,
deleteImagePruneSettings
@@ -17,9 +18,9 @@ import { authorize } from '$lib/server/authorize';
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
- if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
- return json({ error: 'Permission denied' }, { status: 403 });
- }
+
+ const permDenied = await auth.requirePermission('schedules', 'edit');
+ if (permDenied) return permDenied;
try {
const { type, id } = params;
@@ -33,6 +34,8 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
// Hard delete container schedule
const schedule = await getAutoUpdateSettingById(scheduleId);
if (schedule) {
+ const envDenied = await auth.requireEnvAccess(schedule.environmentId);
+ if (envDenied) return envDenied;
await deleteAutoUpdateSchedule(schedule.containerName, schedule.environmentId ?? undefined);
// Unregister from croner
unregisterSchedule(scheduleId, 'container_update');
@@ -40,6 +43,12 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ success: true });
} else if (type === 'git_stack_sync') {
+ const stack = await getGitStack(scheduleId);
+ if (!stack) {
+ return json({ error: 'Schedule not found' }, { status: 404 });
+ }
+ const envDenied = await auth.requireEnvAccess(stack.environmentId);
+ if (envDenied) return envDenied;
// Disable auto-update for git stack (don't delete the stack itself)
await updateGitStack(scheduleId, {
autoUpdate: false,
@@ -52,12 +61,16 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
} else if (type === 'env_update_check') {
// Delete env update check settings (scheduleId is environmentId)
+ const envDenied = await auth.requireEnvAccess(scheduleId);
+ if (envDenied) return envDenied;
await deleteEnvUpdateCheckSettings(scheduleId);
unregisterSchedule(scheduleId, 'env_update_check');
return json({ success: true });
} else if (type === 'image_prune') {
// Delete image prune settings (scheduleId is environmentId)
+ const envDenied = await auth.requireEnvAccess(scheduleId);
+ if (envDenied) return envDenied;
await deleteImagePruneSettings(scheduleId);
unregisterSchedule(scheduleId, 'image_prune');
return json({ success: true });
diff --git a/src/routes/api/schedules/[type]/[id]/run/+server.ts b/src/routes/api/schedules/[type]/[id]/run/+server.ts
index 85aafb9..8eb607e 100644
--- a/src/routes/api/schedules/[type]/[id]/run/+server.ts
+++ b/src/routes/api/schedules/[type]/[id]/run/+server.ts
@@ -12,12 +12,13 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
+import { getAutoUpdateSettingById, getGitStack } from '$lib/server/db';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
- if (auth.authEnabled && !await auth.can('schedules', 'run')) {
- return json({ error: 'Permission denied' }, { status: 403 });
- }
+
+ const permDenied = await auth.requirePermission('schedules', 'run');
+ if (permDenied) return permDenied;
try {
const { type, id } = params;
@@ -27,6 +28,37 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid schedule ID' }, { status: 400 });
}
+ // Resolve schedule → environmentId so we can enforce per-env access
+ // before triggering. System schedules (env null) are gated only by
+ // the global schedules:run check above.
+ let scheduleEnvId: number | null = null;
+ switch (type) {
+ case 'container_update': {
+ const setting = await getAutoUpdateSettingById(scheduleId);
+ if (!setting) return json({ error: 'Schedule not found' }, { status: 404 });
+ scheduleEnvId = setting.environmentId;
+ break;
+ }
+ case 'git_stack_sync': {
+ const stack = await getGitStack(scheduleId);
+ if (!stack) return json({ error: 'Schedule not found' }, { status: 404 });
+ scheduleEnvId = stack.environmentId;
+ break;
+ }
+ case 'env_update_check':
+ case 'image_prune':
+ scheduleEnvId = scheduleId;
+ break;
+ case 'system_cleanup':
+ scheduleEnvId = null;
+ break;
+ default:
+ return json({ error: 'Invalid schedule type' }, { status: 400 });
+ }
+
+ const envDenied = await auth.requireEnvAccess(scheduleEnvId);
+ if (envDenied) return envDenied;
+
let result: { success: boolean; executionId?: number; error?: string };
switch (type) {
@@ -46,6 +78,7 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
result = await triggerImagePrune(scheduleId);
break;
default:
+ // Unreachable — validated in the resolution switch above.
return json({ error: 'Invalid schedule type' }, { status: 400 });
}
diff --git a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts
index b226c34..b1f2947 100644
--- a/src/routes/api/schedules/[type]/[id]/toggle/+server.ts
+++ b/src/routes/api/schedules/[type]/[id]/toggle/+server.ts
@@ -11,9 +11,9 @@ import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
- if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
- return json({ error: 'Permission denied' }, { status: 403 });
- }
+
+ const permDenied = await auth.requirePermission('schedules', 'edit');
+ if (permDenied) return permDenied;
try {
const { type, id } = params;
@@ -28,6 +28,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
if (!setting) {
return json({ error: 'Schedule not found' }, { status: 404 });
}
+ const envDenied = await auth.requireEnvAccess(setting.environmentId);
+ if (envDenied) return envDenied;
const newEnabled = !setting.enabled;
await updateAutoUpdateSettingById(scheduleId, {
@@ -47,6 +49,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
if (!stack) {
return json({ error: 'Schedule not found' }, { status: 404 });
}
+ const envDenied = await auth.requireEnvAccess(stack.environmentId);
+ if (envDenied) return envDenied;
const newEnabled = !stack.autoUpdate;
await updateGitStack(scheduleId, {
@@ -63,6 +67,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
return json({ success: true, enabled: newEnabled });
} else if (type === 'env_update_check') {
// scheduleId is environmentId for env update check
+ const envDenied = await auth.requireEnvAccess(scheduleId);
+ if (envDenied) return envDenied;
const config = await getEnvUpdateCheckSettings(scheduleId);
if (!config) {
return json({ error: 'Schedule not found' }, { status: 404 });
@@ -84,6 +90,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
return json({ success: true, enabled: newEnabled });
} else if (type === 'image_prune') {
// scheduleId is environmentId for image prune
+ const envDenied = await auth.requireEnvAccess(scheduleId);
+ if (envDenied) return envDenied;
const config = await getImagePruneSettings(scheduleId);
if (!config) {
return json({ error: 'Schedule not found' }, { status: 404 });
diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts
index 5d921a2..b412a0c 100644
--- a/src/routes/api/settings/general/+server.ts
+++ b/src/routes/api/settings/general/+server.ts
@@ -40,11 +40,16 @@ export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export type DownloadFormat = 'tar' | 'tar.gz' | 'raw';
export type EventCollectionMode = 'stream' | 'poll';
+export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge';
+
+const VALID_ACTION_ICON_SIZES: ActionIconSize[] = ['small', 'normal', 'large', 'xlarge'];
export interface GeneralSettings {
confirmDestructive: boolean;
showStoppedContainers: boolean;
highlightUpdates: boolean;
+ coloredActionButtons: boolean;
+ actionIconSize: ActionIconSize;
timeFormat: TimeFormat;
dateFormat: DateFormat;
downloadFormat: DownloadFormat;
@@ -102,12 +107,17 @@ export interface GeneralSettings {
animateIcons: boolean;
// Skip Dockhand's scanner images (grype, trivy) during 'prune all unused' (#625)
protectScannerImages: boolean;
+ // Scanner Advanced settings (#1219). Empty = use auto-detection.
+ defaultScannerNetworkMode: string;
+ defaultScannerDns: string[];
}
const DEFAULT_SETTINGS: Omit = {
confirmDestructive: true,
showStoppedContainers: true,
highlightUpdates: true,
+ coloredActionButtons: false,
+ actionIconSize: 'normal' as const,
timeFormat: '24h',
dateFormat: 'DD.MM.YYYY',
downloadFormat: 'tar',
@@ -138,6 +148,8 @@ const DEFAULT_SETTINGS: Omit typeof v === 'string') : [];
+ } catch {
+ return [];
+ }
+ }
+ return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
+}
+
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// UI preferences (time format, date format) should be available to all authenticated users
@@ -181,6 +211,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
confirmDestructive,
showStoppedContainers,
highlightUpdates,
+ coloredActionButtons,
+ actionIconSize,
timeFormat,
dateFormat,
downloadFormat,
@@ -219,11 +251,15 @@ export const GET: RequestHandler = async ({ cookies }) => {
honorProxyLabels,
showImageChangelogLinks,
animateIcons,
- protectScannerImages
+ protectScannerImages,
+ defaultScannerNetworkMode,
+ defaultScannerDnsRaw
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
getSetting('highlight_updates'),
+ getSetting('colored_action_buttons'),
+ getSetting('action_icon_size'),
getSetting('time_format'),
getSetting('date_format'),
getSetting('download_format'),
@@ -262,13 +298,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('animate_icons'),
- getSetting('protect_scanner_images')
+ getSetting('protect_scanner_images'),
+ getSetting('default_scanner_network_mode'),
+ getSetting('default_scanner_dns')
]);
const settings: GeneralSettings = {
confirmDestructive: confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive,
showStoppedContainers: showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers,
highlightUpdates: highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates,
+ coloredActionButtons: coloredActionButtons ?? DEFAULT_SETTINGS.coloredActionButtons,
+ actionIconSize: (VALID_ACTION_ICON_SIZES.includes(actionIconSize as ActionIconSize) ? actionIconSize : DEFAULT_SETTINGS.actionIconSize) as ActionIconSize,
timeFormat: timeFormat ?? DEFAULT_SETTINGS.timeFormat,
dateFormat: dateFormat ?? DEFAULT_SETTINGS.dateFormat,
downloadFormat: downloadFormat ?? DEFAULT_SETTINGS.downloadFormat,
@@ -309,7 +349,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons,
- protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages
+ protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
+ defaultScannerNetworkMode: defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
+ defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRaw)
};
return json(settings);
@@ -327,7 +369,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
- const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages } = body;
+ const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages, defaultScannerNetworkMode, defaultScannerDns } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -338,6 +380,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (highlightUpdates !== undefined) {
await setSetting('highlight_updates', highlightUpdates);
}
+ if (coloredActionButtons !== undefined) {
+ await setSetting('colored_action_buttons', coloredActionButtons);
+ }
+ if (actionIconSize !== undefined && VALID_ACTION_ICON_SIZES.includes(actionIconSize)) {
+ await setSetting('action_icon_size', actionIconSize);
+ }
if (timeFormat !== undefined && (timeFormat === '12h' || timeFormat === '24h')) {
await setSetting('time_format', timeFormat);
}
@@ -479,12 +527,28 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (protectScannerImages !== undefined && typeof protectScannerImages === 'boolean') {
await setSetting('protect_scanner_images', protectScannerImages);
}
+ if (defaultScannerNetworkMode !== undefined && typeof defaultScannerNetworkMode === 'string') {
+ // Free-form network mode (host / bridge / none / custom-network-name / '').
+ // Trim and cap length to avoid pathological values from a corrupted UI.
+ const trimmed = defaultScannerNetworkMode.trim().slice(0, 200);
+ await setSetting('default_scanner_network_mode', trimmed);
+ }
+ if (defaultScannerDns !== undefined && Array.isArray(defaultScannerDns)) {
+ const cleaned = defaultScannerDns
+ .filter((d) => typeof d === 'string')
+ .map((d: string) => d.trim())
+ .filter((d) => d.length > 0)
+ .slice(0, 10); // sane upper bound; Docker accepts more but no one needs it
+ await setSetting('default_scanner_dns', JSON.stringify(cleaned));
+ }
// Fetch all settings in parallel for the response
const [
confirmDestructiveVal,
showStoppedContainersVal,
highlightUpdatesVal,
+ coloredActionButtonsVal,
+ actionIconSizeVal,
timeFormatVal,
dateFormatVal,
downloadFormatVal,
@@ -523,11 +587,15 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
honorProxyLabelsVal,
showImageChangelogLinksVal,
animateIconsVal,
- protectScannerImagesVal
+ protectScannerImagesVal,
+ defaultScannerNetworkModeVal,
+ defaultScannerDnsRawVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
getSetting('highlight_updates'),
+ getSetting('colored_action_buttons'),
+ getSetting('action_icon_size'),
getSetting('time_format'),
getSetting('date_format'),
getSetting('download_format'),
@@ -566,13 +634,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('animate_icons'),
- getSetting('protect_scanner_images')
+ getSetting('protect_scanner_images'),
+ getSetting('default_scanner_network_mode'),
+ getSetting('default_scanner_dns')
]);
const settings: GeneralSettings = {
confirmDestructive: confirmDestructiveVal ?? DEFAULT_SETTINGS.confirmDestructive,
showStoppedContainers: showStoppedContainersVal ?? DEFAULT_SETTINGS.showStoppedContainers,
highlightUpdates: highlightUpdatesVal ?? DEFAULT_SETTINGS.highlightUpdates,
+ coloredActionButtons: coloredActionButtonsVal ?? DEFAULT_SETTINGS.coloredActionButtons,
+ actionIconSize: (VALID_ACTION_ICON_SIZES.includes(actionIconSizeVal as ActionIconSize) ? actionIconSizeVal : DEFAULT_SETTINGS.actionIconSize) as ActionIconSize,
timeFormat: timeFormatVal ?? DEFAULT_SETTINGS.timeFormat,
dateFormat: dateFormatVal ?? DEFAULT_SETTINGS.dateFormat,
downloadFormat: downloadFormatVal ?? DEFAULT_SETTINGS.downloadFormat,
@@ -613,7 +685,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages,
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
- animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
+ animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons,
+ defaultScannerNetworkMode: defaultScannerNetworkModeVal ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
+ defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRawVal)
};
return json(settings);
diff --git a/src/routes/api/settings/theme/+server.ts b/src/routes/api/settings/theme/+server.ts
index f5ac754..b196089 100644
--- a/src/routes/api/settings/theme/+server.ts
+++ b/src/routes/api/settings/theme/+server.ts
@@ -14,7 +14,9 @@ const DEFAULT_THEME_SETTINGS = {
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono',
- animateIcons: true
+ animateIcons: true,
+ coloredActionButtons: false,
+ actionIconSize: 'normal'
};
export const GET: RequestHandler = async () => {
@@ -27,7 +29,9 @@ export const GET: RequestHandler = async () => {
gridFontSize,
terminalFont,
editorFont,
- animateIcons
+ animateIcons,
+ coloredActionButtons,
+ actionIconSize
] = await Promise.all([
getSetting('theme_light'),
getSetting('theme_dark'),
@@ -36,7 +40,9 @@ export const GET: RequestHandler = async () => {
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
- getSetting('animate_icons')
+ getSetting('animate_icons'),
+ getSetting('colored_action_buttons'),
+ getSetting('action_icon_size')
]);
return json({
@@ -47,7 +53,9 @@ export const GET: RequestHandler = async () => {
gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont,
- animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons
+ animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons,
+ coloredActionButtons: coloredActionButtons ?? DEFAULT_THEME_SETTINGS.coloredActionButtons,
+ actionIconSize: actionIconSize ?? DEFAULT_THEME_SETTINGS.actionIconSize
});
} catch (error) {
console.error('Failed to get theme settings:', error);
diff --git a/src/routes/api/templates/+server.ts b/src/routes/api/templates/+server.ts
index 996729e..9c340e1 100644
--- a/src/routes/api/templates/+server.ts
+++ b/src/routes/api/templates/+server.ts
@@ -20,6 +20,47 @@ export interface TemplateItem {
stars?: number;
pulls?: number;
network?: string;
+ /** Free-form maintainer / install notes from the upstream catalog. */
+ note?: string;
+ /** Project / homepage URL the user can click to learn more or report issues (#1211). */
+ projectUrl?: string;
+}
+
+/**
+ * Best-effort first-URL extraction from a template's description or note.
+ * Order matters: markdown link first, then HTML href, then a bare URL.
+ */
+function extractFirstUrl(text: string | undefined | null): string | null {
+ if (!text) return null;
+ const md = text.match(/\[[^\]]+\]\((https?:\/\/[^)]+)\)/);
+ if (md) return md[1];
+ const html = text.match(/]*href=["']([^"']+)["']/i);
+ if (html) return html[1];
+ const plain = text.match(/(https?:\/\/[^\s<>"')]+)/);
+ if (plain) return plain[1];
+ return null;
+}
+
+/**
+ * Resolve the "Project" URL surfaced on the card.
+ * Priority:
+ * 1. URL embedded in `description` — LSIO-style templates have a markdown
+ * link to the actual project repo here, which is what users want most.
+ * 2. URL embedded in `note` — Portainer v3 catalogs often put a homepage
+ * link in the install notes.
+ * 3. `maintainer` field — Lissy93's templates carry a per-template
+ * maintainer URL; not always the project itself but usually close.
+ * 4. `repository.url` — only for stack-type templates, points to the repo
+ * hosting the compose file (closest thing we have).
+ */
+function resolveProjectUrl(entry: any, description: string, note: string): string | undefined {
+ return (
+ extractFirstUrl(description) ||
+ extractFirstUrl(note) ||
+ (typeof entry.maintainer === 'string' ? entry.maintainer.trim() : null) ||
+ (entry.repository && typeof entry.repository.url === 'string' ? entry.repository.url : null) ||
+ undefined
+ );
}
// Server-side cache: url → { data, fetchedAt }
@@ -43,14 +84,18 @@ function normalizePortainerTemplate(entry: any, sourceName: string): TemplateIte
if (entry.type === 2) return null;
const title = entry.title || entry.name || '';
+ const description = entry.description || '';
+ const note = typeof entry.note === 'string' ? entry.note : '';
const template: TemplateItem = {
id: hashId(sourceName, title),
type: entry.type === 3 ? 'stack' : 'container',
title,
- description: entry.description || '',
+ description,
logo: entry.logo || '',
categories: Array.isArray(entry.categories) ? entry.categories : [],
source: sourceName,
+ note: note || undefined,
+ projectUrl: resolveProjectUrl(entry, description, note)
};
if (entry.type === 3 && entry.repository) {
@@ -77,11 +122,18 @@ function normalizePortainerTemplate(entry: any, sourceName: string): TemplateIte
function normalizeLinuxServerTemplate(entry: any): TemplateItem | null {
if (!entry.name || entry.deprecated) return null;
+ const description = entry.description || '';
+ // LSIO fleet entries sometimes carry a project_url; prefer it over regex sniffing.
+ const projectUrl =
+ (typeof entry.project_url === 'string' && entry.project_url) ||
+ extractFirstUrl(description) ||
+ `https://github.com/linuxserver/docker-${entry.name}`;
+
return {
id: hashId('LinuxServer.io', entry.name),
type: 'container',
title: entry.name,
- description: entry.description || '',
+ description,
logo: entry.project_logo || '',
categories: entry.category ? entry.category.split(',').map((c: string) => c.trim()).filter(Boolean) : [],
source: 'LinuxServer.io',
@@ -94,6 +146,7 @@ function normalizeLinuxServerTemplate(entry: any): TemplateItem | null {
restartPolicy: 'unless-stopped',
stars: entry.stars,
pulls: entry.monthly_pulls,
+ projectUrl
};
}
diff --git a/src/routes/audit/+page.svelte b/src/routes/audit/+page.svelte
index effc98a..fffb99d 100644
--- a/src/routes/audit/+page.svelte
+++ b/src/routes/audit/+page.svelte
@@ -906,7 +906,7 @@
{:else if column.id === 'actions'}
{ e.stopPropagation(); showDetails(log); }}>
-
+
{/if}
diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte
index 98c85bf..a5fba25 100644
--- a/src/routes/containers/+page.svelte
+++ b/src/routes/containers/+page.svelte
@@ -2091,7 +2091,7 @@
onOpenChange={(open) => confirmUpdateId = open ? container.id : null}
>
{#snippet children({ open })}
-
+
{/snippet}
{/if}
@@ -2112,7 +2112,7 @@
title="Open logs"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
-
+
{/if}
{/if}
@@ -2135,7 +2135,7 @@
onclick={(e: MouseEvent) => e.stopPropagation()}
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
-
+
@@ -2244,7 +2244,7 @@
title="Browse files"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
-
+
{/if}
-
+
{#if $canAccess('containers', 'create')}
-
+
{/if}
{#if !container.systemContainer}
@@ -2274,7 +2274,7 @@
title="Unpause"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
-
+
{/if}
{:else if container.state !== 'running' && container.state !== 'restarting'}
@@ -2285,7 +2285,7 @@
title="Start"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
-
+
{/if}
{/if}
@@ -2301,7 +2301,7 @@
onOpenChange={(open) => confirmRestartId = open ? container.id : null}
>
{#snippet children({ open })}
-
+
{/snippet}
{/if}
@@ -2313,7 +2313,7 @@
title="Pause"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
-
+
{/if}
{#if $canAccess('containers', 'stop')}
@@ -2327,7 +2327,7 @@
onOpenChange={(open) => confirmStopId = open ? container.id : null}
>
{#snippet children({ open })}
-
+
{/snippet}
{/if}
@@ -2344,7 +2344,7 @@
onOpenChange={(open) => confirmDeleteId = open ? container.id : null}
>
{#snippet children({ open })}
-
+
{/snippet}
{/if}
diff --git a/src/routes/images/+page.svelte b/src/routes/images/+page.svelte
index cdc67e1..8d11532 100644
--- a/src/routes/images/+page.svelte
+++ b/src/routes/images/+page.svelte
@@ -589,14 +589,14 @@
}
async function tagImage() {
- if (!tagNewRepo.trim()) return;
+ if (!tagNewRepo.trim() || !tagNewTag.trim()) return;
tagging = true;
- const newTag = `${tagNewRepo.trim()}:${tagNewTag.trim() || 'latest'}`;
+ const newTag = `${tagNewRepo.trim()}:${tagNewTag.trim()}`;
try {
const response = await fetch(appendEnvParam(`/api/images/${encodeURIComponent(tagImageId)}/tag`, envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ repo: tagNewRepo.trim(), tag: tagNewTag.trim() || 'latest' })
+ body: JSON.stringify({ repo: tagNewRepo.trim(), tag: tagNewTag.trim() })
});
if (response.ok) {
toast.success(`Tagged as ${newTag}`);
@@ -1007,7 +1007,7 @@
title="Run container"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
-
+
{/if}
{#if scannerEnabled && $canAccess('images', 'inspect')}
@@ -1017,7 +1017,7 @@
title="Scan for vulnerabilities"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
-
+
{/if}
{#if $canAccess('images', 'push')}
@@ -1027,7 +1027,7 @@
title="Push to registry"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
-
+
{/if}
@@ -1092,7 +1092,7 @@
title="View layers"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
-
+
{/if}
{#if $canAccess('containers', 'create')}
@@ -1102,7 +1102,7 @@
title="Run container"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
-
+
{/if}
{#if scannerEnabled && $canAccess('images', 'inspect')}
@@ -1112,7 +1112,7 @@
title="Scan for vulnerabilities"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
-
+
{/if}
{#if $canAccess('images', 'push')}
@@ -1122,7 +1122,7 @@
title="Push to registry"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
-
+
{/if}
{#if $canAccess('images', 'inspect')}
@@ -1133,7 +1133,7 @@
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer {exportingId === tagInfo.fullRef ? 'animate-pulse' : ''}"
disabled={exportingId === tagInfo.fullRef}
>
-
+
{/if}
{#if $canAccess('images', 'build')}
@@ -1143,7 +1143,7 @@
title="Tag image"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
-
+
{/if}
{#if $canAccess('images', 'remove') && tagInfo.containers === 0}
@@ -1158,7 +1158,7 @@
onOpenChange={(open) => confirmDeleteId = open ? tagInfo.fullRef : null}
>
{#snippet children({ open })}
-
+
{/snippet}
@@ -1235,14 +1235,14 @@