This commit is contained in:
jarek
2026-01-31 09:35:19 +01:00
parent 8f3a7eb435
commit 610548ed66
17 changed files with 343 additions and 105 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.13",
"version": "1.0.14",
"type": "module",
"scripts": {
"dev": "bunx --bun vite dev",
+73 -36
View File
@@ -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<FontSize>('normal');
let selectedGridFontSize = $state<FontSize>('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);
}
</script>
+2 -1
View File
@@ -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<FontSize>('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'}
>
<span class="text-muted-foreground">{lastUpdated.toLocaleTimeString()}</span>
<span class="text-muted-foreground">{formatTime(lastUpdated, { includeSeconds: true })}</span>
{#if isConnected}
<Wifi class="{iconSizeLargeClass()}" />
<span class="font-medium">Live</span>
+15 -3
View File
@@ -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",
+1 -7
View File
@@ -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"
},
+6 -1
View File
@@ -155,6 +155,7 @@ export async function createEnvironment(env: Omit<Environment, 'id' | 'createdAt
tlsCa: env.tlsCa || null,
tlsCert: env.tlsCert || null,
tlsKey: encrypt(env.tlsKey) || null,
tlsSkipVerify: env.tlsSkipVerify ?? false,
icon: env.icon || 'globe',
socketPath: env.socketPath || '/var/run/docker.sock',
collectActivity: env.collectActivity !== false,
@@ -1149,7 +1150,11 @@ export async function updateAuthSettings(data: Partial<AuthSettingsData>): 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();
}
+32 -7
View File
@@ -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<never>((_, 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
+6 -2
View File
@@ -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}` });
+49 -5
View File
@@ -76,6 +76,10 @@ const SCANNER_CACHE_DIR = 'scanner-cache';
// Track running scanner instances to detect concurrent scans
const runningScanners = new Map<string, number>(); // 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<string, Promise<string>>();
// 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<string> {
// 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<string> {
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
@@ -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 => {
+34 -9
View File
@@ -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<string, string>;
/** Non-secret variables from database (needed for compose interpolation) */
nonSecretVars?: Record<string, string>;
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
);
}
@@ -31,7 +31,8 @@ const lastPollTime: Map<number, number> = new Map();
// Recent event cache for deduplication (key: timeNano-containerId-action)
const recentEvents: Map<string, number> = 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<typeof setInterval> | 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<void> {
// 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) => {
@@ -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<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
promise,
new Promise<T>(resolve => setTimeout(() => resolve(fallback), ms))
]);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<T>((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<typeof setInterval> | null = null;
let diskCheckInterval: ReturnType<typeof setInterval> | 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<void> {
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);
+12 -8
View File
@@ -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<K extends keyof ThemePreferences>(
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 {
+11 -6
View File
@@ -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
+31 -10
View File
@@ -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
+1 -1
View File
@@ -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) {