+ These variables are available for compose file interpolation using ${'{VAR_NAME}'} syntax.
+ To pass them to containers, reference them in the compose file's environment: section.
+
+ {:else if showInterpolationHint && secretCount > 0}
+
+
+
+
+
+ These variables are available for compose file interpolation using ${'{VAR_NAME}'} syntax.
+ To pass them to containers, reference them in the compose file's environment: section.
+
+
+
+
+
+ {secretCount} secret{secretCount === 1 ? '' : 's'} not shown.
+ Secrets are never written to disk and are injected via shell environment when the stack starts.
+
+
+
+ {:else if showInterpolationHint}
+
+
+
+
+ These variables are available for compose file interpolation using ${'{VAR_NAME}'} syntax.
+ To pass them to containers, reference them in the compose file's environment: section.
+
+
{:else if secretCount > 0}
@@ -459,7 +508,7 @@
/>
{:else}
= {
+ 'Europe/Kyiv': 'Europe/Kiev',
+ 'Asia/Ho_Chi_Minh': 'Asia/Saigon',
+ 'America/Nuuk': 'America/Godthab',
+ 'Pacific/Kanton': 'Pacific/Enderbury'
+ };
+
+ // Reverse map: canonical → modern alias names (for display hints)
+ const TIMEZONE_DISPLAY_HINTS: Record = Object.fromEntries(
+ Object.entries(TIMEZONE_ALIASES).map(([modern, canonical]) => {
+ const city = modern.split('/').pop()!.replace(/_/g, ' ');
+ return [canonical, city];
+ })
+ );
+
// Common timezones to show at the top
const commonTimezones = [
'UTC',
@@ -47,16 +63,26 @@
// Other timezones (excluding common ones)
const otherTimezones = allTimezones.filter((tz) => !commonTimezones.includes(tz));
+ // Check if a timezone matches the search query (including alias names)
+ function matchesSearch(tz: string, query: string): boolean {
+ const q = query.toLowerCase();
+ if (tz.toLowerCase().includes(q)) return true;
+ // Check if any alias points to this timezone
+ const hint = TIMEZONE_DISPLAY_HINTS[tz];
+ if (hint && hint.toLowerCase().includes(q)) return true;
+ return false;
+ }
+
// Filter based on search query
const filteredCommon = $derived(
searchQuery
- ? commonTimezones.filter((tz) => tz.toLowerCase().includes(searchQuery.toLowerCase()))
+ ? commonTimezones.filter((tz) => matchesSearch(tz, searchQuery))
: commonTimezones
);
const filteredOther = $derived(
searchQuery
- ? otherTimezones.filter((tz) => tz.toLowerCase().includes(searchQuery.toLowerCase()))
+ ? otherTimezones.filter((tz) => matchesSearch(tz, searchQuery))
: otherTimezones
);
@@ -78,7 +104,9 @@
const parts = formatter.formatToParts(now);
const offsetPart = parts.find((p) => p.type === 'timeZoneName');
if (offsetPart) {
- return `${tz} (${offsetPart.value})`;
+ const hint = TIMEZONE_DISPLAY_HINTS[tz];
+ const extra = hint ? `, ${hint}` : '';
+ return `${tz} (${offsetPart.value}${extra})`;
}
} catch {
// If formatting fails, just return the timezone name
diff --git a/src/lib/components/ui/empty-state/no-environment.svelte b/src/lib/components/ui/empty-state/no-environment.svelte
index 1a137ef..b589d8b 100644
--- a/src/lib/components/ui/empty-state/no-environment.svelte
+++ b/src/lib/components/ui/empty-state/no-environment.svelte
@@ -21,7 +21,7 @@
description="Add a Docker environment in Settings to get started"
>
diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json
index 7ea9565..9c37092 100644
--- a/src/lib/data/changelog.json
+++ b/src/lib/data/changelog.json
@@ -1,4 +1,38 @@
[
+ {
+ "version": "1.0.15",
+ "date": "2026-02-08",
+ "changes": [
+ { "type": "feature", "text": "Auto-detect build directives: Git stacks with build: directives now automatically rebuild images on deploy" },
+ { "type": "feature", "text": "Pull before update option: New option to pull latest image before container auto-update" },
+ { "type": "feature", "text": "Usage filter on images page by usage status (used/unused/all)" },
+ { "type": "feature", "text": "Show repository name for untagged images: Better identification of images without tags" },
+ { "type": "fix", "text": "Fix IPv6 address not accepted in environment Public IP field" },
+ { "type": "fix", "text": "Fix IPv6 port link URLs by adding bracket formatting" },
+ { "type": "fix", "text": "Fix custom compose filename not used in SSE deploy" },
+ { "type": "fix", "text": "Fix env var leakage from Dockhand to user stacks" },
+ { "type": "fix", "text": "Fix SMTP notification test returning false success" },
+ { "type": "fix", "text": "Fix custom compose file path ignored for Hawser git stack deployments" },
+ { "type": "fix", "text": "Fix escaped $$ variables (Docker Compose syntax) incorrectly flagged as missing" },
+ { "type": "fix", "text": "Use native compose pull and up when updating stack containers" },
+ { "type": "fix", "text": "Fix vulnerability scans hanging indefinitely or failing with JSON parse errors" },
+ { "type": "fix", "text": "Fix memory leaks in SSE event streams and unconsumed Docker API response bodies" },
+ { "type": "improvement", "text": "Sort vulnerability scan results by severity by default" },
+ { "type": "improvement", "text": "Copy button for compose file contents in stack modal" },
+ { "type": "improvement", "text": "Confirmation dialog before git stack sync" },
+ { "type": "fix", "text": "Fix timezone aliases (e.g. Europe/Kyiv) not saving correctly" },
+ { "type": "fix", "text": "Fix login crash with large session timeout values" },
+ { "type": "fix", "text": "Fix profile display name not persisting due to field name mismatch" },
+ { "type": "fix", "text": "Fix date formatting not respecting user preferences" },
+ { "type": "fix", "text": "Fix static IP not preserved during container auto-update" },
+ { "type": "fix", "text": "Fix stack adoption path conflict across different environments" },
+ { "type": "fix", "text": "Fix container auto-update causing permission denied on bind mounts" },
+ { "type": "fix", "text": "Fix health tab not showing healthcheck configuration" },
+ { "type": "fix", "text": "Fix stack custom paths reset after edit" },
+ { "type": "fix", "text": "Autofocus on login username and MFA code fields" }
+ ],
+ "imageTag": "fnsys/dockhand:v1.0.15"
+ },
{
"version": "1.0.14",
"date": "2026-01-31",
diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts
index d4a9c30..3bcd12f 100644
--- a/src/lib/server/auth.ts
+++ b/src/lib/server/auth.ts
@@ -186,13 +186,19 @@ export async function createUserSession(
// Get session timeout from settings
const settings = await getAuthSettings();
- const expiresAt = new Date(Date.now() + settings.sessionTimeout * 1000).toISOString();
+ // Safety: ensure sessionTimeout is valid (1 second to 30 days), default to 24h if invalid
+ const MAX_SESSION_TIMEOUT = 2592000; // 30 days in seconds
+ const DEFAULT_SESSION_TIMEOUT = 86400; // 24 hours in seconds
+ const sessionTimeout = (settings?.sessionTimeout > 0 && settings?.sessionTimeout <= MAX_SESSION_TIMEOUT)
+ ? settings.sessionTimeout
+ : DEFAULT_SESSION_TIMEOUT;
+ const expiresAt = new Date(Date.now() + sessionTimeout * 1000).toISOString();
// Create session in database
const session = await dbCreateSession(sessionId, userId, provider, expiresAt);
// Set secure cookie
- setSessionCookie(cookies, sessionId, settings.sessionTimeout);
+ setSessionCookie(cookies, sessionId, sessionTimeout);
// Update user's last login time
await updateUser(userId, { lastLogin: new Date().toISOString() });
diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts
index 8017c68..4127daf 100644
--- a/src/lib/server/db.ts
+++ b/src/lib/server/db.ts
@@ -1148,7 +1148,11 @@ export async function updateAuthSettings(data: Partial): Promi
if (data.authEnabled !== undefined) updateData.authEnabled = data.authEnabled;
if (data.defaultProvider !== undefined) updateData.defaultProvider = data.defaultProvider;
- if (data.sessionTimeout !== undefined) updateData.sessionTimeout = data.sessionTimeout;
+ if (data.sessionTimeout !== undefined) {
+ // Cap session timeout to safe maximum (30 days)
+ const MAX_SESSION_TIMEOUT = 2592000; // 30 days in seconds
+ updateData.sessionTimeout = Math.min(Math.max(1, data.sessionTimeout), MAX_SESSION_TIMEOUT);
+ }
// 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);
@@ -2614,6 +2618,34 @@ export async function getStackSource(stackName: string, environmentId?: number |
} as StackSourceWithRepo;
}
+export async function getStackSourceByComposePath(composePath: string, environmentId?: number | null): Promise {
+ const envCondition = environmentId !== undefined && environmentId !== null
+ ? eq(stackSources.environmentId, environmentId)
+ : isNull(stackSources.environmentId);
+
+ const results = await db.select().from(stackSources)
+ .where(and(eq(stackSources.composePath, composePath), envCondition));
+
+ if (!results[0]) return null;
+ const row = results[0];
+
+ let repository = null;
+ let gitStackData = null;
+
+ if (row.gitRepositoryId) {
+ repository = await getGitRepository(row.gitRepositoryId);
+ }
+ if (row.gitStackId) {
+ gitStackData = await getGitStack(row.gitStackId);
+ }
+
+ return {
+ ...row,
+ repository,
+ gitStack: gitStackData
+ } as StackSourceWithRepo;
+}
+
export async function getStackSources(environmentId?: number | null): Promise {
let results;
if (environmentId !== undefined && environmentId !== null) {
diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts
index 0f6a3e0..b2261a1 100644
--- a/src/lib/server/docker.ts
+++ b/src/lib/server/docker.ts
@@ -233,27 +233,39 @@ function processStreamFrames(
onStdout?: (data: string) => void,
onStderr?: (data: string) => void
): { stdout: string; remaining: Buffer } {
- let stdout = '';
+ // Collect stdout frame payloads as raw buffers first, then decode to UTF-8 once
+ // at the end. Decoding each frame individually corrupts multi-byte UTF-8 characters
+ // that may be split across frame boundaries (observed with Grype on Synology NAS).
+ const stdoutChunks: Buffer[] = [];
+ let stdoutLen = 0;
let offset = 0;
while (buffer.length >= offset + 8) {
const streamType = buffer.readUInt8(offset);
const frameSize = buffer.readUInt32BE(offset + 4);
+ // Validate stream type (0=stdin, 1=stdout, 2=stderr)
+ if (streamType > 2) break;
+
+ // Sanity check - no single frame should be > 10MB
+ if (frameSize > 10 * 1024 * 1024) break;
+
if (buffer.length < offset + 8 + frameSize) break;
- const payload = buffer.slice(offset + 8, offset + 8 + frameSize).toString('utf-8');
+ const payloadBuf = buffer.slice(offset + 8, offset + 8 + frameSize);
if (streamType === 1) {
- stdout += payload;
- onStdout?.(payload);
+ stdoutChunks.push(payloadBuf);
+ stdoutLen += payloadBuf.length;
+ onStdout?.(payloadBuf.toString('utf-8'));
} else if (streamType === 2) {
- onStderr?.(payload);
+ onStderr?.(payloadBuf.toString('utf-8'));
}
offset += 8 + frameSize;
}
+ const stdout = Buffer.concat(stdoutChunks, stdoutLen).toString('utf-8');
return { stdout, remaining: buffer.slice(offset) };
}
@@ -402,6 +414,16 @@ function edgeResponseToResponse(edgeResponse: EdgeResponse): Response {
});
}
+/**
+ * Drain a response body to release the underlying socket/TLS connection.
+ * Must be called on any Response whose body won't otherwise be consumed.
+ */
+export async function drainResponse(response: Response): Promise {
+ if (!response.bodyUsed) {
+ try { await response.arrayBuffer(); } catch {}
+ }
+}
+
/**
* Make a request to the Docker API
* Exported for use by stacks.ts module
@@ -565,23 +587,18 @@ export async function dockerFetch(
finalOptions.keepalive = false;
}
- // Explicitly close connection to prevent TLS session reuse issues
- // But only for non-streaming requests (logs, events, exec need keep-alive)
- if (!streaming) {
- finalOptions.headers = {
- ...finalOptions.headers,
- 'Connection': 'close'
- };
- }
-
- // Optional verbose TLS debugging
+ // Optional verbose TLS debugging
if (process.env.DEBUG_TLS) {
// @ts-ignore - Bun-specific verbose option
finalOptions.verbose = true;
}
}
- // @ts-ignore - Bun supports timeout option
+ // Add default timeout for non-streaming requests to prevent socket accumulation
+ if (!streaming && !finalOptions.signal) {
+ finalOptions.signal = AbortSignal.timeout(30000);
+ }
+
try {
const response = await fetch(url, { ...finalOptions, ...bunOptions });
const elapsed = Date.now() - startTime;
@@ -748,23 +765,28 @@ export async function getContainerStats(id: string, envId?: number | null) {
}
export async function startContainer(id: string, envId?: number | null) {
- await dockerFetch(`/containers/${id}/start`, { method: 'POST' }, envId);
+ const response = await dockerFetch(`/containers/${id}/start`, { method: 'POST' }, envId);
+ await drainResponse(response);
}
export async function stopContainer(id: string, envId?: number | null) {
- await dockerFetch(`/containers/${id}/stop`, { method: 'POST' }, envId);
+ const response = await dockerFetch(`/containers/${id}/stop`, { method: 'POST' }, envId);
+ await drainResponse(response);
}
export async function restartContainer(id: string, envId?: number | null) {
- await dockerFetch(`/containers/${id}/restart`, { method: 'POST' }, envId);
+ const response = await dockerFetch(`/containers/${id}/restart`, { method: 'POST' }, envId);
+ await drainResponse(response);
}
export async function pauseContainer(id: string, envId?: number | null) {
- await dockerFetch(`/containers/${id}/pause`, { method: 'POST' }, envId);
+ const response = await dockerFetch(`/containers/${id}/pause`, { method: 'POST' }, envId);
+ await drainResponse(response);
}
export async function unpauseContainer(id: string, envId?: number | null) {
- await dockerFetch(`/containers/${id}/unpause`, { method: 'POST' }, envId);
+ const response = await dockerFetch(`/containers/${id}/unpause`, { method: 'POST' }, envId);
+ await drainResponse(response);
}
export async function removeContainer(id: string, force = false, envId?: number | null) {
@@ -787,7 +809,8 @@ export async function removeContainer(id: string, force = false, envId?: number
}
export async function renameContainer(id: string, newName: string, envId?: number | null) {
- await dockerFetch(`/containers/${id}/rename?name=${encodeURIComponent(newName)}`, { method: 'POST' }, envId);
+ const response = await dockerFetch(`/containers/${id}/rename?name=${encodeURIComponent(newName)}`, { method: 'POST' }, envId);
+ await drainResponse(response);
}
export async function getContainerLogs(id: string, tail = 100, envId?: number | null): Promise {
@@ -1643,6 +1666,7 @@ export async function listImages(envId?: number | null): Promise {
id: image.Id,
repoTags: image.RepoTags || [],
tags: image.RepoTags || [],
+ repoDigests: image.RepoDigests || [],
size: image.Size,
virtualSize: image.VirtualSize || image.Size,
created: image.Created,
@@ -1969,6 +1993,7 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise
export async function resizeExec(execId: string, cols: number, rows: number, envId?: number | null) {
try {
- await dockerFetch(`/exec/${execId}/resize?h=${rows}&w=${cols}`, { method: 'POST' }, envId);
+ const response = await dockerFetch(`/exec/${execId}/resize?h=${rows}&w=${cols}`, { method: 'POST' }, envId);
+ await drainResponse(response);
} catch {
// Resize may fail if exec is not running, ignore
}
@@ -2941,6 +2968,7 @@ export async function getDockerEvents(
);
if (!response.ok) {
+ await drainResponse(response);
throw new Error(`Docker events API returned ${response.status}`);
}
@@ -3007,7 +3035,7 @@ export async function runContainer(options: {
try {
// Start container
console.log(`[runContainer] Starting container ${containerId}...`);
- await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId);
+ await drainResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId));
// Wait for container to finish
console.log(`[runContainer] Waiting for container ${containerId} to finish...`);
@@ -3035,7 +3063,7 @@ export async function runContainer(options: {
} finally {
// Always cleanup container manually
try {
- await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId);
+ await drainResponse(await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId));
} catch {
// Ignore cleanup errors
}
@@ -3053,6 +3081,7 @@ export async function runContainerWithStreaming(options: {
envId?: number | null;
onStdout?: (data: string) => void;
onStderr?: (data: string) => void;
+ timeout?: number; // Overall timeout in ms (0 or undefined = no timeout)
}): Promise {
const baseName = options.name || `dockhand-stream-${Date.now()}`;
const containerName = `${baseName}-${randomSuffix()}`;
@@ -3084,60 +3113,79 @@ export async function runContainerWithStreaming(options: {
const config = await getDockerConfig(options.envId ?? undefined);
try {
- // Start container
- await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId);
+ const doWork = async () => {
+ // Start container
+ await drainResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId));
- // Stream stderr for real-time progress while container runs
- if (config.connectionType === 'hawser-edge' && config.environmentId) {
- await streamEdgeStderr(config.environmentId, containerId, options.onStderr);
- } else {
- await streamLocalStderr(containerId, options.envId, options.onStderr);
- }
+ // Create abort controller to cancel stderr stream when container exits
+ // On some Docker hosts (e.g. Synology NAS with older kernels), follow=true
+ // streams don't close when the container exits, causing indefinite hangs.
+ const abortController = new AbortController();
- // 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)
- 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 as Error).message}`);
- }
+ // Start stderr streaming (non-blocking - may hang on some hosts)
+ const stderrPromise = (config.connectionType === 'hawser-edge' && config.environmentId)
+ ? streamEdgeStderr(config.environmentId, containerId, options.onStderr, abortController.signal)
+ : streamLocalStderr(containerId, options.envId, options.onStderr, abortController.signal);
+ stderrPromise.catch(() => {}); // Suppress unhandled rejection
- // 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) {
+ // Wait for container to exit - this is the reliable signal
+ let exitCode: number | undefined;
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
+ const waitResult = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId);
+ const waitData = await waitResult.json() as { StatusCode?: number };
+ exitCode = waitData.StatusCode;
+ console.log(`[runContainerWithStreaming] Container exited with code: ${exitCode}`);
+ } catch (err) {
+ console.warn(`[runContainerWithStreaming] Wait warning: ${(err as Error).message}`);
}
- }
- return stdout;
+ // Container exited - abort stderr stream (it may be hanging on some Docker hosts)
+ abortController.abort();
+ // Give brief moment for any final stderr data to flush
+ await Promise.race([stderrPromise, new Promise(r => setTimeout(r, 1000))]);
+
+ // 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 stderrOutput = demuxDockerStream(stderrBuffer, { separateStreams: true });
+ const stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
+ if (stderrText) {
+ console.error(`[runContainerWithStreaming] Container stderr: ${stderrText.substring(0, 1000)}`);
+ }
+ } catch {
+ // Ignore stderr fetch errors
+ }
+ }
+
+ return stdout;
+ };
+
+ const effectiveTimeout = options.timeout ?? 0;
+ if (effectiveTimeout > 0) {
+ return await Promise.race([
+ doWork(),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error(
+ `Container execution timed out after ${Math.round(effectiveTimeout / 1000)}s`
+ )), effectiveTimeout)
+ )
+ ]);
+ } else {
+ return await doWork();
+ }
} finally {
// Always cleanup container
try {
- await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId);
+ await drainResponse(await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId));
} catch {
// Ignore cleanup errors
}
@@ -3148,7 +3196,8 @@ export async function runContainerWithStreaming(options: {
async function streamLocalStderr(
containerId: string,
envId: number | null | undefined,
- onStderr?: (data: string) => void
+ onStderr?: (data: string) => void,
+ signal?: AbortSignal
): Promise {
const response = await dockerFetch(
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=true`,
@@ -3159,13 +3208,20 @@ async function streamLocalStderr(
const reader = response.body?.getReader();
if (!reader) return;
- let buffer: Buffer = Buffer.alloc(0);
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buffer = Buffer.concat([buffer, Buffer.from(value)]);
- const result = processStreamFrames(buffer, undefined, onStderr);
- buffer = result.remaining;
+ // Cancel reader when abort signal fires (container exited)
+ signal?.addEventListener('abort', () => reader.cancel(), { once: true });
+
+ try {
+ let buffer: Buffer = Buffer.alloc(0);
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer = Buffer.concat([buffer, Buffer.from(value)]);
+ const result = processStreamFrames(buffer, undefined, onStderr);
+ buffer = result.remaining;
+ }
+ } catch {
+ // Reader was cancelled via abort signal - expected
}
}
@@ -3173,10 +3229,16 @@ async function streamLocalStderr(
async function streamEdgeStderr(
environmentId: number,
containerId: string,
- onStderr?: (data: string) => void
+ onStderr?: (data: string) => void,
+ signal?: AbortSignal
): Promise {
return new Promise((resolve, reject) => {
let buffer: Buffer = Buffer.alloc(0);
+ let resolved = false;
+ const finish = () => { if (!resolved) { resolved = true; resolve(); } };
+
+ // Resolve when abort signal fires (container exited)
+ signal?.addEventListener('abort', finish, { once: true });
sendEdgeStreamRequest(
environmentId,
@@ -3184,6 +3246,7 @@ async function streamEdgeStderr(
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=true`,
{
onData: (data: string) => {
+ if (resolved) return;
try {
const decoded = Buffer.from(data, 'base64');
buffer = Buffer.concat([buffer, decoded]);
@@ -3193,12 +3256,13 @@ async function streamEdgeStderr(
// Ignore decode errors
}
},
- onEnd: () => resolve(),
+ onEnd: () => finish(),
onError: (error: string) => {
// Container exited = success
if (error.includes('container') && (error.includes('exited') || error.includes('not running'))) {
- resolve();
- } else {
+ finish();
+ } else if (!resolved) {
+ resolved = true;
reject(new Error(error));
}
}
@@ -3207,6 +3271,38 @@ async function streamEdgeStderr(
});
}
+// Extract stdout from a buffer, with raw fallback if frame parsing returns nothing.
+// Mirrors demuxDockerStream's fallback (line ~202-205) for non-multiplexed Docker output.
+function extractStdoutFromBuffer(buffer: Buffer): string {
+ const result = processStreamFrames(buffer, undefined, undefined);
+
+ if (buffer.length > 100000) {
+ console.log(
+ `[extractStdoutFromBuffer] Raw buffer: ${buffer.length} bytes, stdout: ${result.stdout.length} chars, ` +
+ `remaining: ${result.remaining.length} bytes`
+ );
+ }
+
+ if (result.stdout.length === 0 && buffer.length > 0) {
+ console.warn(
+ `[extractStdoutFromBuffer] Frame parsing empty but buffer has ${buffer.length} bytes. ` +
+ `First 16 bytes: [${Array.from(buffer.slice(0, 16)).join(', ')}]. Falling back to raw.`
+ );
+ return buffer.toString('utf-8').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
+ }
+
+ // If there's remaining data after frame parsing, append it as raw text
+ if (result.remaining.length > 0) {
+ console.warn(
+ `[extractStdoutFromBuffer] ${result.remaining.length} bytes remaining after frame parsing, appending as raw`
+ );
+ const rawTail = result.remaining.toString('utf-8').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
+ return result.stdout + rawTail;
+ }
+
+ return result.stdout;
+}
+
// Fetch stdout after container has exited (reliable, no race)
async function fetchContainerStdout(
containerId: string,
@@ -3223,19 +3319,34 @@ async function fetchContainerStdout(
const bodyData = typeof response.body === 'string'
? Buffer.from(response.body, 'base64')
: Buffer.from(response.body);
- const result = processStreamFrames(bodyData, undefined, undefined);
- return result.stdout;
+ return extractStdoutFromBuffer(bodyData);
}
- // Local/standard mode
+ // Local/standard mode - read via streaming to handle large Docker log responses
const response = await dockerFetch(
`/containers/${containerId}/logs?stdout=true&stderr=false&follow=false`,
{},
envId
);
- const buffer = Buffer.from(await response.arrayBuffer());
- const result = processStreamFrames(buffer, undefined, undefined);
- return result.stdout;
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ const buffer = Buffer.from(await response.arrayBuffer());
+ return extractStdoutFromBuffer(buffer);
+ }
+ const chunks: Uint8Array[] = [];
+ let totalSize = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (value) {
+ chunks.push(value);
+ totalSize += value.length;
+ }
+ }
+ const buffer = Buffer.concat(chunks, totalSize);
+
+ return extractStdoutFromBuffer(buffer);
}
// Push image to registry
@@ -3859,7 +3970,10 @@ async function ensureVolumeHelperImage(envId?: number | null): Promise {
async function isContainerRunning(containerId: string, envId?: number | null): Promise {
try {
const response = await dockerFetch(`/containers/${containerId}/json`, {}, envId);
- if (!response.ok) return false;
+ if (!response.ok) {
+ await response.text(); // Consume body to release socket
+ return false;
+ }
const info = await response.json();
return info.State?.Running === true;
} catch {
@@ -3934,7 +4048,7 @@ export async function getOrCreateVolumeHelperContainer(
const containerId = response.Id;
// Start the container
- await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, envId);
+ await drainResponse(await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, envId));
// Cache the container
volumeHelperCache.set(cacheKey, {
@@ -4021,13 +4135,13 @@ export async function removeVolumeHelperContainer(
): Promise {
try {
// Stop the container first (force)
- await dockerFetch(`/containers/${containerId}/stop?t=1`, { method: 'POST' }, envId);
+ await drainResponse(await dockerFetch(`/containers/${containerId}/stop?t=1`, { method: 'POST' }, envId));
} catch {
// Ignore stop errors
}
// Remove the container
- await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, envId);
+ await drainResponse(await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, envId));
}
/**
@@ -4046,6 +4160,7 @@ async function cleanupStaleVolumeHelpersForEnv(envId?: number | null): Promise(); // key: "grype" or "trivy", value: count
+// Per-type serial lock to prevent concurrent scans of the same scanner type.
+// This avoids DB lock conflicts AND ensures the second scan uses warm cache
+// instead of re-downloading the entire vulnerability database (~100MB).
+const scannerLocks = new Map>(); // key: "grype" or "trivy"
+
+async function withScannerLock(scannerType: string, fn: () => Promise): Promise {
+ const existing = scannerLocks.get(scannerType);
+ if (existing) {
+ console.log(`[Scanner] Waiting for previous ${scannerType} scan to complete...`);
+ await existing.catch(() => {}); // Don't fail if previous scan errored
+ }
+
+ let resolve: () => void;
+ const lockPromise = new Promise(r => { resolve = r; });
+ scannerLocks.set(scannerType, lockPromise);
+
+ try {
+ return await fn();
+ } finally {
+ resolve!();
+ if (scannerLocks.get(scannerType) === lockPromise) {
+ scannerLocks.delete(scannerType);
+ }
+ }
+}
// Track in-progress scans per image to prevent duplicate scans
// Key: "{scannerType}:{imageName}", Value: Promise that resolves to the scan result
@@ -249,6 +273,71 @@ async function ensureScannerImage(
}
}
+// Extract JSON object from raw scanner output that may contain non-JSON content
+// (binary Docker stream headers, warning lines, control characters)
+function extractJson(output: string): string {
+ const firstBrace = output.indexOf('{');
+ const lastBrace = output.lastIndexOf('}');
+ if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) {
+ throw new Error('No JSON object found in scanner output');
+ }
+ return output.slice(firstBrace, lastBrace + 1);
+}
+
+/**
+ * Sanitize control characters inside JSON string values that would cause parse failures.
+ * Some scanners (Grype) may include raw control chars (newlines, tabs, null bytes)
+ * in vulnerability descriptions that aren't properly JSON-escaped.
+ */
+function sanitizeJsonString(json: string): string {
+ // Replace unescaped control characters (0x00-0x1F) inside JSON string values
+ // by walking through the string and tracking whether we're inside a quoted string
+ let result = '';
+ let inString = false;
+ let escaped = false;
+ let sanitized = 0;
+
+ for (let i = 0; i < json.length; i++) {
+ const ch = json.charCodeAt(i);
+
+ if (escaped) {
+ result += json[i];
+ escaped = false;
+ continue;
+ }
+
+ if (inString) {
+ if (ch === 0x5C) { // backslash
+ result += json[i];
+ escaped = true;
+ } else if (ch === 0x22) { // closing quote
+ result += json[i];
+ inString = false;
+ } else if (ch < 0x20) {
+ // Control character inside a string - escape it
+ if (ch === 0x0A) result += '\\n';
+ else if (ch === 0x0D) result += '\\r';
+ else if (ch === 0x09) result += '\\t';
+ else result += `\\u${ch.toString(16).padStart(4, '0')}`;
+ sanitized++;
+ } else {
+ result += json[i];
+ }
+ } else {
+ if (ch === 0x22) { // opening quote
+ inString = true;
+ }
+ result += json[i];
+ }
+ }
+
+ if (sanitized > 0) {
+ console.warn(`[Scanner] Sanitized ${sanitized} control characters in JSON output`);
+ }
+
+ return result;
+}
+
// Parse Grype JSON output
function parseGrypeOutput(output: string): { vulnerabilities: Vulnerability[]; summary: VulnerabilitySeverity } {
const vulnerabilities: Vulnerability[] = [];
@@ -263,10 +352,10 @@ function parseGrypeOutput(output: string): { vulnerabilities: Vulnerability[]; s
console.log('[Grype] Raw output length:', output.length);
console.log('[Grype] Output starts with:', output.slice(0, 200));
+ console.log('[Grype] Output ends with:', JSON.stringify(output.slice(-50)));
try {
- const data = JSON.parse(output);
- console.log('[Grype] Parsed JSON, matches count:', data.matches?.length || 0);
+ const data = JSON.parse(sanitizeJsonString(extractJson(output)));
if (data.matches) {
for (const match of data.matches) {
@@ -295,9 +384,9 @@ function parseGrypeOutput(output: string): { vulnerabilities: Vulnerability[]; s
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Grype] Failed to parse output:', errorMsg);
- if (output.length > 0) {
- console.error('[Grype] Output preview:', output.slice(0, 200));
- }
+ console.error('[Grype] Output length:', output.length);
+ console.error('[Grype] First 200 chars:', output.slice(0, 200));
+ console.error('[Grype] Last 200 chars:', output.slice(-200));
// Check if output looks like an error message from grype
const firstLine = output.split('\n')[0].trim();
if (firstLine && !firstLine.startsWith('{')) {
@@ -306,7 +395,6 @@ function parseGrypeOutput(output: string): { vulnerabilities: Vulnerability[]; s
throw new Error('Failed to parse scanner output - ensure CLI args include "-o json"');
}
- console.log('[Grype] Parsed vulnerabilities:', vulnerabilities.length);
return { vulnerabilities, summary };
}
@@ -323,7 +411,7 @@ function parseTrivyOutput(output: string): { vulnerabilities: Vulnerability[]; s
};
try {
- const data = JSON.parse(output);
+ const data = JSON.parse(sanitizeJsonString(extractJson(output)));
const results = data.Results || [];
for (const result of results) {
@@ -354,9 +442,9 @@ function parseTrivyOutput(output: string): { vulnerabilities: Vulnerability[]; s
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
console.error('[Trivy] Failed to parse output:', errorMsg);
- if (output.length > 0) {
- console.error('[Trivy] Output preview:', output.slice(0, 200));
- }
+ console.error('[Trivy] Output length:', output.length);
+ console.error('[Trivy] First 32 bytes (hex):', Buffer.from(output.slice(0, 32)).toString('hex'));
+ console.error('[Trivy] Full output:', output);
// Check if output looks like an error message from trivy
const firstLine = output.split('\n')[0].trim();
if (firstLine && !firstLine.startsWith('{')) {
@@ -469,21 +557,26 @@ async function runScannerContainerImpl(
cmd: string[],
envId?: number,
onOutput?: (line: string) => void
+): Promise {
+ // Serialize scans of the same type to avoid DB lock conflicts and re-downloads
+ return withScannerLock(scannerType, () =>
+ runScannerContainerCore(scannerImage, scannerType, imageName, cmd, envId, onOutput)
+ );
+}
+
+async function runScannerContainerCore(
+ 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'}`);
- // Check if another scanner of the same type is already running
- // If so, use a unique cache subdirectory to avoid lock conflicts
- const currentCount = runningScanners.get(scannerType) || 0;
- const scanId = currentCount > 0 ? `-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : '';
-
- // Increment running counter
- runningScanners.set(scannerType, currentCount + 1);
-
- // Configure volume mount based on scanner type
- // Use a unique subdirectory if another scan is in progress
+ // Always use the base cache path — serial lock prevents concurrent conflicts
const basePath = scannerType === 'grype' ? '/cache/grype' : '/cache/trivy';
- const dbPath = scanId ? `${basePath}${scanId}` : basePath;
+ const dbPath = basePath;
// Detect the host Docker socket path based on connection type
// For local socket environments, detect the actual host socket path (handles rootless Docker)
@@ -547,60 +640,47 @@ async function runScannerContainerImpl(
console.log(`[Scanner] Container bind mounts: ${JSON.stringify(binds)}`);
// Environment variables to ensure scanners use the correct cache path
- // For concurrent scans, use a unique subdirectory
const envVars = scannerType === 'grype'
? [`GRYPE_DB_CACHE_DIR=${dbPath}`]
: [`TRIVY_CACHE_DIR=${dbPath}`];
- if (scanId) {
- console.log(`[Scanner] Concurrent scan detected - using unique cache dir: ${dbPath}`);
- }
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
if (containerUser) {
console.log(`[Scanner] Running scanner container as UID ${containerUser} to match socket owner`);
}
- try {
- // Run the scanner container
- const output = await runContainerWithStreaming({
- image: scannerImage,
- cmd,
- binds,
- env: envVars,
- name: `dockhand-${scannerType}-${Date.now()}`,
- user: containerUser,
- envId,
- onStderr: (data) => {
- // Stream stderr lines for real-time progress output
- const lines = data.split('\n');
- for (const line of lines) {
- if (line.trim()) {
- onOutput?.(line);
- }
+ // Run the scanner container with a 10-minute timeout to prevent indefinite hangs
+ const output = await runContainerWithStreaming({
+ image: scannerImage,
+ cmd,
+ binds,
+ env: envVars,
+ name: `dockhand-${scannerType}-${Date.now()}`,
+ user: containerUser,
+ envId,
+ timeout: 600_000, // 10 minutes
+ onStderr: (data) => {
+ // Stream stderr lines for real-time progress output
+ const lines = data.split('\n');
+ for (const line of lines) {
+ if (line.trim()) {
+ onOutput?.(line);
}
}
- });
-
- console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
- if (output.length === 0) {
- console.error(`[Scanner] WARNING: Empty output from ${scannerType} container`);
- console.error(`[Scanner] This may indicate the scanner couldn't access Docker socket`);
- console.error(`[Scanner] Host socket path used: ${hostSocketPath}`);
- } else if (output.length < 100) {
- console.log(`[Scanner] ${scannerType} output preview: ${output}`);
}
+ });
- return output;
- } finally {
- // Decrement running counter
- const newCount = (runningScanners.get(scannerType) || 1) - 1;
- if (newCount <= 0) {
- runningScanners.delete(scannerType);
- } else {
- runningScanners.set(scannerType, newCount);
- }
+ console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
+ if (output.length === 0) {
+ console.error(`[Scanner] WARNING: Empty output from ${scannerType} container`);
+ console.error(`[Scanner] This may indicate the scanner couldn't access Docker socket`);
+ console.error(`[Scanner] Host socket path used: ${hostSocketPath}`);
+ } else if (output.length < 100) {
+ console.log(`[Scanner] ${scannerType} output preview: ${output}`);
}
+
+ return output;
}
// Scan image with Grype
@@ -967,11 +1047,11 @@ export async function checkScannerUpdates(envId?: number): Promise<{
img.tags?.includes(imageName)
);
- if (localImage) {
- result[scanner].localDigest = localImage.id?.substring(7, 19); // Short digest
- // Note: Remote digest checking would require pulling or using registry API
- // For simplicity, we just note that checking for updates requires a pull
- result[scanner].hasUpdate = false;
+ if (localImage && localImage.id) {
+ const updateResult = await checkImageUpdateAvailable(imageName, localImage.id, envId);
+ result[scanner].hasUpdate = updateResult.hasUpdate;
+ result[scanner].localDigest = updateResult.currentDigest;
+ result[scanner].remoteDigest = updateResult.registryDigest;
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
diff --git a/src/lib/server/scheduler/tasks/container-update.ts b/src/lib/server/scheduler/tasks/container-update.ts
index dc8da9f..bcb863b 100644
--- a/src/lib/server/scheduler/tasks/container-update.ts
+++ b/src/lib/server/scheduler/tasks/container-update.ts
@@ -42,7 +42,256 @@ import {
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
-import { startStack, getStackComposeFile } from '../../stacks';
+import { getStackComposeFile, updateStackService, pullStackService } from '../../stacks';
+
+// =============================================================================
+// TYPES
+// =============================================================================
+
+interface ScanContext {
+ newImageId: string;
+ currentImageId: string;
+ envId: number | undefined;
+ vulnerabilityCriteria: VulnerabilityCriteria;
+ log: (msg: string) => void;
+}
+
+interface ScanOutcome {
+ blocked: boolean;
+ reason?: string;
+ scanResults?: ScanResult[];
+ scanSummary?: VulnerabilitySeverity;
+}
+
+interface ExecutionDetails {
+ mode: string;
+ newDigest?: string;
+ vulnerabilityCriteria: VulnerabilityCriteria;
+ reason?: string;
+ blockReason?: string;
+ summary: { checked: number; updated: number; blocked: number; failed: number };
+ containers: Array<{
+ name: string;
+ status: string;
+ blockReason?: string;
+ scannerResults?: Array<{
+ scanner: string;
+ critical: number;
+ high: number;
+ medium: number;
+ low: number;
+ negligible: number;
+ unknown: number;
+ }>;
+ }>;
+ scanResult?: {
+ summary: VulnerabilitySeverity;
+ scanners: string[];
+ scannedAt?: string;
+ scannerResults: Array<{
+ scanner: string;
+ critical: number;
+ high: number;
+ medium: number;
+ low: number;
+ negligible: number;
+ unknown: number;
+ }>;
+ };
+}
+
+// =============================================================================
+// HELPER FUNCTIONS
+// =============================================================================
+
+/**
+ * Scan an image and check if the update should be blocked based on vulnerability criteria.
+ * Handles scanning, saving results, and comparing with current image for 'more_than_current'.
+ */
+async function scanAndCheckBlock(ctx: ScanContext): Promise {
+ const { newImageId, currentImageId, envId, vulnerabilityCriteria, log } = ctx;
+
+ log(`Scanning new image for vulnerabilities...`);
+
+ const scanResults = await scanImage(newImageId, envId, (progress) => {
+ const scannerTag = progress.scanner ? `[${progress.scanner}]` : '[scan]';
+ if (progress.message) {
+ log(`${scannerTag} ${progress.message}`);
+ }
+ if (progress.output) {
+ log(`${scannerTag} ${progress.output}`);
+ }
+ });
+
+ if (scanResults.length === 0) {
+ return { blocked: false, scanResults };
+ }
+
+ const scanSummary = combineScanSummaries(scanResults);
+ log(`Scan result: ${scanSummary.critical} critical, ${scanSummary.high} high, ${scanSummary.medium} medium, ${scanSummary.low} low`);
+
+ // Save scan results
+ for (const result of scanResults) {
+ try {
+ await saveVulnerabilityScan({
+ environmentId: envId ?? null,
+ imageId: newImageId,
+ imageName: result.imageName,
+ scanner: result.scanner,
+ scannedAt: result.scannedAt,
+ scanDuration: result.scanDuration,
+ criticalCount: result.summary.critical,
+ highCount: result.summary.high,
+ mediumCount: result.summary.medium,
+ lowCount: result.summary.low,
+ negligibleCount: result.summary.negligible,
+ unknownCount: result.summary.unknown,
+ vulnerabilities: result.vulnerabilities,
+ error: result.error ?? null
+ });
+ } catch (saveError: any) {
+ log(`Warning: Could not save scan results: ${saveError.message}`);
+ }
+ }
+
+ // Handle 'more_than_current' criteria - need to get/scan current image
+ let currentScanSummary: VulnerabilitySeverity | undefined;
+ if (vulnerabilityCriteria === 'more_than_current') {
+ log(`Looking up cached scan for current image...`);
+ try {
+ const cachedScan = await getCombinedScanForImage(currentImageId, envId ?? null);
+ if (cachedScan) {
+ currentScanSummary = cachedScan;
+ log(`Cached scan: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`);
+ } else {
+ log(`No cached scan found, scanning current image...`);
+ const currentScanResults = await scanImage(currentImageId, envId, (progress) => {
+ const tag = progress.scanner ? `[${progress.scanner}]` : '[scan]';
+ if (progress.message) log(`${tag} ${progress.message}`);
+ });
+ if (currentScanResults.length > 0) {
+ currentScanSummary = combineScanSummaries(currentScanResults);
+ log(`Current image: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`);
+ // Save for future use
+ for (const result of currentScanResults) {
+ try {
+ await saveVulnerabilityScan({
+ environmentId: envId ?? null,
+ imageId: currentImageId,
+ imageName: result.imageName,
+ scanner: result.scanner,
+ scannedAt: result.scannedAt,
+ scanDuration: result.scanDuration,
+ criticalCount: result.summary.critical,
+ highCount: result.summary.high,
+ mediumCount: result.summary.medium,
+ lowCount: result.summary.low,
+ negligibleCount: result.summary.negligible,
+ unknownCount: result.summary.unknown,
+ vulnerabilities: result.vulnerabilities,
+ error: result.error ?? null
+ });
+ } catch { /* ignore */ }
+ }
+ }
+ }
+ } catch (cacheError: any) {
+ log(`Warning: Could not get current scan: ${cacheError.message}`);
+ }
+ }
+
+ // Check if update should be blocked
+ const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, currentScanSummary);
+
+ if (blocked) {
+ log(`UPDATE BLOCKED: ${reason}`);
+ return { blocked: true, reason, scanResults, scanSummary };
+ }
+
+ log(`Scan passed vulnerability criteria`);
+ return { blocked: false, scanResults, scanSummary };
+}
+
+/**
+ * Build scanner results array from scan results for execution details.
+ */
+function buildScannerResults(scanResults: ScanResult[]) {
+ return scanResults.map(r => ({
+ scanner: r.scanner,
+ critical: r.summary.critical,
+ high: r.summary.high,
+ medium: r.summary.medium,
+ low: r.summary.low,
+ negligible: r.summary.negligible,
+ unknown: r.summary.unknown
+ }));
+}
+
+/**
+ * Build execution details for a blocked update.
+ */
+function buildBlockedDetails(
+ containerName: string,
+ vulnerabilityCriteria: VulnerabilityCriteria,
+ reason: string,
+ scanResults: ScanResult[],
+ scanSummary: VulnerabilitySeverity
+): ExecutionDetails {
+ const scannerResults = buildScannerResults(scanResults);
+ return {
+ mode: 'auto_update',
+ reason: 'vulnerabilities_found',
+ blockReason: reason,
+ vulnerabilityCriteria,
+ summary: { checked: 1, updated: 0, blocked: 1, failed: 0 },
+ containers: [{
+ name: containerName,
+ status: 'blocked',
+ blockReason: reason,
+ scannerResults
+ }],
+ scanResult: {
+ summary: scanSummary,
+ scanners: scanResults.map(r => r.scanner),
+ scannedAt: scanResults[0]?.scannedAt,
+ scannerResults
+ }
+ };
+}
+
+/**
+ * Build execution details for a successful update.
+ */
+function buildSuccessDetails(
+ containerName: string,
+ newDigest: string | undefined,
+ vulnerabilityCriteria: VulnerabilityCriteria,
+ scanResults?: ScanResult[],
+ scanSummary?: VulnerabilitySeverity
+): ExecutionDetails {
+ const scannerResults = scanResults ? buildScannerResults(scanResults) : undefined;
+ return {
+ mode: 'auto_update',
+ newDigest,
+ vulnerabilityCriteria,
+ summary: { checked: 1, updated: 1, blocked: 0, failed: 0 },
+ containers: [{
+ name: containerName,
+ status: 'updated',
+ scannerResults
+ }],
+ scanResult: scanSummary ? {
+ summary: scanSummary,
+ scanners: scanResults?.map(r => r.scanner) || [],
+ scannedAt: scanResults?.[0]?.scannedAt,
+ scannerResults: scannerResults || []
+ } : undefined
+ };
+}
+
+// =============================================================================
+// MAIN UPDATE FUNCTION
+// =============================================================================
/**
* Execute a container auto-update.
@@ -147,10 +396,11 @@ export async function runContainerUpdate(
const containerLabels = inspectData.Config?.Labels || {};
const composeProject = containerLabels['com.docker.compose.project'];
const composeService = containerLabels['com.docker.compose.service'];
- const isStackContainer = !!composeProject;
+ const composeConfigFiles = containerLabels['com.docker.compose.project.config_files'];
+ const isStackContainer = !!(composeProject && composeService);
if (isStackContainer) {
- log(`Container is part of compose stack: ${composeProject} (service: ${composeService})`);
+ log(`Container is part of compose stack: ${composeProject} (service: ${composeService}, configFiles: ${composeConfigFiles || 'none'})`);
} else {
log(`Container is standalone (not part of a compose stack)`);
}
@@ -162,30 +412,15 @@ export async function runContainerUpdate(
]);
const vulnerabilityCriteria = (updateSetting?.vulnerabilityCriteria || 'never') as VulnerabilityCriteria;
- // Scan if scanning is enabled (scanner !== 'none')
- // The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
const shouldScan = scannerSettings.scanner !== 'none';
// =============================================================================
- // SAFE UPDATE FLOW
- // =============================================================================
- // 1. Registry check (no pull) - determine if update is available
- // 2. If scanning enabled:
- // a. Pull new image (overwrites original tag temporarily)
- // b. Get new image ID
- // c. SAFETY: Restore original tag to point to OLD image
- // d. Tag new image with temp suffix for scanning
- // e. Scan temp image
- // f. If blocked: remove temp image, original tag still safe
- // g. If approved: re-tag to original and proceed
- // 3. If no scanning: simple pull and update
+ // CHECK FOR UPDATES
// =============================================================================
- // Step 1: Check for update using registry check (no pull)
log(`Checking registry for updates: ${imageNameFromConfig}`);
const registryCheck = await checkImageUpdateAvailable(imageNameFromConfig, currentImageId, envId);
- // Handle local images or registry errors
if (registryCheck.isLocalImage) {
log(`Local image detected - skipping (auto-update requires registry)`);
await updateScheduleExecution(execution.id, {
@@ -199,7 +434,6 @@ export async function runContainerUpdate(
if (registryCheck.error) {
log(`Registry check error: ${registryCheck.error}`);
- // Don't fail on transient errors, just skip this run
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
@@ -221,232 +455,257 @@ export async function runContainerUpdate(
}
log(`Update available! Registry digest: ${registryCheck.registryDigest?.substring(0, 19) || 'unknown'}`);
-
- // Variables for scan results
- let scanResults: ScanResult[] | undefined;
- let scanSummary: VulnerabilitySeverity | undefined;
- let newImageId: string | null = null;
const newDigest = registryCheck.registryDigest;
- // Step 2: Safe pull with temp tag protection (if scanning enabled)
- if (shouldScan) {
- log(`Safe-pull enabled (scanner: ${scannerSettings.scanner}, criteria: ${vulnerabilityCriteria})`);
+ // =============================================================================
+ // STACK CONTAINER: Compose-native flow
+ // =============================================================================
+ // 1. Check if we have the compose file
+ // 2. docker compose pull
+ // 3. Scan if enabled, block if needed
+ // 4. docker compose up -d
+ // =============================================================================
- // Check if this is a digest-based image (can't use temp tags)
- if (isDigestBasedImage(imageNameFromConfig)) {
- log(`Digest-based image detected - temp tag protection not available`);
- // Fall through to simple flow
- } else {
- const tempTag = getTempImageTag(imageNameFromConfig);
- log(`Using temp tag for safe pull: ${tempTag}`);
+ if (isStackContainer) {
+ const composeResult = await getStackComposeFile(composeProject, envId, composeConfigFiles);
+ log(`Compose lookup result: success=${composeResult.success}, composePath=${composeResult.composePath || 'none'}`);
+
+ if (composeResult.success) {
+ log(`Using compose-native update for stack: ${composeProject}`);
try {
- // Step 2a: Pull new image (overwrites original tag)
- log(`Pulling new image: ${imageNameFromConfig}`);
- await pullImage(imageNameFromConfig, undefined, envId);
-
- // Step 2b: Get new image ID
- newImageId = await getImageIdByTag(imageNameFromConfig, envId);
- if (!newImageId) {
- throw new Error('Failed to get new image ID after pull');
+ // Pull via docker compose
+ log(`Running: docker compose pull ${composeService}`);
+ const pullResult = await pullStackService(composeProject, composeService, envId, composeConfigFiles);
+ if (!pullResult.success) {
+ throw new Error(pullResult.error || 'docker compose pull failed');
}
- log(`New image pulled: ${newImageId.substring(0, 19)}`);
+ log(`Compose pull completed`);
- // Step 2c: SAFETY - Restore original tag to OLD image
- log(`Restoring original tag to current safe image...`);
- const [oldRepo, oldTag] = parseImageNameAndTag(imageNameFromConfig);
- await tagImage(currentImageId, oldRepo, oldTag, envId);
- log(`Original tag ${imageNameFromConfig} restored to safe image`);
+ // Get new image ID
+ const newImageId = await getImageIdByTag(imageNameFromConfig, envId);
+ if (!newImageId) {
+ throw new Error('Failed to get new image ID after compose pull');
+ }
+ log(`New image ID: ${newImageId.substring(0, 19)}`);
- // Step 2d: Tag new image with temp suffix
- const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
- await tagImage(newImageId, tempRepo, tempTagName, envId);
- log(`New image tagged as: ${tempTag}`);
+ // Scan if enabled
+ let scanOutcome: ScanOutcome = { blocked: false };
+ if (shouldScan) {
+ try {
+ scanOutcome = await scanAndCheckBlock({
+ newImageId,
+ currentImageId,
+ envId,
+ vulnerabilityCriteria,
+ log
+ });
- // Step 2e: Scan temp image
- log(`Scanning new image for vulnerabilities...`);
- try {
- scanResults = await scanImage(tempTag, envId, (progress) => {
- const scannerTag = progress.scanner ? `[${progress.scanner}]` : '[scan]';
- if (progress.message) {
- log(`${scannerTag} ${progress.message}`);
- }
- if (progress.output) {
- log(`${scannerTag} ${progress.output}`);
- }
- });
-
- if (scanResults.length > 0) {
- scanSummary = combineScanSummaries(scanResults);
- log(`Scan result: ${scanSummary.critical} critical, ${scanSummary.high} high, ${scanSummary.medium} medium, ${scanSummary.low} low`);
-
- // Save scan results
- for (const result of scanResults) {
- try {
- await saveVulnerabilityScan({
- environmentId: envId ?? null,
- imageId: newImageId,
- imageName: result.imageName,
- scanner: result.scanner,
- scannedAt: result.scannedAt,
- scanDuration: result.scanDuration,
- criticalCount: result.summary.critical,
- highCount: result.summary.high,
- mediumCount: result.summary.medium,
- lowCount: result.summary.low,
- negligibleCount: result.summary.negligible,
- unknownCount: result.summary.unknown,
- vulnerabilities: result.vulnerabilities,
- error: result.error ?? null
- });
- } catch (saveError: any) {
- log(`Warning: Could not save scan results: ${saveError.message}`);
- }
- }
-
- // Handle 'more_than_current' criteria
- let currentScanSummary: VulnerabilitySeverity | undefined;
- if (vulnerabilityCriteria === 'more_than_current') {
- log(`Looking up cached scan for current image...`);
- try {
- const cachedScan = await getCombinedScanForImage(currentImageId, envId ?? null);
- if (cachedScan) {
- currentScanSummary = cachedScan;
- log(`Cached scan: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`);
- } else {
- log(`No cached scan found, scanning current image...`);
- const currentScanResults = await scanImage(currentImageId, envId, (progress) => {
- const tag = progress.scanner ? `[${progress.scanner}]` : '[scan]';
- if (progress.message) log(`${tag} ${progress.message}`);
- });
- if (currentScanResults.length > 0) {
- currentScanSummary = combineScanSummaries(currentScanResults);
- log(`Current image: ${currentScanSummary.critical} critical, ${currentScanSummary.high} high`);
- // Save for future use
- for (const result of currentScanResults) {
- try {
- await saveVulnerabilityScan({
- environmentId: envId ?? null,
- imageId: currentImageId,
- imageName: result.imageName,
- scanner: result.scanner,
- scannedAt: result.scannedAt,
- scanDuration: result.scanDuration,
- criticalCount: result.summary.critical,
- highCount: result.summary.high,
- mediumCount: result.summary.medium,
- lowCount: result.summary.low,
- negligibleCount: result.summary.negligible,
- unknownCount: result.summary.unknown,
- vulnerabilities: result.vulnerabilities,
- error: result.error ?? null
- });
- } catch { /* ignore */ }
- }
- }
- }
- } catch (cacheError: any) {
- log(`Warning: Could not get current scan: ${cacheError.message}`);
- }
- }
-
- // Check if update should be blocked
- const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, currentScanSummary);
-
- if (blocked) {
- // Step 2f: BLOCKED - Remove temp image, original tag is safe
- log(`UPDATE BLOCKED: ${reason}`);
- log(`Removing blocked image: ${tempTag}`);
- await removeTempImage(newImageId, envId);
- log(`Blocked image removed - container will continue using safe image`);
+ if (scanOutcome.blocked) {
+ // Restore old tag so container keeps using safe image
+ log(`Restoring original tag to safe image...`);
+ const [oldRepo, oldTag] = parseImageNameAndTag(imageNameFromConfig);
+ await tagImage(currentImageId, oldRepo, oldTag, envId);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
- details: {
- mode: 'auto_update',
- reason: 'vulnerabilities_found',
- blockReason: reason,
+ details: buildBlockedDetails(
+ containerName,
vulnerabilityCriteria,
- summary: { checked: 1, updated: 0, blocked: 1, failed: 0 },
- containers: [{
- name: containerName,
- status: 'blocked',
- blockReason: reason,
- scannerResults: scanResults.map(r => ({
- scanner: r.scanner,
- critical: r.summary.critical,
- high: r.summary.high,
- medium: r.summary.medium,
- low: r.summary.low,
- negligible: r.summary.negligible,
- unknown: r.summary.unknown
- }))
- }],
- scanResult: {
- summary: scanSummary,
- scanners: scanResults.map(r => r.scanner),
- scannedAt: scanResults[0]?.scannedAt,
- scannerResults: scanResults.map(r => ({
- scanner: r.scanner,
- critical: r.summary.critical,
- high: r.summary.high,
- medium: r.summary.medium,
- low: r.summary.low,
- negligible: r.summary.negligible,
- unknown: r.summary.unknown
- }))
- }
- }
+ scanOutcome.reason!,
+ scanOutcome.scanResults!,
+ scanOutcome.scanSummary!
+ )
});
await sendEventNotification('auto_update_blocked', {
title: 'Auto-update blocked',
- message: `Container "${containerName}" update blocked: ${reason}`,
+ message: `Container "${containerName}" update blocked: ${scanOutcome.reason}`,
type: 'warning'
}, envId);
return;
}
+ } catch (scanError: any) {
+ log(`Scan failed: ${scanError.message}`);
+ log(`Restoring original tag...`);
+ const [oldRepo, oldTag] = parseImageNameAndTag(imageNameFromConfig);
+ await tagImage(currentImageId, oldRepo, oldTag, envId);
- log(`Scan passed vulnerability criteria`);
+ await updateScheduleExecution(execution.id, {
+ status: 'failed',
+ completedAt: new Date().toISOString(),
+ duration: Date.now() - startTime,
+ errorMessage: `Vulnerability scan failed: ${scanError.message}`
+ });
+ return;
}
- } catch (scanError: any) {
- // Scan failure - cleanup temp image and fail
- log(`Scan failed: ${scanError.message}`);
- log(`Removing temp image due to scan failure...`);
- await removeTempImage(newImageId, envId);
-
- await updateScheduleExecution(execution.id, {
- status: 'failed',
- completedAt: new Date().toISOString(),
- duration: Date.now() - startTime,
- errorMessage: `Vulnerability scan failed: ${scanError.message}`
- });
- return;
}
- // Step 2g: APPROVED - Re-tag to original for update
- log(`Re-tagging approved image to: ${imageNameFromConfig}`);
- await tagImage(newImageId, oldRepo, oldTag, envId);
- log(`Image ready for update`);
+ // Apply update via docker compose up
+ log(`Running: docker compose up -d ${composeService}`);
+ const upResult = await updateStackService(composeProject, composeService, envId, composeConfigFiles);
+ if (!upResult.success) {
+ throw new Error(upResult.error || 'docker compose up failed');
+ }
- // Clean up temp tag (optional, image will be removed when container is recreated)
- try {
- await removeTempImage(tempTag, envId);
- } catch { /* ignore cleanup errors */ }
+ // Success
+ await updateAutoUpdateLastUpdated(containerName, envId);
+ log(`Successfully updated container: ${containerName}`);
- } catch (pullError: any) {
- log(`Safe-pull failed: ${pullError.message}`);
+ await updateScheduleExecution(execution.id, {
+ status: 'success',
+ completedAt: new Date().toISOString(),
+ duration: Date.now() - startTime,
+ details: buildSuccessDetails(
+ containerName,
+ newDigest,
+ vulnerabilityCriteria,
+ scanOutcome.scanResults,
+ scanOutcome.scanSummary
+ )
+ });
+
+ await sendEventNotification('auto_update_success', {
+ title: 'Container auto-updated',
+ message: `Container "${containerName}" was updated to a new image version`,
+ type: 'success'
+ }, envId);
+
+ return;
+
+ } catch (composeError: any) {
+ log(`Compose update failed: ${composeError.message}`);
await updateScheduleExecution(execution.id, {
status: 'failed',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
- errorMessage: `Failed to pull image: ${pullError.message}`
+ errorMessage: `Stack update failed: ${composeError.message}`
+ });
+
+ await sendEventNotification('auto_update_failed', {
+ title: 'Auto-update failed',
+ message: `Container "${containerName}" auto-update failed: ${composeError.message}`,
+ type: 'error'
+ }, envId);
+
+ return;
+ }
+ }
+
+ // No compose file found - fall through to standalone flow
+ log(`No compose file found for stack "${composeProject}" - using standalone update`);
+ log(`TIP: Import this stack into Dockhand for compose-native updates`);
+ }
+
+ // =============================================================================
+ // STANDALONE CONTAINER: Temp-tag protection flow
+ // =============================================================================
+ // 1. Pull new image (overwrites tag)
+ // 2. Restore original tag to OLD image (safety)
+ // 3. Tag new image with temp suffix
+ // 4. Scan temp image, block if needed
+ // 5. Re-tag to original, recreate container
+ // =============================================================================
+
+ let newImageId: string | null = null;
+ let scanOutcome: ScanOutcome = { blocked: false };
+
+ if (shouldScan && !isDigestBasedImage(imageNameFromConfig)) {
+ const tempTag = getTempImageTag(imageNameFromConfig);
+ log(`Using temp tag for safe pull: ${tempTag}`);
+
+ try {
+ // Pull new image
+ log(`Pulling new image: ${imageNameFromConfig}`);
+ await pullImage(imageNameFromConfig, undefined, envId);
+
+ // Get new image ID
+ newImageId = await getImageIdByTag(imageNameFromConfig, envId);
+ if (!newImageId) {
+ throw new Error('Failed to get new image ID after pull');
+ }
+ log(`New image pulled: ${newImageId.substring(0, 19)}`);
+
+ // Restore original tag to OLD image for safety
+ log(`Restoring original tag to safe image...`);
+ const [oldRepo, oldTag] = parseImageNameAndTag(imageNameFromConfig);
+ await tagImage(currentImageId, oldRepo, oldTag, envId);
+
+ // Tag new image with temp suffix
+ const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
+ await tagImage(newImageId, tempRepo, tempTagName, envId);
+ log(`New image tagged as: ${tempTag}`);
+
+ // Scan new image (by ID, not temp tag - for proper cache storage)
+ try {
+ scanOutcome = await scanAndCheckBlock({
+ newImageId,
+ currentImageId,
+ envId,
+ vulnerabilityCriteria,
+ log
+ });
+
+ if (scanOutcome.blocked) {
+ log(`Removing blocked image: ${tempTag}`);
+ await removeTempImage(newImageId, envId);
+
+ await updateScheduleExecution(execution.id, {
+ status: 'skipped',
+ completedAt: new Date().toISOString(),
+ duration: Date.now() - startTime,
+ details: buildBlockedDetails(
+ containerName,
+ vulnerabilityCriteria,
+ scanOutcome.reason!,
+ scanOutcome.scanResults!,
+ scanOutcome.scanSummary!
+ )
+ });
+
+ await sendEventNotification('auto_update_blocked', {
+ title: 'Auto-update blocked',
+ message: `Container "${containerName}" update blocked: ${scanOutcome.reason}`,
+ type: 'warning'
+ }, envId);
+
+ return;
+ }
+ } catch (scanError: any) {
+ log(`Scan failed: ${scanError.message}`);
+ log(`Removing temp image...`);
+ await removeTempImage(newImageId, envId);
+
+ await updateScheduleExecution(execution.id, {
+ status: 'failed',
+ completedAt: new Date().toISOString(),
+ duration: Date.now() - startTime,
+ errorMessage: `Vulnerability scan failed: ${scanError.message}`
});
return;
}
+
+ // Re-tag approved image to original
+ log(`Re-tagging approved image to: ${imageNameFromConfig}`);
+ await tagImage(newImageId, oldRepo, oldTag, envId);
+
+ // Clean up temp tag
+ try {
+ await removeTempImage(tempTag, envId);
+ } catch { /* ignore */ }
+
+ } catch (pullError: any) {
+ log(`Pull failed: ${pullError.message}`);
+ await updateScheduleExecution(execution.id, {
+ status: 'failed',
+ completedAt: new Date().toISOString(),
+ duration: Date.now() - startTime,
+ errorMessage: `Failed to pull image: ${pullError.message}`
+ });
+ return;
}
} else {
// No scanning - simple pull
@@ -467,73 +726,35 @@ export async function runContainerUpdate(
}
// =============================================================================
- // Update the container based on type
+ // RECREATE CONTAINER
// =============================================================================
- let success = false;
if (isStackContainer) {
- log(`Updating via docker compose for stack: ${composeProject}`);
-
- // Try stack-based update first
- const stackSuccess = await updateStackContainer(composeProject!, composeService!, envId, log);
-
- if (stackSuccess) {
- success = true;
- } else {
- // Fallback: Stack is external (not managed by Dockhand), use container recreation
- log(`Fallback: Recreating container directly (stack "${composeProject}" not managed by Dockhand)`);
- log(`WARNING: Some compose-specific settings may not be preserved`);
- log(`Consider importing this stack into Dockhand for full configuration preservation`);
- success = await recreateContainer(containerName, envId, log);
- }
+ log(`External stack - recreating container directly`);
+ log(`WARNING: Some compose settings may not be preserved`);
} else {
- log(`Updating standalone container via recreation...`);
- success = await recreateContainer(containerName, envId, log);
+ log(`Recreating standalone container...`);
}
+ const success = await recreateContainer(containerName, envId, log);
+
if (success) {
await updateAutoUpdateLastUpdated(containerName, envId);
log(`Successfully updated container: ${containerName}`);
+
await updateScheduleExecution(execution.id, {
status: 'success',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
- details: {
- mode: 'auto_update',
+ details: buildSuccessDetails(
+ containerName,
newDigest,
vulnerabilityCriteria,
- summary: { checked: 1, updated: 1, blocked: 0, failed: 0 },
- containers: [{
- name: containerName,
- status: 'updated',
- scannerResults: scanResults?.map(r => ({
- scanner: r.scanner,
- critical: r.summary.critical,
- high: r.summary.high,
- medium: r.summary.medium,
- low: r.summary.low,
- negligible: r.summary.negligible,
- unknown: r.summary.unknown
- }))
- }],
- scanResult: scanSummary ? {
- summary: scanSummary,
- scanners: scanResults?.map(r => r.scanner) || [],
- scannedAt: scanResults?.[0]?.scannedAt,
- scannerResults: scanResults?.map(r => ({
- scanner: r.scanner,
- critical: r.summary.critical,
- high: r.summary.high,
- medium: r.summary.medium,
- low: r.summary.low,
- negligible: r.summary.negligible,
- unknown: r.summary.unknown
- })) || []
- } : undefined
- }
+ scanOutcome.scanResults,
+ scanOutcome.scanSummary
+ )
});
- // Send notification for successful update
await sendEventNotification('auto_update_success', {
title: 'Container auto-updated',
message: `Container "${containerName}" was updated to a new image version`,
@@ -542,6 +763,7 @@ export async function runContainerUpdate(
} else {
throw new Error('Failed to recreate container');
}
+
} catch (error: any) {
log(`Error: ${error.message}`);
await updateScheduleExecution(execution.id, {
@@ -551,7 +773,6 @@ export async function runContainerUpdate(
errorMessage: error.message
});
- // Send notification for failed update
await sendEventNotification('auto_update_failed', {
title: 'Auto-update failed',
message: `Container "${containerName}" auto-update failed: ${error.message}`,
@@ -561,16 +782,12 @@ export async function runContainerUpdate(
}
// =============================================================================
-// EXPORTED HELPER FUNCTIONS (reused by batch-update-stream and batch-update)
+// EXPORTED HELPER FUNCTIONS
// =============================================================================
/**
* Recreate a standalone container with comprehensive settings preservation.
* Extracts and preserves 50+ container settings from the original container.
- *
- * Note: For containers that are part of a Docker Compose stack, use
- * updateStackContainer() instead, which uses `docker compose up -d` to
- * preserve ALL settings including network aliases, static IPs, etc.
*/
export async function recreateContainer(
containerName: string,
@@ -578,7 +795,6 @@ export async function recreateContainer(
log?: (msg: string) => void
): Promise {
try {
- // Find the container by name
const containers = await listContainers(true, envId);
const container = containers.find(c => c.name === containerName);
@@ -587,7 +803,6 @@ export async function recreateContainer(
return false;
}
- // Get full container config
const inspectData = await inspectContainer(container.id, envId) as any;
const wasRunning = inspectData.State.Running;
const hostConfig = inspectData.HostConfig;
@@ -596,26 +811,20 @@ export async function recreateContainer(
log?.(`Recreating container: ${containerName} (was running: ${wasRunning})`);
log?.(`Preserving all container settings...`);
- // Stop container if running
if (wasRunning) {
log?.('Stopping container...');
await stopContainer(container.id, envId);
}
- // Remove old container
log?.('Removing old container...');
await removeContainer(container.id, true, envId);
- // Extract ALL settings using the shared helper function
const containerOptions = extractContainerOptions(inspectData);
- // Extract additional networks for reconnection (not handled by extractContainerOptions)
- // The helper extracts primary network settings, but we need to handle secondary networks separately
+ // Handle additional networks
const networkSettings = inspectData.NetworkSettings?.Networks || {};
const primaryNetwork = hostConfig.NetworkMode || 'bridge';
const shortContainerId = container.id.substring(0, 12);
-
- // Extract compose labels for alias reconstruction
const composeProject = config.Labels?.['com.docker.compose.project'];
const composeService = config.Labels?.['com.docker.compose.service'];
@@ -635,25 +844,16 @@ export async function recreateContainer(
(primaryNetwork === 'bridge' && (netName === 'bridge' || netName === 'default'));
if (isPrimary) {
- // Log primary network info
if (containerOptions.networkAliases?.length) {
log?.(`Primary network aliases: ${containerOptions.networkAliases.join(', ')}`);
}
if (containerOptions.networkIpv4Address) {
log?.(`Primary network static IPv4: ${containerOptions.networkIpv4Address}`);
}
- if (containerOptions.macAddress) {
- log?.(`Primary network MAC address: ${containerOptions.macAddress}`);
- }
- if (containerOptions.networkGwPriority !== undefined) {
- log?.(`Primary network gateway priority: ${containerOptions.networkGwPriority}`);
- }
} else {
- // Secondary network - add to reconnection list
const secondaryAliases = ((netConf.Aliases?.length > 0 ? netConf.Aliases : netConf.DNSNames) || [])
.filter((a: string) => a !== container.id && a !== shortContainerId);
- // For compose containers, ensure service name and project-service aliases on secondary networks
if (composeProject && composeService) {
if (!secondaryAliases.includes(composeService)) {
secondaryAliases.push(composeService);
@@ -676,61 +876,32 @@ export async function recreateContainer(
}
if (additionalNetworks.length > 0) {
- log?.(`Will reconnect to ${additionalNetworks.length} additional network(s): ${additionalNetworks.map(n => n.name).join(', ')}`);
+ log?.(`Will reconnect to ${additionalNetworks.length} additional network(s)`);
}
- // Log extra hosts if present
- if (containerOptions.extraHosts?.length) {
- log?.(`Extra hosts: ${containerOptions.extraHosts.join(', ')}`);
- }
-
- // Log device requests if present (GPU, etc.)
- if (containerOptions.deviceRequests?.length) {
- for (const dr of containerOptions.deviceRequests) {
- const caps = dr.capabilities?.flat().join(',') || 'none';
- log?.(`Device request: driver=${dr.driver || 'default'}, count=${dr.count}, capabilities=[${caps}]`);
- }
- }
-
- // Create new container with ALL preserved settings
- log?.('Creating new container with preserved settings...');
+ log?.('Creating new container...');
const newContainer = await createContainer(containerOptions, envId);
- // Reconnect to additional networks with aliases, static IPs, and gateway priority (before starting)
- if (additionalNetworks.length > 0) {
- log?.(`Reconnecting to ${additionalNetworks.length} additional network(s)...`);
- for (const netInfo of additionalNetworks) {
- try {
- await connectContainerToNetwork(netInfo.name, newContainer.id, envId, {
- aliases: netInfo.aliases.length > 0 ? netInfo.aliases : undefined,
- ipv4Address: netInfo.ipv4Address,
- ipv6Address: netInfo.ipv6Address,
- gwPriority: netInfo.gwPriority
- });
- log?.(` Connected to: ${netInfo.name}`);
- if (netInfo.aliases.length > 0) {
- log?.(` Aliases: ${netInfo.aliases.join(', ')}`);
- }
- if (netInfo.ipv4Address) {
- log?.(` Static IPv4: ${netInfo.ipv4Address}`);
- }
- if (netInfo.gwPriority !== undefined) {
- log?.(` Gateway priority: ${netInfo.gwPriority}`);
- }
- } catch (netError: any) {
- log?.(` Warning: Failed to connect to network "${netInfo.name}": ${netError.message}`);
- // Don't fail the entire update for network connection issues
- }
+ for (const netInfo of additionalNetworks) {
+ try {
+ await connectContainerToNetwork(netInfo.name, newContainer.id, envId, {
+ aliases: netInfo.aliases.length > 0 ? netInfo.aliases : undefined,
+ ipv4Address: netInfo.ipv4Address,
+ ipv6Address: netInfo.ipv6Address,
+ gwPriority: netInfo.gwPriority
+ });
+ log?.(` Connected to: ${netInfo.name}`);
+ } catch (netError: any) {
+ log?.(` Warning: Failed to connect to "${netInfo.name}": ${netError.message}`);
}
}
- // Start if was running
if (wasRunning) {
log?.('Starting new container...');
await newContainer.start();
}
- log?.('Container recreated successfully with all settings preserved');
+ log?.('Container recreated successfully');
return true;
} catch (error: any) {
log?.(`Failed to recreate container: ${error.message}`);
@@ -740,57 +911,36 @@ export async function recreateContainer(
/**
* Update a container that is part of a Docker Compose stack.
- * Uses `docker compose up -d` which preserves ALL configuration from the compose file.
+ * Uses `docker compose up -d ` which preserves all compose configuration.
*
- * @param stackName - The compose project name (com.docker.compose.project label)
- * @param serviceName - The service name within the stack (com.docker.compose.service label)
- * @param envId - Optional environment ID
- * @param log - Optional logging function
* @returns true if update succeeded, false if stack not found (use fallback)
*/
export async function updateStackContainer(
stackName: string,
serviceName: string,
envId?: number,
- log?: (msg: string) => void
+ log?: (msg: string) => void,
+ composeConfigPath?: string
): Promise {
try {
- log?.(`Looking up stack configuration for: ${stackName}`);
+ log?.(`Looking up stack: ${stackName}`);
- // Check if we have the compose file for this stack
- const composeResult = await getStackComposeFile(stackName, envId);
+ const composeResult = await getStackComposeFile(stackName, envId, composeConfigPath);
if (!composeResult.success || !composeResult.content) {
- // Stack is "external" - we don't have the compose file
- log?.(`WARNING: No compose file found for stack "${stackName}"`);
- log?.(`This stack may have been created outside Dockhand`);
- log?.(`Falling back to container recreation (some settings may be lost)`);
- log?.(`TIP: Import the stack in Dockhand to preserve all settings on future updates`);
- return false; // Signal to use fallback
+ log?.(`No compose file found for stack "${stackName}"`);
+ log?.(`TIP: Import the stack in Dockhand for compose-native updates`);
+ return false;
}
- log?.(`Found compose file for stack: ${stackName}`);
- log?.(`Running: docker compose up -d (service: ${serviceName})`);
-
- // Use startStack which runs `docker compose up -d`
- // This will recreate only containers with changed images
- const result = await startStack(stackName, envId);
+ log?.(`Running: docker compose up -d ${serviceName}`);
+ const result = await updateStackService(stackName, serviceName, envId, composeConfigPath);
if (result.success) {
- log?.(`Stack updated successfully via docker compose`);
- if (result.output) {
- // Log compose output (shows which containers were recreated)
- const lines = result.output.split('\n').filter((l: string) => l.trim());
- for (const line of lines) {
- log?.(`[compose] ${line}`);
- }
- }
+ log?.(`Service ${serviceName} updated via docker compose`);
return true;
} else {
log?.(`docker compose up failed: ${result.error || 'Unknown error'}`);
- if (result.output) {
- log?.(`Output: ${result.output}`);
- }
return false;
}
} catch (error: any) {
diff --git a/src/lib/server/scheduler/tasks/env-update-check.ts b/src/lib/server/scheduler/tasks/env-update-check.ts
index bb59474..ae0e186 100644
--- a/src/lib/server/scheduler/tasks/env-update-check.ts
+++ b/src/lib/server/scheduler/tasks/env-update-check.ts
@@ -22,9 +22,6 @@ import {
inspectContainer,
checkImageUpdateAvailable,
pullImage,
- stopContainer,
- removeContainer,
- createContainer,
getTempImageTag,
isDigestBasedImage,
getImageIdByTag,
@@ -34,6 +31,8 @@ import {
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
+import { recreateContainer, updateStackContainer } from './container-update';
+import { pullStackService } from '../../stacks';
interface UpdateInfo {
containerId: string;
@@ -239,9 +238,14 @@ export async function runEnvUpdateCheckJob(
// Get full container config
const inspectData = await inspectContainer(update.containerId, environmentId) as any;
- const wasRunning = inspectData.State.Running;
const containerConfig = inspectData.Config;
- const hostConfig = inspectData.HostConfig;
+
+ // Detect stack membership early (needed for both pull and recreate)
+ const containerLabels = containerConfig.Labels || {};
+ const composeProject = containerLabels['com.docker.compose.project'];
+ const composeService = containerLabels['com.docker.compose.service'];
+ const composeConfigFiles = containerLabels['com.docker.compose.project.config_files'];
+ const isStackContainer = !!(composeProject && composeService);
// SAFE-PULL FLOW
if (shouldScan && !isDigestBasedImage(update.imageName)) {
@@ -249,8 +253,17 @@ export async function runEnvUpdateCheckJob(
await log(` Safe-pull with temp tag: ${tempTag}`);
// Step 1: Pull new image
- await log(` Pulling ${update.imageName}...`);
- await pullImage(update.imageName, () => {}, environmentId);
+ if (isStackContainer) {
+ await log(` Pulling via compose (stack: ${composeProject}, service: ${composeService})...`);
+ const pullResult = await pullStackService(composeProject, composeService, environmentId, composeConfigFiles);
+ if (!pullResult.success) {
+ await log(` Compose pull failed, falling back to direct pull...`);
+ await pullImage(update.imageName, () => {}, environmentId);
+ }
+ } else {
+ await log(` Pulling ${update.imageName}...`);
+ await pullImage(update.imageName, () => {}, environmentId);
+ }
// Step 2: Get new image ID
const newImageId = await getImageIdByTag(update.imageName, environmentId);
@@ -359,48 +372,38 @@ export async function runEnvUpdateCheckJob(
} catch { /* ignore cleanup errors */ }
} else {
// Simple pull (no scanning or digest-based image)
- await log(` Pulling ${update.imageName}...`);
- await pullImage(update.imageName, () => {}, environmentId);
- }
-
- // Stop container if running
- if (wasRunning) {
- await log(` Stopping...`);
- await stopContainer(update.containerId, environmentId);
- }
-
- // Remove old container
- await log(` Removing old container...`);
- await removeContainer(update.containerId, true, environmentId);
-
- // Prepare port bindings
- const ports: { [key: string]: { HostPort: string } } = {};
- if (hostConfig.PortBindings) {
- for (const [containerPort, bindings] of Object.entries(hostConfig.PortBindings)) {
- if (bindings && (bindings as any[]).length > 0) {
- ports[containerPort] = { HostPort: (bindings as any[])[0].HostPort || '' };
+ if (isStackContainer) {
+ await log(` Pulling via compose (stack: ${composeProject}, service: ${composeService})...`);
+ const pullResult = await pullStackService(composeProject, composeService, environmentId, composeConfigFiles);
+ if (!pullResult.success) {
+ await log(` Compose pull failed, falling back to direct pull...`);
+ await pullImage(update.imageName, () => {}, environmentId);
}
+ } else {
+ await log(` Pulling ${update.imageName}...`);
+ await pullImage(update.imageName, () => {}, environmentId);
}
}
- // Create new container
- await log(` Creating new container...`);
- const newContainer = await createContainer({
- name: update.containerName,
- image: update.imageName,
- ports,
- volumeBinds: hostConfig.Binds || [],
- env: containerConfig.Env || [],
- labels: containerConfig.Labels || {},
- cmd: containerConfig.Cmd || undefined,
- restartPolicy: hostConfig.RestartPolicy?.Name || 'no',
- networkMode: hostConfig.NetworkMode || undefined
- }, environmentId);
-
- // Start if was running
- if (wasRunning) {
- await log(` Starting...`);
- await newContainer.start();
+ // Recreate container using compose-native or full recreation
+ if (isStackContainer) {
+ await log(` Updating via compose (stack: ${composeProject}, service: ${composeService})`);
+ const stackSuccess = await updateStackContainer(
+ composeProject, composeService, environmentId,
+ (msg) => { log(` ${msg}`); },
+ composeConfigFiles
+ );
+ if (!stackSuccess) {
+ await log(` Compose file not found, falling back to container recreation...`);
+ const ok = await recreateContainer(update.containerName, environmentId,
+ (msg) => { log(` ${msg}`); });
+ if (!ok) throw new Error('Container recreation failed');
+ }
+ } else {
+ await log(` Recreating standalone container...`);
+ const ok = await recreateContainer(update.containerName, environmentId,
+ (msg) => { log(` ${msg}`); });
+ if (!ok) throw new Error('Container recreation failed');
}
await log(` Updated successfully`);
diff --git a/src/lib/server/stack-scanner.ts b/src/lib/server/stack-scanner.ts
index dd02d4d..e813d0a 100644
--- a/src/lib/server/stack-scanner.ts
+++ b/src/lib/server/stack-scanner.ts
@@ -213,9 +213,9 @@ export async function adoptStack(
// Get all existing stack sources to check for duplicates
const existingSources = await getStackSources();
- // Check if already adopted (by composePath)
+ // Check if already adopted (by composePath within the same environment)
const alreadyAdopted = existingSources.some(
- (s) => s.composePath === stack.composePath
+ (s) => s.composePath === stack.composePath && s.environmentId === environmentId
);
if (alreadyAdopted) {
diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts
index a8fdf40..81a030c 100644
--- a/src/lib/server/stacks.ts
+++ b/src/lib/server/stacks.ts
@@ -22,7 +22,8 @@ import {
deleteStackEnvVars,
removePendingContainerUpdate,
deleteAutoUpdateSchedule,
- getAutoUpdateSetting
+ getAutoUpdateSetting,
+ getStackSourceByComposePath
} from './db';
import { unregisterSchedule } from './scheduler';
import { deleteGitStackFiles, parseEnvFileContent } from './git';
@@ -273,21 +274,31 @@ export async function getStackDir(stackName: string, envId?: number | null): Pro
/**
* Find stack directory, checking paths in order:
- * 1. New path (envName): $DATA_DIR/stacks///
- * 2. ID-based path (envId): $DATA_DIR/stacks///
- * 3. Legacy path: $DATA_DIR/stacks//
+ * 1. Database: Custom composePath in stackSources table (adopted/imported stacks)
+ * 2. New path (envName): $DATA_DIR/stacks///
+ * 3. ID-based path (envId): $DATA_DIR/stacks///
+ * 4. Legacy path: $DATA_DIR/stacks//
*
* Automatically looks up environment name from database.
* Always checks legacy path for backwards compatibility with pre-env stacks.
*/
export async function findStackDir(stackName: string, envId?: number | null): Promise {
+ // 1. Check database for custom compose path first (adopted/imported stacks)
+ const source = await getStackSource(stackName, envId);
+ if (source?.composePath) {
+ const customDir = dirname(source.composePath);
+ if (existsSync(customDir)) {
+ return customDir;
+ }
+ }
+
const stacksDir = getStacksDir();
// Look up environment name if we have an ID
if (envId) {
const env = await getEnvironment(envId);
- // 1. Check new path (with envName)
+ // 2. Check new path (with envName)
if (env) {
const namePath = join(stacksDir, env.name, stackName);
if (existsSync(namePath)) {
@@ -295,14 +306,14 @@ export async function findStackDir(stackName: string, envId?: number | null): Pr
}
}
- // 2. Check ID-based path
+ // 3. Check ID-based path
const idPath = join(stacksDir, String(envId), stackName);
if (existsSync(idPath)) {
return idPath;
}
}
- // 3. Always check legacy path (stacks created before env-scoping was added)
+ // 4. Always check legacy path (stacks created before env-scoping was added)
const legacyPath = join(stacksDir, stackName);
if (existsSync(legacyPath)) {
return legacyPath;
@@ -339,9 +350,15 @@ export interface GetComposeFileResult {
*/
export async function getStackComposeFile(
stackName: string,
- envId?: number | null
+ envId?: number | null,
+ composeConfigPath?: string
): Promise {
- const source = await getStackSource(stackName, envId);
+ let source = await getStackSource(stackName, envId);
+
+ // Fallback: try lookup by compose file path from Docker labels
+ if (!source && composeConfigPath) {
+ source = await getStackSourceByComposePath(composeConfigPath, envId);
+ }
// Case 1: Stack not in database = untracked (discovered from Docker but not imported)
// User must select the compose file location - don't guess from default location
@@ -742,6 +759,10 @@ interface ComposeCommandOptions {
envPath?: string;
/** When true, write non-secret envVars to .env.dockhand override file (git stacks only) */
useOverrideFile?: boolean;
+ /** Target specific service only (with --no-deps) for single-service updates */
+ serviceName?: string;
+ /** Compose filename for Hawser (e.g., "docker-compose.prod.yml") - extracted from composePath */
+ composeFileName?: string;
}
/**
@@ -767,7 +788,8 @@ async function executeLocalCompose(
workingDir?: string,
customComposePath?: string,
customEnvPath?: string,
- useOverrideFile?: boolean
+ useOverrideFile?: boolean,
+ serviceName?: string
): Promise {
const logPrefix = `[Stack:${stackName}]`;
@@ -817,20 +839,34 @@ async function executeLocalCompose(
}
}
- // Build spawn environment:
- // 1. Start with process.env
- // 2. Add DOCKER_HOST if specified
- // 3. Add non-secret envVars (for backward compat when .env file doesn't exist)
- // 4. Add secret envVars (CRITICAL: these are NEVER written to disk, only passed via shell env)
- const spawnEnv: Record = { ...(process.env as Record) };
+ // Build spawn environment with ONLY essential system variables.
+ // CRITICAL: Do NOT spread process.env! Docker Compose shell env has higher
+ // priority than --env-file, so Dockhand's vars would override user's .env values.
+ const spawnEnv: Record = {
+ PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
+ HOME: process.env.HOME || '/root',
+ };
+
+ // Docker connection config
if (dockerHost) {
spawnEnv.DOCKER_HOST = dockerHost;
+ } else if (process.env.DOCKER_HOST) {
+ spawnEnv.DOCKER_HOST = process.env.DOCKER_HOST;
}
- // Non-secret vars (backup for when .env file doesn't exist yet)
- if (envVars) {
+
+ // Check if .env file exists on disk (for legacy support decision)
+ const defaultEnvPath = join(stackDir, '.env');
+ const hasEnvFile = existsSync(defaultEnvPath) || (customEnvPath && existsSync(customEnvPath));
+
+ // LEGACY SUPPORT: Only inject envVars via shell if NO .env file exists
+ // This is for stacks created with older Dockhand versions that stored env vars
+ // in DB but didn't write .env files to disk.
+ // For modern stacks with .env files, Docker Compose reads them via --env-file.
+ if (!hasEnvFile && envVars) {
Object.assign(spawnEnv, envVars);
}
- // SECRET vars: injected via shell environment at runtime (NEVER written to .env file)
+
+ // SECRET vars: always injected via shell env (NEVER written to .env files)
if (secretVars) {
Object.assign(spawnEnv, secretVars);
}
@@ -876,8 +912,7 @@ async function executeLocalCompose(
const useStdin = finalComposeContent !== composeContent;
const args = ['docker', 'compose', '-p', stackName, '-f', useStdin ? '-' : composeFile];
- // Always auto-detect .env in compose directory
- const defaultEnvPath = join(stackDir, '.env');
+ // Always auto-detect .env in compose directory (defaultEnvPath already defined above)
if (existsSync(defaultEnvPath)) {
args.push('--env-file', defaultEnvPath);
}
@@ -909,6 +944,10 @@ async function executeLocalCompose(
case 'up':
args.push('up', '-d', '--remove-orphans');
if (forceRecreate) args.push('--force-recreate');
+ // If targeting a specific service, only update that service
+ if (serviceName) {
+ args.push(serviceName);
+ }
break;
case 'down':
args.push('down');
@@ -925,6 +964,10 @@ async function executeLocalCompose(
break;
case 'pull':
args.push('pull');
+ // If targeting a specific service, pull only that service
+ if (serviceName) {
+ args.push(serviceName);
+ }
break;
}
@@ -938,6 +981,7 @@ async function executeLocalCompose(
console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)');
console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false);
console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false);
+ console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)');
console.log(`${logPrefix} Env vars count:`, envVars ? Object.keys(envVars).length : 0);
if (envVars && Object.keys(envVars).length > 0) {
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
@@ -1062,7 +1106,9 @@ async function executeComposeViaHawser(
secretVars?: Record,
forceRecreate?: boolean,
removeVolumes?: boolean,
- stackFiles?: Record
+ stackFiles?: Record,
+ serviceName?: string,
+ composeFileName?: string
): Promise {
const logPrefix = `[Stack:${stackName}]`;
// Import dockerFetch dynamically to avoid circular dependency
@@ -1080,6 +1126,8 @@ async function executeComposeViaHawser(
console.log(`${logPrefix} Environment ID:`, envId);
console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false);
console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false);
+ console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)');
+ console.log(`${logPrefix} Compose filename:`, composeFileName ?? '(auto-detect)');
console.log(`${logPrefix} Non-secret env vars count:`, envVars ? Object.keys(envVars).length : 0);
console.log(`${logPrefix} Secret env vars count:`, secretCount);
if (allEnvVars && Object.keys(allEnvVars).length > 0) {
@@ -1128,11 +1176,13 @@ async function executeComposeViaHawser(
operation,
projectName: stackName,
composeFile: composeContent,
+ composeFileName, // Explicit compose filename to use (e.g., "docker-compose.prod.yml")
envVars: allEnvVars, // All vars (including secrets) - Hawser injects via shell env
files, // Files including .env (secrets NOT in .env file)
forceRecreate: forceRecreate || false,
removeVolumes: removeVolumes || false,
- registries // Registry credentials for docker login
+ registries, // Registry credentials for docker login
+ serviceName // Target specific service only (with --no-deps)
});
console.log(`${logPrefix} Sending request to Hawser agent...`);
@@ -1198,7 +1248,7 @@ async function executeComposeCommand(
envVars?: Record,
secretVars?: Record
): Promise {
- const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile } = options;
+ const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath, useOverrideFile, serviceName, composeFileName } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1219,7 +1269,8 @@ async function executeComposeCommand(
workingDir,
composePath,
envPath,
- useOverrideFile
+ useOverrideFile,
+ serviceName
);
}
@@ -1251,7 +1302,9 @@ async function executeComposeCommand(
secretVars,
forceRecreate,
removeVolumes,
- stackFiles
+ stackFiles,
+ serviceName,
+ composeFileName
);
}
@@ -1281,7 +1334,8 @@ async function executeComposeCommand(
workingDir,
composePath,
envPath,
- useOverrideFile
+ useOverrideFile,
+ serviceName
);
}
@@ -1301,7 +1355,8 @@ async function executeComposeCommand(
workingDir,
composePath,
envPath,
- useOverrideFile
+ useOverrideFile,
+ serviceName
);
}
}
@@ -1530,9 +1585,10 @@ export interface RequireComposeResult {
*/
async function requireComposeFile(
stackName: string,
- envId?: number | null
+ envId?: number | null,
+ composeConfigPath?: string
): Promise {
- const composeResult = await getStackComposeFile(stackName, envId);
+ const composeResult = await getStackComposeFile(stackName, envId, composeConfigPath);
// If compose file not found, return info about what's needed
if (!composeResult.success) {
@@ -2011,7 +2067,9 @@ export async function deployStack(options: DeployStackOptions): Promise.
+ * This is the Compose-native approach to pulling images for auto-updates.
+ *
+ * @param stackName - The compose project name
+ * @param serviceName - The service name to pull
+ * @param envId - Optional environment ID
+ * @returns Operation result
+ */
+export async function pullStackService(
+ stackName: string,
+ serviceName: string,
+ envId?: number | null,
+ composeConfigPath?: string
+): Promise {
+ const result = await requireComposeFile(stackName, envId, composeConfigPath);
+
+ if (!result.success) {
+ return {
+ success: false,
+ error: result.error || `Compose file not found for stack "${stackName}"`
+ };
+ }
+
+ return executeComposeCommand(
+ 'pull',
+ {
+ stackName,
+ envId,
+ workingDir: result.stackDir,
+ composePath: result.composePath,
+ envPath: result.envPath,
+ serviceName
+ },
+ result.content!,
+ result.nonSecretVars,
+ result.secretVars
+ );
+}
+
+/**
+ * Update a specific service within a stack using docker compose up -d --no-deps.
+ * Docker Compose detects image changes naturally (the image is pulled beforehand),
+ * so --force-recreate is not needed and can cause permission issues on bind mounts.
+ * This preserves all compose configuration (static IPs, network aliases, etc.) while only
+ * recreating the specified service when its image has changed.
+ *
+ * @param stackName - The compose project name
+ * @param serviceName - The service name to update
+ * @param envId - Optional environment ID
+ * @returns Operation result
+ */
+export async function updateStackService(
+ stackName: string,
+ serviceName: string,
+ envId?: number | null,
+ composeConfigPath?: string
+): Promise {
+ const result = await requireComposeFile(stackName, envId, composeConfigPath);
+
+ if (!result.success) {
+ return {
+ success: false,
+ error: result.error || `Compose file not found for stack "${stackName}"`
+ };
+ }
+
+ // Don't use forceRecreate - Docker Compose will detect the image change
+ // naturally since the image was already pulled before this function is called.
+ // Using forceRecreate can cause permission issues on bind mounts.
+ // This matches the behavior of: docker compose pull && docker compose up -d
+ return executeComposeCommand(
+ 'up',
+ {
+ stackName,
+ envId,
+ workingDir: result.stackDir,
+ composePath: result.composePath,
+ envPath: result.envPath,
+ serviceName
+ },
+ result.content!,
+ result.nonSecretVars,
+ result.secretVars
+ );
+}
+
// =============================================================================
// ENVIRONMENT VARIABLE HELPERS
// =============================================================================
@@ -2187,3 +2332,4 @@ export async function saveStackEnvVars(
// They can be removed once all imports are updated
export type { StackOperationResult as CreateStackResult };
+
diff --git a/src/lib/types.ts b/src/lib/types.ts
index ddae809..16a3289 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -42,6 +42,7 @@ export interface ImageInfo {
id: string;
repoTags: string[];
tags: string[]; // Alias for repoTags, populated by API
+ repoDigests: string[]; // Repository digests (e.g., "nginx@sha256:abc123") - used for untagged images
created: number;
size: number;
virtualSize: number;
diff --git a/src/lib/utils/url.ts b/src/lib/utils/url.ts
new file mode 100644
index 0000000..8bf9f1a
--- /dev/null
+++ b/src/lib/utils/url.ts
@@ -0,0 +1,15 @@
+/**
+ * Formats a URL with proper IPv6 bracket handling.
+ * IPv6 addresses must be wrapped in brackets in URLs.
+ *
+ * @example
+ * formatHostPortUrl('192.168.1.1', 8080) // 'http://192.168.1.1:8080'
+ * formatHostPortUrl('2001:db8::1', 8080) // 'http://[2001:db8::1]:8080'
+ * formatHostPortUrl('localhost', 8080) // 'http://localhost:8080'
+ */
+export function formatHostPortUrl(host: string, port: number): string {
+ // Check if host is IPv6 (contains colons and is not already bracketed)
+ const isIPv6 = host.includes(':') && !host.startsWith('[');
+ const formattedHost = isIPv6 ? `[${host}]` : host;
+ return `http://${formattedHost}:${port}`;
+}
diff --git a/src/routes/api/audit/events/+server.ts b/src/routes/api/audit/events/+server.ts
index a3c2668..1fe76ec 100644
--- a/src/routes/api/audit/events/+server.ts
+++ b/src/routes/api/audit/events/+server.ts
@@ -21,11 +21,13 @@ export const GET: RequestHandler = async ({ cookies }) => {
});
}
+ let heartbeatInterval: ReturnType;
+ let onAuditEvent: (data: AuditEventData) => void;
+
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
- // Send SSE event
const sendEvent = (type: string, data: any) => {
const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
try {
@@ -35,11 +37,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
};
- // Send initial connection event
sendEvent('connected', { timestamp: new Date().toISOString() });
- // Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
- const heartbeatInterval = setInterval(() => {
+ heartbeatInterval = setInterval(() => {
try {
sendEvent('heartbeat', { timestamp: new Date().toISOString() });
} catch {
@@ -47,24 +47,15 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
}, 5000);
- // Listen for audit events
- const onAuditEvent = (data: AuditEventData) => {
+ onAuditEvent = (data: AuditEventData) => {
sendEvent('audit', data);
};
auditEvents.on('audit', onAuditEvent);
-
- // Cleanup when client disconnects
- const cleanup = () => {
- clearInterval(heartbeatInterval);
- auditEvents.off('audit', onAuditEvent);
- };
-
- // Note: SvelteKit doesn't provide a direct way to detect client disconnect
- // The cleanup will happen when the stream errors or the server shuts down
- // For production, consider using a WebSocket instead for better connection management
-
- return cleanup;
+ },
+ cancel() {
+ clearInterval(heartbeatInterval);
+ auditEvents.off('audit', onAuditEvent);
}
});
diff --git a/src/routes/api/containers/[id]/stats/+server.ts b/src/routes/api/containers/[id]/stats/+server.ts
index c06e99c..36d7949 100644
--- a/src/routes/api/containers/[id]/stats/+server.ts
+++ b/src/routes/api/containers/[id]/stats/+server.ts
@@ -113,7 +113,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
- console.error('Failed to get container stats:', error);
+ if (error.statusCode === 404) {
+ return json({ error: 'Container not found' }, { status: 404 });
+ }
+ console.error('Failed to get container stats:', error.message || error);
return json({ error: error.message || 'Failed to get stats' }, { status: 500 });
}
};
diff --git a/src/routes/api/containers/batch-update-stream/+server.ts b/src/routes/api/containers/batch-update-stream/+server.ts
index 01be464..a18657b 100644
--- a/src/routes/api/containers/batch-update-stream/+server.ts
+++ b/src/routes/api/containers/batch-update-stream/+server.ts
@@ -16,6 +16,7 @@ import { getScannerSettings, scanImage } from '$lib/server/scanner';
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
import { recreateContainer, updateStackContainer } from '$lib/server/scheduler/tasks/container-update';
+import { pullStackService } from '$lib/server/stacks';
export interface ScanResult {
critical: number;
@@ -56,6 +57,15 @@ export interface UpdateProgress {
scannerResults?: ScannerResult[];
blockReason?: string;
scanner?: string;
+ vulnerabilities?: Array<{
+ id: string;
+ severity: string;
+ package: string;
+ version: string;
+ fixedVersion?: string;
+ link?: string;
+ scanner: string;
+ }>;
}
/**
@@ -198,6 +208,13 @@ export const POST: RequestHandler = async (event) => {
continue;
}
+ // Detect stack membership early (needed for both pull and recreate)
+ const containerLabels = config.Labels || {};
+ const composeProject = containerLabels['com.docker.compose.project'];
+ const composeService = containerLabels['com.docker.compose.service'];
+ const composeConfigFiles = containerLabels['com.docker.compose.project.config_files'];
+ const isStackContainer = !!(composeProject && composeService);
+
// Step 1: Pull latest image
safeEnqueue({
type: 'progress',
@@ -210,19 +227,37 @@ export const POST: RequestHandler = async (event) => {
});
try {
- await pullImage(imageName, (data: any) => {
- // Send pull progress as log entries
- if (data.status) {
- safeEnqueue({
- type: 'pull_log',
- containerId,
- containerName,
- pullStatus: data.status,
- pullId: data.id,
- pullProgress: data.progress
- });
+ if (isStackContainer) {
+ const pullResult = await pullStackService(composeProject, composeService!, envIdNum, composeConfigFiles);
+ if (!pullResult.success) {
+ // Fallback to direct pull
+ await pullImage(imageName, (data: any) => {
+ if (data.status) {
+ safeEnqueue({
+ type: 'pull_log',
+ containerId,
+ containerName,
+ pullStatus: data.status,
+ pullId: data.id,
+ pullProgress: data.progress
+ });
+ }
+ }, envIdNum);
}
- }, envIdNum);
+ } else {
+ await pullImage(imageName, (data: any) => {
+ if (data.status) {
+ safeEnqueue({
+ type: 'pull_log',
+ containerId,
+ containerName,
+ pullStatus: data.status,
+ pullId: data.id,
+ pullProgress: data.progress
+ });
+ }
+ }, envIdNum);
+ }
} catch (pullError: any) {
safeEnqueue({
type: 'progress',
@@ -289,13 +324,13 @@ export const POST: RequestHandler = async (event) => {
try {
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
- if (progress.message) {
+ if (progress.output || progress.message) {
safeEnqueue({
type: 'scan_log',
containerId,
containerName,
scanner: progress.scanner,
- message: progress.message
+ message: progress.output || progress.message
});
}
});
@@ -352,12 +387,27 @@ export const POST: RequestHandler = async (event) => {
}
}
+ // Collect vulnerabilities from all scanners (cap at 100)
+ const vulnerabilities = scanResults
+ .flatMap(r => r.vulnerabilities || [])
+ .slice(0, 100)
+ .map(v => ({
+ id: v.id,
+ severity: v.severity,
+ package: v.package,
+ version: v.version,
+ fixedVersion: v.fixedVersion,
+ link: v.link,
+ scanner: v.scanner
+ }));
+
safeEnqueue({
type: 'scan_complete',
containerId,
containerName,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
+ vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
message: finalScanResult
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
: 'Scan complete: no vulnerabilities found'
@@ -415,12 +465,6 @@ export const POST: RequestHandler = async (event) => {
} catch { /* ignore cleanup errors */ }
}
- // Detect if container is part of a Docker Compose stack
- const containerLabels = config.Labels || {};
- const composeProject = containerLabels['com.docker.compose.project'];
- const composeService = containerLabels['com.docker.compose.service'];
- const isStackContainer = !!composeProject;
-
// Progress logging function for shared functions
const logProgress = (message: string) => {
safeEnqueue({
@@ -452,7 +496,7 @@ export const POST: RequestHandler = async (event) => {
});
// Try stack-based update first
- const stackSuccess = await updateStackContainer(composeProject, composeService!, envIdNum, logProgress);
+ const stackSuccess = await updateStackContainer(composeProject, composeService!, envIdNum, logProgress, composeConfigFiles);
if (stackSuccess) {
updateSuccess = true;
diff --git a/src/routes/api/containers/stats/+server.ts b/src/routes/api/containers/stats/+server.ts
index ed1bf6e..22ed2dd 100644
--- a/src/routes/api/containers/stats/+server.ts
+++ b/src/routes/api/containers/stats/+server.ts
@@ -170,7 +170,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
- console.error('Failed to get container stats:', error);
+ console.error('Failed to get container stats:', error.message || error);
return json([], { status: 200 }); // Return empty array instead of error
}
};
diff --git a/src/routes/api/environments/[id]/timezone/+server.ts b/src/routes/api/environments/[id]/timezone/+server.ts
index 06606e1..e5b81ce 100644
--- a/src/routes/api/environments/[id]/timezone/+server.ts
+++ b/src/routes/api/environments/[id]/timezone/+server.ts
@@ -8,6 +8,18 @@ import {
} from '$lib/server/db';
import { refreshSchedulesForEnvironment } from '$lib/server/scheduler';
+/** Map of modern IANA timezone names to their canonical equivalents recognized by Bun/ICU */
+const TIMEZONE_ALIASES: Record = {
+ 'Europe/Kyiv': 'Europe/Kiev',
+ 'Asia/Ho_Chi_Minh': 'Asia/Saigon',
+ 'America/Nuuk': 'America/Godthab',
+ 'Pacific/Kanton': 'Pacific/Enderbury'
+};
+
+function normalizeTimezone(tz: string): string {
+ return TIMEZONE_ALIASES[tz] || tz;
+}
+
/**
* Get timezone for an environment.
*/
@@ -26,7 +38,8 @@ export const GET: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Environment not found' }, { status: 404 });
}
- const timezone = await getEnvironmentTimezone(id);
+ const rawTimezone = await getEnvironmentTimezone(id);
+ const timezone = normalizeTimezone(rawTimezone);
return json({ timezone });
} catch (error) {
@@ -54,7 +67,7 @@ export const POST: RequestHandler = async ({ params, request, cookies }) => {
}
const data = await request.json();
- const timezone = data.timezone || 'UTC';
+ const timezone = normalizeTimezone(data.timezone || 'UTC');
// Validate timezone
const validTimezones = Intl.supportedValuesOf('timeZone');
diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts
index 8b9e3ec..dc5d5b8 100644
--- a/src/routes/api/events/+server.ts
+++ b/src/routes/api/events/+server.ts
@@ -33,10 +33,13 @@ export const GET: RequestHandler = async ({ url }) => {
);
}
+ let heartbeatInterval: ReturnType;
+ let controllerClosed = false;
+ let eventReader: ReadableStreamDefaultReader | null = null;
+
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
- let controllerClosed = false;
// Safe close helper - prevents "Controller is already closed" errors
const safeClose = () => {
@@ -50,7 +53,7 @@ export const GET: RequestHandler = async ({ url }) => {
}
};
- // Send initial connection event
+ // Send SSE event
const sendEvent = (type: string, data: any) => {
if (controllerClosed) return;
try {
@@ -63,7 +66,7 @@ export const GET: RequestHandler = async ({ url }) => {
};
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
- const heartbeatInterval = setInterval(() => {
+ heartbeatInterval = setInterval(() => {
try {
sendEvent('heartbeat', { timestamp: new Date().toISOString() });
} catch {
@@ -71,6 +74,7 @@ export const GET: RequestHandler = async ({ url }) => {
}
}, 5000);
+ // Send initial connection event
sendEvent('connected', { timestamp: new Date().toISOString(), envId: envIdNum });
try {
@@ -87,14 +91,14 @@ export const GET: RequestHandler = async ({ url }) => {
return;
}
- const reader = eventStream.getReader();
+ eventReader = eventStream.getReader();
const decoder = new TextDecoder();
let buffer = '';
const processEvents = async () => {
try {
while (true) {
- const { done, value } = await reader.read();
+ const { done, value } = await eventReader!.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
@@ -129,9 +133,7 @@ export const GET: RequestHandler = async ({ url }) => {
} catch (error: any) {
// Don't log full stack trace for expected connection errors
const isConnectionError = error?.code === 'ECONNRESET' || error?.code === 'ECONNREFUSED';
- if (isConnectionError) {
- // Silent - these are handled by event-subprocess reconnection logic
- } else {
+ if (!isConnectionError) {
console.error('Docker event stream error:', error?.message || error);
}
sendEvent('error', { message: error?.message || 'Stream connection lost' });
@@ -157,6 +159,11 @@ export const GET: RequestHandler = async ({ url }) => {
clearInterval(heartbeatInterval);
safeClose();
}
+ },
+ cancel() {
+ controllerClosed = true;
+ clearInterval(heartbeatInterval);
+ eventReader?.cancel().catch(() => {});
}
});
diff --git a/src/routes/api/notifications/[id]/test/+server.ts b/src/routes/api/notifications/[id]/test/+server.ts
index e84f1dd..2c6403c 100644
--- a/src/routes/api/notifications/[id]/test/+server.ts
+++ b/src/routes/api/notifications/[id]/test/+server.ts
@@ -15,11 +15,14 @@ export const POST: RequestHandler = async ({ params }) => {
return json({ error: 'Notification setting not found' }, { status: 404 });
}
- const success = await testNotification(setting);
+ const result = await testNotification(setting);
return json({
- success,
- message: success ? 'Test notification sent successfully' : 'Failed to send test notification'
+ success: result.success,
+ message: result.success
+ ? 'Test notification sent successfully'
+ : 'Failed to send test notification',
+ error: result.error
});
} catch (error: any) {
console.error('Error testing notification:', error);
diff --git a/src/routes/api/stacks/[name]/env/validate/+server.ts b/src/routes/api/stacks/[name]/env/validate/+server.ts
index dbf3060..1c81b3f 100644
--- a/src/routes/api/stacks/[name]/env/validate/+server.ts
+++ b/src/routes/api/stacks/[name]/env/validate/+server.ts
@@ -17,6 +17,7 @@ interface ValidationResult {
* Extract environment variables from compose YAML content.
* Matches ${VAR_NAME} and ${VAR_NAME:-default} patterns.
* Ignores variables in commented lines (lines starting with #).
+ * Ignores escaped $$ (Docker Compose escape syntax for literal $).
* Returns { required: [...], optional: [...] }
*/
function extractComposeVars(yaml: string): { required: string[]; optional: string[] } {
@@ -33,7 +34,8 @@ function extractComposeVars(yaml: string): { required: string[]; optional: strin
}
// Match ${VAR_NAME} (required) and ${VAR_NAME:-default} or ${VAR_NAME-default} (optional)
- const regex = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:-?)[^}]*)?\}/g;
+ // Use negative lookbehind (?
diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte
index d6042a4..427771a 100644
--- a/src/routes/containers/+page.svelte
+++ b/src/routes/containers/+page.svelte
@@ -80,6 +80,7 @@
import { canAccess } from '$lib/stores/auth';
import { vulnerabilityCriteriaIcons } from '$lib/utils/update-steps';
import { ipToNumber } from '$lib/utils/ip';
+ import { formatHostPortUrl } from '$lib/utils/url';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellDetectionResult } from '$lib/utils/shell-detection';
import { DataGrid } from '$lib/components/data-grid';
import type { ColumnConfig } from '$lib/types';
@@ -1240,7 +1241,7 @@
// Priority 1: Use publicIp if configured
if (env.publicIp) {
- return `http://${env.publicIp}:${publicPort}`;
+ return formatHostPortUrl(env.publicIp, publicPort);
}
// Priority 2: Extract from host for direct/hawser-standard
@@ -1249,11 +1250,11 @@
if (connectionType === 'direct' && env.host) {
// Remote Docker via TCP - extract host from URL (e.g., tcp://192.168.1.4:2376)
const host = extractHostFromUrl(env.host);
- if (host) return `http://${host}:${publicPort}`;
+ if (host) return formatHostPortUrl(host, publicPort);
} else if (connectionType === 'hawser-standard' && env.host) {
// Hawser standard mode - extract host from URL
const host = extractHostFromUrl(env.host);
- if (host) return `http://${host}:${publicPort}`;
+ if (host) return formatHostPortUrl(host, publicPort);
}
// No public IP available for socket or hawser-edge
@@ -1464,7 +1465,7 @@
{#if $canAccess('containers', 'create')}
{/if}
@@ -1482,7 +1483,7 @@
{:else if updateCheckStatus === 'error'}
{:else}
-
+
{/if}
Check for updates
@@ -1494,7 +1495,7 @@
class="border-amber-500/40 text-amber-600 hover:bg-amber-500/10 hover:border-amber-500"
title="Update all containers with available updates"
>
-
+
Update all ({updatableContainersCount})
{/if}
@@ -1518,7 +1519,7 @@
{:else if pruneStatus === 'error'}
{:else}
-
+
{/if}
Prune
@@ -2177,7 +2178,7 @@
diff --git a/src/routes/containers/BatchUpdateModal.svelte b/src/routes/containers/BatchUpdateModal.svelte
index e72fe78..f0b414a 100644
--- a/src/routes/containers/BatchUpdateModal.svelte
+++ b/src/routes/containers/BatchUpdateModal.svelte
@@ -4,7 +4,7 @@
import { Badge } from '$lib/components/ui/badge';
import { Progress } from '$lib/components/ui/progress';
import * as Tooltip from '$lib/components/ui/tooltip';
- import { CircleArrowUp, Loader2, AlertCircle, CheckCircle2, XCircle, ChevronDown, ChevronRight } from 'lucide-svelte';
+ import { CircleArrowUp, Loader2, AlertCircle, CheckCircle2, XCircle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-svelte';
import { appendEnvParam } from '$lib/stores/environment';
import type { VulnerabilityCriteria } from '$lib/server/db';
import type { StepType } from '$lib/utils/update-steps';
@@ -49,6 +49,16 @@
scanner: 'grype' | 'trivy';
}
+ interface VulnerabilityEntry {
+ id: string;
+ severity: string;
+ package: string;
+ version: string;
+ fixedVersion?: string;
+ link?: string;
+ scanner: string;
+ }
+
interface ContainerProgress {
containerId: string;
containerName: string;
@@ -59,12 +69,15 @@
scanLogs: ScanLogEntry[];
scanResult?: ScanResult;
scannerResults?: ScannerResult[];
+ vulnerabilities?: VulnerabilityEntry[];
blockReason?: string;
showLogs: boolean;
}
let status = $state<'idle' | 'updating' | 'complete' | 'error'>('idle');
let progress = $state([]);
+ let progressListEl = $state(null);
+ let scrollTick = $state(0);
let currentIndex = $state(0);
let totalCount = $state(0);
let summary = $state<{ total: number; success: number; failed: number; blocked: number } | null>(null);
@@ -142,6 +155,7 @@
try {
const data = JSON.parse(line.slice(6));
+ scrollTick++;
if (data.type === 'start') {
totalCount = data.total;
@@ -164,7 +178,7 @@
error: data.error,
pullLogs: [],
scanLogs: [],
- showLogs: true // Auto-expand for the first/current container
+ showLogs: true,
}];
}
@@ -217,11 +231,12 @@
progress = [...progress];
}
} else if (data.type === 'scan_complete') {
- // Store scan result and individual scanner results
+ // Store scan result, individual scanner results, and vulnerabilities
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanResult = data.scanResult;
containerProgress.scannerResults = data.scannerResults;
+ containerProgress.vulnerabilities = data.vulnerabilities;
progress = [...progress];
}
} else if (data.type === 'blocked') {
@@ -286,6 +301,22 @@
}
}
+const severityOrder: Record = { critical: 0, high: 1, medium: 2, low: 3, negligible: 4, unknown: 5 };
+
+ function sortedVulns(vulns: VulnerabilityEntry[]): VulnerabilityEntry[] {
+ return [...vulns].sort((a, b) => (severityOrder[a.severity.toLowerCase()] ?? 9) - (severityOrder[b.severity.toLowerCase()] ?? 9));
+ }
+
+ function severityColor(severity: string): string {
+ switch (severity.toLowerCase()) {
+ case 'critical': return 'bg-red-600 text-white';
+ case 'high': return 'bg-orange-500 text-white';
+ case 'medium': return 'bg-amber-500 text-white';
+ case 'low': return 'bg-blue-500 text-white';
+ default: return 'bg-gray-500 text-white';
+ }
+ }
+
async function forceUpdateContainer(containerId: string) {
const item = progress.find(p => p.containerId === containerId);
if (!item || item.step !== 'blocked') return;
@@ -389,6 +420,16 @@
startUpdate();
}
});
+
+ // Auto-scroll progress list to bottom on SSE data (not UI toggles)
+ $effect(() => {
+ scrollTick;
+ if (progressListEl) {
+ requestAnimationFrame(() => {
+ progressListEl?.scrollTo({ top: progressListEl.scrollHeight, behavior: 'smooth' });
+ });
+ }
+ });
@@ -436,11 +477,11 @@
{#if progress.length > 0}
-