diff --git a/package.json b/package.json index c7b5667..c9cc043 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.13", + "version": "1.0.14", "type": "module", "scripts": { "dev": "bunx --bun vite dev", diff --git a/src/lib/components/ThemeSelector.svelte b/src/lib/components/ThemeSelector.svelte index 259b411..e03f15a 100644 --- a/src/lib/components/ThemeSelector.svelte +++ b/src/lib/components/ThemeSelector.svelte @@ -8,19 +8,6 @@ // Preload all monospace Google Fonts so dropdown previews render correctly let monoFontsLoaded = $state(false); - onMount(() => { - const fontsToLoad = monospaceFonts.filter(f => f.googleFont); - if (fontsToLoad.length === 0) { - monoFontsLoaded = true; - return; - } - const families = fontsToLoad.map(f => `family=${f.googleFont}`).join('&'); - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`; - link.onload = () => { monoFontsLoaded = true; }; - document.head.appendChild(link); - }); // Font size options const fontSizes: { id: FontSize; name: string }[] = [ @@ -38,66 +25,116 @@ let { userId }: Props = $props(); - // Local state bound to selects - let selectedLightTheme = $state($themeStore.lightTheme); - let selectedDarkTheme = $state($themeStore.darkTheme); - let selectedFont = $state($themeStore.font); - let selectedFontSize = $state($themeStore.fontSize); - let selectedGridFontSize = $state($themeStore.gridFontSize); - let selectedTerminalFont = $state($themeStore.terminalFont); - let selectedEditorFont = $state($themeStore.editorFont); + // When editing global settings (no userId), skip applying theme visually + // This prevents global theme changes from affecting the current user's session + const skipApply = !userId; - // Sync local state with store changes + // Local state bound to selects - initialized with defaults, will be populated on mount + let selectedLightTheme = $state('default'); + let selectedDarkTheme = $state('default'); + let selectedFont = $state('system'); + let selectedFontSize = $state('normal'); + let selectedGridFontSize = $state('normal'); + let selectedTerminalFont = $state('system-mono'); + let selectedEditorFont = $state('system-mono'); + + onMount(async () => { + // Load monospace fonts for dropdown previews + const fontsToLoad = monospaceFonts.filter(f => f.googleFont); + if (fontsToLoad.length > 0) { + const families = fontsToLoad.map(f => `family=${f.googleFont}`).join('&'); + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`; + link.onload = () => { monoFontsLoaded = true; }; + document.head.appendChild(link); + } else { + monoFontsLoaded = true; + } + + // Fetch settings from the appropriate source + if (userId) { + // User profile: sync with themeStore (which has user's preferences) + selectedLightTheme = $themeStore.lightTheme; + selectedDarkTheme = $themeStore.darkTheme; + selectedFont = $themeStore.font; + selectedFontSize = $themeStore.fontSize; + selectedGridFontSize = $themeStore.gridFontSize; + selectedTerminalFont = $themeStore.terminalFont; + selectedEditorFont = $themeStore.editorFont; + } else { + // Global settings: fetch directly from API + try { + const res = await fetch('/api/settings/theme'); + if (res.ok) { + const data = await res.json(); + selectedLightTheme = data.lightTheme || 'default'; + selectedDarkTheme = data.darkTheme || 'default'; + selectedFont = data.font || 'system'; + selectedFontSize = data.fontSize || 'normal'; + selectedGridFontSize = data.gridFontSize || 'normal'; + selectedTerminalFont = data.terminalFont || 'system-mono'; + selectedEditorFont = data.editorFont || 'system-mono'; + } + } catch { + // Use defaults on error + } + } + }); + + // Sync with themeStore changes only when editing user profile $effect(() => { - selectedLightTheme = $themeStore.lightTheme; - selectedDarkTheme = $themeStore.darkTheme; - selectedFont = $themeStore.font; - selectedFontSize = $themeStore.fontSize; - selectedGridFontSize = $themeStore.gridFontSize; - selectedTerminalFont = $themeStore.terminalFont; - selectedEditorFont = $themeStore.editorFont; + if (userId) { + selectedLightTheme = $themeStore.lightTheme; + selectedDarkTheme = $themeStore.darkTheme; + selectedFont = $themeStore.font; + selectedFontSize = $themeStore.fontSize; + selectedGridFontSize = $themeStore.gridFontSize; + selectedTerminalFont = $themeStore.terminalFont; + selectedEditorFont = $themeStore.editorFont; + } }); async function handleLightThemeChange(value: string | undefined) { if (!value) return; selectedLightTheme = value; - await themeStore.setPreference('lightTheme', value, userId); + await themeStore.setPreference('lightTheme', value, userId, skipApply); } async function handleDarkThemeChange(value: string | undefined) { if (!value) return; selectedDarkTheme = value; - await themeStore.setPreference('darkTheme', value, userId); + await themeStore.setPreference('darkTheme', value, userId, skipApply); } async function handleFontChange(value: string | undefined) { if (!value) return; selectedFont = value; - await themeStore.setPreference('font', value, userId); + await themeStore.setPreference('font', value, userId, skipApply); } async function handleFontSizeChange(value: string | undefined) { if (!value) return; selectedFontSize = value as FontSize; - await themeStore.setPreference('fontSize', value as FontSize, userId); + await themeStore.setPreference('fontSize', value as FontSize, userId, skipApply); } async function handleGridFontSizeChange(value: string | undefined) { if (!value) return; selectedGridFontSize = value as FontSize; - await themeStore.setPreference('gridFontSize', value as FontSize, userId); + await themeStore.setPreference('gridFontSize', value as FontSize, userId, skipApply); } async function handleTerminalFontChange(value: string | undefined) { if (!value) return; selectedTerminalFont = value; - await themeStore.setPreference('terminalFont', value, userId); + await themeStore.setPreference('terminalFont', value, userId, skipApply); } async function handleEditorFontChange(value: string | undefined) { if (!value) return; selectedEditorFont = value; - await themeStore.setPreference('editorFont', value, userId); + await themeStore.setPreference('editorFont', value, userId, skipApply); } diff --git a/src/lib/components/host-info.svelte b/src/lib/components/host-info.svelte index 5ca3677..a966b32 100644 --- a/src/lib/components/host-info.svelte +++ b/src/lib/components/host-info.svelte @@ -8,6 +8,7 @@ import { getIconComponent } from '$lib/utils/icons'; import { toast } from 'svelte-sonner'; import { themeStore, type FontSize } from '$lib/stores/theme'; + import { formatTime } from '$lib/stores/settings'; // Font size scaling for header let fontSize = $state('normal'); @@ -451,7 +452,7 @@ class="flex items-center gap-2 {isConnected ? 'text-emerald-500' : 'text-muted-foreground'}" title={isConnected ? 'Live updates connected' : 'Live updates disconnected'} > - {lastUpdated.toLocaleTimeString()} + {formatTime(lastUpdated, { includeSeconds: true })} {#if isConnected} Live diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 60054fd..7ea9565 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,15 @@ [ + { + "version": "1.0.14", + "date": "2026-01-31", + "changes": [ + { "type": "fix", "text": "Fix environment variables in .env not interpolated during remote deployment" }, + { "type": "fix", "text": "Fix stack variables not re-injected during stack start/stop" }, + { "type": "fix", "text": "Fix time format 12/24 setting not respected in header clock" }, + { "type": "fix", "text": "Fix skip TLS verification not saved on new environment" } + ], + "imageTag": "fnsys/dockhand:v1.0.14" + }, { "version": "1.0.13", "date": "2026-01-23", @@ -19,10 +30,11 @@ { "type": "fix", "text": "Fix git stacks creating duplicate compose.yaml alongside repo file" }, { "type": "fix", "text": "Fix env vars not showing after stack create" }, { "type": "fix", "text": "Fix stack path defaults accidentally enforced over custom paths" }, - { "type": "fix", "text": "Fix adopted stack save & restart breaking paths and env vars" } + { "type": "fix", "text": "Fix adopted stack save & restart breaking paths and env vars" }, + { "type": "fix", "text": "Add more information to container audit logs including diff of changes" }, + { "type": "fix", "text": "Preserve container settings on restart and auto-update" } ], - "imageTag": "fnsys/dockhand:v1.0.13", - "comingSoon": true + "imageTag": "fnsys/dockhand:v1.0.13" }, { "version": "1.0.12", diff --git a/src/lib/data/dependencies.json b/src/lib/data/dependencies.json index f943cde..53e8d93 100644 --- a/src/lib/data/dependencies.json +++ b/src/lib/data/dependencies.json @@ -5,12 +5,6 @@ "license": "MIT", "repository": "https://github.com/codemirror/autocomplete" }, - { - "name": "@codemirror/commands", - "version": "6.10.0", - "license": "MIT", - "repository": "https://github.com/codemirror/commands" - }, { "name": "@codemirror/commands", "version": "6.10.1", @@ -553,7 +547,7 @@ }, { "name": "svelte", - "version": "5.47.1", + "version": "5.46.4", "license": "MIT", "repository": "https://github.com/sveltejs/svelte" }, diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 1cee923..8017c68 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -155,6 +155,7 @@ export async function createEnvironment(env: Omit): Promi if (data.defaultProvider !== undefined) updateData.defaultProvider = data.defaultProvider; if (data.sessionTimeout !== undefined) updateData.sessionTimeout = data.sessionTimeout; - await db.update(authSettings).set(updateData).where(eq(authSettings.id, 1)); + // Get existing row's id (may not be 1 after db reset/migration) + const existing = await db.select({ id: authSettings.id }).from(authSettings).limit(1); + if (existing[0]) { + await db.update(authSettings).set(updateData).where(eq(authSettings.id, existing[0].id)); + } return getAuthSettings(); } diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index b62ea89..0f6a3e0 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -3097,17 +3097,42 @@ export async function runContainerWithStreaming(options: { // Wait for container to fully exit before fetching stdout // The stderr stream may close before the container finishes writing to stdout // Use a timeout to prevent hanging if something goes wrong (container should already be exited) - const waitPromise = dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId); - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('Container wait timeout after 10s')), 10000) - ); - await Promise.race([waitPromise, timeoutPromise]).catch((err) => { + let exitCode: number | undefined; + try { + const waitPromise = dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Container wait timeout after 10s')), 10000) + ); + const waitResult = await Promise.race([waitPromise, timeoutPromise]); + const waitData = await waitResult.json() as { StatusCode?: number }; + exitCode = waitData.StatusCode; + console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode}`); + } catch (err) { // Log but don't fail - container might already be gone or stderr stream was reliable - console.warn(`[runContainerWithStreaming] Wait warning: ${err.message}`); - }); + console.warn(`[runContainerWithStreaming] Wait warning: ${(err as Error).message}`); + } // Container has exited. Now fetch stdout reliably (no race condition). const stdout = await fetchContainerStdout(containerId, config, options.envId); + + // If stdout is empty and exit code is non-zero, fetch stderr for debugging + if (stdout.length === 0 && exitCode !== 0) { + try { + const stderrResponse = await dockerFetch( + `/containers/${containerId}/logs?stdout=false&stderr=true&follow=false`, + {}, + options.envId + ); + const stderrBuffer = Buffer.from(await stderrResponse.arrayBuffer()); + const stderrResult = processStreamFrames(stderrBuffer, undefined, undefined); + if (stderrResult.stderr) { + console.error(`[runContainerWithStreaming] Container stderr: ${stderrResult.stderr.substring(0, 1000)}`); + } + } catch { + // Ignore stderr fetch errors + } + } + return stdout; } finally { // Always cleanup container diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 0a9b586..7768d5e 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -1097,13 +1097,17 @@ export async function deployGitStackWithProgress( }); if (result.success) { - // Record the stack source + // Record the stack source with resolved compose path for consistency + const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId); + const resolvedComposePath = join(stackDir, basename(gitStack.composePath)); + await upsertStackSource({ stackName: gitStack.stackName, environmentId: gitStack.environmentId, sourceType: 'git', gitRepositoryId: gitStack.repositoryId, - gitStackId: stackId + gitStackId: stackId, + composePath: resolvedComposePath }); onProgress({ status: 'complete', message: `Successfully deployed ${gitStack.stackName}` }); diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts index 308519f..626c809 100644 --- a/src/lib/server/scanner.ts +++ b/src/lib/server/scanner.ts @@ -76,6 +76,10 @@ const SCANNER_CACHE_DIR = 'scanner-cache'; // Track running scanner instances to detect concurrent scans const runningScanners = new Map(); // key: "grype" or "trivy", value: count +// Track in-progress scans per image to prevent duplicate scans +// Key: "{scannerType}:{imageName}", Value: Promise that resolves to the scan result +const inProgressScans = new Map>(); + // Default CLI arguments for scanners (image name is substituted for {image}) export const DEFAULT_GRYPE_ARGS = '-o json -v {image}'; export const DEFAULT_TRIVY_ARGS = 'image --format json {image}'; @@ -433,6 +437,38 @@ async function runScannerContainer( cmd: string[], envId?: number, onOutput?: (line: string) => void +): Promise { + // Check if a scan for this exact image is already in progress + // This prevents duplicate scans when multiple containers use the same image + const scanKey = `${scannerType}:${imageName}:${envId ?? 'local'}`; + const existingScan = inProgressScans.get(scanKey); + if (existingScan) { + console.log(`[Scanner] Reusing in-progress ${scannerType} scan for: ${imageName}`); + return existingScan; + } + + // Create the actual scan promise + const scanPromise = runScannerContainerImpl(scannerImage, scannerType, imageName, cmd, envId, onOutput); + + // Register it so concurrent requests can reuse it + inProgressScans.set(scanKey, scanPromise); + + try { + return await scanPromise; + } finally { + // Clean up the tracking entry when done + inProgressScans.delete(scanKey); + } +} + +// Internal implementation of scanner container run +async function runScannerContainerImpl( + scannerImage: string, + scannerType: 'grype' | 'trivy', + imageName: string, + cmd: string[], + envId?: number, + onOutput?: (line: string) => void ): Promise { console.log(`[Scanner] Starting ${scannerType} scan for image: ${imageName}, envId: ${envId ?? 'local'}`); @@ -451,17 +487,25 @@ async function runScannerContainer( // Detect the host Docker socket path based on connection type // For local socket environments, detect the actual host socket path (handles rootless Docker) - // For remote environments (hawser/direct), scanner runs remotely and uses standard path + // For remote environments (hawser/direct with host), scanner runs remotely and uses standard path const env = envId ? await getEnvironment(envId) : undefined; const connectionType = env?.connectionType; + // Determine if this is a local socket environment: + // - connectionType === 'socket' (explicit) + // - connectionType is null/undefined (default behavior) + // - connectionType === 'direct' but no host specified (legacy local environments) + const isLocalSocket = !connectionType || + connectionType === 'socket' || + (connectionType === 'direct' && !env?.host); + let hostSocketPath: string; let containerUser: string | undefined; - if (!connectionType || connectionType === 'socket') { + if (isLocalSocket) { // Local socket environment - detect host socket path (handles rootless Docker) hostSocketPath = getHostDockerSocket(); - console.log(`[Scanner] Local socket scan - detected host Docker socket: ${hostSocketPath}`); + console.log(`[Scanner] Local socket scan (${connectionType || 'default'}) - detected host Docker socket: ${hostSocketPath}`); // For user-specific Docker sockets, run scanner as that user // e.g., /run/user/1000/docker.sock -> run as UID 1000 @@ -471,10 +515,10 @@ async function runScannerContainer( console.log(`[Scanner] Rootless Docker detected (UID ${containerUser})`); } } else { - // Remote environment (direct/hawser-standard/hawser-edge) + // Remote environment (direct with host/hawser-standard/hawser-edge) // Scanner runs on remote host, uses remote host's standard Docker socket hostSocketPath = '/var/run/docker.sock'; - console.log(`[Scanner] Remote scan (${connectionType}) - using standard socket path: ${hostSocketPath}`); + console.log(`[Scanner] Remote scan (${connectionType}, host: ${env?.host}) - using standard socket path: ${hostSocketPath}`); } // Determine cache storage strategy based on environment diff --git a/src/lib/server/scheduler/tasks/image-prune.ts b/src/lib/server/scheduler/tasks/image-prune.ts index a411c71..0f655fb 100644 --- a/src/lib/server/scheduler/tasks/image-prune.ts +++ b/src/lib/server/scheduler/tasks/image-prune.ts @@ -74,7 +74,12 @@ export async function runImagePrune( // Extract space reclaimed and images removed from result const spaceReclaimed = result?.SpaceReclaimed || 0; - const imagesRemoved = result?.ImagesDeleted?.length || 0; + // Count unique images by filtering Untagged entries that are not digest references + // Docker returns multiple entries per image: Untagged (tag), Untagged (digest @sha256:), Deleted (layers) + // We only count tag-based Untagged entries to get actual image count + const imagesRemoved = result?.ImagesDeleted + ?.filter((img: any) => img.Untagged && !img.Untagged.includes('@sha256:')) + .length || 0; // Format space for human-readable output const formatBytes = (bytes: number): string => { diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 77a1259..a8fdf40 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -25,7 +25,7 @@ import { getAutoUpdateSetting } from './db'; import { unregisterSchedule } from './scheduler'; -import { deleteGitStackFiles } from './git'; +import { deleteGitStackFiles, parseEnvFileContent } from './git'; import { cleanPem } from '$lib/utils/pem'; import { rewriteComposeVolumePaths, getHostDataDir } from './host-path'; @@ -1225,18 +1225,35 @@ async function executeComposeCommand( switch (env.connectionType) { case 'hawser-standard': - case 'hawser-edge': + case 'hawser-edge': { + // For Hawser deployments, we need to read the .env file and send variables via envVars + // because Docker Compose on the remote host may not auto-read the .env file reliably. + // Local deployments use --env-file flag, but Hawser needs variables injected via shell env. + let hawserEnvVars = envVars; + if (envPath && existsSync(envPath)) { + try { + const envFileContent = await Bun.file(envPath).text(); + const envFileVars = parseEnvFileContent(envFileContent, stackName); + // Merge: envFileVars (lowest) < envVars (DB overrides) + // secretVars are handled separately in executeComposeViaHawser + hawserEnvVars = { ...envFileVars, ...(envVars || {}) }; + console.log(`[Stack:${stackName}] Read ${Object.keys(envFileVars).length} vars from .env file for Hawser injection`); + } catch (err) { + console.warn(`[Stack:${stackName}] Failed to read .env file at ${envPath}:`, err); + } + } return executeComposeViaHawser( operation, stackName, composeContent, envId!, - envVars, + hawserEnvVars, secretVars, forceRecreate, removeVolumes, stackFiles ); + } case 'direct': { const port = env.port || 2375; @@ -1490,6 +1507,8 @@ export interface RequireComposeResult { success: boolean; content?: string; secretVars?: Record; + /** Non-secret variables from database (needed for compose interpolation) */ + nonSecretVars?: Record; needsFileLocation?: boolean; error?: string; /** Directory containing the compose file (for working directory) */ @@ -1534,6 +1553,10 @@ async function requireComposeFile( // These are NEVER written to disk const secretVars = await getSecretEnvVarsAsRecord(stackName, envId); + // Get NON-SECRET variables from database (needed for compose interpolation) + // For git stacks without .env files, these are the only source of env vars + const nonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId); + // Determine env file path for --env-file flag // For stacks with custom composePath (adopted/external), derive envPath from same directory // For internal stacks, use the default data directory @@ -1558,11 +1581,13 @@ async function requireComposeFile( } // Docker Compose reads non-secrets from the .env file via --env-file. - // Only secrets need to be injected via shell environment. + // Secrets and non-secrets from DB need to be injected via shell environment + // for stacks without .env files (e.g., git stacks with manual env vars). return { success: true, content: composeResult.content!, secretVars, + nonSecretVars, stackDir: composeResult.stackDir, composePath: composeResult.composePath ?? undefined, envPath: envFilePath ?? undefined @@ -1588,7 +1613,7 @@ export async function startStack( 'up', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, - undefined, + result.nonSecretVars, result.secretVars ); } @@ -1612,7 +1637,7 @@ export async function stopStack( 'stop', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, - undefined, + result.nonSecretVars, result.secretVars ); } @@ -1636,7 +1661,7 @@ export async function restartStack( 'restart', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, - undefined, + result.nonSecretVars, result.secretVars ); } @@ -1661,7 +1686,7 @@ export async function downStack( 'down', { stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, - undefined, + result.nonSecretVars, result.secretVars ); } @@ -2026,7 +2051,7 @@ export async function pullStackImages( 'pull', { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, - undefined, + result.nonSecretVars, result.secretVars ); } diff --git a/src/lib/server/subprocesses/event-subprocess.ts b/src/lib/server/subprocesses/event-subprocess.ts index f1acd6d..18e7f84 100644 --- a/src/lib/server/subprocesses/event-subprocess.ts +++ b/src/lib/server/subprocesses/event-subprocess.ts @@ -31,7 +31,8 @@ const lastPollTime: Map = new Map(); // Recent event cache for deduplication (key: timeNano-containerId-action) const recentEvents: Map = new Map(); const DEDUP_WINDOW_MS = 5000; // 5 second window for deduplication -const CACHE_CLEANUP_INTERVAL_MS = 30000; // Clean up cache every 30 seconds +const CACHE_CLEANUP_INTERVAL_MS = 5000; // Clean up cache every 5 seconds (match dedup window) +const MAX_DEDUP_CACHE_SIZE = 500; // Hard limit to prevent unbounded growth let cacheCleanupInterval: ReturnType | null = null; let isShuttingDown = false; @@ -126,14 +127,25 @@ interface DockerEvent { /** * Clean up old entries from the deduplication cache + * Also enforces max size limit with LRU eviction */ function cleanupRecentEvents() { const now = Date.now(); + // First pass: remove expired entries for (const [key, timestamp] of recentEvents.entries()) { if (now - timestamp > DEDUP_WINDOW_MS) { recentEvents.delete(key); } } + // Second pass: enforce max size with LRU eviction if still too large + if (recentEvents.size > MAX_DEDUP_CACHE_SIZE) { + const entries = Array.from(recentEvents.entries()) + .sort((a, b) => a[1] - b[1]); // Sort by timestamp (oldest first) + const toRemove = entries.slice(0, entries.length - MAX_DEDUP_CACHE_SIZE); + for (const [key] of toRemove) { + recentEvents.delete(key); + } + } } /** @@ -274,9 +286,11 @@ async function pollEnvironmentEvents(envId: number, envName: string) { } } finally { try { + // Cancel the stream first to ensure proper cleanup, then release lock + await reader.cancel(); reader.releaseLock(); } catch { - // Reader already released + // Reader already released or stream closed } } @@ -362,6 +376,8 @@ async function startEnvironmentCollector(envId: number, envName: string) { } finally { if (reader) { try { + // Cancel the stream first to ensure proper cleanup, then release lock + await reader.cancel(); reader.releaseLock(); } catch { // Reader already released or stream closed - ignore @@ -376,6 +392,8 @@ async function startEnvironmentCollector(envId: number, envName: string) { } catch (error: any) { if (reader) { try { + // Cancel the stream first to ensure proper cleanup, then release lock + await reader.cancel(); reader.releaseLock(); } catch { // Reader already released or stream closed - ignore @@ -624,7 +642,17 @@ async function start(): Promise { // Start periodic cache cleanup cacheCleanupInterval = setInterval(cleanupRecentEvents, CACHE_CLEANUP_INTERVAL_MS); - console.log('[EventSubprocess] Started deduplication cache cleanup (every 30s)'); + console.log('[EventSubprocess] Started deduplication cache cleanup (every 5s)'); + + // Start memory diagnostics logging (every 5 minutes) + setInterval(() => { + const mem = process.memoryUsage(); + console.log( + `[EventSubprocess] Memory: heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB, ` + + `rss=${Math.round(mem.rss / 1024 / 1024)}MB, ` + + `dedup=${recentEvents.size}, collectors=${collectors.size}, pollers=${pollIntervals.size}` + ); + }, 5 * 60 * 1000); // Listen for commands from main process process.on('message', (message: MainProcessCommand) => { diff --git a/src/lib/server/subprocesses/metrics-subprocess.ts b/src/lib/server/subprocesses/metrics-subprocess.ts index 408fd93..976be43 100644 --- a/src/lib/server/subprocesses/metrics-subprocess.ts +++ b/src/lib/server/subprocesses/metrics-subprocess.ts @@ -20,12 +20,20 @@ const ENV_DISK_TIMEOUT = 20000; // 20 seconds timeout per environment for disk c /** * Timeout wrapper - returns fallback if promise takes too long + * IMPORTANT: Properly clears the timeout to prevent memory leaks */ function withTimeout(promise: Promise, ms: number, fallback: T): Promise { - return Promise.race([ - promise, - new Promise(resolve => setTimeout(() => resolve(fallback), ms)) - ]); + let timeoutId: ReturnType | null = null; + + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => resolve(fallback), ms); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + }); } // Track last disk warning sent per environment to avoid spamming @@ -35,6 +43,8 @@ const DISK_WARNING_COOLDOWN = 3600000; // 1 hour between warnings let collectInterval: ReturnType | null = null; let diskCheckInterval: ReturnType | null = null; let isShuttingDown = false; +let collectionCycleCount = 0; +const MEMORY_LOG_INTERVAL = 10; // Log memory every 10 cycles (~5 minutes at 30s interval) /** * Send message to main process @@ -170,6 +180,15 @@ async function collectMetrics() { console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed: ${reason}`); } }); + + // Periodic memory logging for diagnostics + collectionCycleCount++; + if (collectionCycleCount % MEMORY_LOG_INTERVAL === 0) { + const memUsage = process.memoryUsage(); + const heapMB = Math.round(memUsage.heapUsed / 1024 / 1024); + const rssMB = Math.round(memUsage.rss / 1024 / 1024); + console.log(`[MetricsSubprocess] Memory: heap=${heapMB}MB, rss=${rssMB}MB (cycle ${collectionCycleCount})`); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`[MetricsSubprocess] Metrics collection error: ${message}`); @@ -432,6 +451,15 @@ async function start(): Promise { console.log('[MetricsSubprocess] Disk space monitoring disabled (SKIP_DF_COLLECTION=true)'); } + // Start memory diagnostics logging (every 5 minutes) + setInterval(() => { + const mem = process.memoryUsage(); + console.log( + `[MetricsSubprocess] Memory: heap=${Math.round(mem.heapUsed / 1024 / 1024)}MB, ` + + `rss=${Math.round(mem.rss / 1024 / 1024)}MB` + ); + }, 5 * 60 * 1000); + // Listen for commands from main process process.on('message', (message: MainProcessCommand) => { handleCommand(message); diff --git a/src/lib/stores/theme.ts b/src/lib/stores/theme.ts index cf36e39..a0ec242 100644 --- a/src/lib/stores/theme.ts +++ b/src/lib/stores/theme.ts @@ -113,18 +113,22 @@ function createThemeStore() { } }, - // Update a preference and apply immediately + // Update a preference and optionally apply immediately + // skipApply: when true, saves to database but doesn't apply visually (for global settings when user is logged in) async setPreference( key: K, value: ThemePreferences[K], - userId?: number + userId?: number, + skipApply?: boolean ) { - update((prefs) => { - const newPrefs = { ...prefs, [key]: value }; - saveToStorage(newPrefs); - applyTheme(newPrefs); - return newPrefs; - }); + if (!skipApply) { + update((prefs) => { + const newPrefs = { ...prefs, [key]: value }; + saveToStorage(newPrefs); + applyTheme(newPrefs); + return newPrefs; + }); + } // Save to database (async, non-blocking) try { diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index 2422d5a..8719d98 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -140,15 +140,20 @@ export const POST: RequestHandler = async (event) => { // Save environment variable overrides before deploying if (data.envVars && Array.isArray(data.envVars) && data.envVars.length > 0) { - await setStackEnvVars( - trimmedStackName, - data.environmentId || null, - data.envVars.filter((v: any) => v.key?.trim()).map((v: any) => ({ + // Filter out masked secrets - on initial creation there are no existing secrets + // If a secret has value '***', it means something went wrong in the UI + const varsToSave = data.envVars + .filter((v: any) => v.key?.trim()) + .filter((v: any) => !(v.isSecret && v.value === '***')) + .map((v: any) => ({ key: v.key.trim(), value: v.value ?? '', isSecret: v.isSecret ?? false - })) - ); + })); + + if (varsToSave.length > 0) { + await setStackEnvVars(trimmedStackName, data.environmentId || null, varsToSave); + } } // If deployNow is set, deploy immediately diff --git a/src/routes/api/git/stacks/[id]/+server.ts b/src/routes/api/git/stacks/[id]/+server.ts index 5345786..b8a968c 100644 --- a/src/routes/api/git/stacks/[id]/+server.ts +++ b/src/routes/api/git/stacks/[id]/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars } from '$lib/server/db'; +import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars } from '$lib/server/db'; import { deleteGitStackFiles, deployGitStack } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; @@ -99,15 +99,36 @@ export const PUT: RequestHandler = async (event) => { if (data.envVars && Array.isArray(data.envVars)) { const stackName = data.stackName || existing.stackName; const envId = updated.environmentId ?? null; - await setStackEnvVars( - stackName, - envId, - data.envVars.filter((v: any) => v.key?.trim()).map((v: any) => ({ - key: v.key.trim(), - value: v.value ?? '', - isSecret: v.isSecret ?? false - })) - ); + + // Get existing secrets to preserve masked values + const existingVars = await getStackEnvVars(stackName, envId, false); // false = unmasked + const existingByKey = new Map(existingVars.map(v => [v.key, v])); + + const varsToSave = data.envVars + .filter((v: any) => v.key?.trim()) + .map((v: any) => { + // Preserve existing secret value if submitted value is masked + if (v.isSecret && v.value === '***') { + const existingVar = existingByKey.get(v.key.trim()); + if (existingVar && existingVar.isSecret) { + return { + key: v.key.trim(), + value: existingVar.value, // Use real value from DB + isSecret: true + }; + } + // No existing secret found - skip this entry (shouldn't happen normally) + return null; + } + return { + key: v.key.trim(), + value: v.value ?? '', + isSecret: v.isSecret ?? false + }; + }) + .filter(Boolean); // Remove nulls + + await setStackEnvVars(stackName, envId, varsToSave as any); } // If deployNow is set, deploy after saving diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index a9db12e..f1eb2cf 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -75,7 +75,7 @@ applyTheme(themeStore.get()); // Initialize theme from app settings (no user yet, so fetches from /api/settings/theme) - themeStore.init(); + await themeStore.init(); // Set error from URL if present if (urlError) {