This commit is contained in:
jarek committed 2026-06-15 14:56:51 +02:00
1 parent d465ecfe96
commit 83c3a5ea09
85 files changed
+10118 -1046

No files matched your search

@@ -0,0 +1,80 @@
/**
* POST /api/containers/[id]/update-runtime
*
* In-place update of a running container's restart policy, CPU/memory limits,
* blkio weights, and pids limit — the only properties Docker can change
* without recreating the container. The body must contain ONLY fields from
* IN_PLACE_UPDATE_FIELDS (see docker.ts); any unknown fields are silently
* dropped so a confused or malicious caller can't sneak a recreate-only
* field (image, env, ports, etc.) through this path.
*
* Returns Docker's response — typically `{ Warnings: string[] | null }`.
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import { inspectContainer, updateContainerRuntime, IN_PLACE_UPDATE_FIELDS, type InPlaceUpdateField } from '$lib/server/docker';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Same permission as the recreate-style update — anyone who could edit
// the container the slow way should be able to edit it the fast way.
if (auth.authEnabled && !await auth.can('containers', 'create', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
// Sanity-check: at least one allowed field must be present. Empty/all-unknown
// payloads get a clear error rather than silently calling Docker with {}.
const allowed = new Set<string>(IN_PLACE_UPDATE_FIELDS);
const filtered: Partial<Record<InPlaceUpdateField, unknown>> = {};
for (const [k, v] of Object.entries(body)) {
if (allowed.has(k) && v !== undefined) filtered[k as InPlaceUpdateField] = v;
}
if (Object.keys(filtered).length === 0) {
return json({
error: 'No supported fields provided',
supportedFields: Array.from(allowed)
}, { status: 400 });
}
try {
const result = await updateContainerRuntime(params.id, filtered, envIdNum);
// Audit log — include which fields were touched, not their values
// (CPU/memory numbers are non-sensitive, but the audit row stays
// cleaner if we just record the keys).
let containerName = params.id;
try {
const inspect = await inspectContainer(params.id, envIdNum);
containerName = inspect.Name?.replace(/^\//, '') || params.id;
} catch { /* fallback to id */ }
await auditContainer(event, 'update', params.id, containerName, envIdNum, {
inPlace: true,
fields: Object.keys(filtered),
warnings: result.Warnings ?? []
});
return json({ success: true, warnings: result.Warnings ?? [] });
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: 'Container not found' }, { status: 404 });
}
return json({ error: error?.message || 'Update failed' }, { status: 500 });
}
};
@@ -4,7 +4,7 @@ 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 { isUpdateDisabledByLabel } from '$lib/server/container-labels';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
import { createJobResponse } from '$lib/server/sse';
export interface UpdateCheckResult {
@@ -42,7 +42,9 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
const allContainers = await listContainers(true, envIdNum);
const containers = allContainers;
// 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));
send('progress', { checked: 0, total: containers.length });
@@ -65,8 +67,19 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
};
}
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
const updateDisabled = isUpdateDisabledByLabel(inspectData.Config?.Labels);
if (updateDisabled) {
return {
containerId: container.id,
containerName: container.name,
imageName,
hasUpdate: false,
systemContainer: isSystemContainer(imageName) || null,
updateDisabled: true
};
}
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
return {
containerId: container.id,
+4 -2
View File
@@ -7,6 +7,7 @@ import { invalidateTokenCacheForUser } from '$lib/server/api-tokens';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
import { validateEnvName } from '$lib/utils/env-name';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -71,8 +72,9 @@ export const POST: RequestHandler = async (event) => {
try {
const data = await request.json();
if (!data.name) {
return json({ error: 'Name is required' }, { status: 400 });
const nameCheck = validateEnvName(data.name);
if (!nameCheck.ok) {
return json({ error: nameCheck.reason }, { status: 400 });
}
// Check if environment with this name already exists
+61 -1
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import { join } from 'path';
import { existsSync, rmSync } from 'fs';
import { existsSync, rmSync, renameSync } from 'fs';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment, deleteEnvironment, getEnvironmentPublicIps, setEnvironmentPublicIp, deleteEnvironmentPublicIp, deleteEnvUpdateCheckSettings, deleteImagePruneSettings, getGitStacksForEnvironmentOnly, deleteGitStack } from '$lib/server/db';
import { clearDockerClientCache } from '$lib/server/docker';
@@ -11,6 +11,7 @@ import { auditEnvironment } from '$lib/server/audit';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
import { cleanPem } from '$lib/utils/pem';
import { validateEnvName } from '$lib/utils/env-name';
import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser';
import { computeAuditDiff } from '$lib/utils/diff';
@@ -64,6 +65,65 @@ export const PUT: RequestHandler = async (event) => {
const data = await request.json();
// #1179: validate name if it's being changed. Existing invalid names are
// not auto-corrected — only writes go through this check.
const isRename = data.name !== undefined && data.name !== oldEnv.name;
if (isRename) {
const nameCheck = validateEnvName(data.name);
if (!nameCheck.ok) {
return json({ error: nameCheck.reason }, { status: 400 });
}
}
// Rename on-disk directories BEFORE the DB write. If the fs rename
// fails (cross-mount EXDEV, perm error, target exists), we surface a
// 409 and leave the DB untouched — better than the previous behavior
// of silently orphaning stacks under the old name.
//
// Applies to ALL connection types. For socket/direct envs the staging
// dir IS the deployed dir, so containers need a redeploy after rename
// (see client warning). For Hawser envs the agent owns the deployed
// dir on the remote host and isn't affected, but Dockhand still keeps
// a local staging copy under stacks/<envName>/<stackName>/ (for the
// in-app editor) and ALL git stacks clone to git-repos/<envName>/
// regardless of where they ultimately deploy — so the rename matters
// locally for every env type.
if (isRename) {
const stacksDir = getStacksDir();
const gitReposDir = getGitReposDir();
const oldStacks = join(stacksDir, oldEnv.name);
const newStacks = join(stacksDir, data.name);
const oldRepos = join(gitReposDir, oldEnv.name);
const newRepos = join(gitReposDir, data.name);
// Refuse to overwrite a target dir that already holds someone
// else's data.
if (existsSync(oldStacks) && existsSync(newStacks)) {
return json({
error: `Cannot rename: ${newStacks} already exists. Pick a different name or move that directory out of the way.`
}, { status: 409 });
}
if (existsSync(oldRepos) && existsSync(newRepos)) {
return json({
error: `Cannot rename: ${newRepos} already exists. Pick a different name or move that directory out of the way.`
}, { status: 409 });
}
try {
if (existsSync(oldStacks)) renameSync(oldStacks, newStacks);
if (existsSync(oldRepos)) renameSync(oldRepos, newRepos);
} catch (err: any) {
// Best-effort rollback if the second rename failed after the first
// succeeded. Avoids leaving the filesystem in a split state.
try { if (existsSync(newStacks) && !existsSync(oldStacks)) renameSync(newStacks, oldStacks); } catch {}
try { if (existsSync(newRepos) && !existsSync(oldRepos)) renameSync(newRepos, oldRepos); } catch {}
const code = err?.code === 'EXDEV'
? 'EXDEV: stacks dir is on a different filesystem from the rename target. Move it back to the same filesystem to rename this environment.'
: (err?.message || 'Rename failed');
return json({ error: code }, { status: 409 });
}
}
// Clear cached Docker client before updating
clearDockerClientCache(id);
+16 -3
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectImage, tagImage, pushImage, parseRegistryUrl } from '$lib/server/docker';
import { encodeRegistryAuth } from '$lib/server/registry-auth';
import { getRegistry, getEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit';
@@ -110,8 +111,9 @@ export const POST: RequestHandler = async (event) => {
const authServerAddress = isDockerHub ? 'https://index.docker.io/v1/' : registryHost;
const authConfig = registry.username && registry.password
? {
username: registry.username,
password: registry.password,
// Trim to neutralize stray whitespace in legacy stored credentials (#1105)
username: registry.username.trim(),
password: registry.password.trim(),
serveraddress: authServerAddress
}
: {
@@ -148,7 +150,18 @@ export const POST: RequestHandler = async (event) => {
return;
}
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
const authHeader = encodeRegistryAuth(authConfig);
{
const u = (authConfig as { username?: string }).username ?? '';
const p = (authConfig as { password?: string }).password ?? '';
const userLast = u.length ? u.charCodeAt(u.length - 1).toString(16) : 'na';
const pwLast = p.length ? p.charCodeAt(p.length - 1).toString(16) : 'na';
console.log(
`[Push/Edge] auth: registry=${registryHost} user(len=${u.length},last=0x${userLast}) ` +
`pw(len=${p.length},last=0x${pwLast}) serveraddress=${authConfig.serveraddress} ` +
`authHeader(len=${authHeader.length},prefix=${authHeader.slice(0, 16)})`
);
}
await new Promise<void>((resolve, reject) => {
sendEdgeStreamRequest(
+1 -1
View File
@@ -63,7 +63,7 @@ export const POST: RequestHandler = async (event) => {
} else if (type === 'apprise') {
const appriseConfig = config as AppriseConfig;
if (!appriseConfig.urls?.length) {
return json({ error: 'Apprise config requires at least one URL' }, { status: 400 });
return json({ error: 'Webhook config requires at least one URL' }, { status: 400 });
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ export const PUT: RequestHandler = async (event) => {
} else if (existing.type === 'apprise') {
const appriseConfig = config as AppriseConfig;
if (!appriseConfig.urls?.length) {
return json({ error: 'Apprise config requires at least one URL' }, { status: 400 });
return json({ error: 'Webhook config requires at least one URL' }, { status: 400 });
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (data.type === 'apprise') {
const config = data.config;
if (!config.urls?.length) {
return json({ error: 'At least one Apprise URL is required' }, { status: 400 });
return json({ error: 'At least one webhook URL is required' }, { status: 400 });
}
}
@@ -45,7 +45,7 @@ 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 } = {};
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean } = {};
if (data.lightTheme !== undefined) {
if (!validLightThemeIds.includes(data.lightTheme)) {
@@ -96,6 +96,13 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
updates.editorFont = data.editorFont;
}
if (data.animateIcons !== undefined) {
if (typeof data.animateIcons !== 'boolean') {
return json({ error: 'Invalid animateIcons' }, { status: 400 });
}
updates.animateIcons = data.animateIcons;
}
await setUserThemePreferences(currentUser.id, updates);
// Return updated preferences
+13 -2
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { getRegistries, createRegistry, setDefaultRegistry } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditRegistry } from '$lib/server/audit';
import { parseRegistryUrl, DOCKER_HUB_HOSTS } from '$lib/server/docker';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -38,11 +39,21 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Name and URL are required' }, { status: 400 });
}
// Trim username/password to prevent stray whitespace from copy-paste corrupting
// the X-Registry-Auth / Authorization headers (#1105).
const trimmedUsername = typeof data.username === 'string' ? data.username.trim() : undefined;
const trimmedPassword = typeof data.password === 'string' ? data.password.trim() : undefined;
// Diagnostic logging (#1105) — never logs the plaintext credential
const { host: normalizedHost } = parseRegistryUrl(data.url);
const hubTag = DOCKER_HUB_HOSTS.has(normalizedHost) ? ' (docker-hub)' : '';
console.log(`[Registry] create: url=${data.url} normalized=${normalizedHost}${hubTag} user(len=${trimmedUsername?.length ?? 0}) pw(len=${trimmedPassword?.length ?? 0})`);
const registry = await createRegistry({
name: data.name,
url: data.url,
username: data.username || undefined,
password: data.password || undefined,
username: trimmedUsername || undefined,
password: trimmedPassword || undefined,
isDefault: data.isDefault || false
});
+14 -2
View File
@@ -4,6 +4,7 @@ import { getRegistry, updateRegistry, deleteRegistry, setDefaultRegistry } from
import { authorize } from '$lib/server/authorize';
import { auditRegistry } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
import { parseRegistryUrl, DOCKER_HUB_HOSTS } from '$lib/server/docker';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -51,11 +52,22 @@ export const PUT: RequestHandler = async (event) => {
}
const data = await request.json();
// Trim username/password to prevent stray whitespace from copy-paste corrupting
// the X-Registry-Auth / Authorization headers (#1105).
const trimmedUsername = typeof data.username === 'string' ? data.username.trim() : data.username;
const trimmedPassword = typeof data.password === 'string' ? data.password.trim() : data.password;
// Diagnostic logging (#1105) — never logs the plaintext credential
const userLen = typeof trimmedUsername === 'string' ? trimmedUsername.length : 0;
const pwLen = typeof trimmedPassword === 'string' ? trimmedPassword.length : 0;
const { host: normalizedHost } = parseRegistryUrl(data.url);
const hubTag = DOCKER_HUB_HOSTS.has(normalizedHost) ? ' (docker-hub)' : '';
console.log(`[Registry] update id=${id}: url=${data.url} normalized=${normalizedHost}${hubTag} user(len=${userLen}) pw(len=${pwLen})`);
const registry = await updateRegistry(id, {
name: data.name,
url: data.url,
username: data.username,
password: data.password,
username: trimmedUsername,
password: trimmedPassword,
isDefault: data.isDefault
});
+130
View File
@@ -0,0 +1,130 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { parseRegistryUrl, getRegistryAuthHeader, DOCKER_HUB_HOSTS } from '$lib/server/docker';
import { getRegistry } from '$lib/server/db';
/**
* Test registry connectivity and credentials.
*
* Accepts either inline credentials (from the modal form) or a registry ID
* (to test an already-saved registry using stored credentials).
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('registries', 'view')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const data = await request.json();
let url: string;
let username: string | undefined;
let password: string | undefined;
if (data.registryId) {
// Test a saved registry — fetch credentials from DB
const reg = await getRegistry(data.registryId);
if (!reg) return json({ error: 'Registry not found' }, { status: 404 });
url = reg.url;
username = reg.username || undefined;
password = reg.password || undefined;
} else {
url = data.url;
username = data.username?.trim() || undefined;
password = data.password?.trim() || undefined;
}
if (!url) {
return json({ error: 'URL is required' }, { status: 400 });
}
const parsed = parseRegistryUrl(url);
const apiBase = `${parsed.protocol}://${parsed.host}`;
try {
// Step 1: connectivity — can we reach /v2/ at all?
const pingResp = await fetch(`${apiBase}/v2/`, {
method: 'GET',
headers: { 'User-Agent': 'Dockhand/1.0' },
signal: AbortSignal.timeout(10_000)
});
const hasAuth = pingResp.status === 401;
const isOpen = pingResp.ok;
if (!isOpen && !hasAuth) {
return json({
success: false,
connectivity: false,
message: `Registry returned HTTP ${pingResp.status}`
});
}
// Step 2: if credentials provided, test authentication.
// Empty scope — we only care whether the registry accepts the login.
// Asking for a privileged scope like registry:catalog:* causes Docker
// Hub to reject the request with HTTP 400 for non-admin users even
// when their credentials are valid.
if (username && password) {
const authHeader = await getRegistryAuthHeader(url, '', { username, password });
if (!authHeader) {
return json({
success: false,
connectivity: true,
authenticated: false,
message: 'Authentication failed — check username and password'
});
}
// Verify the token works
const authResp = await fetch(`${apiBase}/v2/`, {
method: 'GET',
headers: {
'User-Agent': 'Dockhand/1.0',
'Authorization': authHeader
},
signal: AbortSignal.timeout(10_000)
});
if (!authResp.ok) {
return json({
success: false,
connectivity: true,
authenticated: false,
message: `Authentication token rejected (HTTP ${authResp.status})`
});
}
const isHub = DOCKER_HUB_HOSTS.has(parsed.host);
return json({
success: true,
connectivity: true,
authenticated: true,
message: isHub
? `Connected to Docker Hub as ${username}`
: `Connected and authenticated as ${username}`
});
}
// No credentials — just report connectivity
const isHub = DOCKER_HUB_HOSTS.has(parsed.host);
return json({
success: true,
connectivity: true,
authenticated: null,
message: isHub
? 'Docker Hub is reachable (no credentials to test)'
: isOpen
? 'Registry is reachable (no auth required)'
: 'Registry is reachable (requires authentication)'
});
} catch (e: any) {
const msg = e?.cause?.code || e?.message || String(e);
return json({
success: false,
connectivity: false,
message: `Connection failed: ${msg}`
});
}
};
+19 -7
View File
@@ -90,6 +90,8 @@ export interface GeneralSettings {
defaultComposeTemplate: string;
// Label filter mode
labelFilterMode: 'any' | 'all';
// Whether spinning icons (animate-spin etc.) are animated (#1169)
animateIcons: boolean;
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
@@ -122,6 +124,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: DEFAULT_TRIVY_IMAGE,
labelFilterMode: 'any' as const,
animateIcons: true,
defaultComposeTemplate: `version: "3.8"
services:
@@ -199,7 +202,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
defaultGrypeImage,
defaultTrivyImage,
defaultComposeTemplate,
labelFilterMode
labelFilterMode,
animateIcons
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -238,7 +242,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('default_grype_image'),
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode')
getSetting('label_filter_mode'),
getSetting('animate_icons')
]);
const settings: GeneralSettings = {
@@ -281,7 +286,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplate ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons
};
return json(settings);
@@ -299,7 +305,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 } = body;
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, animateIcons } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -439,6 +445,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (labelFilterMode !== undefined && (labelFilterMode === 'any' || labelFilterMode === 'all')) {
await setSetting('label_filter_mode', labelFilterMode);
}
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
await setSetting('animate_icons', animateIcons);
}
// Fetch all settings in parallel for the response
const [
@@ -479,7 +488,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultGrypeImageVal,
defaultTrivyImageVal,
defaultComposeTemplateVal,
labelFilterModeVal
labelFilterModeVal,
animateIconsVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -518,7 +528,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('default_grype_image'),
getSetting('default_trivy_image'),
getSetting('default_compose_template'),
getSetting('label_filter_mode')
getSetting('label_filter_mode'),
getSetting('animate_icons')
]);
const settings: GeneralSettings = {
@@ -561,7 +572,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE,
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
};
return json(settings);
+8 -4
View File
@@ -13,7 +13,8 @@ const DEFAULT_THEME_SETTINGS = {
fontSize: 'normal',
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono'
editorFont: 'system-mono',
animateIcons: true
};
export const GET: RequestHandler = async () => {
@@ -25,7 +26,8 @@ export const GET: RequestHandler = async () => {
fontSize,
gridFontSize,
terminalFont,
editorFont
editorFont,
animateIcons
] = await Promise.all([
getSetting('theme_light'),
getSetting('theme_dark'),
@@ -33,7 +35,8 @@ export const GET: RequestHandler = async () => {
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font')
getSetting('theme_editor_font'),
getSetting('animate_icons')
]);
return json({
@@ -43,7 +46,8 @@ export const GET: RequestHandler = async () => {
fontSize: fontSize ?? DEFAULT_THEME_SETTINGS.fontSize,
gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont,
animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons
});
} catch (error) {
console.error('Failed to get theme settings:', error);
+3 -2
View File
@@ -158,8 +158,9 @@ export const POST: RequestHandler = async ({ params, url, cookies, request }) =>
defined = envVars.map(v => v.key).sort();
}
// Calculate missing and unused
const missing = required.filter(v => !defined.includes(v));
// Calculate missing and unused. Built-in Docker/Compose vars are provided implicitly
// by the runtime, so they're never "missing" even if not in the user's env panel.
const missing = required.filter(v => !defined.includes(v) && !DOCKER_COMPOSE_BUILTIN_VARS.has(v));
const unused = defined.filter(v => !required.includes(v) && !optional.includes(v) && !DOCKER_COMPOSE_BUILTIN_VARS.has(v));
const result: ValidationResult = {