From 188ba1967d4d05e67921ee1533a569054ffea0fe Mon Sep 17 00:00:00 2001 From: jarek Date: Sun, 8 Feb 2026 09:59:06 +0100 Subject: [PATCH] 1.0.15 --- package.json | 2 +- src/hooks.server.ts | 1 + src/lib/components/AvatarCropper.svelte | 4 +- src/lib/components/CodeEditor.svelte | 36 +- .../components/ColumnSettingsPopover.svelte | 2 +- src/lib/components/ImagePullModal.svelte | 10 +- src/lib/components/PullTab.svelte | 2 +- src/lib/components/ScanTab.svelte | 2 +- src/lib/components/StackEnvVarsEditor.svelte | 2 +- src/lib/components/StackEnvVarsPanel.svelte | 63 +- src/lib/components/ThemeSelector.svelte | 10 +- src/lib/components/TimezoneSelector.svelte | 34 +- .../ui/empty-state/no-environment.svelte | 2 +- src/lib/data/changelog.json | 34 + src/lib/server/auth.ts | 10 +- src/lib/server/db.ts | 34 +- src/lib/server/docker.ts | 301 ++++-- src/lib/server/git.ts | 14 +- src/lib/server/scanner.ts | 216 +++-- .../scheduler/tasks/container-update.ts | 866 ++++++++++-------- .../scheduler/tasks/env-update-check.ts | 93 +- src/lib/server/stack-scanner.ts | 4 +- src/lib/server/stacks.ts | 208 ++++- src/lib/types.ts | 1 + src/lib/utils/url.ts | 15 + src/routes/api/audit/events/+server.ts | 27 +- .../api/containers/[id]/stats/+server.ts | 5 +- .../containers/batch-update-stream/+server.ts | 86 +- src/routes/api/containers/stats/+server.ts | 2 +- .../api/environments/[id]/timezone/+server.ts | 17 +- src/routes/api/events/+server.ts | 23 +- .../api/notifications/[id]/test/+server.ts | 9 +- .../api/stacks/[name]/env/validate/+server.ts | 7 +- src/routes/audit/+page.svelte | 2 +- src/routes/containers/+page.svelte | 17 +- src/routes/containers/BatchUpdateModal.svelte | 99 +- .../containers/ContainerInspectModal.svelte | 111 ++- .../containers/ContainerSettingsTab.svelte | 22 +- .../containers/ContainerTerminal.svelte | 2 +- .../containers/CreateContainerModal.svelte | 4 +- .../containers/EditContainerModal.svelte | 18 +- src/routes/environments/+page.svelte | 8 +- src/routes/images/+page.svelte | 66 +- src/routes/images/ImageScanModal.svelte | 2 +- src/routes/images/PushToRegistryModal.svelte | 2 +- src/routes/images/ScanResultsView.svelte | 68 +- .../images/VulnerabilityScanModal.svelte | 756 --------------- src/routes/networks/+page.svelte | 4 +- .../networks/ConnectContainerModal.svelte | 2 +- src/routes/networks/CreateNetworkModal.svelte | 16 +- .../networks/NetworkInspectModal.svelte | 7 +- src/routes/profile/+page.svelte | 17 +- src/routes/profile/ChangePasswordModal.svelte | 4 +- src/routes/profile/DisableMfaModal.svelte | 4 +- src/routes/profile/MfaSetupModal.svelte | 12 +- src/routes/registry/+page.svelte | 4 +- .../registry/CopyToRegistryModal.svelte | 4 +- src/routes/schedules/+page.svelte | 4 +- src/routes/settings/auth/AuthTab.svelte | 2 +- .../settings/auth/ldap/LdapModal.svelte | 6 +- .../settings/auth/ldap/LdapSubTab.svelte | 4 +- .../settings/auth/oidc/OidcModal.svelte | 8 +- .../settings/auth/oidc/SsoSubTab.svelte | 2 +- .../settings/auth/roles/RoleModal.svelte | 4 +- .../settings/auth/roles/RolesSubTab.svelte | 4 +- .../settings/auth/users/UserModal.svelte | 6 +- .../settings/auth/users/UsersSubTab.svelte | 4 +- .../config-sets/ConfigSetModal.svelte | 12 +- .../settings/config-sets/ConfigSetsTab.svelte | 4 +- .../environments/EnvironmentModal.svelte | 57 +- .../settings/general/ScanResultsModal.svelte | 2 +- .../settings/git/GitCredentialsTab.svelte | 2 +- .../settings/git/GitRepositoriesTab.svelte | 2 +- src/routes/settings/license/LicenseTab.svelte | 9 +- .../notifications/NotificationModal.svelte | 6 +- .../notifications/NotificationsTab.svelte | 4 +- .../settings/registries/RegistriesTab.svelte | 4 +- .../settings/registries/RegistryModal.svelte | 4 +- src/routes/stacks/+page.svelte | 15 +- src/routes/stacks/FilesystemBrowser.svelte | 6 +- .../stacks/GitDeployProgressPopover.svelte | 190 ++-- src/routes/stacks/GitStackModal.svelte | 42 +- src/routes/stacks/ImportStackModal.svelte | 4 +- src/routes/stacks/StackModal.svelte | 63 +- src/routes/volumes/+page.svelte | 2 +- src/routes/volumes/CloneVolumeModal.svelte | 4 +- src/routes/volumes/CreateVolumeModal.svelte | 8 +- 87 files changed, 2103 insertions(+), 1774 deletions(-) create mode 100644 src/lib/utils/url.ts delete mode 100644 src/routes/images/VulnerabilityScanModal.svelte diff --git a/package.json b/package.json index c9cc043..06dd725 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.14", + "version": "1.0.15", "type": "module", "scripts": { "dev": "bunx --bun vite dev", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 0c284b0..d5dfbaa 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,3 +1,4 @@ +// v1.0.12 import { initDatabase, hasAdminUser } from '$lib/server/db'; import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager'; import { startScheduler } from '$lib/server/scheduler'; diff --git a/src/lib/components/AvatarCropper.svelte b/src/lib/components/AvatarCropper.svelte index 0104650..96576de 100644 --- a/src/lib/components/AvatarCropper.svelte +++ b/src/lib/components/AvatarCropper.svelte @@ -257,7 +257,7 @@ onclick={handleCancel} disabled={saving} > - + Cancel diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index c617312..e36595f 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -314,14 +314,15 @@ for (const marker of markers) { // Find all occurrences of this variable in the text // Match ${VAR_NAME} or ${VAR_NAME:-...} or $VAR_NAME patterns + // Use negative lookbehind (? line.includes(p)); + const hasVariable = varPatterns.some(p => p.test(line)); if (hasVariable) { gutterMarkers.push({ from: pos, diff --git a/src/lib/components/ColumnSettingsPopover.svelte b/src/lib/components/ColumnSettingsPopover.svelte index c08e56a..4376ed4 100644 --- a/src/lib/components/ColumnSettingsPopover.svelte +++ b/src/lib/components/ColumnSettingsPopover.svelte @@ -92,7 +92,7 @@ onclick={resetToDefaults} title="Reset to defaults" > - + Reset diff --git a/src/lib/components/ImagePullModal.svelte b/src/lib/components/ImagePullModal.svelte index 664acaa..f921636 100644 --- a/src/lib/components/ImagePullModal.svelte +++ b/src/lib/components/ImagePullModal.svelte @@ -428,7 +428,7 @@ Removing... {:else} - + Remove image {/if} @@ -437,7 +437,7 @@ onclick={handleClose} disabled={isDeleting} > - + Keep image {:else if showDeleteButton && pullStatus === 'complete' && !envHasScanning} @@ -451,7 +451,7 @@ Removing... {:else} - + Remove image {/if} @@ -460,7 +460,7 @@ onclick={handleClose} disabled={isDeleting} > - + Keep image {:else} @@ -476,7 +476,7 @@ onclick={startPullFromConfigure} disabled={!configImageName.trim()} > - + Pull {:else if pullStatus === 'complete' || scanStatus === 'complete'} diff --git a/src/lib/components/PullTab.svelte b/src/lib/components/PullTab.svelte index 83091be..f9a0227 100644 --- a/src/lib/components/PullTab.svelte +++ b/src/lib/components/PullTab.svelte @@ -339,7 +339,7 @@ Pulling... {:else} - + Pull {/if} diff --git a/src/lib/components/ScanTab.svelte b/src/lib/components/ScanTab.svelte index 9452d0f..764d655 100644 --- a/src/lib/components/ScanTab.svelte +++ b/src/lib/components/ScanTab.svelte @@ -298,7 +298,7 @@

Scan {imageName} for vulnerabilities

diff --git a/src/lib/components/StackEnvVarsEditor.svelte b/src/lib/components/StackEnvVarsEditor.svelte index 335e115..c7fc232 100644 --- a/src/lib/components/StackEnvVarsEditor.svelte +++ b/src/lib/components/StackEnvVarsEditor.svelte @@ -248,7 +248,7 @@

No environment variables defined.

{#if !readonly} {/if} diff --git a/src/lib/components/StackEnvVarsPanel.svelte b/src/lib/components/StackEnvVarsPanel.svelte index 2f0952e..795f2e5 100644 --- a/src/lib/components/StackEnvVarsPanel.svelte +++ b/src/lib/components/StackEnvVarsPanel.svelte @@ -4,7 +4,7 @@ import StackEnvVarsEditor, { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte'; import CodeEditor from '$lib/components/CodeEditor.svelte'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; - import { Plus, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert, HelpCircle } from 'lucide-svelte'; + import { Plus, Upload, Trash2, List, FileText, AlertTriangle, ShieldAlert, HelpCircle, Info } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; interface Props { @@ -18,6 +18,7 @@ placeholder?: { key: string; value: string }; infoText?: string; existingSecretKeys?: Set; + showInterpolationHint?: boolean; theme?: 'light' | 'dark'; class?: string; onchange?: () => void; @@ -35,6 +36,7 @@ placeholder = { key: 'VARIABLE_NAME', value: 'value' }, infoText, existingSecretKeys = new Set(), + showInterpolationHint = false, theme = 'dark', class: className = '', onchange, @@ -54,6 +56,17 @@ // Count of secrets (for display in hint) const secretCount = $derived(variables.filter(v => v.isSecret && v.key.trim()).length); + // Generate text representation from variables (non-secrets only) + // This is used for text view display + const generatedRawContent = $derived.by(() => { + const nonSecrets = variables.filter(v => v.key.trim() && !v.isSecret); + if (nonSecrets.length === 0) return ''; + return nonSecrets.map(v => `${v.key.trim()}=${v.value}`).join('\n') + '\n'; + }); + + // Text editor content - either from file (rawContent prop) or generated from variables + const textEditorContent = $derived(rawContent.trim() ? rawContent : generatedRawContent); + /** * Sync variables with rawContent after initial load. * Pass the loaded data directly to avoid timing issues with bindable props. @@ -61,7 +74,7 @@ */ export function syncAfterLoad(loadedVars: EnvVar[], loadedRaw: string) { if (!loadedRaw.trim()) { - // No raw content - just use the loaded variables as-is + // No raw content from file - just set variables, text view will use generatedRawContent variables = loadedVars; rawContent = ''; return; @@ -298,7 +311,7 @@ -
+

{@html infoText}

@@ -351,12 +364,12 @@ {@render headerActions()} {/if} {#if viewMode === 'form'} {/if} @@ -377,7 +390,7 @@ class="h-6 text-xs px-2 {hasContent ? 'text-destructive hover:text-destructive' : 'text-muted-foreground/50 cursor-not-allowed'}" disabled={!hasContent} > - + Clear {/snippet} @@ -394,11 +407,47 @@
{#if viewMode === 'form'} + {#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. +

+
+ {/if}
${`{VAR}`} required ${`{VAR:-default}`} optional ${`{VAR:?error}`} required w/ error
+ {: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} -
+
{#each progress as item (item.containerId)} {@const StepIcon = getStepIcon(item.step)} {@const isActive = item.step !== 'done' && item.step !== 'failed' && item.step !== 'blocked'} - {@const hasLogs = item.pullLogs.length > 0 || item.scanLogs.length > 0} + {@const hasLogs = item.pullLogs.length > 0 || item.scanLogs.length > 0 || (item.vulnerabilities && item.vulnerabilities.length > 0)}
@@ -560,6 +601,54 @@
{/each} {/if} + {#if item.vulnerabilities && item.vulnerabilities.length > 0} +
+
+ {item.vulnerabilities.length}{item.vulnerabilities.length >= 100 ? '+' : ''} vulnerabilities found +
+
+ + + + + + + + + + + + {#each sortedVulns(item.vulnerabilities).slice(0, 50) as vuln} + + + + + + + + {/each} + +
CVESeverityPackageVersionFixed
+ {#if vuln.link} + + {vuln.id} + + + {:else} + {vuln.id} + {/if} + + + {vuln.severity} + + {vuln.package}{vuln.version}{vuln.fixedVersion || '\u2014'}
+ {#if item.vulnerabilities.length > 50} +
+ ...and {item.vulnerabilities.length - 50} more +
+ {/if} +
+ {/if}
{/if}
diff --git a/src/routes/containers/ContainerInspectModal.svelte b/src/routes/containers/ContainerInspectModal.svelte index 5fb9b0a..6d20349 100644 --- a/src/routes/containers/ContainerInspectModal.svelte +++ b/src/routes/containers/ContainerInspectModal.svelte @@ -12,6 +12,7 @@ import LogsPanel from '../logs/LogsPanel.svelte'; import FileBrowserPanel from './FileBrowserPanel.svelte'; import { formatDateTime } from '$lib/stores/settings'; + import { formatHostPortUrl } from '$lib/utils/url'; interface Props { open: boolean; @@ -111,16 +112,16 @@ if (!env) return null; // 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 const connectionType = env.connectionType || 'socket'; if (connectionType === 'direct' && env.host) { const host = extractHostFromUrl(env.host); - if (host) return `http://${host}:${publicPort}`; + if (host) return formatHostPortUrl(host, publicPort); } else if (connectionType === 'hawser-standard' && env.host) { 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 return null; @@ -1266,41 +1267,81 @@ - {#if containerData.State?.Health} -
-
-
-

Status

- - {containerData.State.Health.Status} - -
-
-

Failing Streak

- {containerData.State.Health.FailingStreak || 0} -
-
- - {#if containerData.State.Health.Log && containerData.State.Health.Log.length > 0} -
-

Health check log

-
- {#each containerData.State.Health.Log.slice(-5) as log} -
-
- - Exit: {log.ExitCode} - - {formatDate(log.End)} -
- {#if log.Output} - {log.Output.trim()} - {/if} -
- {/each} + {@const healthConfig = containerData.Config?.Healthcheck} + {@const healthState = containerData.State?.Health} + {@const formatNs = (ns: number) => ns ? `${ns / 1e9}s` : '-'} + {#if healthConfig || healthState} +
+ + {#if healthConfig && healthConfig.Test && healthConfig.Test.length > 0} +
+

Configuration

+
+
+

Command

+ {healthConfig.Test.join(' ')} +
+
+

Interval

+ {formatNs(healthConfig.Interval)} +
+
+

Timeout

+ {formatNs(healthConfig.Timeout)} +
+
+

Retries

+ {healthConfig.Retries || '-'} +
+
+

Start period

+ {formatNs(healthConfig.StartPeriod)} +
{/if} + + + {#if healthState} +
+

Status

+
+
+

Current status

+ + {healthState.Status} + +
+
+

Failing streak

+ {healthState.FailingStreak || 0} +
+
+
+ + {#if healthState.Log && healthState.Log.length > 0} +
+

Health check log

+
+ {#each healthState.Log.slice(-5) as log} +
+
+ + Exit: {log.ExitCode} + + {formatDate(log.End)} +
+ {#if log.Output} + {log.Output.trim()} + {/if} +
+ {/each} +
+
+ {/if} + {:else if healthConfig} +

Waiting for first health check to complete...

+ {/if}
{:else}

No health check configured

diff --git a/src/routes/containers/ContainerSettingsTab.svelte b/src/routes/containers/ContainerSettingsTab.svelte index f2e3965..7795eba 100644 --- a/src/routes/containers/ContainerSettingsTab.svelte +++ b/src/routes/containers/ContainerSettingsTab.svelte @@ -645,12 +645,10 @@
- {#if mode !== 'create'} -
- - -
- {/if} +
+ + +
@@ -719,7 +717,7 @@

Port mappings

@@ -760,7 +758,7 @@

Volume mappings

@@ -801,7 +799,7 @@

Environment variables

@@ -837,7 +835,7 @@

Labels

@@ -1239,7 +1237,7 @@
@@ -1426,7 +1424,7 @@
diff --git a/src/routes/containers/ContainerTerminal.svelte b/src/routes/containers/ContainerTerminal.svelte index d2a451f..bdb43a5 100644 --- a/src/routes/containers/ContainerTerminal.svelte +++ b/src/routes/containers/ContainerTerminal.svelte @@ -372,7 +372,7 @@
diff --git a/src/routes/containers/EditContainerModal.svelte b/src/routes/containers/EditContainerModal.svelte index 2025ca4..d9e9511 100644 --- a/src/routes/containers/EditContainerModal.svelte +++ b/src/routes/containers/EditContainerModal.svelte @@ -228,6 +228,7 @@ let loading = $state(false); let loadingData = $state(true); let error = $state(''); + let abortController: AbortController | null = null; let statusMessage = $state(''); let visible = $state(false); @@ -764,6 +765,8 @@ } loading = true; + abortController = new AbortController(); + const signal = abortController.signal; const containerConfigChanged = hasContainerConfigChanged(); const autoUpdateChanged = hasAutoUpdateChanged(); @@ -785,7 +788,8 @@ ), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name.trim() }) + body: JSON.stringify({ name: name.trim() }), + signal }); const result = await response.json(); @@ -920,7 +924,8 @@ const response = await fetch(appendEnvParam(`/api/containers/${containerId}/update`, $currentEnvironment?.id), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) + body: JSON.stringify(payload), + signal }); const result = await response.json(); @@ -951,13 +956,20 @@ onSuccess(); onClose(); } catch (err) { + if (signal.aborted) return; error = 'Failed to update container: ' + String(err); } finally { loading = false; + abortController = null; } } function handleClose() { + if (abortController) { + abortController.abort(); + abortController = null; + } + loading = false; onClose(); } @@ -1117,7 +1129,7 @@
- @@ -327,7 +327,7 @@ {#if testResult === 'testing'} {:else} - + {/if} Test @@ -464,7 +464,7 @@ {#if formSaving} {:else} - + {/if} Add @@ -583,7 +583,7 @@ {#if formSaving} {:else} - + {/if} Save diff --git a/src/routes/images/+page.svelte b/src/routes/images/+page.svelte index feffc4d..b4e337b 100644 --- a/src/routes/images/+page.svelte +++ b/src/routes/images/+page.svelte @@ -11,7 +11,7 @@ import { Label } from '$lib/components/ui/label'; import * as Dialog from '$lib/components/ui/dialog'; import * as Select from '$lib/components/ui/select'; - import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown, CircleDashed } from 'lucide-svelte'; + import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown, CircleDashed, CircleDot, Circle, Filter } from 'lucide-svelte'; import { broom, whale } from '@lucide/lab'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import BatchOperationModal from '$lib/components/BatchOperationModal.svelte'; @@ -106,6 +106,10 @@ let sortField = $state('created'); let sortDirection = $state('desc'); + // Filter state + type UsageFilter = 'all' | 'in-use' | 'unused'; + let usageFilter = $state('all'); + // Expanded rows state let expandedRepos = $state>(new Set()); @@ -187,11 +191,21 @@ for (const image of images) { if (image.tags.length === 0) { - // Handle untagged images - const key = ''; + // Handle untagged images - try to extract repo name from RepoDigests + let repoName = ''; + if (image.repoDigests && image.repoDigests.length > 0) { + // RepoDigests format: "nginx@sha256:abc123" or "registry.example.com/myapp@sha256:abc123" + const digest = image.repoDigests[0]; + const atIndex = digest.indexOf('@'); + if (atIndex > 0) { + repoName = digest.slice(0, atIndex); + } + } + + const key = repoName; if (!groups.has(key)) { groups.set(key, { - repoName: '', + repoName, tags: [], totalSize: 0, latestCreated: 0, @@ -201,7 +215,7 @@ } const group = groups.get(key)!; group.tags.push({ - tag: image.id.slice(7, 19), + tag: repoName === '' ? image.id.slice(7, 19) : '', fullRef: image.id, imageId: image.id, size: image.size, @@ -268,8 +282,18 @@ const query = searchQuery.toLowerCase().trim(); let filtered = groupedImages; + + // Apply usage filter + if (usageFilter !== 'all') { + filtered = filtered.filter(group => { + const isInUse = group.containers > 0; + return usageFilter === 'in-use' ? isInUse : !isInUse; + }); + } + + // Apply search filter if (query) { - filtered = groupedImages.filter(group => { + filtered = filtered.filter(group => { if (group.repoName.toLowerCase().includes(query)) return true; if (group.tags.some(t => t.tag.toLowerCase().includes(query))) return true; if (group.tags.some(t => t.imageId.toLowerCase().includes(query))) return true; @@ -656,7 +680,7 @@ icon={Images} title="Images" count={sortedGroups.length} - total={searchQuery && sortedGroups.length !== groupedImages.length ? groupedImages.length : undefined} + total={(searchQuery || usageFilter !== 'all') && sortedGroups.length !== groupedImages.length ? groupedImages.length : undefined} />
@@ -669,6 +693,34 @@ class="pl-8 h-8 w-48 text-sm" />
+ + + {#if usageFilter === 'all'} + + All + {:else if usageFilter === 'in-use'} + + In use + {:else} + + Unused + {/if} + + + + + All + + + + In use + + + + Unused + + + {#if $canAccess('images', 'remove')} {#snippet child({ props })} {/snippet} diff --git a/src/routes/images/PushToRegistryModal.svelte b/src/routes/images/PushToRegistryModal.svelte index 7b367d6..7841e0b 100644 --- a/src/routes/images/PushToRegistryModal.svelte +++ b/src/routes/images/PushToRegistryModal.svelte @@ -284,7 +284,7 @@ onclick={startPush} disabled={!targetRegistryId || pushableRegistries.length === 0} > - + Push {/if} diff --git a/src/routes/images/ScanResultsView.svelte b/src/routes/images/ScanResultsView.svelte index 9c87eca..e7c99b0 100644 --- a/src/routes/images/ScanResultsView.svelte +++ b/src/routes/images/ScanResultsView.svelte @@ -1,6 +1,6 @@ - - - - - - {#if stage === 'complete' && activeResult} - {#if activeResult.summary.critical > 0 || activeResult.summary.high > 0} - - {:else if activeResult.summary.medium > 0} - - {:else} - - {/if} - {:else if stage === 'error'} - - {:else} - - {/if} - Vulnerability scan - - -
Scanning {imageName}
- {#if activeResult?.imageId} -
SHA: {activeResult.imageId.replace('sha256:', '')}
- {/if} -
-
- -
- {#if stage !== 'complete' && stage !== 'error'} - -
-
- - {message} -
-
-
-
- {#if scanner} -

- Using {scanner === 'grype' ? 'Grype (Anchore)' : 'Trivy (Aqua Security)'} scanner -

- {/if} - - -
-
-
- - Scanner output -
- -
-
- {#each activeOutputLines as line} -
- {#if line.startsWith('[grype]')} - grype - {line.slice(8)} - {:else if line.startsWith('[trivy]')} - trivy - {line.slice(8)} - {:else if line.startsWith('[dockhand]')} - dockhand - {line.slice(11)} - {:else} - {line} - {/if} -
- {/each} -
-
-
- {:else if stage === 'error'} - -
- - {#if Object.keys(scannerErrors).length > 0} -
- {#each Object.entries(scannerErrors) as [scannerName, scannerError]} -
-
- -
-

{scannerName === 'grype' ? 'Grype' : 'Trivy'} failed

-

{scannerError}

-
-
-
- {/each} -
- {:else} -
-
- -
-

Scan failed

-

{error}

-
-
-
- {/if} - - - - -
-
-
- - Scanner output -
- -
-
- {#each activeOutputLines as line} -
- {#if line.startsWith('[grype]')} - grype - {line.slice(8)} - {:else if line.startsWith('[trivy]')} - trivy - {line.slice(8)} - {:else if line.startsWith('[dockhand]')} - dockhand - {line.slice(11)} - {:else} - {line} - {/if} -
- {/each} -
-
-
- {:else if stage === 'complete' && activeResult} - -
- - {#if results.length > 1} -
- {#each results as r} - - {/each} -
- {/if} - - - {#if Object.keys(scannerErrors).length > 0} -
- {#each Object.entries(scannerErrors) as [scannerName, scannerError]} -
-
- -
- {scannerName === 'grype' ? 'Grype' : 'Trivy'} failed: - {scannerError} -
-
-
- {/each} -
- {/if} - - -
- {#if activeResult.summary.critical > 0} - - {activeResult.summary.critical} Critical - - {/if} - {#if activeResult.summary.high > 0} - - {activeResult.summary.high} High - - {/if} - {#if activeResult.summary.medium > 0} - - {activeResult.summary.medium} Medium - - {/if} - {#if activeResult.summary.low > 0} - - {activeResult.summary.low} Low - - {/if} - {#if activeResult.summary.negligible > 0} - - {activeResult.summary.negligible} Negligible - - {/if} - {#if activeResult.summary.unknown > 0} - - {activeResult.summary.unknown} Unknown - - {/if} - {#if activeResult.vulnerabilities.length === 0} - - - No vulnerabilities found - - {/if} -
- - -
- Scanner: {activeResult.scanner === 'grype' ? 'Grype' : 'Trivy'} - Duration: {formatDuration(activeResult.scanDuration)} - Total: {activeResult.vulnerabilities.length} vulnerabilities -
- - - {#if activeResult.vulnerabilities.length > 0} -
- - - - - - - - - - - - {#each activeResult.vulnerabilities.slice(0, 100) as vuln, i} - toggleVulnDetails(vuln.id + i)} - > - - - - - - - {#if expandedVulns.has(vuln.id + i) && vuln.description} - - - - {/if} - {/each} - -
CVE IDSeverityPackageInstalledFixed in
- - - - {vuln.severity} - - - {vuln.package} - - {vuln.version} - - {#if vuln.fixedVersion} - {vuln.fixedVersion} - {:else} - No fix available - {/if} -
-

{vuln.description}

-
- {#if activeResult.vulnerabilities.length > 100} -
- Showing 100 of {activeResult.vulnerabilities.length} vulnerabilities -
- {/if} -
- {/if} - - -
-
-
- - Scanner output ({activeOutputLines.length} lines) -
- -
-
- {#each activeOutputLines as line} -
- {#if line.startsWith('[grype]')} - grype - {line.slice(8)} - {:else if line.startsWith('[trivy]')} - trivy - {line.slice(8)} - {:else if line.startsWith('[dockhand]')} - dockhand - {line.slice(11)} - {:else} - {line} - {/if} -
- {/each} -
-
-
- {/if} -
- - - {#if stage === 'complete'} -
- - {#if activeResult && activeResult.vulnerabilities.length > 0} - - - {#snippet child({ props })} - - {/snippet} - - - - - Markdown report (.md) - - - - CSV spreadsheet (.csv) - - - - JSON data (.json) - - - - {/if} -
- {:else} -
- {/if} - -
-
-
diff --git a/src/routes/networks/+page.svelte b/src/routes/networks/+page.svelte index 3853359..36030b9 100644 --- a/src/routes/networks/+page.svelte +++ b/src/routes/networks/+page.svelte @@ -550,12 +550,12 @@
{/if} {#if $canAccess('networks', 'create')} {/if} diff --git a/src/routes/networks/ConnectContainerModal.svelte b/src/routes/networks/ConnectContainerModal.svelte index 9827ca6..6f787aa 100644 --- a/src/routes/networks/ConnectContainerModal.svelte +++ b/src/routes/networks/ConnectContainerModal.svelte @@ -163,7 +163,7 @@ {#if submitting} {:else} - + {/if} Connect diff --git a/src/routes/networks/CreateNetworkModal.svelte b/src/routes/networks/CreateNetworkModal.svelte index fa28bb8..6458de7 100644 --- a/src/routes/networks/CreateNetworkModal.svelte +++ b/src/routes/networks/CreateNetworkModal.svelte @@ -192,11 +192,11 @@ } // Build IPAM config - if (subnet || gateway || ipRange || auxAddresses.length > 0 || ipamDriver !== 'default' || ipamOptions.length > 0) { + if (subnet.trim() || gateway.trim() || ipRange.trim() || auxAddresses.length > 0 || ipamDriver !== 'default' || ipamOptions.length > 0) { const ipamConfig: Record = {}; - if (subnet) ipamConfig.subnet = subnet; - if (gateway) ipamConfig.gateway = gateway; - if (ipRange) ipamConfig.ipRange = ipRange; + if (subnet.trim()) ipamConfig.subnet = subnet.trim(); + if (gateway.trim()) ipamConfig.gateway = gateway.trim(); + if (ipRange.trim()) ipamConfig.ipRange = ipRange.trim(); if (auxAddresses.length > 0) { const auxObj: Record = {}; for (const a of auxAddresses) { @@ -490,7 +490,7 @@

Reserve IP addresses for network devices (e.g., host=192.168.1.1)

@@ -516,7 +516,7 @@
{#each ipamOptions as opt, i} @@ -543,7 +543,7 @@

Set driver-specific options (-o key=value)

@@ -581,7 +581,7 @@

Set metadata labels on the network

diff --git a/src/routes/networks/NetworkInspectModal.svelte b/src/routes/networks/NetworkInspectModal.svelte index 983599d..d2dfe3c 100644 --- a/src/routes/networks/NetworkInspectModal.svelte +++ b/src/routes/networks/NetworkInspectModal.svelte @@ -4,6 +4,7 @@ import { Badge } from '$lib/components/ui/badge'; import { Loader2, Network } from 'lucide-svelte'; import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; + import { formatDateTime } from '$lib/stores/settings'; import ContainerTile from '../containers/ContainerTile.svelte'; import ContainerInspectModal from '../containers/ContainerInspectModal.svelte'; @@ -54,9 +55,9 @@ } } - function formatDate(dateString: string): string { + function formatNetworkDate(dateString: string): string { if (!dateString) return 'N/A'; - return new Date(dateString).toLocaleString(); + return formatDateTime(dateString, true); } @@ -101,7 +102,7 @@

Created

-

{formatDate(networkData.Created)}

+

{formatNetworkDate(networkData.Created)}

Internal

diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte index 50b22aa..cbb45ef 100644 --- a/src/routes/profile/+page.svelte +++ b/src/routes/profile/+page.svelte @@ -25,6 +25,7 @@ Palette } from 'lucide-svelte'; import { authStore } from '$lib/stores/auth'; + import { formatDateTime } from '$lib/stores/settings'; import * as Alert from '$lib/components/ui/alert'; import AvatarCropper from '$lib/components/AvatarCropper.svelte'; import * as Avatar from '$lib/components/ui/avatar'; @@ -116,7 +117,7 @@ if (response.ok) { profile = await response.json(); formEmail = profile?.email || ''; - formDisplayName = profile?.display_name || ''; + formDisplayName = profile?.displayName || ''; } else if (response.status === 401) { goto('/login'); } else { @@ -141,7 +142,7 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: formEmail.trim() || null, - display_name: formDisplayName.trim() || null + displayName: formDisplayName.trim() || null }) }); @@ -208,9 +209,9 @@ showSuccessMessage('MFA disabled successfully'); } - function formatDate(dateStr: string | null): string { + function formatProfileDate(dateStr: string | null): string { if (!dateStr) return 'Never'; - return new Date(dateStr).toLocaleString(); + return formatDateTime(dateStr, true); } async function saveAvatar(dataUrl: string) { @@ -413,14 +414,14 @@

- {formatDate(profile.createdAt)} + {formatProfileDate(profile.createdAt)}

- {formatDate(profile.lastLogin)} + {formatProfileDate(profile.lastLogin)}

@@ -468,7 +469,7 @@ {#if formSaving} {:else} - + {/if} Save changes @@ -550,7 +551,7 @@ {#if mfaLoading} {:else} - + {/if} Setup MFA diff --git a/src/routes/profile/ChangePasswordModal.svelte b/src/routes/profile/ChangePasswordModal.svelte index 2475231..29eede8 100644 --- a/src/routes/profile/ChangePasswordModal.svelte +++ b/src/routes/profile/ChangePasswordModal.svelte @@ -122,9 +122,9 @@ diff --git a/src/routes/profile/DisableMfaModal.svelte b/src/routes/profile/DisableMfaModal.svelte index 4e61092..d592c4a 100644 --- a/src/routes/profile/DisableMfaModal.svelte +++ b/src/routes/profile/DisableMfaModal.svelte @@ -52,9 +52,9 @@ diff --git a/src/routes/profile/MfaSetupModal.svelte b/src/routes/profile/MfaSetupModal.svelte index 2241188..0770745 100644 --- a/src/routes/profile/MfaSetupModal.svelte +++ b/src/routes/profile/MfaSetupModal.svelte @@ -134,22 +134,22 @@
@@ -194,9 +194,9 @@ diff --git a/src/routes/registry/+page.svelte b/src/routes/registry/+page.svelte index 89fd6e8..2f0799b 100644 --- a/src/routes/registry/+page.svelte +++ b/src/routes/registry/+page.svelte @@ -536,7 +536,7 @@ {#if loading} {:else} - + {/if} Search @@ -545,7 +545,7 @@ {#if browsing} {:else} - + {/if} Browse diff --git a/src/routes/registry/CopyToRegistryModal.svelte b/src/routes/registry/CopyToRegistryModal.svelte index b3b9b15..63804f0 100644 --- a/src/routes/registry/CopyToRegistryModal.svelte +++ b/src/routes/registry/CopyToRegistryModal.svelte @@ -475,7 +475,7 @@ onclick={startCopy} disabled={!targetRegistryId || pushableRegistries.length === 0} > - + Start copy {:else if currentStep === 'scan' && scanStatus === 'complete'} @@ -491,7 +491,7 @@
{/if} {/if} diff --git a/src/routes/schedules/+page.svelte b/src/routes/schedules/+page.svelte index 394eebd..994cf6e 100644 --- a/src/routes/schedules/+page.svelte +++ b/src/routes/schedules/+page.svelte @@ -1061,10 +1061,10 @@ onclick={toggleHideSystemJobs} > {#if hideSystemJobs} - + Show system ({systemJobCount}) {:else} - + Hide system {/if} diff --git a/src/routes/settings/auth/AuthTab.svelte b/src/routes/settings/auth/AuthTab.svelte index a981e5a..90cdc7e 100644 --- a/src/routes/settings/auth/AuthTab.svelte +++ b/src/routes/settings/auth/AuthTab.svelte @@ -279,7 +279,7 @@ {#if authSaving} {:else} - + {/if} Save settings diff --git a/src/routes/settings/auth/ldap/LdapModal.svelte b/src/routes/settings/auth/ldap/LdapModal.svelte index d9fab59..171861f 100644 --- a/src/routes/settings/auth/ldap/LdapModal.svelte +++ b/src/routes/settings/auth/ldap/LdapModal.svelte @@ -483,7 +483,7 @@ {/if}
@@ -497,9 +497,9 @@ {#if formSaving} {:else if isEditing} - + {:else} - + {/if} {isEditing ? 'Save' : 'Add configuration'} diff --git a/src/routes/settings/auth/ldap/LdapSubTab.svelte b/src/routes/settings/auth/ldap/LdapSubTab.svelte index ea1aacb..4994841 100644 --- a/src/routes/settings/auth/ldap/LdapSubTab.svelte +++ b/src/routes/settings/auth/ldap/LdapSubTab.svelte @@ -194,7 +194,7 @@ LDAP / Active Directory integration is available with an enterprise license. Connect to your organization's directory services for centralized authentication.

@@ -214,7 +214,7 @@
{#if $canAccess('settings', 'edit')} {/if} diff --git a/src/routes/settings/auth/oidc/OidcModal.svelte b/src/routes/settings/auth/oidc/OidcModal.svelte index e50580c..6132d77 100644 --- a/src/routes/settings/auth/oidc/OidcModal.svelte +++ b/src/routes/settings/auth/oidc/OidcModal.svelte @@ -381,7 +381,7 @@

{#if onNavigateToLicense} {/if} @@ -424,7 +424,7 @@ variant="outline" onclick={addRoleMapping} > - + Add mapping @@ -500,9 +500,9 @@ {#if formSaving} {:else if isEditing} - + {:else} - + {/if} {isEditing ? 'Save' : 'Add provider'} diff --git a/src/routes/settings/auth/oidc/SsoSubTab.svelte b/src/routes/settings/auth/oidc/SsoSubTab.svelte index e6db213..82a73cf 100644 --- a/src/routes/settings/auth/oidc/SsoSubTab.svelte +++ b/src/routes/settings/auth/oidc/SsoSubTab.svelte @@ -169,7 +169,7 @@ {#if $canAccess('settings', 'edit')} {/if} diff --git a/src/routes/settings/auth/roles/RoleModal.svelte b/src/routes/settings/auth/roles/RoleModal.svelte index 9d313e4..9cbcd6c 100644 --- a/src/routes/settings/auth/roles/RoleModal.svelte +++ b/src/routes/settings/auth/roles/RoleModal.svelte @@ -621,9 +621,9 @@ {#if formSaving} {:else if isEditing} - + {:else} - + {/if} {isEditing ? 'Save' : 'Create role'} diff --git a/src/routes/settings/auth/roles/RolesSubTab.svelte b/src/routes/settings/auth/roles/RolesSubTab.svelte index 1eedc6a..97ff5ca 100644 --- a/src/routes/settings/auth/roles/RolesSubTab.svelte +++ b/src/routes/settings/auth/roles/RolesSubTab.svelte @@ -259,7 +259,7 @@ roles with granular permissions and assign them to users.

@@ -281,7 +281,7 @@ {#if $canAccess('settings', 'edit')} {/if} diff --git a/src/routes/settings/auth/users/UserModal.svelte b/src/routes/settings/auth/users/UserModal.svelte index f8d96a4..be4e4e5 100644 --- a/src/routes/settings/auth/users/UserModal.svelte +++ b/src/routes/settings/auth/users/UserModal.svelte @@ -570,14 +570,14 @@ {/if} - + {#if isEditing} @@ -587,7 +587,7 @@ {#if formSaving} {:else} - + {/if} Create user diff --git a/src/routes/settings/auth/users/UsersSubTab.svelte b/src/routes/settings/auth/users/UsersSubTab.svelte index 095c775..cc76f37 100644 --- a/src/routes/settings/auth/users/UsersSubTab.svelte +++ b/src/routes/settings/auth/users/UsersSubTab.svelte @@ -261,7 +261,7 @@ {#if $canAccess('users', 'create')} {/if} @@ -482,7 +482,7 @@ diff --git a/src/routes/settings/config-sets/ConfigSetModal.svelte b/src/routes/settings/config-sets/ConfigSetModal.svelte index a2e0447..2ad5baf 100644 --- a/src/routes/settings/config-sets/ConfigSetModal.svelte +++ b/src/routes/settings/config-sets/ConfigSetModal.svelte @@ -269,7 +269,7 @@
{#each formEnvVars as envVar, i} @@ -288,7 +288,7 @@
{#each formLabels as label, i} @@ -307,7 +307,7 @@
{#each formPorts as port, i} @@ -351,7 +351,7 @@
{#each formVolumes as vol, i} @@ -376,9 +376,9 @@ {#if formSaving} {:else if isEditing} - + {:else} - + {/if} {isEditing ? 'Save' : 'Add'} diff --git a/src/routes/settings/config-sets/ConfigSetsTab.svelte b/src/routes/settings/config-sets/ConfigSetsTab.svelte index 9569e83..07d84ad 100644 --- a/src/routes/settings/config-sets/ConfigSetsTab.svelte +++ b/src/routes/settings/config-sets/ConfigSetsTab.svelte @@ -96,7 +96,7 @@
{#if $canAccess('configsets', 'create')} {/if} @@ -158,7 +158,7 @@ size="sm" onclick={() => openCfgModal(cfg)} > - + Edit {/if} diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index 9354ccc..78114f6 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -72,7 +72,7 @@ import { focusFirstInput } from '$lib/utils'; import { authStore, canAccess } from '$lib/stores/auth'; import { licenseStore } from '$lib/stores/license'; - import { formatDateTime } from '$lib/stores/settings'; + import { formatDateTime, formatDate } from '$lib/stores/settings'; import { getLabelColor, getLabelBgColor, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; import EventTypesEditor from './EventTypesEditor.svelte'; import UpdatesTab from './tabs/UpdatesTab.svelte'; @@ -296,6 +296,13 @@ return match ? match[1] : null; } + /** + * Strip protocol and port from a host/IP string + */ + function stripHostProtocol(value: string): string { + return value.replace(/^(?:\w+:\/\/)/, '').replace(/[:/].*$/, ''); + } + /** * Auto-copy host to publicIp when user enters host value * @param force - If true, always update publicIp (used on blur) @@ -608,9 +615,12 @@ if (!formHost.trim()) { formErrors.host = 'Host is required'; hasErrors = true; - } else if (!isValidHost(formHost.trim())) { - formErrors.host = 'Invalid host. Enter a valid IP address or hostname.'; - hasErrors = true; + } else { + formHost = stripHostProtocol(formHost.trim()); + if (!isValidHost(formHost)) { + formErrors.host = 'Enter an IP address or hostname only (no protocol or port)'; + hasErrors = true; + } } } @@ -640,7 +650,7 @@ labels: formLabels, connectionType: formConnectionType, hawserToken: formHawserToken || undefined, - publicIp: formConnectionType !== 'hawser-edge' ? (formPublicIp.trim() || undefined) : undefined + publicIp: formConnectionType !== 'hawser-edge' ? (stripHostProtocol(formPublicIp.trim()) || undefined) : undefined }) }); @@ -718,9 +728,12 @@ if (!formHost.trim()) { formErrors.host = 'Host is required'; hasErrors = true; - } else if (!isValidHost(formHost.trim())) { - formErrors.host = 'Invalid host. Enter a valid IP address or hostname.'; - hasErrors = true; + } else { + formHost = stripHostProtocol(formHost.trim()); + if (!isValidHost(formHost)) { + formErrors.host = 'Enter an IP address or hostname only (no protocol or port)'; + hasErrors = true; + } } } @@ -750,7 +763,7 @@ labels: formLabels, connectionType: formConnectionType, hawserToken: formHawserToken || undefined, - publicIp: formConnectionType !== 'hawser-edge' ? (formPublicIp.trim() || null) : null + publicIp: formConnectionType !== 'hawser-edge' ? (stripHostProtocol(formPublicIp.trim()) || null) : null }) }); @@ -1797,7 +1810,7 @@

Version: {environment.hawserVersion}

{/if} {#if environment.hawserLastSeen} -

Last seen: {new Date(environment.hawserLastSeen).toLocaleString()}

+

Last seen: {formatDateTime(environment.hawserLastSeen, true)}

{/if}
{/if} @@ -1818,7 +1831,7 @@ {#if generatingToken} {:else} - + {/if} Regenerate @@ -1833,7 +1846,7 @@ {#if generatingToken} {:else} - + {/if} Generate @@ -1895,7 +1908,7 @@ @@ -1956,7 +1969,7 @@ {#if hawserToken.lastUsed} - Last used: {new Date(hawserToken.lastUsed).toLocaleDateString()} + Last used: {formatDate(hawserToken.lastUsed)} {/if} @@ -2475,16 +2488,16 @@ class="mr-auto" > {#if testingConnection} - + Testing... {:else if testResult?.success} - + Test connection {:else if testResult && !testResult.success} - + Test connection {:else} - + Test connection {/if} @@ -2496,9 +2509,9 @@ @@ -2509,9 +2522,9 @@ diff --git a/src/routes/settings/general/ScanResultsModal.svelte b/src/routes/settings/general/ScanResultsModal.svelte index fdbb028..1a88754 100644 --- a/src/routes/settings/general/ScanResultsModal.svelte +++ b/src/routes/settings/general/ScanResultsModal.svelte @@ -588,7 +588,7 @@ Adopting... {:else} - + Adopt selected {/if} diff --git a/src/routes/settings/git/GitCredentialsTab.svelte b/src/routes/settings/git/GitCredentialsTab.svelte index b4b7157..cccb69a 100644 --- a/src/routes/settings/git/GitCredentialsTab.svelte +++ b/src/routes/settings/git/GitCredentialsTab.svelte @@ -92,7 +92,7 @@ {#if $canAccess('settings', 'edit')} {/if} diff --git a/src/routes/settings/git/GitRepositoriesTab.svelte b/src/routes/settings/git/GitRepositoriesTab.svelte index 57a4619..dc8be2d 100644 --- a/src/routes/settings/git/GitRepositoriesTab.svelte +++ b/src/routes/settings/git/GitRepositoriesTab.svelte @@ -139,7 +139,7 @@ {#if $canAccess('settings', 'edit')} {/if} diff --git a/src/routes/settings/license/LicenseTab.svelte b/src/routes/settings/license/LicenseTab.svelte index cc094b4..c50a0e4 100644 --- a/src/routes/settings/license/LicenseTab.svelte +++ b/src/routes/settings/license/LicenseTab.svelte @@ -8,6 +8,7 @@ import { Crown, Building2, Key, RefreshCw, ShieldCheck, XCircle } from 'lucide-svelte'; import { canAccess } from '$lib/stores/auth'; import { licenseStore } from '$lib/stores/license'; + import { formatDate } from '$lib/stores/settings'; // License state interface LicenseInfo { @@ -169,11 +170,11 @@

Issued

-

{new Date(licenseInfo.payload?.issued || '').toLocaleDateString()}

+

{formatDate(licenseInfo.payload?.issued || '')}

Expires

-

{licenseInfo.payload?.expires ? new Date(licenseInfo.payload.expires).toLocaleDateString() : 'Never (Perpetual)'}

+

{licenseInfo.payload?.expires ? formatDate(licenseInfo.payload.expires) : 'Never (Perpetual)'}

@@ -183,7 +184,7 @@ {#if $canAccess('settings', 'edit')}
@@ -245,7 +246,7 @@ {#if licenseFormSaving} {:else} - + {/if} Activate license diff --git a/src/routes/settings/notifications/NotificationModal.svelte b/src/routes/settings/notifications/NotificationModal.svelte index ea09408..3c193df 100644 --- a/src/routes/settings/notifications/NotificationModal.svelte +++ b/src/routes/settings/notifications/NotificationModal.svelte @@ -481,7 +481,7 @@ jsons://hostname/webhook/path" Failed {:else} - + Test {/if} @@ -491,9 +491,9 @@ jsons://hostname/webhook/path" {#if formSaving} {:else if isEditing} - + {:else} - + {/if} {isEditing ? 'Save' : 'Add'} diff --git a/src/routes/settings/notifications/NotificationsTab.svelte b/src/routes/settings/notifications/NotificationsTab.svelte index 52113d2..6c02679 100644 --- a/src/routes/settings/notifications/NotificationsTab.svelte +++ b/src/routes/settings/notifications/NotificationsTab.svelte @@ -151,7 +151,7 @@
{#if $canAccess('notifications', 'create')} {/if} @@ -227,7 +227,7 @@ onclick={() => testNotification(notif.id)} disabled={testingNotif !== null} > - + Test {#if $canAccess('notifications', 'edit')} diff --git a/src/routes/settings/registries/RegistriesTab.svelte b/src/routes/settings/registries/RegistriesTab.svelte index 7a8e7c3..77d1d18 100644 --- a/src/routes/settings/registries/RegistriesTab.svelte +++ b/src/routes/settings/registries/RegistriesTab.svelte @@ -106,7 +106,7 @@
{#if $canAccess('registries', 'create')} {/if} @@ -167,7 +167,7 @@ size="sm" onclick={() => setRegDefault(registry.id)} > - + Set default {/if} diff --git a/src/routes/settings/registries/RegistryModal.svelte b/src/routes/settings/registries/RegistryModal.svelte index be9f791..89511ff 100644 --- a/src/routes/settings/registries/RegistryModal.svelte +++ b/src/routes/settings/registries/RegistryModal.svelte @@ -142,9 +142,9 @@ {#if formSaving} {:else if isEditing} - + {:else} - + {/if} {isEditing ? 'Save' : 'Add'} diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 91ddfca..ff47831 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -30,6 +30,7 @@ import { DataGrid } from '$lib/components/data-grid'; import type { DataGridSortState } from '$lib/components/data-grid/types'; import { ErrorDialog } from '$lib/components/ui/error-dialog'; + import { formatHostPortUrl } from '$lib/utils/url'; type SortField = 'name' | 'containers' | 'status' | 'cpu' | 'memory'; type SortDirection = 'asc' | 'desc'; @@ -83,7 +84,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 @@ -91,10 +92,10 @@ if (connectionType === 'direct' && env.host) { const host = extractHostFromUrl(env.host); - if (host) return `http://${host}:${publicPort}`; + if (host) return formatHostPortUrl(host, publicPort); } else if (connectionType === 'hawser-standard' && env.host) { 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 @@ -1157,20 +1158,20 @@ defaultIcon={Layers} /> {#if $canAccess('stacks', 'create')} {/if} diff --git a/src/routes/stacks/FilesystemBrowser.svelte b/src/routes/stacks/FilesystemBrowser.svelte index 22feab2..6a657d5 100644 --- a/src/routes/stacks/FilesystemBrowser.svelte +++ b/src/routes/stacks/FilesystemBrowser.svelte @@ -270,10 +270,10 @@ disabled={scanning || !currentPath} > {#if scanning} - + Scanning... {:else} - + Scan this folder {/if} @@ -373,7 +373,7 @@ {#if selectMode === 'directory'} {:else if selectMode === 'file_or_directory'} diff --git a/src/routes/stacks/GitDeployProgressPopover.svelte b/src/routes/stacks/GitDeployProgressPopover.svelte index 2740062..180d6ea 100644 --- a/src/routes/stacks/GitDeployProgressPopover.svelte +++ b/src/routes/stacks/GitDeployProgressPopover.svelte @@ -11,10 +11,12 @@ GitBranch, FileCode, Server, - Link + Link, + AlertTriangle } from 'lucide-svelte'; import type { Snippet } from 'svelte'; import { Progress } from '$lib/components/ui/progress'; + import { appSettings } from '$lib/stores/settings'; interface Props { stackId: number; @@ -34,11 +36,14 @@ } let open = $state(false); - let overallStatus = $state<'idle' | 'deploying' | 'complete' | 'error'>('idle'); + let overallStatus = $state<'idle' | 'confirming' | 'deploying' | 'complete' | 'error'>('idle'); let currentStep = $state(null); let steps = $state([]); let errorMessage = $state(''); + // Get the confirmDestructive setting from the store + const confirmDestructive = $derived($appSettings.confirmDestructive); + function getStepIcon(status: string) { switch (status) { case 'connecting': @@ -140,20 +145,34 @@ function handleOpenChange(isOpen: boolean) { // Only allow closing via the Close button (not by clicking outside) - // When deploying, complete, or error - require explicit close + // When confirming, deploying, complete, or error - require explicit close if (!isOpen && overallStatus !== 'idle') { return; } - // Start deploy when opening + // When opening, show confirmation first if enabled if (isOpen && !open) { - startDeploy(); + if (confirmDestructive) { + overallStatus = 'confirming'; + open = true; + } else { + startDeploy(); + } return; } open = isOpen; } + function handleConfirmDeploy() { + startDeploy(); + } + + function handleCancelConfirm() { + open = false; + overallStatus = 'idle'; + } + function handleClose() { open = false; // Reset state when closed @@ -175,91 +194,114 @@ {@render children()} - -
-
- - {stackName} + {#if overallStatus === 'confirming'} + +
+
+ +
+

Sync from git?

+

+ This will pull latest changes for {stackName}. Containers will only restart if the configuration changed. +

+
+
+
+ + +
+ {:else} + +
+
+ + {stackName} +
- -
-
- {#if overallStatus === 'idle'} - - Initializing... - {:else if overallStatus === 'deploying'} - - Deploying... - {:else if overallStatus === 'complete'} - - Complete! - {:else if overallStatus === 'error'} - - Failed + +
+
+ {#if overallStatus === 'idle'} + + Initializing... + {:else if overallStatus === 'deploying'} + + Deploying... + {:else if overallStatus === 'complete'} + + Complete! + {:else if overallStatus === 'error'} + + Failed + {/if} +
+ {#if currentStep?.step && currentStep?.totalSteps} + + {currentStep.step}/{currentStep.totalSteps} + {/if}
- {#if currentStep?.step && currentStep?.totalSteps} - - {currentStep.step}/{currentStep.totalSteps} - + + {#if currentStep?.message && overallStatus === 'deploying'} +

{currentStep.message}

+ {/if} + + {#if currentStep?.totalSteps} + + {/if} + + {#if errorMessage} +
+ + {errorMessage} +
{/if}
- {#if currentStep?.message && overallStatus === 'deploying'} -

{currentStep.message}

- {/if} - - {#if currentStep?.totalSteps} - - {/if} - - {#if errorMessage} -
- - {errorMessage} + + {#if steps.length > 0} +
+
+ {#each steps as step, index (index)} + {@const StepIcon = getStepIcon(step.status)} + {@const isCurrentStep = index === steps.length - 1 && overallStatus === 'deploying'} +
+ + + {step.message || step.status} + +
+ {/each} +
{/if} -
- - {#if steps.length > 0} -
-
- {#each steps as step, index (index)} - {@const StepIcon = getStepIcon(step.status)} - {@const isCurrentStep = index === steps.length - 1 && overallStatus === 'deploying'} -
- - - {step.message || step.status} - -
- {/each} + + {#if overallStatus === 'complete' || overallStatus === 'error'} +
+
-
- {/if} - - - {#if overallStatus === 'complete' || overallStatus === 'error'} -
- -
+ {/if} {/if} diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index b60ec52..1ac3a55 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -47,6 +47,7 @@ id: number; stackName: string; repositoryId: number; + environmentId: number | null; composePath: string; envFilePath: string | null; autoUpdate: boolean; @@ -112,13 +113,17 @@ // Track which gitStack was initialized to avoid repeated resets let lastInitializedStackId = $state(undefined); + let isInitializing = $state(false); $effect(() => { if (open) { const currentStackId = gitStack?.id ?? null; - if (lastInitializedStackId !== currentStackId) { + if (lastInitializedStackId !== currentStackId && !isInitializing) { lastInitializedStackId = currentStackId; - resetForm(); + isInitializing = true; + resetForm().finally(() => { + isInitializing = false; + }); } } else { lastInitializedStackId = undefined; @@ -237,14 +242,18 @@ if (!gitStack) return; try { - const response = await fetch(`/api/stacks/${encodeURIComponent(gitStack.stackName)}/env${environmentId ? `?env=${environmentId}` : ''}`); + // Use gitStack.environmentId when editing, fall back to prop for new stacks + const envIdToUse = gitStack.environmentId ?? environmentId; + const response = await fetch(`/api/stacks/${encodeURIComponent(gitStack.stackName)}/env${envIdToUse ? `?env=${envIdToUse}` : ''}`); if (response.ok) { const data = await response.json(); - envVars = data.variables || []; + const loadedVars = data.variables || []; // Track existing secret keys (secrets loaded from DB cannot have visibility toggled) existingSecretKeys = new Set( - envVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim()) + loadedVars.filter((v: EnvVar) => v.isSecret && v.key.trim()).map((v: EnvVar) => v.key.trim()) ); + // Set envVars - the panel's $effect will auto-sync rawContent for text view + envVars = loadedVars; } } catch (e) { console.error('Failed to load env var overrides:', e); @@ -324,7 +333,7 @@ } } - function resetForm() { + async function resetForm() { // Clear state BEFORE async loads to avoid race conditions formError = ''; errors = {}; @@ -346,12 +355,14 @@ formWebhookEnabled = gitStack.webhookEnabled; formWebhookSecret = gitStack.webhookSecret || ''; formDeployNow = false; - // Load env files and overrides for editing (async - will populate envFiles, envVars, fileEnvVars) - loadEnvFiles(); - loadEnvVarsOverrides(); - if (gitStack.envFilePath) { - loadEnvFileContents(gitStack.envFilePath); - } + + // Load env files and overrides SYNCHRONOUSLY to avoid race conditions + // Wait for all loads to complete before allowing any other effect to run + await Promise.all([ + loadEnvFiles(), + loadEnvVarsOverrides(), + gitStack.envFilePath ? loadEnvFileContents(gitStack.envFilePath) : Promise.resolve() + ]); } else { formRepoMode = repositories.length > 0 ? 'existing' : 'new'; formRepositoryId = null; @@ -892,8 +903,9 @@ {#snippet headerActions()} {#if !gitStack} @@ -910,7 +922,7 @@ Loading... {:else} - + Populate {/if} @@ -939,7 +951,7 @@ Deploying... {:else} - + Save and deploy {/if} diff --git a/src/routes/stacks/ImportStackModal.svelte b/src/routes/stacks/ImportStackModal.svelte index d88a98c..580ecd3 100644 --- a/src/routes/stacks/ImportStackModal.svelte +++ b/src/routes/stacks/ImportStackModal.svelte @@ -411,7 +411,7 @@ Adopting... {:else} - + Adopt {selectedCount} stack(s) {/if} @@ -488,7 +488,7 @@ Adopting... {:else} - + Adopt stack {/if} diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index 3d12586..f5b6dea 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -91,6 +91,7 @@ // UI state let composePathCopied = $state(false); let envPathCopied = $state(false); + let composeContentCopied = $state(false); let needsFileLocation = $state(false); // Container info for untracked stacks @@ -1506,7 +1507,7 @@ services: Browse to locate the compose file for this stack. The editor will load the file contents once selected.

@@ -1516,15 +1517,35 @@ services:
{:else} - +
+ +
+ +
+ +
{/if}
{/if} @@ -1587,19 +1608,19 @@ services: @@ -1607,19 +1628,19 @@ services: @@ -1677,7 +1698,7 @@ services: Leave files
@@ -1754,10 +1775,10 @@ services: diff --git a/src/routes/volumes/+page.svelte b/src/routes/volumes/+page.svelte index 43b765a..19e7a19 100644 --- a/src/routes/volumes/+page.svelte +++ b/src/routes/volumes/+page.svelte @@ -471,7 +471,7 @@ {#if $canAccess('volumes', 'create')} {/if} diff --git a/src/routes/volumes/CloneVolumeModal.svelte b/src/routes/volumes/CloneVolumeModal.svelte index 13d4ca2..8206c4c 100644 --- a/src/routes/volumes/CloneVolumeModal.svelte +++ b/src/routes/volumes/CloneVolumeModal.svelte @@ -108,9 +108,9 @@ diff --git a/src/routes/volumes/CreateVolumeModal.svelte b/src/routes/volumes/CreateVolumeModal.svelte index 9384940..40a1f1b 100644 --- a/src/routes/volumes/CreateVolumeModal.svelte +++ b/src/routes/volumes/CreateVolumeModal.svelte @@ -368,7 +368,7 @@
@@ -462,7 +462,7 @@
@@ -494,7 +494,7 @@ onclick={addDriverOpt} disabled={creating} > - + Add option
@@ -543,7 +543,7 @@ onclick={addLabel} disabled={creating} > - + Add label