1.0.14
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.13",
|
||||
"version": "1.0.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite dev",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}` });
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user