From 7643807717114b8a453057d893085fcdcce45483 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sun, 11 Jan 2026 07:16:18 +0100 Subject: [PATCH] 1.0.7 --- Dockerfile | 27 +- drizzle-pg/meta/_journal.json | 7 + drizzle/meta/_journal.json | 7 + package.json | 68 +- src/hooks.server.ts | 7 +- src/lib/components/CodeEditor.svelte | 9 +- src/lib/components/StackEnvVarsEditor.svelte | 2 +- src/lib/components/StackEnvVarsPanel.svelte | 47 +- src/lib/components/TimezoneSelector.svelte | 2 +- src/lib/components/data-grid/DataGrid.svelte | 38 +- src/lib/components/icon-picker.svelte | 2 +- .../ui/date-picker/date-picker.svelte | 2 +- .../ui/dialog/dialog-content.svelte | 2 +- .../ui/dialog/dialog-overlay.svelte | 2 +- .../dropdown-menu-content.svelte | 2 +- .../dropdown-menu-sub-content.svelte | 2 +- .../ui/popover/popover-content.svelte | 2 +- .../ui/select/select-content.svelte | 2 +- .../ui/tooltip/tooltip-content.svelte | 2 +- src/lib/config/grid-columns.ts | 6 +- src/lib/data/changelog.json | 19 + src/lib/data/dependencies.json | 322 +---- src/lib/server/auth.ts | 56 +- src/lib/server/db.ts | 174 ++- src/lib/server/db/drizzle.ts | 8 +- src/lib/server/db/schema/index.ts | 2 + src/lib/server/db/schema/pg-schema.ts | 2 + src/lib/server/docker.ts | 121 +- src/lib/server/hawser.ts | 18 +- src/lib/server/metrics-collector.ts | 271 ----- src/lib/server/stacks.ts | 713 +++++++++-- src/lib/server/subprocess-manager.ts | 39 +- .../server/subprocesses/event-subprocess.ts | 204 +++- .../server/subprocesses/metrics-subprocess.ts | 37 +- src/lib/stores/grid-preferences.ts | 17 +- src/lib/stores/settings.ts | 62 +- src/lib/types.ts | 5 +- src/routes/+page.svelte | 2 +- src/routes/activity/+page.svelte | 21 +- .../containers/[id]/logs/stream/+server.ts | 38 +- .../api/containers/[id]/stats/+server.ts | 36 +- src/routes/api/containers/stats/+server.ts | 38 +- src/routes/api/dashboard/stats/+server.ts | 2 +- .../api/dashboard/stats/stream/+server.ts | 91 +- src/routes/api/environments/+server.ts | 18 +- src/routes/api/environments/[id]/+server.ts | 7 +- src/routes/api/environments/test/+server.ts | 78 +- src/routes/api/events/+server.ts | 11 +- src/routes/api/git/stacks/+server.ts | 4 +- src/routes/api/registry/tags/+server.ts | 45 +- src/routes/api/settings/general/+server.ts | 102 +- src/routes/api/stacks/+server.ts | 44 +- src/routes/api/stacks/[name]/+server.ts | 5 +- .../api/stacks/[name]/compose/+server.ts | 41 +- src/routes/api/stacks/[name]/down/+server.ts | 5 +- src/routes/api/stacks/[name]/env/+server.ts | 39 +- .../api/stacks/[name]/env/raw/+server.ts | 69 +- .../api/stacks/[name]/restart/+server.ts | 5 +- src/routes/api/stacks/[name]/start/+server.ts | 5 +- src/routes/api/stacks/[name]/stop/+server.ts | 5 +- src/routes/api/stacks/sources/+server.ts | 3 +- src/routes/api/system/+server.ts | 1 + src/routes/containers/+page.svelte | 12 +- src/routes/containers/BatchUpdateModal.svelte | 2 +- .../containers/ContainerInspectModal.svelte | 128 +- .../containers/ContainerSettingsTab.svelte | 20 +- .../containers/CreateContainerModal.svelte | 8 +- .../containers/EditContainerModal.svelte | 10 +- src/routes/containers/FileBrowserModal.svelte | 2 +- src/routes/containers/FileBrowserPanel.svelte | 39 +- src/routes/dashboard/EnvironmentTile.svelte | 123 +- src/routes/dashboard/dashboard-header.svelte | 25 +- src/routes/dashboard/index.ts | 1 + src/routes/images/+page.svelte | 107 +- .../images/ImagePullProgressPopover.svelte | 2 +- src/routes/networks/CreateNetworkModal.svelte | 2 +- .../networks/NetworkInspectModal.svelte | 2 +- src/routes/registry/+page.svelte | 139 ++- src/routes/settings/about/AboutTab.svelte | 14 +- .../environments/EnvironmentModal.svelte | 23 +- src/routes/settings/general/GeneralTab.svelte | 130 +- src/routes/stacks/+page.svelte | 128 +- .../stacks/GitDeployProgressPopover.svelte | 2 +- src/routes/stacks/StackModal.svelte | 1083 ++++++++++++++--- src/routes/volumes/VolumeBrowserModal.svelte | 2 +- src/routes/volumes/VolumeInspectModal.svelte | 2 +- 86 files changed, 3644 insertions(+), 1385 deletions(-) delete mode 100644 src/lib/server/metrics-collector.ts diff --git a/Dockerfile b/Dockerfile index 902b76f..1e2e0c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,9 @@ # - Reproducible builds from open-source Wolfi packages # - Minimal attack surface with only required packages # -# Bun is copied from the official oven/bun image (app-builder stage) to ensure -# compatibility with all x86_64 CPUs (including those without AVX2 like Celeron). +# Bun is copied from the official oven/bun image (app-builder stage). +# For CPUs without AVX support (Celeron, Atom, pre-Haswell), build with: +# docker build --build-arg BUN_VARIANT=baseline -t dockhand:baseline . # ============================================================================= # ----------------------------------------------------------------------------- @@ -75,10 +76,15 @@ RUN apko build apko.yaml dockhand-base:latest output.tar \ # Alpine's musl libc causes rayon/tokio thread pool panics during svelte-adapter-bun build FROM oven/bun:1.3.5-debian AS app-builder +# Build argument for Bun variant (regular or baseline) +# baseline is for CPUs without AVX support (Celeron, Atom, pre-Haswell) +ARG BUN_VARIANT=regular +ARG TARGETARCH + WORKDIR /app # Install build dependencies -RUN apt-get update && apt-get install -y --no-install-recommends jq git && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends jq git curl unzip ca-certificates && rm -rf /var/lib/apt/lists/* # Copy package files and install ALL dependencies (needed for build) COPY package.json bun.lock* bunfig.toml ./ @@ -95,6 +101,19 @@ RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun run b RUN rm -rf node_modules && bun install --production --frozen-lockfile \ && rm -rf node_modules/@types node_modules/bun-types +# Download baseline Bun binary if BUN_VARIANT=baseline (for CPUs without AVX) +# Only applies to amd64 - ARM64 doesn't have AVX concept +ARG BUN_VERSION=1.3.5 +RUN if [ "$BUN_VARIANT" = "baseline" ] && [ "$TARGETARCH" = "amd64" ]; then \ + echo "Downloading Bun baseline binary for CPUs without AVX support..." && \ + curl -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" -o /tmp/bun.zip && \ + unzip -o /tmp/bun.zip -d /tmp && \ + cp /tmp/bun-linux-x64-baseline/bun /usr/local/bin/bun && \ + chmod +x /usr/local/bin/bun && \ + rm -rf /tmp/bun.zip /tmp/bun-linux-x64-baseline && \ + echo "Bun baseline binary installed successfully"; \ + fi + # ----------------------------------------------------------------------------- # Stage 3: Final Image (Scratch + Custom Wolfi OS) # ----------------------------------------------------------------------------- @@ -105,6 +124,8 @@ COPY --from=os-builder /work/rootfs/ / # Copy Bun from official image - ensures compatibility with all x86_64 CPUs (no AVX2 requirement) # Wolfi's bun package requires AVX2 which breaks on Celeron/Atom CPUs +# For baseline builds (BUN_VARIANT=baseline), this contains the baseline binary (no AVX requirement) +# For regular builds, this contains the standard oven/bun binary COPY --from=app-builder /usr/local/bin/bun /usr/bin/bun WORKDIR /app diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json index b439adc..590bb1f 100644 --- a/drizzle-pg/meta/_journal.json +++ b/drizzle-pg/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1766763867484, "tag": "0002_add_pending_container_updates", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1767687362730, + "tag": "0003_add_stack_paths", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 868151f..f4192f3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1766763860091, "tag": "0002_add_pending_container_updates", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1767689000000, + "tag": "0003_add_stack_paths", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 24cc896..033063d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.4", + "version": "1.0.3", "type": "module", "scripts": { "dev": "bunx --bun vite dev", @@ -39,7 +39,7 @@ }, "dependencies": { "@codemirror/autocomplete": "6.20.0", - "@codemirror/commands": "6.10.0", + "@codemirror/commands": "6.10.1", "@codemirror/lang-css": "6.3.1", "@codemirror/lang-html": "6.4.11", "@codemirror/lang-javascript": "6.2.4", @@ -48,63 +48,71 @@ "@codemirror/lang-python": "6.2.1", "@codemirror/lang-sql": "6.10.0", "@codemirror/lang-xml": "6.1.0", - "@codemirror/language": "6.11.3", + "@codemirror/lang-yaml": "6.1.2", + "@codemirror/language": "6.12.1", "@codemirror/search": "6.5.11", + "@codemirror/state": "6.5.3", + "@codemirror/theme-one-dark": "6.1.3", + "@codemirror/view": "6.39.9", "@lezer/highlight": "1.2.3", "@lucide/lab": "^0.1.2", + "codemirror": "6.0.2", "croner": "9.1.0", "cronstrue": "3.9.0", - "drizzle-orm": "0.45.0", + "drizzle-orm": "0.45.1", + "hash-wasm": "4.12.0", "js-yaml": "^4.1.1", - "ldapts": "^8.0.9", - "nodemailer": "^7.0.11", + "ldapts": "^8.1.3", + "nodemailer": "^7.0.12", "otpauth": "^9.4.1", - "postgres": "3.4.7", + "postgres": "3.4.8", "qrcode": "^1.5.4", - "svelte-dnd-action": "0.9.68", + "svelte-dnd-action": "0.9.69", "svelte-sonner": "1.0.7" }, "devDependencies": { - "@codemirror/lang-yaml": "^6.1.2", - "@codemirror/state": "^6.5.2", - "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.38.8", - "@internationalized/date": "^3.10.0", + "@internationalized/date": "^3.10.1", "@layerstack/tailwind": "^1.0.1", - "@lucide/svelte": "^0.544.0", + "@lucide/svelte": "^0.562.0", "@playwright/test": "1.57.0", - "@sveltejs/kit": "^2.48.5", - "@sveltejs/vite-plugin-svelte": "^6.2.1", - "@tailwindcss/vite": "^4.1.17", - "@types/bun": "^1.2.5", + "@sveltejs/kit": "^2.49.3", + "@sveltejs/vite-plugin-svelte": "^6.2.3", + "@tailwindcss/vite": "^4.1.18", + "@types/bun": "^1.3.5", "@types/js-yaml": "^4.0.9", "@types/nodemailer": "^7.0.4", "@types/qrcode": "^1.5.6", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/xterm": "^5.5.0", - "autoprefixer": "^10.4.22", - "bits-ui": "^2.14.4", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", + "autoprefixer": "^10.4.23", + "bits-ui": "^2.15.4", "clsx": "^2.1.1", - "codemirror": "^6.0.2", "cytoscape": "^3.33.1", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "drizzle-kit": "0.31.8", - "layerchart": "^1.0.12", - "lucide-svelte": "^0.555.0", + "layerchart": "^1.0.13", + "lucide-svelte": "^0.562.0", "mode-watcher": "^1.1.0", "postcss": "^8.5.6", - "svelte": "^5.43.8", + "svelte": "^5.46.1", "svelte-adapter-bun": "1.0.1", - "svelte-check": "^4.3.4", + "svelte-check": "^4.3.5", "svelte-easy-crop": "^5.0.0", "svelte-virtual-scroll-list": "^1.3.0", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.2.2", - "tailwindcss": "^4.1.17", + "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", - "vite": "^7.2.2" + "vite": "^7.3.1" + }, + "overrides": { + "@codemirror/state": "6.5.3", + "@codemirror/view": "6.39.9", + "@codemirror/language": "6.12.1", + "@lezer/common": "1.5.0", + "@lezer/highlight": "1.2.3" } } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 10be1d1..c5bb24f 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,6 +4,7 @@ import { startScheduler } from '$lib/server/scheduler'; import { isAuthEnabled, validateSession } from '$lib/server/auth'; import { setServerStartTime } from '$lib/server/uptime'; import { checkLicenseExpiry, getHostname } from '$lib/server/license'; +import { initCryptoFallback } from '$lib/server/crypto-fallback'; import type { HandleServerError, Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; @@ -20,6 +21,9 @@ let initialized = false; if (!initialized) { try { + // Initialize crypto fallback first (detects old kernels and logs status) + initCryptoFallback(); + setServerStartTime(); // Track when server started initDatabase(); // Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside) @@ -68,7 +72,8 @@ const PUBLIC_PATHS = [ '/api/auth/oidc', '/api/license', '/api/changelog', - '/api/dependencies' + '/api/dependencies', + '/api/health' ]; // Check if path is public diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 25b0c68..0e76cb9 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -225,6 +225,9 @@ // Mutable ref for callback - allows updating without recreating editor let onchangeRef: ((value: string) => void) | undefined = onchange; + // Flag to suppress onchange during programmatic value sync + let isSyncingExternalValue = false; + // Keep callback ref updated when prop changes $effect(() => { onchangeRef = onchange; @@ -660,8 +663,9 @@ view.update(trs); // Check if any transaction changed the document + // Skip onchange during programmatic value sync (only fire for user edits) const lastChangingTr = trs.findLast(tr => tr.docChanged); - if (lastChangingTr && onchangeRef) { + if (lastChangingTr && onchangeRef && !isSyncingExternalValue) { // Defer callback to next microtask to avoid blocking input handling // This allows key repeat to work properly const newContent = lastChangingTr.newDoc.toString(); @@ -801,9 +805,12 @@ // Only update if the external value differs from editor content // This prevents feedback loops from editor changes if (externalValue !== currentContent) { + // Suppress onchange during programmatic sync - only user edits should trigger it + isSyncingExternalValue = true; view.dispatch({ changes: { from: 0, to: currentContent.length, insert: externalValue } }); + isSyncingExternalValue = false; } } }); diff --git a/src/lib/components/StackEnvVarsEditor.svelte b/src/lib/components/StackEnvVarsEditor.svelte index 54a7b10..e0e4ff3 100644 --- a/src/lib/components/StackEnvVarsEditor.svelte +++ b/src/lib/components/StackEnvVarsEditor.svelte @@ -104,7 +104,7 @@
- {#each variables as variable, index (index)} + {#each variables as variable, index (`${index}-${variable.key}`)} {@const source = getSource(variable.key)} {@const isVarRequired = isRequired(variable.key)} {@const isVarOptional = isOptional(variable.key)} diff --git a/src/lib/components/StackEnvVarsPanel.svelte b/src/lib/components/StackEnvVarsPanel.svelte index 30391b5..df98404 100644 --- a/src/lib/components/StackEnvVarsPanel.svelte +++ b/src/lib/components/StackEnvVarsPanel.svelte @@ -46,38 +46,35 @@ let confirmClearOpen = $state(false); let contentAreaRef: HTMLDivElement; let parseWarnings = $state([]); - let hasMergedOnLoad = $state(false); // Count of secrets (for display in hint) const secretCount = $derived(variables.filter(v => v.isSecret && v.key.trim()).length); /** - * Merge variables and rawContent on initial load. - * Called by parent after setting both variables and rawContent. - * This ensures both are in sync regardless of which view mode is active. + * Sync variables with rawContent after initial load. + * Pass the loaded data directly to avoid timing issues with bindable props. + * Merges: secrets from loadedVars (DB) + non-secrets from loadedRaw (file). */ - export function mergeOnLoad() { - if (hasMergedOnLoad) return; - hasMergedOnLoad = true; - - // If rawContent exists, parse it and merge with variables (which may have secrets from DB) - if (rawContent.trim()) { - const { vars: rawVars } = parseRawContent(rawContent); - const rawVarsByKey = new Map(rawVars.map(v => [v.key, v])); - - // Secrets come from variables (DB), non-secrets come from rawContent (file) - // But if a var exists in variables but not in rawContent, keep it (could be new) - const secrets = variables.filter(v => v.isSecret); - const nonSecretsFromRaw = rawVars; - - // Also keep non-secrets from variables that aren't in raw (new vars added before first save) - const rawKeys = new Set(rawVars.map(v => v.key)); - const newNonSecrets = variables.filter(v => !v.isSecret && v.key.trim() && !rawKeys.has(v.key)); - - variables = [...nonSecretsFromRaw, ...newNonSecrets, ...secrets]; + export function syncAfterLoad(loadedVars: EnvVar[], loadedRaw: string) { + if (!loadedRaw.trim()) { + // No raw content - just use the loaded variables as-is + variables = loadedVars; + rawContent = ''; + return; } - // If no rawContent, variables is already correct (from DB), just need to generate raw - // for when user switches to text view (done in handleViewModeChange) + + const { vars: rawVars } = parseRawContent(loadedRaw); + + // Secrets come from loadedVars (DB), non-secrets come from loadedRaw (file) + const secrets = loadedVars.filter(v => v.isSecret); + + // Also keep non-secrets from loadedVars that aren't in raw (new vars added before first save) + const rawKeys = new Set(rawVars.map(v => v.key)); + const newNonSecrets = loadedVars.filter(v => !v.isSecret && v.key.trim() && !rawKeys.has(v.key)); + + // Set both at once to avoid any intermediate states + variables = [...rawVars, ...newNonSecrets, ...secrets]; + rawContent = loadedRaw; } /** diff --git a/src/lib/components/TimezoneSelector.svelte b/src/lib/components/TimezoneSelector.svelte index bb28fe4..0866fdd 100644 --- a/src/lib/components/TimezoneSelector.svelte +++ b/src/lib/components/TimezoneSelector.svelte @@ -111,7 +111,7 @@ {/snippet} - + diff --git a/src/lib/components/data-grid/DataGrid.svelte b/src/lib/components/data-grid/DataGrid.svelte index 2a3ddf4..6b9cf3b 100644 --- a/src/lib/components/data-grid/DataGrid.svelte +++ b/src/lib/components/data-grid/DataGrid.svelte @@ -496,34 +496,32 @@ }); // Row state cache to prevent creating new objects on every scroll + // Use $derived to track dependencies synchronously (unlike $effect which is async) let rowStateCache = new WeakMap(); - let rowStateCacheDataRef: T[] | null = null; - let rowStateCacheExpandedRef: Set | null = null; - let rowStateCacheSelectedRef: Set | null = null; - // Clear row state cache when data or selection/expansion state changes - $effect(() => { - if (data !== rowStateCacheDataRef || - expandedKeys !== rowStateCacheExpandedRef || - selectedKeys !== rowStateCacheSelectedRef) { - rowStateCache = new WeakMap(); - rowStateCacheDataRef = data; - rowStateCacheExpandedRef = expandedKeys; - rowStateCacheSelectedRef = selectedKeys; - } - }); + // Track cache invalidation keys - when these change, cache is stale + let cachedSelectedKeysRef: Set | null = null; + let cachedExpandedKeysRef: Set | null = null; + let cachedHighlightedKeyRef: unknown = undefined; // Helper to get row state (memoized via WeakMap) + // Cache is invalidated synchronously when selection/expansion changes function getRowState(item: T, index: number): DataGridRowState { const actualIndex = virtualScroll ? startIndex + index : index; + // Check if cache needs to be cleared (synchronous check) + if (selectedKeys !== cachedSelectedKeysRef || + expandedKeys !== cachedExpandedKeysRef || + highlightedKey !== cachedHighlightedKeyRef) { + rowStateCache = new WeakMap(); + cachedSelectedKeysRef = selectedKeys; + cachedExpandedKeysRef = expandedKeys; + cachedHighlightedKeyRef = highlightedKey; + } + // Try to get cached state const cached = rowStateCache.get(item as object); if (cached && cached.index === actualIndex) { - // Update mutable fields that may have changed - cached.isSelected = isSelected(item[keyField]); - cached.isHighlighted = highlightedKey === item[keyField]; - cached.isExpanded = isExpanded(item[keyField]); return cached; } @@ -761,7 +759,7 @@ e.stopPropagation(); toggleSelection(item[keyField]); }} - class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}" + class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}" > {#if rowState.isSelected} @@ -870,7 +868,7 @@ - +
{placeholder} {/if} - + =3.17): Uses Bun's native password API (faster) + * On old kernels (<3.17): Uses hash-wasm (WASM-based, no getrandom dependency) + * * Argon2id is the recommended variant - resistant to both side-channel and GPU attacks */ export async function hashPassword(password: string): Promise { + // On old kernels, Bun.password.hash() crashes because it internally uses getrandom() + // Use hash-wasm as a fallback which is pure WASM and doesn't depend on the syscall + if (usingFallback()) { + const salt = secureRandomBytes(ARGON2_SALT_LENGTH); + return argon2id({ + password, + salt, + iterations: ARGON2_TIME_COST, + parallelism: ARGON2_PARALLELISM, + memorySize: ARGON2_MEMORY_COST, + hashLength: ARGON2_HASH_LENGTH, + outputType: 'encoded' // Returns PHC format: $argon2id$v=19$m=65536,t=3,p=1$... + }); + } + + // Modern kernels: use Bun's native implementation (faster) return Bun.password.hash(password, { algorithm: 'argon2id', - memoryCost: 65536, // 64 MB - timeCost: 3 // 3 iterations + memoryCost: ARGON2_MEMORY_COST, + timeCost: ARGON2_TIME_COST }); } /** * Verify a password against a hash * Uses constant-time comparison internally + * + * Both Bun.password and hash-wasm use the same PHC format, so hashes are compatible */ export async function verifyPassword(password: string, hash: string): Promise { try { + // On old kernels, use hash-wasm for verification + if (usingFallback()) { + return await argon2Verify({ password, hash }); + } + + // Modern kernels: use Bun's native implementation return await Bun.password.verify(password, hash); } catch { return false; @@ -130,7 +166,7 @@ export async function verifyPassword(password: string, hash: string): Promise { + const results = await db.select().from(environments).where(eq(environments.name, name)); + return results[0]; +} + export async function createEnvironment(env: Omit): Promise { const result = await db.insert(environments).values({ name: env.name, @@ -2487,6 +2492,8 @@ export interface StackSourceData { sourceType: StackSourceType; gitRepositoryId: number | null; gitStackId: number | null; + composePath: string | null; + envPath: string | null; createdAt: string; updatedAt: string; } @@ -2527,9 +2534,10 @@ export async function getStackSource(stackName: string, environmentId?: number | export async function getStackSources(environmentId?: number | null): Promise { let results; - if (environmentId !== undefined) { + if (environmentId !== undefined && environmentId !== null) { + // Only get stacks for the specific environment results = await db.select().from(stackSources) - .where(or(eq(stackSources.environmentId, environmentId), isNull(stackSources.environmentId))) + .where(eq(stackSources.environmentId, environmentId)) .orderBy(asc(stackSources.stackName)); } else { results = await db.select().from(stackSources).orderBy(asc(stackSources.stackName)); @@ -2563,6 +2571,8 @@ export async function upsertStackSource(data: { sourceType: StackSourceType; gitRepositoryId?: number | null; gitStackId?: number | null; + composePath?: string | null; + envPath?: string | null; }): Promise { const existing = await getStackSource(data.stackName, data.environmentId); @@ -2572,6 +2582,8 @@ export async function upsertStackSource(data: { sourceType: data.sourceType, gitRepositoryId: data.gitRepositoryId || null, gitStackId: data.gitStackId || null, + composePath: data.composePath ?? null, + envPath: data.envPath ?? null, updatedAt: new Date().toISOString() }) .where(eq(stackSources.id, existing.id)); @@ -2582,12 +2594,33 @@ export async function upsertStackSource(data: { environmentId: data.environmentId ?? null, sourceType: data.sourceType, gitRepositoryId: data.gitRepositoryId || null, - gitStackId: data.gitStackId || null + gitStackId: data.gitStackId || null, + composePath: data.composePath ?? null, + envPath: data.envPath ?? null }); return getStackSource(data.stackName, data.environmentId) as Promise; } } +export async function updateStackSource( + stackName: string, + environmentId: number | null, + updates: { composePath?: string | null; envPath?: string | null } +): Promise { + const existing = await getStackSource(stackName, environmentId); + if (!existing) return false; + + await db.update(stackSources) + .set({ + composePath: updates.composePath !== undefined ? updates.composePath : existing.composePath, + envPath: updates.envPath !== undefined ? updates.envPath : existing.envPath, + updatedAt: new Date().toISOString() + }) + .where(eq(stackSources.id, existing.id)); + + return true; +} + export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise { // Delete matching record (either with specific envId or NULL) await db.delete(stackSources) @@ -3083,10 +3116,8 @@ export interface ContainerEventResult { } export async function logContainerEvent(data: ContainerEventCreateData): Promise { - // Timestamp is always a string with nanosecond precision (stored as text in both SQLite and PostgreSQL) - // For PostgreSQL, we convert to Date since the schema uses native timestamp type - const timestamp = isPostgres ? new Date(data.timestamp) : data.timestamp; - + // Timestamp is already an ISO-8601 string from event-subprocess + // Both SQLite and PostgreSQL schemas use mode: 'string' so we pass it directly const result = await db.insert(containerEvents).values({ environmentId: data.environmentId ?? null, containerId: data.containerId, @@ -3094,7 +3125,7 @@ export async function logContainerEvent(data: ContainerEventCreateData): Promise image: data.image ?? null, action: data.action, actorAttributes: data.actorAttributes ? JSON.stringify(data.actorAttributes) : null, - timestamp + timestamp: data.timestamp }).returning(); return getContainerEvent(result[0].id) as Promise; @@ -3896,6 +3927,73 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise { } } +// ============================================================================= +// EXTERNAL STACK PATHS +// ============================================================================= + +const EXTERNAL_STACK_PATHS_KEY = 'external_stack_paths'; + +export async function getExternalStackPaths(): Promise { + const result = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY)); + if (result[0]) { + try { + const parsed = JSON.parse(result[0].value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return []; +} + +export async function setExternalStackPaths(paths: string[]): Promise { + const jsonValue = JSON.stringify(paths); + const existing = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY)); + if (existing.length > 0) { + await db.update(settings) + .set({ value: jsonValue, updatedAt: new Date().toISOString() }) + .where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY)); + } else { + await db.insert(settings).values({ + key: EXTERNAL_STACK_PATHS_KEY, + value: jsonValue + }); + } +} + +// ============================================================================= +// PRIMARY STACK LOCATION +// ============================================================================= + +const PRIMARY_STACK_LOCATION_KEY = 'primary_stack_location'; + +export async function getPrimaryStackLocation(): Promise { + const result = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY)); + if (result[0]?.value) { + return result[0].value; + } + return null; +} + +export async function setPrimaryStackLocation(path: string | null): Promise { + const existing = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY)); + if (path === null) { + // Delete the setting if path is null + if (existing.length > 0) { + await db.delete(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY)); + } + } else if (existing.length > 0) { + await db.update(settings) + .set({ value: path, updatedAt: new Date().toISOString() }) + .where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY)); + } else { + await db.insert(settings).values({ + key: PRIMARY_STACK_LOCATION_KEY, + value: path + }); + } +} + // ============================================================================= // ENVIRONMENT UPDATE CHECK SETTINGS // ============================================================================= @@ -3988,6 +4086,66 @@ export async function setDefaultTimezone(timezone: string): Promise { await setSetting('default_timezone', timezone); } +// ============================================================================= +// BACKGROUND MONITORING SETTINGS +// ============================================================================= + +/** + * Get event collection mode ('stream' or 'poll'). + * Defaults to 'stream' for real-time event streaming. + */ +export async function getEventCollectionMode(): Promise<'stream' | 'poll'> { + const value = await getSetting('event_collection_mode'); + return value || 'stream'; +} + +/** + * Set event collection mode. + */ +export async function setEventCollectionMode(mode: 'stream' | 'poll'): Promise { + await setSetting('event_collection_mode', mode); +} + +/** + * Get event poll interval in milliseconds. + * Defaults to 60000ms (60 seconds). + */ +export async function getEventPollInterval(): Promise { + const value = await getSetting('event_poll_interval'); + return value || 60000; +} + +/** + * Set event poll interval in milliseconds. + * Valid range: 30000ms (30s) to 300000ms (5min). + */ +export async function setEventPollInterval(interval: number): Promise { + if (interval < 30000 || interval > 300000) { + throw new Error('Event poll interval must be between 30s and 300s'); + } + await setSetting('event_poll_interval', interval); +} + +/** + * Get metrics collection interval in milliseconds. + * Defaults to 30000ms (30 seconds) - changed from hardcoded 10s. + */ +export async function getMetricsCollectionInterval(): Promise { + const value = await getSetting('metrics_collection_interval'); + return value || 30000; +} + +/** + * Set metrics collection interval in milliseconds. + * Valid range: 10000ms (10s) to 300000ms (5min). + */ +export async function setMetricsCollectionInterval(interval: number): Promise { + if (interval < 10000 || interval > 300000) { + throw new Error('Metrics collection interval must be between 10s and 300s'); + } + await setSetting('metrics_collection_interval', interval); +} + // ============================================================================= // STACK ENVIRONMENT VARIABLES OPERATIONS // ============================================================================= diff --git a/src/lib/server/db/drizzle.ts b/src/lib/server/db/drizzle.ts index a1e08d3..8eb7eeb 100644 --- a/src/lib/server/db/drizzle.ts +++ b/src/lib/server/db/drizzle.ts @@ -604,21 +604,21 @@ async function initializeDatabase() { logHeader('DATABASE INITIALIZATION'); if (isPostgres) { - // PostgreSQL via Bun.sql + // PostgreSQL via postgres-js (more stable than bun:sql for concurrent queries) validatePostgresUrl(config.databaseUrl!); logInfo(`Database: PostgreSQL`); logInfo(`Connection: ${maskPassword(config.databaseUrl!)}`); - const { drizzle } = await import('drizzle-orm/bun-sql'); - const { SQL } = await import('bun'); + const { drizzle } = await import('drizzle-orm/postgres-js'); + const postgres = (await import('postgres')).default; // Import PostgreSQL schema schema = await import('./schema/pg-schema.js'); if (verbose) logStep('Connecting to PostgreSQL...'); try { - rawClient = new SQL(config.databaseUrl!); + rawClient = postgres(config.databaseUrl!); db = drizzle({ client: rawClient, schema }); logSuccess('PostgreSQL connection established'); } catch (error) { diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index aeab9e6..274531e 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -332,6 +332,8 @@ export const stackSources = sqliteTable('stack_sources', { sourceType: text('source_type').notNull().default('internal'), gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }), gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }), + composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location) + envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location) createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`), updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`) }, (table) => ({ diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index 6b37bf6..2aad1ca 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -335,6 +335,8 @@ export const stackSources = pgTable('stack_sources', { sourceType: text('source_type').notNull().default('internal'), gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }), gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }), + composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location) + envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location) createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(), updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow() }, (table) => ({ diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 0b76f45..050953e 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -469,13 +469,16 @@ export async function dockerFetch( streaming ? 300000 : 30000 // 5 min for streaming, 30s for normal requests ); const elapsed = Date.now() - startTime; - if (elapsed > 5000) { + // Only warn for slow requests, but skip /stats which is expected to be slow (5-10s) + if (elapsed > 5000 && !path.includes('/stats')) { console.warn(`[Docker] Edge env ${config.environmentId}: ${method} ${path} took ${elapsed}ms`); } return edgeResponseToResponse(edgeResponse); - } catch (error) { + } catch (error: any) { const elapsed = Date.now() - startTime; - console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms:`, error); + // Log error message only, not full stack trace + const msg = error?.message || String(error); + console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms: ${msg}`); throw DockerConnectionError.fromError(error); } } @@ -491,13 +494,16 @@ export async function dockerFetch( ...bunOptions }); const elapsed = Date.now() - startTime; - if (elapsed > 5000) { + // Only warn for slow requests, but skip /stats which is expected to be slow (5-10s) + if (elapsed > 5000 && !path.includes('/stats')) { console.warn(`[Docker] Socket: ${method} ${path} took ${elapsed}ms`); } return response; - } catch (error) { + } catch (error: any) { const elapsed = Date.now() - startTime; - console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms:`, error); + // Log error message only, not full stack trace + const msg = error?.message || String(error); + console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms: ${msg}`); throw DockerConnectionError.fromError(error); } } else { @@ -516,21 +522,30 @@ export async function dockerFetch( } // For HTTPS with TLS certificates, we need to configure TLS - // IMPORTANT: Bun requires certificates as Buffer objects, not strings + // Pass certificate strings directly to Bun's fetch - no temp files needed if (config.type === 'https') { const tlsOptions: Record = {}; - // CA certificate - must be array of Buffers for Bun + // DISABLE TLS SESSION CACHING: Bun reuses TLS sessions across different hosts, + // which causes client certificate mismatches in mTLS scenarios. By setting + // sessionTimeout to 0, we force a fresh TLS handshake for every connection. + tlsOptions.sessionTimeout = 0; + + // Set explicit servername for SNI - helps isolate TLS contexts per host + tlsOptions.servername = config.host; + + // Load CA certificate (just this environment's CA, not composite) + // The sessionTimeout=0 should prevent session reuse across hosts if (config.ca) { - tlsOptions.ca = [Buffer.from(config.ca)]; + tlsOptions.ca = [config.ca]; } - // Client certificate and key for mTLS - must be Buffers + // Client cert and key for mTLS authentication if (config.cert) { - tlsOptions.cert = Buffer.from(config.cert); + tlsOptions.cert = [config.cert]; } if (config.key) { - tlsOptions.key = Buffer.from(config.key); + tlsOptions.key = config.key; } // Skip verification (self-signed without CA) @@ -541,8 +556,27 @@ export async function dockerFetch( } if (Object.keys(tlsOptions).length > 0) { - // @ts-ignore - Bun supports tls options with Buffer certs + // @ts-ignore - Bun supports tls options with string certs finalOptions.tls = tlsOptions; + // Force new connection for each request to prevent Bun from reusing + // a TLS session with wrong client certificates (pool key doesn't include certs) + // @ts-ignore - Bun supports keepalive option + 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 + if (process.env.DEBUG_TLS) { + // @ts-ignore - Bun-specific verbose option + finalOptions.verbose = true; } } @@ -550,13 +584,16 @@ export async function dockerFetch( try { const response = await fetch(url, { ...finalOptions, ...bunOptions }); const elapsed = Date.now() - startTime; - if (elapsed > 5000) { + // Only warn for slow requests, but skip /stats which is expected to be slow (5-10s) + if (elapsed > 5000 && !path.includes('/stats')) { console.warn(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} took ${elapsed}ms`); } return response; - } catch (error) { + } catch (error: any) { const elapsed = Date.now() - startTime; - console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms:`, error); + // Log error message only, not full stack trace + const msg = error?.message || String(error); + console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms: ${msg}`); throw DockerConnectionError.fromError(error); } } @@ -994,12 +1031,31 @@ export async function updateContainer(id: string, options: CreateContainerOption // Image operations export async function listImages(envId?: number | null): Promise { - const images = await dockerJsonRequest('/images/json', {}, envId); + // Fetch images and containers in parallel + const [images, containers] = await Promise.all([ + dockerJsonRequest('/images/json', {}, envId), + dockerJsonRequest('/containers/json?all=true', {}, envId).catch(() => [] as any[]) + ]); + + // Build a map of imageId -> container count + // Docker may return -1 for Containers field on some hosts, so we compute it ourselves + const imageContainerCount = new Map(); + for (const container of containers) { + const imageId = container.ImageID || container.Image; + if (imageId) { + imageContainerCount.set(imageId, (imageContainerCount.get(imageId) || 0) + 1); + } + } + return images.map((image) => ({ id: image.Id, + repoTags: image.RepoTags || [], tags: image.RepoTags || [], size: image.Size, - created: image.Created + virtualSize: image.VirtualSize || image.Size, + created: image.Created, + labels: image.Labels || {}, + containers: imageContainerCount.get(image.Id) || 0 })); } @@ -1943,7 +1999,10 @@ export async function pruneContainers(envId?: number | null) { } export async function pruneImages(dangling = true, envId?: number | null) { - const filters = dangling ? '{"dangling":["true"]}' : '{}'; + // dangling=true: only remove untagged images (default Docker behavior) + // dangling=false: remove ALL unused images including tagged ones + // Docker API quirk: to remove all unused, we pass dangling=false filter + const filters = dangling ? '{"dangling":["true"]}' : '{"dangling":["false"]}'; return dockerJsonRequest(`/images/prune?filters=${encodeURIComponent(filters)}`, { method: 'POST' }, envId); } @@ -2042,18 +2101,30 @@ export async function execInContainer( } // Get Docker events as a stream (for SSE) +// For streaming mode: call with just filters +// For polling mode: call with since and until to get a finite window of events export async function getDockerEvents( filters: Record, - envId?: number | null + envId?: number | null, + options?: { since?: string; until?: string } ): Promise | null> { const filterJson = JSON.stringify(filters); + // Build query string with optional since/until for polling mode + let queryString = `filters=${encodeURIComponent(filterJson)}`; + if (options?.since) { + queryString += `&since=${encodeURIComponent(options.since)}`; + } + if (options?.until) { + queryString += `&until=${encodeURIComponent(options.until)}`; + } + try { // Note: We use streaming: true to disable Bun's idle timeout for this long-lived connection. // The Docker events API keeps the connection open indefinitely, sending events as they occur. // Without streaming: true, Bun would terminate the connection after ~5 seconds of inactivity. const response = await dockerFetch( - `/events?filters=${encodeURIComponent(filterJson)}`, + `/events?${queryString}`, { streaming: true }, envId ); @@ -3114,8 +3185,12 @@ async function cleanupStaleVolumeHelpersForEnv(envId?: number | null): Promise { const timeoutHandle = setTimeout(() => { @@ -614,7 +612,7 @@ export function sendEdgeStreamRequest( return { requestId: '', cancel: () => {} }; } - const requestId = crypto.randomUUID(); + const requestId = secureRandomUUID(); // Initialize pendingStreamRequests if not present (can happen in dev mode due to HMR) if (!connection.pendingStreamRequests) { diff --git a/src/lib/server/metrics-collector.ts b/src/lib/server/metrics-collector.ts deleted file mode 100644 index dbccbff..0000000 --- a/src/lib/server/metrics-collector.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { saveHostMetric, getEnvironments, getEnvSetting } from './db'; -import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from './docker'; -import { sendEventNotification } from './notifications'; -import os from 'node:os'; - -const COLLECT_INTERVAL = 10000; // 10 seconds -const DISK_CHECK_INTERVAL = 300000; // 5 minutes -const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings - -let collectorInterval: ReturnType | null = null; -let diskCheckInterval: ReturnType | null = null; - -// Track last disk warning sent per environment to avoid spamming -const lastDiskWarning: Map = new Map(); -const DISK_WARNING_COOLDOWN = 3600000; // 1 hour between warnings - -/** - * Collect metrics for a single environment - */ -async function collectEnvMetrics(env: { id: number; name: string; collectMetrics?: boolean }) { - try { - // Skip environments where metrics collection is disabled - if (env.collectMetrics === false) { - return; - } - - // Get running containers - const containers = await listContainers(false, env.id); // Only running - let totalCpuPercent = 0; - let totalMemUsed = 0; - - // Get stats for each running container - const statsPromises = containers.map(async (container) => { - try { - const stats = await getContainerStats(container.id, env.id) as any; - - // Calculate CPU percentage - const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; - const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; - const cpuCount = stats.cpu_stats.online_cpus || os.cpus().length; - - let cpuPercent = 0; - if (systemDelta > 0 && cpuDelta > 0) { - cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100; - } - - // Get container memory usage - const memUsage = stats.memory_stats?.usage || 0; - const memCache = stats.memory_stats?.stats?.cache || 0; - // Subtract cache from usage to get actual memory used by the container - const actualMemUsed = memUsage - memCache; - - return { cpu: cpuPercent, mem: actualMemUsed > 0 ? actualMemUsed : memUsage }; - } catch { - return { cpu: 0, mem: 0 }; - } - }); - - const statsResults = await Promise.all(statsPromises); - totalCpuPercent = statsResults.reduce((sum, v) => sum + v.cpu, 0); - totalMemUsed = statsResults.reduce((sum, v) => sum + v.mem, 0); - - // Get host total memory from Docker info (this is the remote host's memory) - const info = await getDockerInfo(env.id) as any; - const memTotal = info.MemTotal || os.totalmem(); - - // Calculate memory percentage based on container usage vs host total - const memPercent = memTotal > 0 ? (totalMemUsed / memTotal) * 100 : 0; - - // Normalize CPU by number of cores from the remote host - const cpuCount = info.NCPU || os.cpus().length; - const normalizedCpu = totalCpuPercent / cpuCount; - - // Save to database - await saveHostMetric( - normalizedCpu, - memPercent, - totalMemUsed, - memTotal, - env.id - ); - } catch (error) { - // Skip this environment if it fails (might be offline) - console.error(`Failed to collect metrics for ${env.name}:`, error); - } -} - -async function collectMetrics() { - try { - const environments = await getEnvironments(); - - // Filter enabled environments and collect metrics in parallel - const enabledEnvs = environments.filter(env => env.collectMetrics !== false); - - // Process all environments in parallel for better performance - await Promise.all(enabledEnvs.map(env => collectEnvMetrics(env))); - } catch (error) { - console.error('Metrics collection error:', error); - } -} - -/** - * Check disk space for a single environment - */ -async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics?: boolean }) { - try { - // Skip environments where metrics collection is disabled - if (env.collectMetrics === false) { - return; - } - - // Check if we're in cooldown for this environment - const lastWarningTime = lastDiskWarning.get(env.id); - if (lastWarningTime && Date.now() - lastWarningTime < DISK_WARNING_COOLDOWN) { - return; // Skip this environment, still in cooldown - } - - // Get Docker disk usage data - const diskData = await getDiskUsage(env.id) as any; - if (!diskData) return; - - // Calculate total Docker disk usage using reduce for cleaner code - let totalUsed = 0; - if (diskData.Images) { - totalUsed += diskData.Images.reduce((sum: number, img: any) => sum + (img.Size || 0), 0); - } - if (diskData.Containers) { - totalUsed += diskData.Containers.reduce((sum: number, c: any) => sum + (c.SizeRw || 0), 0); - } - if (diskData.Volumes) { - totalUsed += diskData.Volumes.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0); - } - if (diskData.BuildCache) { - totalUsed += diskData.BuildCache.reduce((sum: number, bc: any) => sum + (bc.Size || 0), 0); - } - - // Get Docker root filesystem info from Docker info - const info = await getDockerInfo(env.id) as any; - const driverStatus = info?.DriverStatus; - - // Try to find "Data Space Total" from driver status - let dataSpaceTotal = 0; - let diskPercentUsed = 0; - - if (driverStatus) { - for (const [key, value] of driverStatus) { - if (key === 'Data Space Total' && typeof value === 'string') { - dataSpaceTotal = parseSize(value); - break; - } - } - } - - // If we found total disk space, calculate percentage - if (dataSpaceTotal > 0) { - diskPercentUsed = (totalUsed / dataSpaceTotal) * 100; - } else { - // Fallback: just report absolute usage if we can't determine percentage - const GB = 1024 * 1024 * 1024; - if (totalUsed > 50 * GB) { - await sendEventNotification('disk_space_warning', { - title: 'High Docker disk usage', - message: `Environment "${env.name}" is using ${formatSize(totalUsed)} of Docker disk space`, - type: 'warning' - }, env.id); - lastDiskWarning.set(env.id, Date.now()); - } - return; - } - - // Check against threshold - const threshold = await getEnvSetting('disk_warning_threshold', env.id) || DEFAULT_DISK_THRESHOLD; - if (diskPercentUsed >= threshold) { - console.log(`[Metrics] Docker disk usage for ${env.name}: ${diskPercentUsed.toFixed(1)}% (threshold: ${threshold}%)`); - - await sendEventNotification('disk_space_warning', { - title: 'Disk space warning', - message: `Environment "${env.name}" Docker disk usage is at ${diskPercentUsed.toFixed(1)}% (${formatSize(totalUsed)} used)`, - type: 'warning' - }, env.id); - - lastDiskWarning.set(env.id, Date.now()); - } - } catch (error) { - // Skip this environment if it fails - console.error(`Failed to check disk space for ${env.name}:`, error); - } -} - -/** - * Check Docker disk usage and send warnings if above threshold - */ -async function checkDiskSpace() { - try { - const environments = await getEnvironments(); - - // Filter enabled environments and check disk space in parallel - const enabledEnvs = environments.filter(env => env.collectMetrics !== false); - - // Process all environments in parallel for better performance - await Promise.all(enabledEnvs.map(env => checkEnvDiskSpace(env))); - } catch (error) { - console.error('Disk space check error:', error); - } -} - -/** - * Parse size string like "107.4GB" to bytes - */ -function parseSize(sizeStr: string): number { - const units: Record = { - 'B': 1, - 'KB': 1024, - 'MB': 1024 * 1024, - 'GB': 1024 * 1024 * 1024, - 'TB': 1024 * 1024 * 1024 * 1024 - }; - - const match = sizeStr.match(/^([\d.]+)\s*([KMGT]?B)$/i); - if (!match) return 0; - - const value = parseFloat(match[1]); - const unit = match[2].toUpperCase(); - return value * (units[unit] || 1); -} - -/** - * Format bytes to human readable string - */ -function formatSize(bytes: number): string { - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let unitIndex = 0; - let size = bytes; - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - - return `${size.toFixed(1)} ${units[unitIndex]}`; -} - -export function startMetricsCollector() { - if (collectorInterval) return; // Already running - - console.log('Starting server-side metrics collector (every 10s)'); - - // Initial collection - collectMetrics(); - - // Schedule regular collection - collectorInterval = setInterval(collectMetrics, COLLECT_INTERVAL); - - // Start disk space checking (every 5 minutes) - console.log('Starting disk space monitoring (every 5 minutes)'); - checkDiskSpace(); // Initial check - diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL); -} - -export function stopMetricsCollector() { - if (collectorInterval) { - clearInterval(collectorInterval); - collectorInterval = null; - } - if (diskCheckInterval) { - clearInterval(diskCheckInterval); - diskCheckInterval = null; - } - lastDiskWarning.clear(); - console.log('Metrics collector stopped'); -} diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index 35d1021..ad4dddb 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -5,8 +5,8 @@ * All lifecycle operations use docker compose commands. */ -import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; import { getEnvironment, getStackEnvVarsAsRecord, @@ -88,22 +88,6 @@ export interface DeployStackOptions { // ERRORS // ============================================================================= -/** - * Error for operations on external stacks without compose files - */ -export class ExternalStackError extends Error { - public readonly stackName: string; - - constructor(stackName: string) { - super( - `Stack "${stackName}" was created outside of Dockhand. ` + - `To manage this stack, first import it by clicking the Import button in the stack menu.` - ); - this.name = 'ExternalStackError'; - this.stackName = stackName; - } -} - /** * Error when compose file is missing for a managed stack */ @@ -232,6 +216,65 @@ export function getStacksDir(): string { return _stacksDir; } +/** + * Get stack directory path for a specific environment. + * New stacks use: $DATA_DIR/stacks/// + * Legacy stacks (no env): $DATA_DIR/stacks// + * + * Automatically looks up environment name from database. + */ +export async function getStackDir(stackName: string, envId?: number | null): Promise { + const stacksDir = getStacksDir(); + if (envId) { + const env = await getEnvironment(envId); + if (env) { + return join(stacksDir, env.name, stackName); + } + } + // Legacy path for stacks without environment + return join(stacksDir, stackName); +} + +/** + * 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// + * + * 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 { + 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) + if (env) { + const namePath = join(stacksDir, env.name, stackName); + if (existsSync(namePath)) { + return namePath; + } + } + + // 2. 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) + const legacyPath = join(stacksDir, stackName); + if (existsSync(legacyPath)) { + return legacyPath; + } + + return null; +} + /** * List stacks that have compose files stored locally */ @@ -256,31 +299,116 @@ export function listManagedStacks(): string[] { // ============================================================================= /** - * Get compose file content for a stack + * Result type for getStackComposeFile + */ +export interface GetComposeFileResult { + success: boolean; + content?: string; + stackDir?: string; + error?: string; + needsFileLocation?: boolean; + composePath?: string | null; + envPath?: string | null; + suggestedEnvPath?: string; +} + +/** + * Get compose file content for a stack. + * + * Unified logic for all stacks: + * - If composePath is set in DB → use custom path + * - If composePath is NULL → use default location (data/stacks/{env}/{name}/) + * - If no source record and no files found → return needsFileLocation: true */ export async function getStackComposeFile( - stackName: string -): Promise<{ success: boolean; content?: string; stackDir?: string; error?: string }> { - const stacksDir = getStacksDir(); - const stackDir = join(stacksDir, stackName); + stackName: string, + envId?: number | null +): Promise { + const source = await getStackSource(stackName, envId); - // Check all common compose file names (Docker Compose v1 and v2 naming conventions) - const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']; + // 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 + if (!source) { + return { + success: false, + needsFileLocation: true, + error: `Select the compose file location for stack "${stackName}"` + }; + } + + // Case 2: Stack has custom composePath set - use it + if (source.composePath) { + try { + if (!existsSync(source.composePath)) { + return { + success: false, + error: `Compose file no longer accessible at ${source.composePath}. The file may have been moved or deleted.`, + composePath: source.composePath, + envPath: source.envPath + }; + } + + const content = await Bun.file(source.composePath).text(); + const stackDir = dirname(source.composePath); + + // For custom paths, suggest .env next to compose if envPath not set + let suggestedEnvPath: string | undefined; + if (source.envPath === null) { + suggestedEnvPath = source.composePath.replace(/\/[^/]+$/, '/.env'); + } - for (const fileName of composeFileNames) { - const file = Bun.file(join(stackDir, fileName)); - if (await file.exists()) { return { success: true, - content: await file.text(), - stackDir + content, + stackDir, + composePath: source.composePath, + envPath: source.envPath, + suggestedEnvPath + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return { + success: false, + error: `Failed to read compose file: ${message}`, + composePath: source.composePath, + envPath: source.envPath }; } } + // Case 3: Stack is in DB but no custom path - check default location + // This is for stacks created in Dockhand using the default data directory + const stackDir = await findStackDir(stackName, envId); + + if (stackDir) { + // Check all common compose file names + const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']; + + for (const fileName of composeFileNames) { + const actualComposePath = join(stackDir, fileName); + const file = Bun.file(actualComposePath); + if (await file.exists()) { + // Check for .env file in the same directory + const envFilePath = join(stackDir, '.env'); + const envExists = existsSync(envFilePath); + + return { + success: true, + content: await file.text(), + stackDir, + // Always return the actual resolved paths for display + composePath: actualComposePath, + envPath: envExists ? envFilePath : null + }; + } + } + } + + // Case 4: Stack is in DB but compose file not found - need user to specify location return { success: false, - error: `Compose file not found for stack "${stackName}". The stack may have been created outside of Dockhand.` + needsFileLocation: true, + error: `Select the compose file location for stack "${stackName}"` }; } @@ -289,22 +417,206 @@ export async function getStackComposeFile( * @param name - Stack name * @param content - Compose file content * @param create - If true, creates a new stack (fails if exists). If false, updates existing (fails if not exists). + * @param envId - Environment ID for path scoping */ export async function saveStackComposeFile( name: string, content: string, - create = false + create = false, + envId?: number | null, + options?: { + composePath?: string; // Custom compose file path + envPath?: string | null; // Custom env path (null = default, '' = none) + moveFromDir?: string; // Old directory to move all files from when path changes + oldComposePath?: string; // Old compose file path for renaming + oldEnvPath?: string; // Old env file path for renaming + } ): Promise<{ success: boolean; error?: string }> { - // Validate stack name - if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + // Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores + // Must also start with a letter or number + if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) { return { success: false, - error: 'Stack name can only contain letters, numbers, hyphens, and underscores' + error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores' }; } - const stacksDir = getStacksDir(); - const stackDir = join(stacksDir, name); + // Check if this stack has a custom compose path configured, or if one was provided + const source = await getStackSource(name, envId); + const composePath = options?.composePath || source?.composePath; + + // Handle compose file move/rename when path changes + if (options?.oldComposePath && options?.composePath && + options.oldComposePath !== options.composePath && + existsSync(options.oldComposePath)) { + const newDir = dirname(options.composePath); + + // Ensure target directory exists + if (!existsSync(newDir)) { + try { + mkdirSync(newDir, { recursive: true }); + } catch (err: any) { + console.warn(`[Stack] Failed to create directory ${newDir}: ${err.message}`); + } + } + + // Move/rename the compose file to new location + try { + renameSync(options.oldComposePath, options.composePath); + console.log(`[Stack] Moved compose file: ${options.oldComposePath} -> ${options.composePath}`); + } catch (renameErr: any) { + // If rename fails (e.g., cross-filesystem), try copy+delete + if (renameErr.code === 'EXDEV') { + try { + const data = readFileSync(options.oldComposePath); + writeFileSync(options.composePath, data); + unlinkSync(options.oldComposePath); + console.log(`[Stack] Copied compose file (cross-fs): ${options.oldComposePath} -> ${options.composePath}`); + } catch (err: any) { + console.warn(`[Stack] Failed to copy compose file: ${err.message}`); + } + } else { + console.warn(`[Stack] Failed to move compose file: ${renameErr.message}`); + } + } + } + + // Handle env file move/rename when path changes + if (options?.oldEnvPath && options?.envPath && + options.oldEnvPath !== options.envPath && + existsSync(options.oldEnvPath)) { + const newDir = dirname(options.envPath); + + // Ensure target directory exists + if (!existsSync(newDir)) { + try { + mkdirSync(newDir, { recursive: true }); + } catch (err: any) { + console.warn(`[Stack] Failed to create directory ${newDir}: ${err.message}`); + } + } + + // Move/rename the env file to new location + try { + renameSync(options.oldEnvPath, options.envPath); + console.log(`[Stack] Moved env file: ${options.oldEnvPath} -> ${options.envPath}`); + } catch (renameErr: any) { + // If rename fails (e.g., cross-filesystem), try copy+delete + if (renameErr.code === 'EXDEV') { + try { + const data = readFileSync(options.oldEnvPath); + writeFileSync(options.envPath, data); + unlinkSync(options.oldEnvPath); + console.log(`[Stack] Copied env file (cross-fs): ${options.oldEnvPath} -> ${options.envPath}`); + } catch (err: any) { + console.warn(`[Stack] Failed to copy env file: ${err.message}`); + } + } else { + console.warn(`[Stack] Failed to move env file: ${renameErr.message}`); + } + } + } + + // Move all files from old directory to new directory when path changes + // Get the new directory from composePath + const newDir = options?.composePath ? dirname(options.composePath) : null; + + if (options?.moveFromDir && newDir && options.moveFromDir !== newDir && existsSync(options.moveFromDir)) { + try { + // Ensure new directory exists + if (!existsSync(newDir)) { + mkdirSync(newDir, { recursive: true }); + } + + // Move all files from old directory to new directory + const files = readdirSync(options.moveFromDir); + for (const file of files) { + const oldFilePath = join(options.moveFromDir, file); + const newFilePath = join(newDir, file); + + try { + // Use rename for atomic move (same filesystem) or copy+delete for cross-filesystem + renameSync(oldFilePath, newFilePath); + console.log(`[Stack] Moved file: ${oldFilePath} -> ${newFilePath}`); + } catch (renameErr: any) { + // If rename fails (e.g., cross-filesystem), try copy+delete + if (renameErr.code === 'EXDEV') { + const stat = statSync(oldFilePath); + if (stat.isDirectory()) { + // For directories, use recursive copy + cpSync(oldFilePath, newFilePath, { recursive: true }); + rmSync(oldFilePath, { recursive: true, force: true }); + } else { + // For files, read and write + const data = readFileSync(oldFilePath); + writeFileSync(newFilePath, data); + unlinkSync(oldFilePath); + } + console.log(`[Stack] Copied file (cross-fs): ${oldFilePath} -> ${newFilePath}`); + } else { + throw renameErr; + } + } + } + + // Remove old directory if it's now empty + try { + const remaining = readdirSync(options.moveFromDir); + if (remaining.length === 0) { + rmSync(options.moveFromDir, { recursive: true, force: true }); + console.log(`[Stack] Removed empty old directory: ${options.moveFromDir}`); + } + } catch { + // Ignore errors when checking/removing old directory + } + } catch (err: any) { + console.warn(`[Stack] Failed to move files from ${options.moveFromDir} to ${newDir}: ${err.message}`); + // Continue with save even if move fails - new files will be written anyway + } + } + + // If a custom composePath is being set (new or update), save it to the database + if (options?.composePath || options?.envPath !== undefined) { + await upsertStackSource({ + stackName: name, + environmentId: envId ?? null, + sourceType: 'internal', + composePath: options?.composePath || source?.composePath || null, + envPath: options?.envPath !== undefined ? options.envPath : (source?.envPath ?? null) + }); + } + + if (composePath) { + // Write directly to the custom compose file path + // Ensure parent directory exists for custom paths + const parentDir = dirname(composePath); + if (!existsSync(parentDir)) { + try { + mkdirSync(parentDir, { recursive: true }); + } catch (err: any) { + return { success: false, error: `Failed to create directory for compose file: ${err.message}` }; + } + } + try { + await Bun.write(composePath, content); + return { success: true }; + } catch (err: any) { + return { success: false, error: `Failed to save compose file: ${err.message}` }; + } + } + + // For creates, use new path; for updates, find existing path first + let stackDir: string; + if (create) { + stackDir = await getStackDir(name, envId); + } else { + const existingDir = await findStackDir(name, envId); + if (!existingDir) { + return { success: false, error: `Stack "${name}" not found` }; + } + stackDir = existingDir; + } + const composeFile = join(stackDir, 'docker-compose.yml'); const exists = existsSync(stackDir); @@ -323,11 +635,6 @@ export async function saveStackComposeFile( } catch (err: any) { return { success: false, error: `Failed to create stack directory: ${err.message}` }; } - } else { - // Updating existing stack - must exist - if (!exists) { - return { success: false, error: `Stack "${name}" not found` }; - } } try { @@ -338,6 +645,68 @@ export async function saveStackComposeFile( } } +// ============================================================================= +// REGISTRY AUTHENTICATION +// ============================================================================= + +/** + * Login to all configured Docker registries before running compose commands. + * This ensures that `docker compose up` can pull images from private registries. + */ +async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Promise { + const { getRegistries } = await import('./db.js'); + const registries = await getRegistries(); + + if (registries.length === 0) { + return; + } + + const spawnEnv: Record = { ...(process.env as Record) }; + if (dockerHost) { + spawnEnv.DOCKER_HOST = dockerHost; + } + + for (const reg of registries) { + if (!reg.username || !reg.password) { + continue; // Skip registries without credentials + } + + try { + // Extract registry host from URL + const url = new URL(reg.url); + const registryHost = url.host; + + console.log(`${logPrefix} Logging into registry: ${registryHost}`); + + const proc = Bun.spawn( + ['docker', 'login', '-u', reg.username, '--password-stdin', registryHost], + { + env: spawnEnv, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe' + } + ); + + // Write password to stdin + const writer = proc.stdin.getWriter(); + await writer.write(new TextEncoder().encode(reg.password)); + await writer.close(); + + const exitCode = await proc.exited; + + if (exitCode === 0) { + console.log(`${logPrefix} Successfully logged into ${registryHost}`); + } else { + const stderr = await new Response(proc.stderr).text(); + console.error(`${logPrefix} Failed to login to ${registryHost}: ${stderr}`); + } + } catch (e) { + console.error(`${logPrefix} Error logging into registry ${reg.name}:`, e); + } + } +} + // ============================================================================= // COMPOSE COMMAND EXECUTION // ============================================================================= @@ -364,11 +733,14 @@ async function executeLocalCompose( envVars?: Record, secretVars?: Record, forceRecreate?: boolean, - removeVolumes?: boolean + removeVolumes?: boolean, + envId?: number | null ): Promise { const logPrefix = `[Stack:${stackName}]`; - const stacksDir = getStacksDir(); - const stackDir = join(stacksDir, stackName); + // For operations that write (up), use getStackDir; for others, try to find existing first + const stackDir = operation === 'up' + ? await getStackDir(stackName, envId) + : (await findStackDir(stackName, envId) || await getStackDir(stackName, envId)); mkdirSync(stackDir, { recursive: true }); const composeFile = join(stackDir, 'docker-compose.yml'); @@ -433,6 +805,11 @@ async function executeLocalCompose( console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2)); } + // Login to registries before pulling images + if (operation === 'up' || operation === 'pull') { + await loginToRegistries(dockerHost, logPrefix); + } + try { console.log(`${logPrefix} Spawning docker compose process...`); const proc = Bun.spawn(args, { @@ -577,6 +954,20 @@ async function executeComposeViaHawser( } } + // Fetch registry credentials for Hawser to use for docker login + const { getRegistries } = await import('./db.js'); + const allRegistries = await getRegistries(); + const registries = allRegistries + .filter(r => r.username && r.password) + .map(r => ({ + url: r.url, + username: r.username!, + password: r.password! + })); + if (registries.length > 0) { + console.log(`${logPrefix} Sending ${registries.length} registry credentials to Hawser`); + } + const body = JSON.stringify({ operation, projectName: stackName, @@ -584,7 +975,8 @@ async function executeComposeViaHawser( 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 + removeVolumes: removeVolumes || false, + registries // Registry credentials for docker login }); console.log(`${logPrefix} Sending request to Hawser agent...`); @@ -665,7 +1057,8 @@ async function executeComposeCommand( envVars, secretVars, forceRecreate, - removeVolumes + removeVolumes, + envId ); } @@ -695,7 +1088,8 @@ async function executeComposeCommand( envVars, secretVars, forceRecreate, - removeVolumes + removeVolumes, + envId ); } @@ -709,7 +1103,8 @@ async function executeComposeCommand( envVars, secretVars, forceRecreate, - removeVolumes + removeVolumes, + envId ); } } @@ -806,6 +1201,37 @@ async function getStackContainers(stackName: string, envId?: number | null): Pro return containers.filter((c) => c.labels['com.docker.compose.project'] === stackName); } +/** + * Extract path hints from Docker container labels for a stack. + * Docker Compose adds labels like: + * - com.docker.compose.project.working_dir: /path/to/stack + * - com.docker.compose.project.config_files: /path/to/docker-compose.yml[,...] + */ +export async function getStackPathHints( + stackName: string, + envId?: number | null +): Promise<{ + workingDir: string | null; + configFiles: string[] | null; +}> { + const containers = await getStackContainers(stackName, envId); + + if (containers.length === 0) { + return { workingDir: null, configFiles: null }; + } + + // Get labels from first container (all containers in stack have same project labels) + const labels = containers[0].labels || {}; + + const workingDir = labels['com.docker.compose.project.working_dir'] || null; + const configFilesRaw = labels['com.docker.compose.project.config_files'] || null; + + // Config files can be comma-separated if multiple compose files were used + const configFiles = configFilesRaw ? configFilesRaw.split(',').map((f: string) => f.trim()) : null; + + return { workingDir, configFiles }; +} + /** * Helper to perform container-based operations for external stacks * Used as fallback when no compose file exists. @@ -878,12 +1304,25 @@ async function withContainerFallback( // ============================================================================= /** - * Ensure we have a compose file for operations, throw appropriate error if not. + * Result type for requireComposeFile - can indicate stack needs file location + */ +export interface RequireComposeResult { + success: boolean; + content?: string; + envVars?: Record; + secretVars?: Record; + needsFileLocation?: boolean; + error?: string; +} + +/** + * Get compose file and env vars for stack operations. * * Returns: * - content: The compose file content * - envVars: Non-secret variables (from .env file, with DB fallback) * - secretVars: Secret variables (from DB only, for shell injection) + * - needsFileLocation: true if stack needs user to specify file paths * * SECURITY: Secrets are NEVER written to .env files. They are stored in the database * and injected via shell environment variables at runtime. @@ -891,16 +1330,22 @@ async function withContainerFallback( async function requireComposeFile( stackName: string, envId?: number | null -): Promise<{ content: string; envVars: Record; secretVars: Record }> { - const composeResult = await getStackComposeFile(stackName); +): Promise { + const composeResult = await getStackComposeFile(stackName, envId); + // If compose file not found, return info about what's needed if (!composeResult.success) { - // Check if this is an external stack - const source = await getStackSource(stackName, envId); - if (!source || source.sourceType === 'external') { - throw new ExternalStackError(stackName); + if (composeResult.needsFileLocation) { + return { + success: false, + needsFileLocation: true, + error: composeResult.error + }; } - throw new ComposeFileNotFoundError(stackName); + return { + success: false, + error: composeResult.error || `Compose file not found for stack "${stackName}"` + }; } // Get SECRET variables from database (for shell injection at runtime) @@ -910,12 +1355,26 @@ async function requireComposeFile( // Get non-secret variables from database (for backward compatibility) const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId); - // Read non-secret vars from .env file (user can edit this file manually) - const stackDir = join(getStacksDir(), stackName); - const envFilePath = join(stackDir, '.env'); + // Read non-secret vars from .env file + // For stacks with custom path, use the env path if set (and not empty string which means "no env file") + // Otherwise, use the .env file in the stack directory + let envFilePath: string | null = null; + + if (composeResult.composePath && composeResult.envPath) { + // Custom compose path with explicit env path + envFilePath = composeResult.envPath; + } else if (composeResult.composePath && composeResult.envPath === '') { + // Custom compose path with explicit "no env file" - don't read any file + envFilePath = null; + } else { + // Default location - look for .env in stack directory + const stackDir = composeResult.stackDir || await findStackDir(stackName, envId) || await getStackDir(stackName, envId); + envFilePath = join(stackDir, '.env'); + } + let fileEnvVars: Record = {}; - if (existsSync(envFilePath)) { + if (envFilePath && existsSync(envFilePath)) { try { const content = await Bun.file(envFilePath).text(); for (const line of content.split('\n')) { @@ -941,85 +1400,80 @@ async function requireComposeFile( // This ensures external edits to .env are respected during deployment const envVars = { ...dbNonSecretVars, ...fileEnvVars }; - return { content: composeResult.content!, envVars, secretVars }; + return { success: true, content: composeResult.content!, envVars, secretVars }; } /** * Start a stack using docker compose up - * Falls back to individual container start for external stacks + * Falls back to individual container start for stacks without compose files */ export async function startStack( stackName: string, envId?: number | null ): Promise { - try { - const { content, envVars, secretVars } = await requireComposeFile(stackName, envId); - return executeComposeCommand('up', { stackName, envId }, content, envVars, secretVars); - } catch (err) { - if (err instanceof ExternalStackError) { - return withContainerFallback(stackName, envId, 'start'); - } - throw err; + const result = await requireComposeFile(stackName, envId); + + if (!result.success) { + // No compose file - fall back to container-based operations + return withContainerFallback(stackName, envId, 'start'); } + + return executeComposeCommand('up', { stackName, envId }, result.content!, result.envVars, result.secretVars); } /** * Stop a stack using docker compose stop - * Falls back to individual container stop for external stacks + * Falls back to individual container stop for stacks without compose files */ export async function stopStack( stackName: string, envId?: number | null ): Promise { - try { - const { content, envVars, secretVars } = await requireComposeFile(stackName, envId); - return executeComposeCommand('stop', { stackName, envId }, content, envVars, secretVars); - } catch (err) { - if (err instanceof ExternalStackError) { - return withContainerFallback(stackName, envId, 'stop'); - } - throw err; + const result = await requireComposeFile(stackName, envId); + + if (!result.success) { + // No compose file - fall back to container-based operations + return withContainerFallback(stackName, envId, 'stop'); } + + return executeComposeCommand('stop', { stackName, envId }, result.content!, result.envVars, result.secretVars); } /** * Restart a stack using docker compose restart - * Falls back to individual container restart for external stacks + * Falls back to individual container restart for stacks without compose files */ export async function restartStack( stackName: string, envId?: number | null ): Promise { - try { - const { content, envVars, secretVars } = await requireComposeFile(stackName, envId); - return executeComposeCommand('restart', { stackName, envId }, content, envVars, secretVars); - } catch (err) { - if (err instanceof ExternalStackError) { - return withContainerFallback(stackName, envId, 'restart'); - } - throw err; + const result = await requireComposeFile(stackName, envId); + + if (!result.success) { + // No compose file - fall back to container-based operations + return withContainerFallback(stackName, envId, 'restart'); } + + return executeComposeCommand('restart', { stackName, envId }, result.content!, result.envVars, result.secretVars); } /** * Down a stack using docker compose down (removes containers, keeps files) - * For external stacks, this is equivalent to stop (no compose file to "down") + * For stacks without compose files, this is equivalent to stop */ export async function downStack( stackName: string, envId?: number | null, removeVolumes = false ): Promise { - try { - const { content, envVars, secretVars } = await requireComposeFile(stackName, envId); - return executeComposeCommand('down', { stackName, envId, removeVolumes }, content, envVars, secretVars); - } catch (err) { - if (err instanceof ExternalStackError) { - // For external stacks, down is the same as stop (no compose file to tear down) - return withContainerFallback(stackName, envId, 'stop'); - } - throw err; + const result = await requireComposeFile(stackName, envId); + + if (!result.success) { + // No compose file - down is the same as stop + return withContainerFallback(stackName, envId, 'stop'); } + + return executeComposeCommand('down', { stackName, envId, removeVolumes }, result.content!, result.envVars, result.secretVars); } /** @@ -1080,8 +1534,7 @@ export async function removeStack( const cleanupErrors: string[] = []; // Delete compose file and directory - const stacksDir = getStacksDir(); - const stackDir = join(stacksDir, stackName); + const stackDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId); if (existsSync(stackDir)) { try { rmSync(stackDir, { recursive: true, force: true }); @@ -1166,19 +1619,19 @@ export async function deployStack(options: DeployStackOptions): Promise { - const stacksDir = getStacksDir(); - const stackDir = join(stacksDir, name); + const stackDir = await getStackDir(name, envId); // Read all files from source directory if provided (for Hawser deployments) let stackFiles: Record | undefined; @@ -1248,9 +1701,16 @@ export async function pullStackImages( stackName: string, envId?: number | null ): Promise<{ success: boolean; output?: string; error?: string }> { - const { content, envVars, secretVars } = await requireComposeFile(stackName, envId); + const result = await requireComposeFile(stackName, envId); - return executeComposeCommand('pull', { stackName, envId }, content, envVars, secretVars); + if (!result.success) { + return { + success: false, + error: result.error || 'Compose file not found' + }; + } + + return executeComposeCommand('pull', { stackName, envId }, result.content!, result.envVars, result.secretVars); } // ============================================================================= @@ -1279,10 +1739,19 @@ export async function saveStackEnvVarsToDb( */ export async function writeStackEnvFile( stackName: string, - variables: { key: string; value: string; isSecret?: boolean }[] + variables: { key: string; value: string; isSecret?: boolean }[], + envId?: number | null, + customEnvPath?: string ): Promise { - const stacksDir = getStacksDir(); - const envFilePath = join(stacksDir, stackName, '.env'); + const envFilePath = customEnvPath + ? customEnvPath + : join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env'); + + // Ensure parent directory exists + const dir = dirname(envFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } // SECURITY: Only write non-secret variables to .env file // Secrets are stored in DB and injected via shell environment at runtime @@ -1302,17 +1771,20 @@ export async function writeStackEnvFile( */ export async function writeRawStackEnvFile( stackName: string, - rawContent: string + rawContent: string, + envId?: number | null, + customEnvPath?: string ): Promise { - const stacksDir = getStacksDir(); - const stackDir = join(stacksDir, stackName); + const envFilePath = customEnvPath + ? customEnvPath + : join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env'); - // Ensure stack directory exists - if (!existsSync(stackDir)) { - mkdirSync(stackDir, { recursive: true }); + // Ensure parent directory exists + const dir = dirname(envFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); } - const envFilePath = join(stackDir, '.env'); await Bun.write(envFilePath, rawContent); } @@ -1329,12 +1801,13 @@ export async function writeRawStackEnvFile( export async function saveStackEnvVars( stackName: string, variables: { key: string; value: string; isSecret?: boolean }[], - envId?: number | null + envId?: number | null, + customEnvPath?: string ): Promise { // Save to database for secret tracking await saveStackEnvVarsToDb(stackName, variables, envId); // Write .env file to disk for Docker Compose - await writeStackEnvFile(stackName, variables); + await writeStackEnvFile(stackName, variables, envId, customEnvPath); } // ============================================================================= diff --git a/src/lib/server/subprocess-manager.ts b/src/lib/server/subprocess-manager.ts index 6db4ec5..f29c770 100644 --- a/src/lib/server/subprocess-manager.ts +++ b/src/lib/server/subprocess-manager.ts @@ -102,7 +102,12 @@ export interface ShutdownCommand { type: 'shutdown'; } -export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand; +export interface UpdateIntervalCommand { + type: 'update_interval'; + intervalMs: number; +} + +export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand | UpdateIntervalCommand; // Subprocess configuration interface SubprocessConfig { @@ -198,6 +203,20 @@ class SubprocessManager { this.sendToEvents({ type: 'refresh_environments' }); } + /** + * Send message to metrics subprocess + */ + sendToMetricsSubprocess(message: MainProcessCommand): void { + this.sendToMetrics(message); + } + + /** + * Send message to events subprocess + */ + sendToEventsSubprocess(message: MainProcessCommand): void { + this.sendToEvents(message); + } + /** * Start the metrics collection subprocess */ @@ -591,3 +610,21 @@ export function refreshSubprocessEnvironments(): void { manager.refreshEnvironments(); } } + +/** + * Send message to event subprocess + */ +export function sendToEventSubprocess(message: MainProcessCommand): void { + if (manager) { + manager.sendToEventsSubprocess(message); + } +} + +/** + * Send message to metrics subprocess + */ +export function sendToMetricsSubprocess(message: MainProcessCommand): void { + if (manager) { + manager.sendToMetricsSubprocess(message); + } +} diff --git a/src/lib/server/subprocesses/event-subprocess.ts b/src/lib/server/subprocesses/event-subprocess.ts index bf37d58..5abfc68 100644 --- a/src/lib/server/subprocesses/event-subprocess.ts +++ b/src/lib/server/subprocesses/event-subprocess.ts @@ -7,7 +7,7 @@ * Communication with main process via IPC (process.send). */ -import { getEnvironments, type ContainerEventAction } from '../db'; +import { getEnvironments, getEventCollectionMode, getEventPollInterval, type ContainerEventAction } from '../db'; import { getDockerEvents } from '../docker'; import type { MainProcessCommand } from '../subprocess-manager'; @@ -19,9 +19,15 @@ const MAX_RECONNECT_DELAY = 60000; // 1 minute max // Only send notifications on status CHANGES, not on every reconnect attempt const environmentOnlineStatus: Map = new Map(); -// Active collectors per environment +// Active collectors per environment (for streaming mode) const collectors: Map = new Map(); +// Poll intervals per environment (for polling mode) +const pollIntervals: Map> = new Map(); + +// Last poll timestamp per environment (for polling mode) +const lastPollTime: Map = new Map(); + // Recent event cache for deduplication (key: timeNano-containerId-action) const recentEvents: Map = new Map(); const DEDUP_WINDOW_MS = 5000; // 5 second window for deduplication @@ -30,6 +36,10 @@ const CACHE_CLEANUP_INTERVAL_MS = 30000; // Clean up cache every 30 seconds let cacheCleanupInterval: ReturnType | null = null; let isShuttingDown = false; +// Track current settings to detect changes +let currentPollInterval: number = 60000; +let currentMode: 'stream' | 'poll' = 'stream'; + // Actions we care about for container activity const CONTAINER_ACTIONS: ContainerEventAction[] = [ 'create', @@ -211,6 +221,76 @@ function processEvent(event: DockerEvent, envId: number) { }); } +/** + * Poll events for a specific environment (polling mode) + */ +async function pollEnvironmentEvents(envId: number, envName: string) { + try { + // Calculate 'since' timestamp (use last poll time, or start from 30s ago if first poll) + const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds + const since = lastPollTime.get(envId) || (now - 30); // Default to 30s ago on first poll + + // Fetch events since last check until now + // IMPORTANT: 'until' is required for polling mode, otherwise Docker keeps the connection open + const eventStream = await getDockerEvents( + { type: ['container'] }, + envId, + { since: since.toString(), until: now.toString() } + ); + + if (!eventStream) { + console.error(`[EventSubprocess] Failed to fetch events for ${envName}`); + updateEnvironmentStatus(envId, envName, false, 'Failed to fetch Docker events'); + return; + } + + // Mark environment as online + updateEnvironmentStatus(envId, envName, true); + + // Read and process all events + const reader = eventStream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (line.trim()) { + try { + const event = JSON.parse(line) as DockerEvent; + processEvent(event, envId); + } catch { + // Ignore parse errors + } + } + } + } + } finally { + try { + reader.releaseLock(); + } catch { + // Reader already released + } + } + + // Update last poll time + lastPollTime.set(envId, now); + + } catch (error: any) { + if (!isShuttingDown) { + console.error(`[EventSubprocess] Poll error for ${envName}:`, error.message); + updateEnvironmentStatus(envId, envName, false, error.message); + } + } +} + /** * Start collecting events for a specific environment */ @@ -332,7 +412,42 @@ async function startEnvironmentCollector(envId: number, envName: string) { } /** - * Stop collecting events for a specific environment + * Start polling mode for a specific environment + */ +async function startEnvironmentPoller(envId: number, envName: string, interval: number) { + // Stop existing poller if any + stopEnvironmentPoller(envId); + + console.log(`[EventSubprocess] Starting poller for ${envName} (every ${interval / 1000}s)`); + + // Initial poll immediately + await pollEnvironmentEvents(envId, envName); + + // Set up interval for subsequent polls + const intervalId = setInterval(async () => { + if (!isShuttingDown) { + await pollEnvironmentEvents(envId, envName); + } + }, interval); + + pollIntervals.set(envId, intervalId); +} + +/** + * Stop polling for a specific environment + */ +function stopEnvironmentPoller(envId: number) { + const intervalId = pollIntervals.get(envId); + if (intervalId) { + clearInterval(intervalId); + pollIntervals.delete(envId); + lastPollTime.delete(envId); + environmentOnlineStatus.delete(envId); + } +} + +/** + * Stop collecting events for a specific environment (streaming mode) */ function stopEnvironmentCollector(envId: number) { const controller = collectors.get(envId); @@ -351,6 +466,21 @@ async function refreshEventCollectors() { try { const environments = await getEnvironments(); + const mode = await getEventCollectionMode(); + const pollInterval = await getEventPollInterval(); + + // Detect if settings changed + const modeChanged = mode !== currentMode; + const intervalChanged = pollInterval !== currentPollInterval; + + if (modeChanged) { + console.log(`[EventSubprocess] Mode changed from ${currentMode} to ${mode}`); + currentMode = mode; + } + if (intervalChanged) { + console.log(`[EventSubprocess] Poll interval changed from ${currentPollInterval}ms to ${pollInterval}ms`); + currentPollInterval = pollInterval; + } // Filter: only collect for environments with activity enabled AND not Hawser Edge const activeEnvIds = new Set( @@ -362,18 +492,55 @@ async function refreshEventCollectors() { // Stop collectors for removed environments or those with collection disabled for (const envId of collectors.keys()) { if (!activeEnvIds.has(envId)) { - console.log(`[EventSubprocess] Stopping collector for environment ${envId}`); + console.log(`[EventSubprocess] Stopping stream collector for environment ${envId}`); stopEnvironmentCollector(envId); } } - // Start collectors for environments with collection enabled + // Stop pollers for removed environments or those with collection disabled + // Also restart all pollers if interval changed + for (const envId of pollIntervals.keys()) { + if (!activeEnvIds.has(envId)) { + console.log(`[EventSubprocess] Stopping poller for environment ${envId}`); + stopEnvironmentPoller(envId); + } else if (intervalChanged && mode === 'poll') { + // Restart poller with new interval + console.log(`[EventSubprocess] Restarting poller for environment ${envId} with new interval`); + stopEnvironmentPoller(envId); + } + } + + // Start collectors based on mode for (const env of environments) { // Skip Hawser Edge (handled by main process) if (env.connectionType === 'hawser-edge') continue; - if (env.collectActivity && !collectors.has(env.id)) { - startEnvironmentCollector(env.id, env.name); + // Skip if activity collection is disabled + if (!env.collectActivity) continue; + + const hasStreamCollector = collectors.has(env.id); + const hasPoller = pollIntervals.has(env.id); + + if (mode === 'stream') { + // Switch from polling to streaming if needed + if (hasPoller) { + console.log(`[EventSubprocess] Switching ${env.name} from poll to stream`); + stopEnvironmentPoller(env.id); + } + // Start stream if not already running + if (!hasStreamCollector) { + startEnvironmentCollector(env.id, env.name); + } + } else if (mode === 'poll') { + // Switch from streaming to polling if needed + if (hasStreamCollector) { + console.log(`[EventSubprocess] Switching ${env.name} from stream to poll`); + stopEnvironmentCollector(env.id); + } + // Start poller if not already running (will also restart after interval change above) + if (!hasPoller) { + startEnvironmentPoller(env.id, env.name, pollInterval); + } } } } catch (error) { @@ -392,6 +559,13 @@ function handleCommand(command: MainProcessCommand): void { refreshEventCollectors(); break; + case 'update_interval': + // This is used by metrics subprocess, but we handle it here too for consistency + // Event subprocess re-reads interval from DB on refresh + console.log('[EventSubprocess] Interval update - refreshing collectors...'); + refreshEventCollectors(); + break; + case 'shutdown': console.log('[EventSubprocess] Shutdown requested'); shutdown(); @@ -411,11 +585,16 @@ function shutdown(): void { cacheCleanupInterval = null; } - // Stop all environment collectors + // Stop all environment stream collectors for (const envId of collectors.keys()) { stopEnvironmentCollector(envId); } + // Stop all environment pollers + for (const envId of pollIntervals.keys()) { + stopEnvironmentPoller(envId); + } + // Clear the deduplication cache recentEvents.clear(); @@ -429,6 +608,15 @@ function shutdown(): void { async function start(): Promise { console.log('[EventSubprocess] Starting container event collection...'); + // Initialize current settings from database + try { + currentMode = await getEventCollectionMode(); + currentPollInterval = await getEventPollInterval(); + console.log(`[EventSubprocess] Initial mode: ${currentMode}, poll interval: ${currentPollInterval}ms`); + } catch (error) { + console.error('[EventSubprocess] Failed to load settings, using defaults:', error); + } + // Start collectors for all environments await refreshEventCollectors(); diff --git a/src/lib/server/subprocesses/metrics-subprocess.ts b/src/lib/server/subprocesses/metrics-subprocess.ts index 139e6fa..74669c0 100644 --- a/src/lib/server/subprocesses/metrics-subprocess.ts +++ b/src/lib/server/subprocesses/metrics-subprocess.ts @@ -7,12 +7,12 @@ * Communication with main process via IPC (process.send). */ -import { getEnvironments, getEnvSetting } from '../db'; +import { getEnvironments, getEnvSetting, getMetricsCollectionInterval } from '../db'; import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from '../docker'; import os from 'node:os'; import type { MainProcessCommand } from '../subprocess-manager'; -const COLLECT_INTERVAL = 10000; // 10 seconds +let COLLECT_INTERVAL = 30000; // 30 seconds (default, will be loaded from settings) const DISK_CHECK_INTERVAL = 300000; // 5 minutes const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings const ENV_METRICS_TIMEOUT = 15000; // 15 seconds timeout per environment for metrics @@ -82,12 +82,16 @@ async function collectEnvMetrics(env: { id: number; name: string; host?: string; cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100; } - // Get container memory usage (subtract cache for actual usage) + // Get container memory usage using the same formula as Docker CLI + // Docker subtracts cache (inactive_file) from total usage + // - cgroup v2: uses 'inactive_file' + // - cgroup v1: uses 'total_inactive_file' const memUsage = stats.memory_stats?.usage || 0; - const memCache = stats.memory_stats?.stats?.cache || 0; - const actualMemUsed = memUsage - memCache; + const memStats = stats.memory_stats?.stats || {}; + const memCache = memStats.inactive_file ?? memStats.total_inactive_file ?? 0; + const actualMemUsed = memCache > 0 && memCache < memUsage ? memUsage - memCache : memUsage; - return { cpuPercent, memUsage: actualMemUsed > 0 ? actualMemUsed : memUsage }; + return { cpuPercent, memUsage: actualMemUsed }; } catch { return { cpuPercent: 0, memUsage: 0 }; } @@ -356,6 +360,16 @@ function handleCommand(command: MainProcessCommand): void { // The next collection cycle will pick up the new environments break; + case 'update_interval': + console.log(`[MetricsSubprocess] Updating collection interval to ${command.intervalMs}ms`); + COLLECT_INTERVAL = command.intervalMs; + // Clear existing interval and restart with new timing + if (collectInterval) { + clearInterval(collectInterval); + collectInterval = setInterval(collectMetrics, COLLECT_INTERVAL); + } + break; + case 'shutdown': console.log('[MetricsSubprocess] Shutdown requested'); shutdown(); @@ -386,8 +400,15 @@ function shutdown(): void { /** * Start the metrics collector */ -function start(): void { - console.log('[MetricsSubprocess] Starting metrics collection (every 10s)...'); +async function start(): Promise { + // Load interval from settings + try { + COLLECT_INTERVAL = await getMetricsCollectionInterval(); + console.log(`[MetricsSubprocess] Starting metrics collection (every ${COLLECT_INTERVAL / 1000}s)...`); + } catch (error) { + console.error('[MetricsSubprocess] Failed to load interval from settings, using default 30s'); + COLLECT_INTERVAL = 30000; + } // Initial collection collectMetrics(); diff --git a/src/lib/stores/grid-preferences.ts b/src/lib/stores/grid-preferences.ts index 3c174af..cdcb276 100644 --- a/src/lib/stores/grid-preferences.ts +++ b/src/lib/stores/grid-preferences.ts @@ -70,8 +70,21 @@ function createGridPreferencesStore() { return getDefaultColumnPreferences(gridId); } - // Return columns in saved order, filtering to visible ones - return gridPrefs.columns.filter((col) => col.visible); + // Merge with defaults to ensure new columns are included + const defaults = getDefaultColumnPreferences(gridId); + const savedIds = new Set(gridPrefs.columns.map((c) => c.id)); + + // Start with saved visible columns + const result = gridPrefs.columns.filter((col) => col.visible); + + // Add any new default columns that aren't in saved preferences + for (const def of defaults) { + if (!savedIds.has(def.id) && def.visible) { + result.push(def); + } + } + + return result; }, // Get all columns for a grid (visible and hidden, in order) diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index d067e7f..d88e1ce 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -4,6 +4,7 @@ import { browser } from '$app/environment'; export type TimeFormat = '12h' | '24h'; export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY'; export type DownloadFormat = 'tar' | 'tar.gz'; +export type EventCollectionMode = 'stream' | 'poll'; export interface AppSettings { confirmDestructive: boolean; @@ -22,6 +23,11 @@ export interface AppSettings { eventCleanupEnabled: boolean; logBufferSizeKb: number; defaultTimezone: string; + eventCollectionMode: EventCollectionMode; + eventPollInterval: number; + metricsCollectionInterval: number; + externalStackPaths: string[]; + primaryStackLocation: string | null; } const DEFAULT_SETTINGS: AppSettings = { @@ -40,7 +46,12 @@ const DEFAULT_SETTINGS: AppSettings = { scheduleCleanupEnabled: true, eventCleanupEnabled: true, logBufferSizeKb: 500, - defaultTimezone: 'UTC' + defaultTimezone: 'UTC', + eventCollectionMode: 'stream', + eventPollInterval: 60000, + metricsCollectionInterval: 30000, + externalStackPaths: [], + primaryStackLocation: null }; // Create a writable store for app settings @@ -73,7 +84,12 @@ function createSettingsStore() { scheduleCleanupEnabled: settings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled, eventCleanupEnabled: settings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled, logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb, - defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone + defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone, + eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode, + eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval, + metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval, + externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths, + primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation }); } } catch { @@ -109,7 +125,12 @@ function createSettingsStore() { scheduleCleanupEnabled: updatedSettings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled, eventCleanupEnabled: updatedSettings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled, logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb, - defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone + defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone, + eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode, + eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval, + metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval, + externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths, + primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation }); } } catch (error) { @@ -248,6 +269,41 @@ function createSettingsStore() { return newSettings; }); }, + setEventCollectionMode: (value: EventCollectionMode) => { + update((current) => { + const newSettings = { ...current, eventCollectionMode: value }; + saveSettings({ eventCollectionMode: value }); + return newSettings; + }); + }, + setEventPollInterval: (value: number) => { + update((current) => { + const newSettings = { ...current, eventPollInterval: value }; + saveSettings({ eventPollInterval: value }); + return newSettings; + }); + }, + setMetricsCollectionInterval: (value: number) => { + update((current) => { + const newSettings = { ...current, metricsCollectionInterval: value }; + saveSettings({ metricsCollectionInterval: value }); + return newSettings; + }); + }, + setExternalStackPaths: (value: string[]) => { + update((current) => { + const newSettings = { ...current, externalStackPaths: value }; + saveSettings({ externalStackPaths: value }); + return newSettings; + }); + }, + setPrimaryStackLocation: (value: string | null) => { + update((current) => { + const newSettings = { ...current, primaryStackLocation: value }; + saveSettings({ primaryStackLocation: value }); + return newSettings; + }); + }, // Manual refresh from database refresh: loadSettings }; diff --git a/src/lib/types.ts b/src/lib/types.ts index 17bf013..a9309d2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -34,6 +34,7 @@ export interface ImageInfo { size: number; virtualSize: number; labels: Record; + containers: number; // Number of containers using this image } export interface VolumeUsage { @@ -90,7 +91,9 @@ export interface ContainerStats { id: string; name: string; cpuPercent: number; - memoryUsage: number; + memoryUsage: number; // Actual usage (total - cache), same as docker stats + memoryRaw: number; // Raw total usage before cache subtraction + memoryCache: number; // File cache (inactive_file) memoryLimit: number; memoryPercent: number; networkRx: number; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a589049..83c36c3 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -335,7 +335,7 @@ connectionType: env.connectionType || 'socket', labels: env.labels || [], scannerEnabled: false, - online: false, + online: undefined, // undefined = connecting, false = offline, true = online containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 }, images: { total: 0, totalSize: 0 }, volumes: { total: 0, totalSize: 0 }, diff --git a/src/routes/activity/+page.svelte b/src/routes/activity/+page.svelte index c859190..172f39d 100644 --- a/src/routes/activity/+page.svelte +++ b/src/routes/activity/+page.svelte @@ -30,7 +30,9 @@ Loader2, FileX, Heart, - Search + Search, + Wifi, + Radio } from 'lucide-svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import { currentEnvironment, environments as environmentsStore } from '$lib/stores/environment'; @@ -38,7 +40,7 @@ import { canAccess } from '$lib/stores/auth'; import ConfirmPopover from '$lib/components/ConfirmPopover.svelte'; import { toast } from 'svelte-sonner'; - import { formatDateTime } from '$lib/stores/settings'; + import { formatDateTime, appSettings } from '$lib/stores/settings'; import { NoEnvironment } from '$lib/components/ui/empty-state'; import { DataGrid } from '$lib/components/data-grid'; @@ -643,7 +645,20 @@
- 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" /> +
+ 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" /> + + {#if ($appSettings.eventCollectionMode || 'stream') === 'stream'} + + Stream + {:else if ($appSettings.eventCollectionMode || 'stream') === 'poll'} + + Poll({($appSettings.eventPollInterval || 60000) / 1000}s) + {:else} + Off + {/if} + +
diff --git a/src/routes/api/containers/[id]/logs/stream/+server.ts b/src/routes/api/containers/[id]/logs/stream/+server.ts index 9e2b77d..fd0d83a 100644 --- a/src/routes/api/containers/[id]/logs/stream/+server.ts +++ b/src/routes/api/containers/[id]/logs/stream/+server.ts @@ -285,11 +285,19 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`; const inspectHeaders: Record = {}; if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken; - inspectResponse = await fetch(inspectUrl, { - headers: inspectHeaders, - // @ts-ignore - tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined - }); + const fetchOpts: any = { headers: inspectHeaders }; + if (config.type === 'https') { + fetchOpts.tls = { + sessionTimeout: 0, // Disable TLS session caching for mTLS + servername: config.host, + rejectUnauthorized: true + }; + if (config.ca) fetchOpts.tls.ca = [config.ca]; + if (config.cert) fetchOpts.tls.cert = [config.cert]; + if (config.key) fetchOpts.tls.key = config.key; + fetchOpts.keepalive = false; + } + inspectResponse = await fetch(inspectUrl, fetchOpts); } if (inspectResponse.ok) { @@ -341,12 +349,22 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`; const logsHeaders: Record = {}; if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken; - response = await fetch(logsUrl, { + const fetchOpts: any = { headers: logsHeaders, - signal: abortController?.signal, - // @ts-ignore - tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined - }); + signal: abortController?.signal + }; + if (config.type === 'https') { + fetchOpts.tls = { + sessionTimeout: 0, // Disable TLS session caching for mTLS + servername: config.host, + rejectUnauthorized: true + }; + if (config.ca) fetchOpts.tls.ca = [config.ca]; + if (config.cert) fetchOpts.tls.cert = [config.cert]; + if (config.key) fetchOpts.tls.key = config.key; + fetchOpts.keepalive = false; + } + response = await fetch(logsUrl, fetchOpts); } if (!response.ok) { diff --git a/src/routes/api/containers/[id]/stats/+server.ts b/src/routes/api/containers/[id]/stats/+server.ts index 5690d85..c06e99c 100644 --- a/src/routes/api/containers/[id]/stats/+server.ts +++ b/src/routes/api/containers/[id]/stats/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getContainerStats } from '$lib/server/docker'; +import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import { hasEnvironments } from '$lib/server/db'; @@ -47,6 +47,28 @@ function calculateBlockIO(stats: any): { read: number; write: number } { return { read, write }; } +/** + * Calculate memory usage the same way Docker CLI does. + * Docker subtracts cache (inactive_file) from total usage to show actual memory consumption. + * - cgroup v2: subtract inactive_file from stats + * - cgroup v1: subtract total_inactive_file from stats + * See: https://docs.docker.com/engine/containers/runmetrics/ + * + * Returns: { usage: actual memory (minus cache), raw: total usage, cache: file cache } + */ +function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } { + const raw = memoryStats?.usage || 0; + const stats = memoryStats?.stats || {}; + + // cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file' + const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0; + + // Only subtract cache if it's less than raw usage (sanity check) + const usage = (cache > 0 && cache < raw) ? raw - cache : raw; + + return { usage, raw, cache }; +} + export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); @@ -67,15 +89,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const stats = await getContainerStats(params.id, envIdNum) as any; const cpuPercent = calculateCpuPercent(stats); - const memoryUsage = stats.memory_stats?.usage || 0; + const memory = calculateMemoryUsage(stats.memory_stats); const memoryLimit = stats.memory_stats?.limit || 1; - const memoryPercent = (memoryUsage / memoryLimit) * 100; + const memoryPercent = (memory.usage / memoryLimit) * 100; const networkIO = calculateNetworkIO(stats); const blockIO = calculateBlockIO(stats); return json({ cpuPercent: Math.round(cpuPercent * 100) / 100, - memoryUsage, + memoryUsage: memory.usage, + memoryRaw: memory.raw, + memoryCache: memory.cache, memoryLimit, memoryPercent: Math.round(memoryPercent * 100) / 100, networkRx: networkIO.rx, @@ -85,6 +109,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { timestamp: Date.now() }); } catch (error: any) { + // Return 404 for deleted environments so client can clear stale cache + if (error instanceof EnvironmentNotFoundError) { + return json({ error: 'Environment not found' }, { status: 404 }); + } console.error('Failed to get container stats:', error); return json({ error: error.message || 'Failed to get stats' }, { status: 500 }); } diff --git a/src/routes/api/containers/stats/+server.ts b/src/routes/api/containers/stats/+server.ts index 60b78a5..ed1bf6e 100644 --- a/src/routes/api/containers/stats/+server.ts +++ b/src/routes/api/containers/stats/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { listContainers, getContainerStats } from '$lib/server/docker'; +import { listContainers, getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import { hasEnvironments } from '$lib/server/db'; import type { ContainerStats } from '$lib/types'; @@ -48,6 +48,28 @@ function calculateBlockIO(stats: any): { read: number; write: number } { return { read, write }; } +/** + * Calculate memory usage the same way Docker CLI does. + * Docker subtracts cache (inactive_file) from total usage to show actual memory consumption. + * - cgroup v2: subtract inactive_file from stats + * - cgroup v1: subtract total_inactive_file from stats + * See: https://docs.docker.com/engine/containers/runmetrics/ + * + * Returns: { usage: actual memory (minus cache), raw: total usage, cache: file cache } + */ +function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } { + const raw = memoryStats?.usage || 0; + const stats = memoryStats?.stats || {}; + + // cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file' + const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0; + + // Only subtract cache if it's less than raw usage (sanity check) + const usage = (cache > 0 && cache < raw) ? raw - cache : raw; + + return { usage, raw, cache }; +} + // Helper to add timeout to promises function withTimeout(promise: Promise, ms: number, fallback: T): Promise { return Promise.race([ @@ -112,10 +134,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => { if (!stats) return null; const cpuPercent = calculateCpuPercent(stats); - // Use raw memory usage (total memory attributed to container) - const memoryUsage = stats.memory_stats?.usage || 0; + // Calculate memory usage the same way Docker CLI does (excludes cache) + const memory = calculateMemoryUsage(stats.memory_stats); const memoryLimit = stats.memory_stats?.limit || 1; - const memoryPercent = (memoryUsage / memoryLimit) * 100; + const memoryPercent = (memory.usage / memoryLimit) * 100; const networkIO = calculateNetworkIO(stats); const blockIO = calculateBlockIO(stats); @@ -123,7 +145,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => { id: container.id, name: container.name, cpuPercent: Math.round(cpuPercent * 100) / 100, - memoryUsage, + memoryUsage: memory.usage, + memoryRaw: memory.raw, + memoryCache: memory.cache, memoryLimit, memoryPercent: Math.round(memoryPercent * 100) / 100, networkRx: networkIO.rx, @@ -142,6 +166,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => { return json(validStats); } catch (error: any) { + // Return 404 for deleted environments so client can clear stale cache + if (error instanceof EnvironmentNotFoundError) { + return json({ error: 'Environment not found' }, { status: 404 }); + } console.error('Failed to get container stats:', error); return json([], { status: 200 }); // Return empty array instead of error } diff --git a/src/routes/api/dashboard/stats/+server.ts b/src/routes/api/dashboard/stats/+server.ts index 02bfe99..50ec6e3 100644 --- a/src/routes/api/dashboard/stats/+server.ts +++ b/src/routes/api/dashboard/stats/+server.ts @@ -52,7 +52,7 @@ export interface EnvironmentStats { updateCheckAutoUpdate: boolean; labels?: string[]; connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge'; - online: boolean; + online?: boolean; // undefined = connecting, false = offline, true = online error?: string; containers: { total: number; diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts index 4fc1f8b..e8b35b4 100644 --- a/src/routes/api/dashboard/stats/stream/+server.ts +++ b/src/routes/api/dashboard/stats/stream/+server.ts @@ -12,7 +12,6 @@ import { listContainers, listImages, listNetworks, - getDockerInfo, getContainerStats, getDiskUsage } from '$lib/server/docker'; @@ -95,6 +94,28 @@ function calculateCpuPercent(stats: any): number { return 0; } +/** + * Calculate memory usage the same way Docker CLI does. + * Docker subtracts cache (inactive_file) from total usage to show actual memory consumption. + * - cgroup v2: subtract inactive_file from stats + * - cgroup v1: subtract total_inactive_file from stats + * See: https://docs.docker.com/engine/containers/runmetrics/ + */ +function calculateMemoryUsage(memoryStats: any): number { + const usage = memoryStats?.usage || 0; + const stats = memoryStats?.stats || {}; + + // cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file' + const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0; + + // Only subtract cache if it's less than usage (sanity check) + if (cache > 0 && cache < usage) { + return usage - cache; + } + + return usage; +} + // Progressive stats loading - returns stats object and emits partial updates via callback async function getEnvironmentStatsProgressive( env: any, @@ -150,23 +171,9 @@ async function getEnvironmentStatsProgressive( envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate; } - // Check if Docker is accessible (with 5 second timeout) - const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null); - if (!dockerInfo) { - envStats.error = 'Connection timeout or Docker not accessible'; - envStats.loading = undefined; // Clear loading states on error - // Send offline status to client - onPartialUpdate({ - id: env.id, - online: false, - error: envStats.error, - loading: undefined - }); - return envStats; - } - envStats.online = true; - // Get all database stats in parallel for better performance + // NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others + // Instead, we determine online status from whether listContainers succeeds const [latestMetrics, eventStats, recentEventsResult, metricsHistory] = await Promise.all([ getLatestHostMetrics(env.id), getContainerEventStats(env.id), @@ -204,10 +211,9 @@ async function getEnvironmentStatsProgressive( })); } - // Send initial update with DB data and online status + // Send initial update with DB data (online status determined later by Docker API success) onPartialUpdate({ id: env.id, - online: true, metrics: envStats.metrics, events: envStats.events, recentEvents: envStats.recentEvents, @@ -223,9 +229,22 @@ async function getEnvironmentStatsProgressive( return size && size > 0 ? size : 0; }; - // PHASE 1: Containers (usually fast) - const containersPromise = withTimeout(listContainers(true, env.id).catch(() => []), 10000, []) + // Track if Docker API is accessible - determined by listContainers success + let dockerApiAccessible = false; + let dockerApiError: string | null = null; + + // PHASE 1: Containers (usually fast) - this determines online status + // Use 10s timeout - this is the critical path that determines if env is online + const containersPromise = withTimeout(listContainers(true, env.id), 10000, null) .then(async (containers) => { + // Timeout returns null + if (containers === null) { + throw new Error('Connection timeout'); + } + // If we got here, Docker API is accessible + dockerApiAccessible = true; + envStats.online = true; + envStats.containers.total = containers.length; envStats.containers.running = containers.filter((c: any) => c.state === 'running').length; envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length; @@ -236,11 +255,39 @@ async function getEnvironmentStatsProgressive( onPartialUpdate({ id: env.id, + online: true, containers: { ...envStats.containers }, loading: { ...envStats.loading! } }); return containers; + }) + .catch((error) => { + // Docker API failed - mark as offline + dockerApiAccessible = false; + const errorStr = String(error); + if (errorStr.includes('not connected') || errorStr.includes('Edge agent')) { + dockerApiError = 'Agent not connected'; + } else if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) { + dockerApiError = 'Docker socket not accessible'; + } else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) { + dockerApiError = 'Connection lost'; + } else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) { + dockerApiError = 'Connection timeout'; + } else { + dockerApiError = 'Connection error'; + } + envStats.error = dockerApiError; + envStats.loading!.containers = false; + + onPartialUpdate({ + id: env.id, + online: false, + error: dockerApiError, + loading: { ...envStats.loading! } + }); + + return [] as any[]; }); // PHASE 2: Images, Networks, Stacks (medium speed) - run in parallel @@ -339,7 +386,7 @@ async function getEnvironmentStatsProgressive( if (!stats) return null; const cpuPercent = calculateCpuPercent(stats); - const memoryUsage = stats.memory_stats?.usage || 0; + const memoryUsage = calculateMemoryUsage(stats.memory_stats); const memoryLimit = stats.memory_stats?.limit || 1; const memoryPercent = (memoryUsage / memoryLimit) * 100; diff --git a/src/routes/api/environments/+server.ts b/src/routes/api/environments/+server.ts index d97c978..46b8d71 100644 --- a/src/routes/api/environments/+server.ts +++ b/src/routes/api/environments/+server.ts @@ -1,9 +1,10 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getEnvironments, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db'; +import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; +import { cleanPem } from '$lib/utils/pem'; export const GET: RequestHandler = async ({ cookies }) => { const auth = await authorize(cookies); @@ -69,6 +70,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => { return json({ error: 'Name is required' }, { status: 400 }); } + // Check if environment with this name already exists + const existing = await getEnvironmentByName(data.name); + if (existing) { + return json({ error: 'An environment with this name already exists' }, { status: 409 }); + } + // Host is required for direct and hawser-standard connections const connectionType = data.connectionType || 'socket'; if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) { @@ -83,9 +90,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { host: data.host, port: data.port || 2375, protocol: data.protocol || 'http', - tlsCa: data.tlsCa, - tlsCert: data.tlsCert, - tlsKey: data.tlsKey, + tlsCa: cleanPem(data.tlsCa), + tlsCert: cleanPem(data.tlsCert), + tlsKey: cleanPem(data.tlsKey), tlsSkipVerify: data.tlsSkipVerify || false, icon: data.icon || 'globe', socketPath: data.socketPath || '/var/run/docker.sock', @@ -124,7 +131,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => { return json(env); } catch (error) { console.error('Failed to create environment:', error); - const message = error instanceof Error ? error.message : 'Failed to create environment'; - return json({ error: message }, { status: 500 }); + return json({ error: 'Failed to create environment' }, { status: 500 }); } }; diff --git a/src/routes/api/environments/[id]/+server.ts b/src/routes/api/environments/[id]/+server.ts index 52c4ac6..1f34bd9 100644 --- a/src/routes/api/environments/[id]/+server.ts +++ b/src/routes/api/environments/[id]/+server.ts @@ -6,6 +6,7 @@ import { deleteGitStackFiles } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager'; import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors'; +import { cleanPem } from '$lib/utils/pem'; import { unregisterSchedule } from '$lib/server/scheduler'; import { closeEdgeConnection } from '$lib/server/hawser'; @@ -62,9 +63,9 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { host: data.host, port: data.port, protocol: data.protocol, - tlsCa: data.tlsCa, - tlsCert: data.tlsCert, - tlsKey: data.tlsKey, + tlsCa: cleanPem(data.tlsCa), + tlsCert: cleanPem(data.tlsCert), + tlsKey: cleanPem(data.tlsKey), tlsSkipVerify: data.tlsSkipVerify, icon: data.icon, socketPath: data.socketPath, diff --git a/src/routes/api/environments/test/+server.ts b/src/routes/api/environments/test/+server.ts index f7221ff..7222c8b 100644 --- a/src/routes/api/environments/test/+server.ts +++ b/src/routes/api/environments/test/+server.ts @@ -60,50 +60,54 @@ export const POST: RequestHandler = async ({ request }) => { headers['X-Hawser-Token'] = config.hawserToken; } - // For HTTPS with custom CA or skip verification, use subprocess to avoid Vite dev server TLS issues - if (protocol === 'https' && (config.tlsCa || config.tlsSkipVerify)) { - const fs = await import('node:fs'); - let tempCaPath = ''; + // For HTTPS with custom CA, client certs, or skip verification, use subprocess to avoid Vite dev server TLS issues + if (protocol === 'https' && (config.tlsCa || config.tlsCert || config.tlsSkipVerify)) { + // Clean PEM content (remove extra whitespace) + const cleanPem = (pem: string) => pem + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join('\n'); - // Clean the certificate - remove leading/trailing whitespace from each line - let cleanedCa = ''; - if (config.tlsCa && !config.tlsSkipVerify) { - cleanedCa = config.tlsCa - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .join('\n'); - - tempCaPath = `/tmp/dockhand-ca-${Date.now()}.pem`; - fs.writeFileSync(tempCaPath, cleanedCa); - } - - // Build Bun script that runs outside Vite's process (Vite interferes with TLS) - const tlsConfig = config.tlsSkipVerify - ? `tls: { rejectUnauthorized: false }` - : `tls: { ca: await Bun.file('${tempCaPath}').text() }`; + // Pass config as base64-encoded JSON to avoid escaping issues + const tlsConfig = { + url: `https://${host}:${port}/info`, + headers, + tlsSkipVerify: config.tlsSkipVerify || false, + ca: config.tlsCa && !config.tlsSkipVerify ? cleanPem(config.tlsCa) : null, + cert: config.tlsCert ? cleanPem(config.tlsCert) : null, + key: config.tlsKey ? cleanPem(config.tlsKey) : null, + host + }; + const configBase64 = Buffer.from(JSON.stringify(tlsConfig)).toString('base64'); + // Inline script with config embedded (bun -e doesn't pass argv correctly) const scriptContent = ` -const response = await fetch('https://${host}:${port}/info', { - headers: ${JSON.stringify(headers)}, - ${tlsConfig} -}); -const body = await response.text(); -console.log(JSON.stringify({ status: response.status, body })); +const config = JSON.parse(Buffer.from('${configBase64}', 'base64').toString()); +try { + const tls = { + sessionTimeout: 0, + servername: config.host, + rejectUnauthorized: !config.tlsSkipVerify + }; + if (config.ca) tls.ca = [config.ca]; + if (config.cert) tls.cert = [config.cert]; + if (config.key) tls.key = config.key; + const response = await fetch(config.url, { + headers: config.headers, + tls, + keepalive: false + }); + const body = await response.text(); + console.log(JSON.stringify({ status: response.status, body })); +} catch (e) { + console.log(JSON.stringify({ error: e.message })); +} `; - const scriptPath = `/tmp/dockhand-test-${Date.now()}.ts`; - fs.writeFileSync(scriptPath, scriptContent); - - const proc = Bun.spawn(['bun', scriptPath], { stdout: 'pipe', stderr: 'pipe' }); + const proc = Bun.spawn(['bun', '-e', scriptContent], { stdout: 'pipe', stderr: 'pipe' }); const output = await new Response(proc.stdout).text(); const stderr = await new Response(proc.stderr).text(); - // Cleanup temp files - if (tempCaPath) { - try { fs.unlinkSync(tempCaPath); } catch {} - } - try { fs.unlinkSync(scriptPath); } catch {} - if (!output.trim()) { throw new Error(stderr || 'Empty response from TLS test subprocess'); } diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts index caeb8c0..2ade909 100644 --- a/src/routes/api/events/+server.ts +++ b/src/routes/api/events/+server.ts @@ -1,5 +1,5 @@ import type { RequestHandler } from './$types'; -import { getDockerEvents } from '$lib/server/docker'; +import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker'; import { getEnvironment } from '$lib/server/db'; export const GET: RequestHandler = async ({ url }) => { @@ -118,8 +118,13 @@ export const GET: RequestHandler = async ({ url }) => { processEvents(); } catch (error: any) { - console.error('Failed to connect to Docker events:', error); - sendEvent('error', { message: error.message || 'Failed to connect to Docker' }); + if (error instanceof EnvironmentNotFoundError) { + // Expected error when environment doesn't exist - don't spam logs + sendEvent('error', { message: 'Environment not found' }); + } else { + console.error('Failed to connect to Docker events:', error); + sendEvent('error', { message: error.message || 'Failed to connect to Docker' }); + } clearInterval(heartbeatInterval); controller.close(); } diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts index 22e5f28..f769413 100644 --- a/src/routes/api/git/stacks/+server.ts +++ b/src/routes/api/git/stacks/+server.ts @@ -11,7 +11,7 @@ import { import { deployGitStack } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; import { registerSchedule } from '$lib/server/scheduler'; -import crypto from 'node:crypto'; +import { secureRandomBytes } from '$lib/server/crypto-fallback'; export const GET: RequestHandler = async ({ url, cookies }) => { const auth = await authorize(cookies); @@ -94,7 +94,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { // Generate webhook secret if webhook is enabled let webhookSecret = data.webhookSecret; if (data.webhookEnabled && !webhookSecret) { - webhookSecret = crypto.randomBytes(32).toString('hex'); + webhookSecret = secureRandomBytes(32).toString('hex'); } const gitStack = await createGitStack({ diff --git a/src/routes/api/registry/tags/+server.ts b/src/routes/api/registry/tags/+server.ts index cba2360..b19312a 100644 --- a/src/routes/api/registry/tags/+server.ts +++ b/src/routes/api/registry/tags/+server.ts @@ -16,7 +16,16 @@ function isDockerHub(url: string): boolean { lower.includes('registry.hub.docker.com'); } -async function fetchDockerHubTags(imageName: string): Promise { +interface PaginatedTags { + tags: TagInfo[]; + total: number; + page: number; + pageSize: number; + hasNext: boolean; + hasPrev: boolean; +} + +async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize: number = 20): Promise { // Docker Hub uses a different API // For official images: https://hub.docker.com/v2/repositories/library//tags // For user images: https://hub.docker.com/v2/repositories///tags @@ -27,7 +36,7 @@ async function fetchDockerHubTags(imageName: string): Promise { repoPath = `library/${imageName}`; } - const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=100&ordering=last_updated`; + const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=${pageSize}&page=${page}&ordering=last_updated`; const response = await fetch(url, { headers: { @@ -45,12 +54,21 @@ async function fetchDockerHubTags(imageName: string): Promise { const data = await response.json(); const results = data.results || []; - return results.map((tag: any) => ({ + const tags = results.map((tag: any) => ({ name: tag.name, size: tag.full_size || tag.images?.[0]?.size, lastUpdated: tag.last_updated || tag.tag_last_pushed, digest: tag.images?.[0]?.digest })); + + return { + tags, + total: data.count || 0, + page, + pageSize, + hasNext: !!data.next, + hasPrev: !!data.previous + }; } async function fetchRegistryTags(registry: any, imageName: string): Promise { @@ -104,16 +122,18 @@ export const GET: RequestHandler = async ({ url }) => { try { const registryId = url.searchParams.get('registry'); const imageName = url.searchParams.get('image'); + const page = parseInt(url.searchParams.get('page') || '1'); + const pageSize = parseInt(url.searchParams.get('pageSize') || '20'); if (!imageName) { return json({ error: 'Image name is required' }, { status: 400 }); } - let tags: TagInfo[]; + let result: PaginatedTags; if (!registryId) { // No registry specified, assume Docker Hub - tags = await fetchDockerHubTags(imageName); + result = await fetchDockerHubTags(imageName, page, pageSize); } else { const registry = await getRegistry(parseInt(registryId)); if (!registry) { @@ -121,13 +141,22 @@ export const GET: RequestHandler = async ({ url }) => { } if (isDockerHub(registry.url)) { - tags = await fetchDockerHubTags(imageName); + result = await fetchDockerHubTags(imageName, page, pageSize); } else { - tags = await fetchRegistryTags(registry, imageName); + // V2 registries don't support pagination well, return all tags + const tags = await fetchRegistryTags(registry, imageName); + result = { + tags, + total: tags.length, + page: 1, + pageSize: tags.length, + hasNext: false, + hasPrev: false + }; } } - return json(tags); + return json(result); } catch (error: any) { console.error('Error fetching tags:', error); diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index 6cd65ba..012429d 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -15,14 +15,26 @@ import { getEventCleanupEnabled, setEventCleanupEnabled, getDefaultTimezone, - setDefaultTimezone + setDefaultTimezone, + getEventCollectionMode, + setEventCollectionMode, + getEventPollInterval, + setEventPollInterval, + getMetricsCollectionInterval, + setMetricsCollectionInterval, + getExternalStackPaths, + setExternalStackPaths, + getPrimaryStackLocation, + setPrimaryStackLocation } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { refreshSystemJobs } from '$lib/server/scheduler'; +import { sendToEventSubprocess, sendToMetricsSubprocess, type UpdateIntervalCommand } from '$lib/server/subprocess-manager'; export type TimeFormat = '12h' | '24h'; export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY'; export type DownloadFormat = 'tar' | 'tar.gz'; +export type EventCollectionMode = 'stream' | 'poll'; export interface GeneralSettings { confirmDestructive: boolean; @@ -41,6 +53,10 @@ export interface GeneralSettings { eventCleanupEnabled: boolean; logBufferSizeKb: number; defaultTimezone: string; + // Background monitoring settings + eventCollectionMode: EventCollectionMode; + eventPollInterval: number; + metricsCollectionInterval: number; // Theme settings (for when auth is disabled) lightTheme: string; darkTheme: string; @@ -48,6 +64,10 @@ export interface GeneralSettings { fontSize: string; gridFontSize: string; terminalFont: string; + // External stack paths + externalStackPaths: string[]; + // Primary stack location + primaryStackLocation: string | null; } const DEFAULT_SETTINGS: Omit = { @@ -61,6 +81,9 @@ const DEFAULT_SETTINGS: Omit { eventCleanupEnabled, logBufferSizeKb, defaultTimezone, + eventCollectionMode, + eventPollInterval, + metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, - terminalFont + terminalFont, + externalStackPaths, + primaryStackLocation ] = await Promise.all([ getSetting('confirm_destructive'), getSetting('show_stopped_containers'), @@ -127,12 +155,17 @@ export const GET: RequestHandler = async ({ cookies }) => { getEventCleanupEnabled(), getSetting('log_buffer_size_kb'), getDefaultTimezone(), + getEventCollectionMode(), + getEventPollInterval(), + getMetricsCollectionInterval(), getSetting('theme_light'), getSetting('theme_dark'), getSetting('theme_font'), getSetting('theme_font_size'), getSetting('theme_grid_font_size'), - getSetting('theme_terminal_font') + getSetting('theme_terminal_font'), + getExternalStackPaths(), + getPrimaryStackLocation() ]); const settings: GeneralSettings = { @@ -152,12 +185,17 @@ export const GET: RequestHandler = async ({ cookies }) => { eventCleanupEnabled, logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb, defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone, + eventCollectionMode: (eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode, + eventPollInterval: eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval, + metricsCollectionInterval: metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval, lightTheme: lightTheme ?? DEFAULT_SETTINGS.lightTheme, darkTheme: darkTheme ?? DEFAULT_SETTINGS.darkTheme, font: font ?? DEFAULT_SETTINGS.font, fontSize: fontSize ?? DEFAULT_SETTINGS.fontSize, gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize, - terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont + terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont, + externalStackPaths, + primaryStackLocation }; return json(settings); @@ -175,7 +213,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { try { const body = await request.json(); - const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont } = body; + const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, externalStackPaths, primaryStackLocation } = body; if (confirmDestructive !== undefined) { await setSetting('confirm_destructive', confirmDestructive); @@ -228,6 +266,25 @@ export const POST: RequestHandler = async ({ request, cookies }) => { // Refresh system jobs to use the new timezone await refreshSystemJobs(); } + if (eventCollectionMode !== undefined && (eventCollectionMode === 'stream' || eventCollectionMode === 'poll')) { + await setEventCollectionMode(eventCollectionMode); + // Notify event subprocess to refresh collectors with new mode + sendToEventSubprocess({ type: 'refresh_environments' }); + } + if (eventPollInterval !== undefined && typeof eventPollInterval === 'number') { + // Validate: 30s - 300s (30 seconds to 5 minutes) + const validatedInterval = Math.max(30000, Math.min(300000, eventPollInterval)); + await setEventPollInterval(validatedInterval); + // Notify event subprocess to refresh collectors with new interval + sendToEventSubprocess({ type: 'refresh_environments' }); + } + if (metricsCollectionInterval !== undefined && typeof metricsCollectionInterval === 'number') { + // Validate: 10s - 300s (10 seconds to 5 minutes) + const validatedInterval = Math.max(10000, Math.min(300000, metricsCollectionInterval)); + await setMetricsCollectionInterval(validatedInterval); + // Notify metrics subprocess to update its collection interval + sendToMetricsSubprocess({ type: 'update_interval', intervalMs: validatedInterval }); + } if (lightTheme !== undefined && VALID_LIGHT_THEMES.includes(lightTheme)) { await setSetting('theme_light', lightTheme); } @@ -246,6 +303,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (terminalFont !== undefined && VALID_TERMINAL_FONTS.includes(terminalFont)) { await setSetting('theme_terminal_font', terminalFont); } + if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) { + // Filter to valid non-empty strings + const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim()); + await setExternalStackPaths(validPaths); + } + if (primaryStackLocation !== undefined) { + // Accept string or null + if (primaryStackLocation === null || (typeof primaryStackLocation === 'string' && primaryStackLocation.trim())) { + await setPrimaryStackLocation(primaryStackLocation); + } else if (primaryStackLocation === '') { + // Empty string means clear the setting + await setPrimaryStackLocation(null); + } + } // Fetch all settings in parallel for the response const [ @@ -265,12 +336,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => { eventCleanupEnabledVal, logBufferSizeKbVal, defaultTimezoneVal, + eventCollectionModeVal, + eventPollIntervalVal, + metricsCollectionIntervalVal, lightThemeVal, darkThemeVal, fontVal, fontSizeVal, gridFontSizeVal, - terminalFontVal + terminalFontVal, + externalStackPathsVal, + primaryStackLocationVal ] = await Promise.all([ getSetting('confirm_destructive'), getSetting('show_stopped_containers'), @@ -288,12 +364,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => { getEventCleanupEnabled(), getSetting('log_buffer_size_kb'), getDefaultTimezone(), + getEventCollectionMode(), + getEventPollInterval(), + getMetricsCollectionInterval(), getSetting('theme_light'), getSetting('theme_dark'), getSetting('theme_font'), getSetting('theme_font_size'), getSetting('theme_grid_font_size'), - getSetting('theme_terminal_font') + getSetting('theme_terminal_font'), + getExternalStackPaths(), + getPrimaryStackLocation() ]); const settings: GeneralSettings = { @@ -313,12 +394,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => { eventCleanupEnabled: eventCleanupEnabledVal, logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb, defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone, + eventCollectionMode: (eventCollectionModeVal ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode, + eventPollInterval: eventPollIntervalVal ?? DEFAULT_SETTINGS.eventPollInterval, + metricsCollectionInterval: metricsCollectionIntervalVal ?? DEFAULT_SETTINGS.metricsCollectionInterval, lightTheme: lightThemeVal ?? DEFAULT_SETTINGS.lightTheme, darkTheme: darkThemeVal ?? DEFAULT_SETTINGS.darkTheme, font: fontVal ?? DEFAULT_SETTINGS.font, fontSize: fontSizeVal ?? DEFAULT_SETTINGS.fontSize, gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize, - terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont + terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont, + externalStackPaths: externalStackPathsVal, + primaryStackLocation: primaryStackLocationVal }; return json(settings); diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts index 39a675e..14c245d 100644 --- a/src/routes/api/stacks/+server.ts +++ b/src/routes/api/stacks/+server.ts @@ -35,11 +35,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const existingNames = new Set(stacks.map((s) => s.name)); for (const source of stackSources) { - // Only add internal/git stacks that aren't already in the list - if ( - !existingNames.has(source.stackName) && - (source.sourceType === 'internal' || source.sourceType === 'git') - ) { + // Add stacks from database that aren't already in the Docker list + // This includes internal, git, and external (adopted) stacks that are currently down + if (!existingNames.has(source.stackName)) { stacks.push({ name: source.stackName, containers: [], @@ -78,7 +76,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { try { const body = await request.json(); - const { name, compose, start, envVars, rawEnvContent } = body; + const { name, compose, start, envVars, rawEnvContent, composePath, envPath } = body; if (!name || typeof name !== 'string') { return json({ error: 'Stack name is required' }, { status: 400 }); @@ -90,7 +88,10 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { // If start is false, only create the compose file without deploying if (start === false) { - const result = await saveStackComposeFile(name, compose, true); + const result = await saveStackComposeFile(name, compose, true, envIdNum, { + composePath: composePath || undefined, + envPath: envPath || undefined + }); if (!result.success) { return json({ error: result.error }, { status: 400 }); } @@ -100,7 +101,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { // - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata) if (rawEnvContent) { // Write raw content to .env file (should NOT contain secrets) - await writeRawStackEnvFile(name, rawEnvContent); + await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined); } // Save ALL vars to DB (secrets for shell injection at runtime) if (envVars && Array.isArray(envVars) && envVars.length > 0) { @@ -108,14 +109,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { } // Fallback: if no rawEnvContent, generate .env from non-secret vars if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) { - await saveStackEnvVars(name, envVars, envIdNum); + await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined); } - // Record the stack as internally created + // Record the stack as internally created with custom paths if provided await upsertStackSource({ stackName: name, environmentId: envIdNum, - sourceType: 'internal' + sourceType: 'internal', + composePath: composePath || undefined, + envPath: envPath || undefined }); return json({ success: true, started: false }); @@ -124,13 +127,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { // Save environment variables BEFORE deploying so they're available during start if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) { // First ensure the stack directory exists by saving compose file - await saveStackComposeFile(name, compose, true); + await saveStackComposeFile(name, compose, true, envIdNum, { + composePath: composePath || undefined, + envPath: envPath || undefined + }); // - rawEnvContent: non-secret vars with comments → .env file // - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata) if (rawEnvContent) { // Write raw content to .env file (should NOT contain secrets) - await writeRawStackEnvFile(name, rawEnvContent); + await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined); } // Save ALL vars to DB (secrets for shell injection at runtime) if (envVars && Array.isArray(envVars) && envVars.length > 0) { @@ -138,7 +144,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { } // Fallback: if no rawEnvContent, generate .env from non-secret vars if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) { - await saveStackEnvVars(name, envVars, envIdNum); + await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined); } } @@ -146,18 +152,22 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { const result = await deployStack({ name, compose, - envId: envIdNum + envId: envIdNum, + composePath: composePath || undefined, + envPath: envPath || undefined }); if (!result.success) { return json({ error: result.error, output: result.output }, { status: 400 }); } - // Record the stack as internally created + // Record the stack as internally created with custom paths if provided await upsertStackSource({ stackName: name, environmentId: envIdNum, - sourceType: 'internal' + sourceType: 'internal', + composePath: composePath || undefined, + envPath: envPath || undefined }); return json({ success: true, started: true, output: result.output }); diff --git a/src/routes/api/stacks/[name]/+server.ts b/src/routes/api/stacks/[name]/+server.ts index 38098b5..9ed0945 100644 --- a/src/routes/api/stacks/[name]/+server.ts +++ b/src/routes/api/stacks/[name]/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { removeStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks'; +import { removeStack, ComposeFileNotFoundError } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { auditStack } from '$lib/server/audit'; import type { RequestHandler } from './$types'; @@ -34,9 +34,6 @@ export const DELETE: RequestHandler = async (event) => { } return json({ success: true }); } catch (error) { - if (error instanceof ExternalStackError) { - return json({ error: error.message }, { status: 400 }); - } if (error instanceof ComposeFileNotFoundError) { return json({ error: error.message }, { status: 404 }); } diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts index fa2664a..750838f 100644 --- a/src/routes/api/stacks/[name]/compose/+server.ts +++ b/src/routes/api/stacks/[name]/compose/+server.ts @@ -4,22 +4,36 @@ import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/ser import { authorize } from '$lib/server/authorize'; // GET /api/stacks/[name]/compose - Get compose file content -export const GET: RequestHandler = async ({ params, cookies }) => { +export const GET: RequestHandler = async ({ params, url, cookies }) => { const auth = await authorize(cookies); if (auth.authEnabled && !(await auth.can('stacks', 'view'))) { return json({ error: 'Permission denied' }, { status: 403 }); } const { name } = params; + const envId = url.searchParams.get('env'); + const envIdNum = envId ? parseInt(envId) : undefined; try { - const result = await getStackComposeFile(name); + const result = await getStackComposeFile(name, envIdNum); if (!result.success) { - return json({ error: result.error }, { status: 404 }); + // Return info about what's needed - unified response for all missing compose files + return json({ + error: result.error, + needsFileLocation: result.needsFileLocation || false, + composePath: result.composePath, + envPath: result.envPath + }, { status: 404 }); } - return json({ content: result.content, stackDir: result.stackDir }); + return json({ + content: result.content, + stackDir: result.stackDir, + composePath: result.composePath, + envPath: result.envPath, + suggestedEnvPath: result.suggestedEnvPath + }); } catch (error: any) { console.error(`Error getting compose file for stack ${name}:`, error); return json({ error: error.message || 'Failed to get compose file' }, { status: 500 }); @@ -41,16 +55,29 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => try { const body = await request.json(); - const { content, restart = false } = body; + const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath } = body; if (!content || typeof content !== 'string') { return json({ error: 'Compose file content is required' }, { status: 400 }); } + // Build options object for custom paths, move operation, and file renames + const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath) + ? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath } + : undefined; + let result; if (restart) { // Deploy with docker compose up -d --force-recreate // Force recreate ensures env var changes are applied + // Note: deployStack uses requireComposeFile which will use saved paths + // Save paths first if provided + if (pathOptions) { + const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions); + if (!saveResult.success) { + return json({ error: saveResult.error }, { status: 500 }); + } + } result = await deployStack({ name, compose: content, @@ -58,8 +85,8 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) => forceRecreate: true }); } else { - // Just save the file without restarting - result = await saveStackComposeFile(name, content); + // Just save the file without restarting (update operation, not create) + result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions); } if (!result.success) { diff --git a/src/routes/api/stacks/[name]/down/+server.ts b/src/routes/api/stacks/[name]/down/+server.ts index 1995f71..19f6aa0 100644 --- a/src/routes/api/stacks/[name]/down/+server.ts +++ b/src/routes/api/stacks/[name]/down/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { downStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks'; +import { downStack, ComposeFileNotFoundError } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { auditStack } from '$lib/server/audit'; import type { RequestHandler } from './$types'; @@ -42,9 +42,6 @@ export const POST: RequestHandler = async (event) => { } return json({ success: true, output: result.output }); } catch (error) { - if (error instanceof ExternalStackError) { - return json({ error: error.message }, { status: 400 }); - } if (error instanceof ComposeFileNotFoundError) { return json({ error: error.message }, { status: 404 }); } diff --git a/src/routes/api/stacks/[name]/env/+server.ts b/src/routes/api/stacks/[name]/env/+server.ts index 4105e12..0265db6 100644 --- a/src/routes/api/stacks/[name]/env/+server.ts +++ b/src/routes/api/stacks/[name]/env/+server.ts @@ -1,9 +1,9 @@ import { json } from '@sveltejs/kit'; -import { getStackEnvVars, setStackEnvVars } from '$lib/server/db'; -import { getStacksDir } from '$lib/server/stacks'; +import { getStackEnvVars, setStackEnvVars, getStackSource } from '$lib/server/db'; +import { findStackDir } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; import type { RequestHandler } from './$types'; /** @@ -61,12 +61,37 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const dbVariables = await getStackEnvVars(stackName, envIdNum, true); const dbByKey = new Map(dbVariables.map(v => [v.key, v])); - // Try to read .env file from stack directory (only contains non-secrets) - const stacksDir = getStacksDir(); - const envFilePath = join(stacksDir, stackName, '.env'); + // Check if this stack has a custom compose path configured + const source = await getStackSource(stackName, envIdNum); + + // Determine the env file path based on path resolution rules: + // - envPath = '' (empty string) → explicitly no env file + // - envPath = '/path/.env' → use custom path + // - envPath = null with composePath → suggest .env next to compose (but don't auto-load) + // - envPath = null without composePath → use default location + let envFilePath: string | null = null; + + if (source?.envPath === '') { + // Empty string = explicitly no env file + envFilePath = null; + } else if (source?.envPath) { + // Custom env path specified + envFilePath = source.envPath; + } else if (source?.composePath) { + // Custom compose path but no env path - suggest .env next to compose + // For loading, check if it exists (but don't fail if it doesn't) + envFilePath = join(dirname(source.composePath), '.env'); + } else { + // Default location - .env in stack directory + const stackDir = await findStackDir(stackName, envIdNum); + if (stackDir) { + envFilePath = join(stackDir, '.env'); + } + } + let fileVars: Record = {}; - if (existsSync(envFilePath)) { + if (envFilePath && existsSync(envFilePath)) { try { const content = await Bun.file(envFilePath).text(); fileVars = parseEnvFile(content); diff --git a/src/routes/api/stacks/[name]/env/raw/+server.ts b/src/routes/api/stacks/[name]/env/raw/+server.ts index e5fac4d..6fbc3c5 100644 --- a/src/routes/api/stacks/[name]/env/raw/+server.ts +++ b/src/routes/api/stacks/[name]/env/raw/+server.ts @@ -1,8 +1,9 @@ import { json } from '@sveltejs/kit'; -import { getStacksDir } from '$lib/server/stacks'; +import { findStackDir, getStackDir } from '$lib/server/stacks'; +import { getStackSource } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { existsSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; import type { RequestHandler } from './$types'; /** @@ -26,11 +27,36 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { try { const stackName = decodeURIComponent(params.name); - const stacksDir = getStacksDir(); - const envFilePath = join(stacksDir, stackName, '.env'); + + // Check if this stack has custom paths configured + const source = await getStackSource(stackName, envIdNum); + + // Determine the env file path based on path resolution rules: + // - envPath = '' (empty string) → explicitly no env file + // - envPath = '/path/.env' → use custom path + // - envPath = null with composePath → suggest .env next to compose + // - envPath = null without composePath → use default location + let envFilePath: string | null = null; + + if (source?.envPath === '') { + // Empty string = explicitly no env file + return json({ content: '', noEnvFile: true }); + } else if (source?.envPath) { + // Custom env path specified + envFilePath = source.envPath; + } else if (source?.composePath) { + // Custom compose path but no env path - suggest .env next to compose + envFilePath = join(dirname(source.composePath), '.env'); + } else { + // Default location - .env in stack directory + const stackDir = await findStackDir(stackName, envIdNum); + if (stackDir) { + envFilePath = join(stackDir, '.env'); + } + } let content = ''; - if (existsSync(envFilePath)) { + if (envFilePath && existsSync(envFilePath)) { try { content = await Bun.file(envFilePath).text(); } catch { @@ -73,12 +99,35 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => return json({ error: 'Invalid request body: content string required' }, { status: 400 }); } - const stacksDir = getStacksDir(); - const stackDir = join(stacksDir, stackName); - const envFilePath = join(stackDir, '.env'); + // Check if this stack has custom paths configured + const source = await getStackSource(stackName, envIdNum); - // Only write if stack directory exists - if (!existsSync(stackDir)) { + // Determine the env file path based on path resolution rules: + // - envPath = '' (empty string) → explicitly no env file, don't write + // - envPath = '/path/.env' → use custom path + // - envPath = null with composePath → suggest .env next to compose + // - envPath = null without composePath → use default location + let envFilePath: string | null = null; + + if (source?.envPath === '') { + // Empty string = explicitly no env file - don't allow writes + return json({ success: true, noEnvFile: true }); + } else if (source?.envPath) { + // Custom env path specified + envFilePath = source.envPath; + } else if (source?.composePath) { + // Custom compose path but no env path - suggest .env next to compose + envFilePath = join(dirname(source.composePath), '.env'); + } else { + // Default location - .env in stack directory + const stackDir = await findStackDir(stackName, envIdNum); + if (stackDir) { + envFilePath = join(stackDir, '.env'); + } + } + + // Only write if we have a valid path + if (!envFilePath) { return json({ error: 'Stack directory not found' }, { status: 404 }); } diff --git a/src/routes/api/stacks/[name]/restart/+server.ts b/src/routes/api/stacks/[name]/restart/+server.ts index 29ecbf5..b4d9ce3 100644 --- a/src/routes/api/stacks/[name]/restart/+server.ts +++ b/src/routes/api/stacks/[name]/restart/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { restartStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks'; +import { restartStack, ComposeFileNotFoundError } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { auditStack } from '$lib/server/audit'; import type { RequestHandler } from './$types'; @@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => { } return json({ success: true, output: result.output }); } catch (error) { - if (error instanceof ExternalStackError) { - return json({ error: error.message }, { status: 400 }); - } if (error instanceof ComposeFileNotFoundError) { return json({ error: error.message }, { status: 404 }); } diff --git a/src/routes/api/stacks/[name]/start/+server.ts b/src/routes/api/stacks/[name]/start/+server.ts index 7e5fc5f..928841e 100644 --- a/src/routes/api/stacks/[name]/start/+server.ts +++ b/src/routes/api/stacks/[name]/start/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { startStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks'; +import { startStack, ComposeFileNotFoundError } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { auditStack } from '$lib/server/audit'; import type { RequestHandler } from './$types'; @@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => { } return json({ success: true, output: result.output }); } catch (error) { - if (error instanceof ExternalStackError) { - return json({ error: error.message }, { status: 400 }); - } if (error instanceof ComposeFileNotFoundError) { return json({ error: error.message }, { status: 404 }); } diff --git a/src/routes/api/stacks/[name]/stop/+server.ts b/src/routes/api/stacks/[name]/stop/+server.ts index d57fa7b..2c2a0fe 100644 --- a/src/routes/api/stacks/[name]/stop/+server.ts +++ b/src/routes/api/stacks/[name]/stop/+server.ts @@ -1,5 +1,5 @@ import { json } from '@sveltejs/kit'; -import { stopStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks'; +import { stopStack, ComposeFileNotFoundError } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { auditStack } from '$lib/server/audit'; import type { RequestHandler } from './$types'; @@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => { } return json({ success: true, output: result.output }); } catch (error) { - if (error instanceof ExternalStackError) { - return json({ error: error.message }, { status: 400 }); - } if (error instanceof ComposeFileNotFoundError) { return json({ error: error.message }, { status: 404 }); } diff --git a/src/routes/api/stacks/sources/+server.ts b/src/routes/api/stacks/sources/+server.ts index d0688d9..a2f1a17 100644 --- a/src/routes/api/stacks/sources/+server.ts +++ b/src/routes/api/stacks/sources/+server.ts @@ -18,10 +18,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => { const sources = await getStackSources(envIdNum); // Convert to a map for easier lookup in the frontend - const sourceMap: Record = {}; + const sourceMap: Record = {}; for (const source of sources) { sourceMap[source.stackName] = { sourceType: source.sourceType, + composePath: source.composePath, repository: source.repository }; } diff --git a/src/routes/api/system/+server.ts b/src/routes/api/system/+server.ts index 04c369a..118fb4f 100644 --- a/src/routes/api/system/+server.ts +++ b/src/routes/api/system/+server.ts @@ -186,6 +186,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { nodeVersion: process.version, platform: os.platform(), arch: os.arch(), + kernel: os.release(), memory: bunInfo.memory, container: containerRuntime, ownContainer diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index 438ad50..5984f2e 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -1,6 +1,7 @@ - + @@ -450,7 +501,7 @@ -
+
{#if loading}
@@ -461,7 +512,7 @@
{:else if containerData} - + showLogs = false}>Overview showLogs = true}>Logs showLogs = false}>Layers @@ -470,6 +521,7 @@ showLogs = false}>Mounts showLogs = false}>Files showLogs = false}>Environment + showLogs = false}>Labels showLogs = false}>Security showLogs = false}>Resources showLogs = false}>Health @@ -642,23 +694,6 @@
{/if} - - {#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0} -
- - Labels ({Object.keys(containerData.Config.Labels).length}) - -
- {#each Object.entries(containerData.Config.Labels) as [key, value]} -
- {key} - = - {value} -
- {/each} -
-
- {/if} @@ -845,8 +880,22 @@ {#each Object.entries(containerData.NetworkSettings.Ports) as [containerPort, hostBindings]} {#if hostBindings && hostBindings.length > 0} {#each hostBindings as binding} + {@const url = getPortUrl(parseInt(binding.HostPort))}
- {binding.HostIp || '0.0.0.0'}:{binding.HostPort} + {#if url} + + {binding.HostIp || '0.0.0.0'}:{binding.HostPort} + + + {:else} + {binding.HostIp || '0.0.0.0'}:{binding.HostPort} + {/if} {containerPort}
@@ -944,6 +993,37 @@ {/if} + + + {#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0} +
+ {#each Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0])) as [key, value]} +
+
+ {key} + = + {value} +
+ +
+ {/each} +
+ {:else} +

No labels

+ {/if} +
+ @@ -1187,7 +1267,7 @@ - + diff --git a/src/routes/containers/ContainerSettingsTab.svelte b/src/routes/containers/ContainerSettingsTab.svelte index ee0c604..90ba559 100644 --- a/src/routes/containers/ContainerSettingsTab.svelte +++ b/src/routes/containers/ContainerSettingsTab.svelte @@ -35,12 +35,12 @@ id: number; name: string; description?: string; - env_vars?: { key: string; value: string }[]; + envVars?: { key: string; value: string }[]; labels?: { key: string; value: string }[]; ports?: { hostPort: string; containerPort: string; protocol: string }[]; volumes?: { hostPath: string; containerPath: string; mode: string }[]; - network_mode: string; - restart_policy: string; + networkMode: string; + restartPolicy: string; } interface DockerNetwork { @@ -315,15 +315,15 @@ const configSet = configSets.find((c) => c.id === parseInt(configSetId)); if (!configSet) return; - if (configSet.env_vars && configSet.env_vars.length > 0) { + if (configSet.envVars && configSet.envVars.length > 0) { if (mode === 'edit') { // Merge mode for edit const existingKeys = new Set(envVars.map(e => e.key).filter(k => k)); - const newEnvVars = configSet.env_vars.filter(e => !existingKeys.has(e.key)); + const newEnvVars = configSet.envVars.filter(e => !existingKeys.has(e.key)); envVars = [...envVars.filter(e => e.key), ...newEnvVars.map(e => ({ ...e }))]; if (envVars.length === 0) envVars = [{ key: '', value: '' }]; } else { - envVars = configSet.env_vars.map((e) => ({ ...e })); + envVars = configSet.envVars.map((e) => ({ ...e })); } } if (configSet.labels && configSet.labels.length > 0) { @@ -356,11 +356,11 @@ volumeMappings = configSet.volumes.map((v) => ({ ...v })); } } - if (configSet.network_mode) { - networkMode = configSet.network_mode; + if (configSet.networkMode) { + networkMode = configSet.networkMode; } - if (configSet.restart_policy) { - restartPolicy = configSet.restart_policy; + if (configSet.restartPolicy) { + restartPolicy = configSet.restartPolicy; } } diff --git a/src/routes/containers/CreateContainerModal.svelte b/src/routes/containers/CreateContainerModal.svelte index 0620e78..71893e8 100644 --- a/src/routes/containers/CreateContainerModal.svelte +++ b/src/routes/containers/CreateContainerModal.svelte @@ -49,12 +49,12 @@ id: number; name: string; description?: string; - env_vars?: { key: string; value: string }[]; + envVars?: { key: string; value: string }[]; labels?: { key: string; value: string }[]; ports?: { hostPort: string; containerPort: string; protocol: string }[]; volumes?: { hostPath: string; containerPath: string; mode: string }[]; - network_mode: string; - restart_policy: string; + networkMode: string; + restartPolicy: string; } @@ -564,7 +564,7 @@ isOpen && focusFirstInput()}> - + Create new container
@@ -785,6 +848,16 @@ {group.tags[0].tag} {/if} + {#if group.containers === 0} + + Unused + + {:else if group.tags.length > 1 && group.tags.some(t => t.containers === 0)} + + + Some unused + + {/if}
{:else if column.id === 'tags'} @@ -867,6 +940,22 @@ {formatSize(tagInfo.size)} {:else if column.id === 'created'} {formatImageDate(tagInfo.created)} + {:else if column.id === 'used'} + {#if tagInfo.containers > 0} + + {tagInfo.containers} container{tagInfo.containers === 1 ? '' : 's'} + + {:else if tagInfo.containers === 0} + + Unused + + {:else} + + {/if} {:else if column.id === 'actions'}
{#if $canAccess('images', 'inspect')} @@ -930,7 +1019,7 @@ {/if} - {#if $canAccess('images', 'remove')} + {#if $canAccess('images', 'remove') && tagInfo.containers === 0}
{@render children()} - +
diff --git a/src/routes/networks/CreateNetworkModal.svelte b/src/routes/networks/CreateNetworkModal.svelte index 5a108b5..fa28bb8 100644 --- a/src/routes/networks/CreateNetworkModal.svelte +++ b/src/routes/networks/CreateNetworkModal.svelte @@ -277,7 +277,7 @@ -
+
diff --git a/src/routes/networks/NetworkInspectModal.svelte b/src/routes/networks/NetworkInspectModal.svelte index 5c9eb9c..983599d 100644 --- a/src/routes/networks/NetworkInspectModal.svelte +++ b/src/routes/networks/NetworkInspectModal.svelte @@ -61,7 +61,7 @@ - + diff --git a/src/routes/registry/+page.svelte b/src/routes/registry/+page.svelte index 65b7add..a7b5404 100644 --- a/src/routes/registry/+page.svelte +++ b/src/routes/registry/+page.svelte @@ -43,8 +43,13 @@ interface ExpandedImageState { loading: boolean; + loadingMore: boolean; error: string; tags: TagInfo[]; + total: number; + page: number; + pageSize: number; + hasNext: boolean; } let registries = $state([]); @@ -226,38 +231,100 @@ const { [imageName]: _, ...rest } = expandedImages; expandedImages = rest; } else { - // Expand and fetch tags - expandedImages = { - ...expandedImages, - [imageName]: { loading: true, error: '', tags: [] } - }; + // Expand and fetch first page + await fetchTagsPage(imageName, 1, true); + } + } - try { - let url = `/api/registry/tags?image=${encodeURIComponent(imageName)}`; - if (selectedRegistryId) { - url += `®istry=${selectedRegistryId}`; - } + async function loadMoreTags(imageName: string) { + const state = expandedImages[imageName]; + if (!state || state.loading || state.loadingMore || !state.hasNext) return; + await fetchTagsPage(imageName, state.page + 1, false); + } - const response = await fetch(url); - if (response.ok) { - const tags = await response.json(); - expandedImages = { - ...expandedImages, - [imageName]: { loading: false, error: '', tags } - }; - } else { - const data = await response.json(); - expandedImages = { - ...expandedImages, - [imageName]: { loading: false, error: data.error || 'Failed to fetch tags', tags: [] } - }; - } - } catch (error: any) { + async function fetchTagsPage(imageName: string, page: number, isFirstLoad: boolean) { + const currentState = expandedImages[imageName]; + + expandedImages = { + ...expandedImages, + [imageName]: { + loading: isFirstLoad, + loadingMore: !isFirstLoad, + error: '', + tags: currentState?.tags || [], + total: currentState?.total || 0, + page: currentState?.page || 0, + pageSize: 20, + hasNext: currentState?.hasNext || false + } + }; + + try { + let url = `/api/registry/tags?image=${encodeURIComponent(imageName)}&page=${page}&pageSize=20`; + if (selectedRegistryId) { + url += `®istry=${selectedRegistryId}`; + } + + const response = await fetch(url); + if (response.ok) { + const data = await response.json(); + const prevState = expandedImages[imageName]; + const existingTags = isFirstLoad ? [] : (prevState?.tags || []); expandedImages = { ...expandedImages, - [imageName]: { loading: false, error: error.message || 'Failed to fetch tags', tags: [] } + [imageName]: { + loading: false, + loadingMore: false, + error: '', + tags: [...existingTags, ...data.tags], + total: data.total, + page: data.page, + pageSize: data.pageSize, + hasNext: data.hasNext + } + }; + } else { + const data = await response.json(); + expandedImages = { + ...expandedImages, + [imageName]: { + ...expandedImages[imageName], + loading: false, + loadingMore: false, + error: data.error || 'Failed to fetch tags' + } }; } + } catch (error: any) { + expandedImages = { + ...expandedImages, + [imageName]: { + ...expandedImages[imageName], + loading: false, + loadingMore: false, + error: error.message || 'Failed to fetch tags' + } + }; + } + } + + function handleTagsWheel(event: WheelEvent, imageName: string) { + const target = event.currentTarget as HTMLElement; + + // Prevent page scroll when at top/bottom of tags list + const atTop = target.scrollTop === 0; + const atBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 1; + + if ((atTop && event.deltaY < 0) || (atBottom && event.deltaY > 0)) { + event.preventDefault(); + } + + // Load more when near bottom + const state = expandedImages[imageName]; + if (!state || !state.hasNext || state.loading || state.loadingMore) return; + + if (target.scrollHeight - target.scrollTop - target.clientHeight < 50) { + loadMoreTags(imageName); } } @@ -328,7 +395,8 @@ ...expandedImages, [imageName]: { ...state, - tags: state.tags.filter(t => t.name !== tag) + tags: state.tags.filter(t => t.name !== tag), + total: Math.max(0, state.total - 1) } }; } @@ -514,9 +582,9 @@ {expandState.error}
{:else if expandState?.tags && expandState.tags.length > 0} -
+
handleTagsWheel(e, result.name)}> - + @@ -590,7 +658,20 @@ {/each}
Tag Size
+ + {#if expandState.loadingMore} +
+ + Loading more... +
+ {/if}
+ + {#if expandState.total > 0} +
+ {expandState.tags.length} of {expandState.total} tags loaded +
+ {/if} {:else}
No tags found diff --git a/src/routes/settings/about/AboutTab.svelte b/src/routes/settings/about/AboutTab.svelte index 802e389..c2547b5 100644 --- a/src/routes/settings/about/AboutTab.svelte +++ b/src/routes/settings/about/AboutTab.svelte @@ -3,7 +3,7 @@ import * as Card from '$lib/components/ui/card'; import { Badge } from '$lib/components/ui/badge'; import { Input } from '$lib/components/ui/input'; - import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee } from 'lucide-svelte'; + import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee, Monitor, Cog, MemoryStick, Database } from 'lucide-svelte'; import * as Tabs from '$lib/components/ui/tabs'; import { onMount, onDestroy } from 'svelte'; import { licenseStore } from '$lib/stores/license'; @@ -198,6 +198,7 @@ nodeVersion: string; platform: string; arch: string; + kernel: string; memory: { heapUsed: number; heapTotal: number; @@ -438,7 +439,7 @@
{/if} {#if serverUptime !== null} -
+
Uptime {formatUptime(serverUptime)}
@@ -558,10 +559,13 @@ {/if} | - Platform + {systemInfo.runtime.platform}/{systemInfo.runtime.arch} | - Memory + + {systemInfo.runtime.kernel} + | + {formatBytes(systemInfo.runtime.memory.rss)}
{#if systemInfo.runtime.container.inContainer} @@ -585,7 +589,7 @@
- + Database
diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index b04f2f7..c0ccfd7 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -415,6 +415,10 @@ newLabelInput = ''; formPublicIp = environment.publicIp || ''; modalTab = 'general'; + // Reset token state for this environment (important when switching between envs) + hawserToken = null; + generatedToken = null; + pendingToken = null; // Load scanner settings, notifications, update check settings, and timezone loadScannerSettings(environment.id); loadEnvNotifications(environment.id); @@ -825,6 +829,11 @@ } } + // Reload only availability/versions without overwriting user's unsaved settings changes + async function reloadScannerAvailability(envId?: number) { + await loadScannerVersionsAsync(envId); + } + async function saveScannerSettings(envId?: number) { try { const response = await fetch('/api/settings/scanner', { @@ -930,7 +939,8 @@ checkingGrypeUpdate = true; grypeUpdateStatus = 'idle'; try { - const response = await fetch('/api/settings/scanner?checkUpdates=true'); + const envParam = environment?.id ? `&env=${environment.id}` : ''; + const response = await fetch(`/api/settings/scanner?checkUpdates=true${envParam}`); const data = await response.json(); if (data.updates) { grypeUpdateStatus = data.updates.grype?.hasUpdate ? 'update-available' : 'up-to-date'; @@ -947,7 +957,8 @@ checkingTrivyUpdate = true; trivyUpdateStatus = 'idle'; try { - const response = await fetch('/api/settings/scanner?checkUpdates=true'); + const envParam = environment?.id ? `&env=${environment.id}` : ''; + const response = await fetch(`/api/settings/scanner?checkUpdates=true${envParam}`); const data = await response.json(); if (data.updates) { trivyUpdateStatus = data.updates.trivy?.hasUpdate ? 'update-available' : 'up-to-date'; @@ -1198,7 +1209,7 @@ { if (o) focusFirstInput(); else onClose(); }}> - + {#if !isEditing} @@ -1362,7 +1373,7 @@ - +
@@ -2112,7 +2123,7 @@ {/if} {#if !loadingScannerVersions} {#if !scannerAvailability.grype} - loadScannerSettings(environment?.id)}> + reloadScannerAvailability(environment?.id)}> + {/if}
@@ -1281,7 +1274,8 @@ {#snippet cell(column, stack, rowState)} {@const source = getStackSource(stack.name)} {#if column.id === 'name'} - {#if source.sourceType === 'internal'} + {#if source.sourceType !== 'git'} + - {:else if source.sourceType === 'git' && source.gitStack} + {#if source.sourceType === 'git' && source.gitStack} + {:else} + + {/if} {/if} {#if stack.containers && stack.containers.length > 0} @@ -1947,6 +1947,12 @@ onSaved={fetchStacks} /> + showImportModal = false} + onAdopted={fetchStacks} +/> + (null); - // Stack location (for edit mode) - let stackLocation = $state(null); - let pathCopied = $state(false); - function copyPath() { - if (stackLocation) { - navigator.clipboard.writeText(stackLocation); - pathCopied = true; - setTimeout(() => pathCopied = false, 2000); + // ─── Path State (Simplified) ───────────────────────────────────────────────── + // Working paths: what we're currently editing (always strings, never null) + let workingComposePath = $state(''); + let workingEnvPath = $state(''); + + // Original paths: loaded from server (for dirty/change detection in edit mode) + let originalComposePath = $state(null); + let originalEnvPath = $state(null); + + // Auto-computed path from API (for create mode - tracks what the default would be) + let autoComputedComposePath = $state(''); + + // Path source info (for hint display) + let pathSource = $state<'default' | 'custom' | 'browsed' | null>(null); + + // UI state + let composePathCopied = $state(false); + let envPathCopied = $state(false); + let needsFileLocation = $state(false); + + // Container info for untracked stacks + let stackContainers = $state<{ name: string; state: string; image: string }[]>([]); + + // Derived: has user customized the compose path from auto-computed default? + const isComposePathCustom = $derived( + workingComposePath !== '' && workingComposePath !== autoComputedComposePath + ); + + // Derived: suggested env path when workingEnvPath is empty + const suggestedEnvPath = $derived( + !workingEnvPath && workingComposePath + ? workingComposePath.replace(/\/[^/]+$/, '/.env') + : null + ); + + // Derived: display path for env (actual or suggested) + const displayEnvPath = $derived(workingEnvPath || suggestedEnvPath || ''); + + // Derived: is env path just a suggestion (not explicitly set)? + const isEnvPathSuggested = $derived(!workingEnvPath && !!suggestedEnvPath); + + // Derived: source hint text for the path bar (only in create mode) + const pathSourceHint = $derived.by(() => { + if (mode !== 'create' || !workingComposePath) return undefined; + switch (pathSource) { + case 'browsed': + return 'Custom location'; + case 'custom': + return 'Using saved location'; + case 'default': + return 'Using default location'; + default: + return undefined; + } + }); + + // Path change confirmation dialog state + let showPathChangeConfirm = $state(false); + let pathChangeOldDir = $state(null); // Old directory to move files from + let pathChangeFileCount = $state(0); // Number of files in old directory + let pendingSaveRestart = $state(false); // Whether user clicked "Save & restart" vs "Save" + + // Browse confirmation dialog state (when selecting different file would replace content) + let showBrowseConfirm = $state(false); + let pendingBrowsePath = $state(null); + let pendingBrowseName = $state(null); + + // Single file browser with dynamic config + let showFileBrowser = $state(false); + let fileBrowserConfig = $state<{ + title: string; + icon?: Component<{ class?: string }>; + selectFilter?: RegExp; + selectMode: 'file' | 'directory' | 'file_or_directory'; + onSelect: (path: string, name: string) => void; + }>({ + title: '', + icon: undefined, + selectFilter: /.*/, + selectMode: 'file', + onSelect: () => {} + }); + + function openComposeBrowser() { + // For untracked stacks (needsFileLocation), only allow selecting files + // For tracked stacks, allow both files and directories + const isUntracked = needsFileLocation; + fileBrowserConfig = { + title: isUntracked ? 'Select compose file' : 'Select compose file or directory', + selectFilter: /\.ya?ml$/, + selectMode: isUntracked ? 'file' : 'file_or_directory', + onSelect: handleComposeSelect + }; + showFileBrowser = true; + } + + function openEnvBrowser() { + fileBrowserConfig = { + title: 'Select environment file or directory', + selectFilter: /\.env($|\.)/, // matches .env, .env.local, app.env, etc. + selectMode: 'file_or_directory', + onSelect: handleEnvSelect + }; + showFileBrowser = true; + } + + function openChangeLocationBrowser() { + const displayName = mode === 'edit' ? stackName : newStackName; + fileBrowserConfig = { + title: `Relocate ${displayName}`, + icon: FolderSync, + selectMode: 'directory', + onSelect: handleChangeLocation + }; + showFileBrowser = true; + } + + // State for change location confirmation + let pendingNewLocation = $state(null); + let pendingNewComposePath = $state(null); + let pendingNewEnvPath = $state(null); + let showChangeLocationConfirm = $state(false); + let changeLocationFileCount = $state(0); + let changeLocationOldDir = $state(null); + let movingLocation = $state(false); + + async function handleChangeLocation(selectedDir: string, _name: string) { + showFileBrowser = false; + + // Get the current compose filename + const currentComposePath = workingComposePath; + const composeFilename = currentComposePath ? currentComposePath.split('/').pop() : 'docker-compose.yml'; + + // Build new paths: create a subfolder with the stack name inside selected directory + const displayName = mode === 'edit' ? stackName : newStackName; + const newDir = `${selectedDir}/${displayName}`; + const newComposePath = `${newDir}/${composeFilename}`; + const newEnvPath = workingEnvPath ? `${newDir}/.env` : ''; + + // Check if old directory has files to move + const envId = $currentEnvironment?.id ?? null; + try { + const response = await fetch( + appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/check-path-change`, envId), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ newComposePath }) + } + ); + + if (response.ok) { + const data = await response.json(); + if (data.hasChanges && data.oldDir && data.fileCount > 0) { + // Show confirmation dialog + pendingNewLocation = newDir; + pendingNewComposePath = newComposePath; + pendingNewEnvPath = newEnvPath; + changeLocationOldDir = data.oldDir; + changeLocationFileCount = data.fileCount; + showChangeLocationConfirm = true; + return; + } + } + } catch (e) { + console.warn('Failed to check path changes:', e); + } + + // No files to move, just update paths + workingComposePath = newComposePath; + workingEnvPath = newEnvPath; + isDirty = true; + } + + function cancelChangeLocation() { + showChangeLocationConfirm = false; + pendingNewLocation = null; + pendingNewComposePath = null; + pendingNewEnvPath = null; + changeLocationOldDir = null; + changeLocationFileCount = 0; + } + + async function confirmChangeLocation() { + if (!pendingNewComposePath || !changeLocationOldDir) return; + + movingLocation = true; + const envId = $currentEnvironment?.id ?? null; + + try { + // Call API to move files + const response = await fetch( + appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/relocate`, envId), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + oldDir: changeLocationOldDir, + newComposePath: pendingNewComposePath, + newEnvPath: pendingNewEnvPath || undefined + }) + } + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to move files'); + } + + const result = await response.json(); + + // Update paths + workingComposePath = pendingNewComposePath; + workingEnvPath = pendingNewEnvPath || ''; + originalComposePath = pendingNewComposePath; + originalEnvPath = pendingNewEnvPath || null; + + // Reload content from new location + if (result.composeContent) { + composeContent = result.composeContent; + } + if (result.envVars) { + envVars = result.envVars; + } + if (result.rawEnvContent !== undefined) { + rawEnvContent = result.rawEnvContent; + } + + // Reset dirty flag since we just reloaded + isDirty = false; + + } catch (e: any) { + operationError = { + title: 'Failed to move files', + message: e.message || 'An error occurred while moving files' + }; + } finally { + movingLocation = false; + showChangeLocationConfirm = false; + pendingNewLocation = null; + pendingNewComposePath = null; + pendingNewEnvPath = null; + changeLocationOldDir = null; + changeLocationFileCount = 0; + } + } + + // Generic copy function that returns a reset callback + function copyToClipboard(text: string | null, setCopied: (v: boolean) => void) { + if (text) { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + } + + // Parse env vars from raw content + function parseEnvVarsFromRaw(content: string) { + const vars: EnvVar[] = []; + const lines = content.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIndex = trimmed.indexOf('='); + if (eqIndex > 0) { + const key = trimmed.substring(0, eqIndex); + const value = trimmed.substring(eqIndex + 1); + vars.push({ key, value, isSecret: false }); + } + } + envVars = vars; + } + + // Handle compose file selection from browser + async function handleComposeSelect(path: string, name: string) { + const isDirectory = !path.match(/\.ya?ml$/i); + + // If selecting a file in edit mode with existing content, show confirmation + if (mode === 'edit' && !isDirectory && composeContent.trim()) { + // Check if it's the same file (no confirmation needed) + const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path; + if (normalizedPath !== workingComposePath) { + pendingBrowsePath = path; + pendingBrowseName = name; + showBrowseConfirm = true; + showFileBrowser = false; + return; + } + } + + // Continue with file selection + await proceedWithComposeSelect(path, name); + } + + // Proceed with compose file selection (after optional confirmation) + async function proceedWithComposeSelect(path: string, name: string) { + // Check if it's a directory (no extension or doesn't end with .yml/.yaml) + const isDirectory = !path.match(/\.ya?ml$/i); + const baseDir = path.endsWith('/') ? path.slice(0, -1) : path; + let finalPath = path; + + if (isDirectory) { + const stackName = newStackName.trim(); + if (stackName) { + // If we have a stack name, include the subfolder + finalPath = `${baseDir}/${stackName}/docker-compose.yml`; + } else { + // No stack name yet - just show the selected directory + finalPath = `${baseDir}/`; + } + } + + workingComposePath = finalPath; + pathSource = 'browsed'; + showFileBrowser = false; + + // Auto-suggest .env in the same directory (only if we have a full path) + if (!isDirectory || newStackName.trim()) { + const dir = finalPath.replace(/\/[^/]+$/, ''); + if (!workingEnvPath) { + workingEnvPath = `${dir}/.env`; + } + } + + // Load compose file content when selecting a file (not directory) + if (!isDirectory) { + await loadFilesFromLocalFilesystem(finalPath, workingEnvPath || suggestedEnvPath || ''); + } + isDirty = true; + } + + // Cancel browse confirmation + function cancelBrowseConfirm() { + showBrowseConfirm = false; + pendingBrowsePath = null; + pendingBrowseName = null; + } + + // Confirm browse and load the new file + async function confirmBrowseAndLoad() { + showBrowseConfirm = false; + if (pendingBrowsePath && pendingBrowseName) { + await proceedWithComposeSelect(pendingBrowsePath, pendingBrowseName); + } + pendingBrowsePath = null; + pendingBrowseName = null; + } + + // Handle env file selection from browser + async function handleEnvSelect(path: string, name: string) { + // Check if it's a directory (no extension or doesn't contain .env) + const isDirectory = !path.match(/\.env($|\.)/i); + let finalPath = path; + if (isDirectory) { + // Append default env filename + finalPath = path.endsWith('/') ? `${path}.env` : `${path}/.env`; + } + + workingEnvPath = finalPath; + showFileBrowser = false; + + // Load env content when selecting a file (not directory) + if (!isDirectory) { + try { + const envResponse = await fetch(`/api/system/files/content?path=${encodeURIComponent(finalPath)}`); + if (envResponse.ok) { + const envData = await envResponse.json(); + rawEnvContent = envData.content || ''; + parseEnvVarsFromRaw(rawEnvContent); + } else { + rawEnvContent = ''; + } + } catch (e) { + console.error('Failed to load env file:', e); + } + } + isDirty = true; + } + + // Load files from local filesystem (when user selects paths) + async function loadFilesFromLocalFilesystem(composeFilePath: string, envFilePath: string) { + try { + // Load compose file + const composeResponse = await fetch(`/api/system/files/content?path=${encodeURIComponent(composeFilePath)}`); + if (composeResponse.ok) { + const composeData = await composeResponse.json(); + composeContent = composeData.content || ''; + workingComposePath = composeFilePath; + // Clear the needsFileLocation flag since we now have content + needsFileLocation = false; + stackContainers = []; + } else { + const err = await composeResponse.json(); + console.error('Failed to load compose file:', err.error); + } + + // Try to load .env file (only set workingEnvPath if it exists) + if (envFilePath) { + const envResponse = await fetch(`/api/system/files/content?path=${encodeURIComponent(envFilePath)}`); + if (envResponse.ok) { + const envData = await envResponse.json(); + rawEnvContent = envData.content || ''; + workingEnvPath = envFilePath; + parseEnvVarsFromRaw(rawEnvContent); + } else { + // .env file not found - clear env path + rawEnvContent = ''; + workingEnvPath = ''; + } + } + } catch (e) { + console.error('Failed to load files:', e); } } @@ -255,50 +666,92 @@ services: loading = true; loadError = null; error = null; + needsFileLocation = false; try { const envId = $currentEnvironment?.id ?? null; // Load compose file - const response = await fetch(`/api/stacks/${encodeURIComponent(stackName)}/compose`); + const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId)); const data = await response.json(); if (!response.ok) { + // Check if this stack needs file location selection + if (data.needsFileLocation) { + needsFileLocation = true; + // Initialize paths from response (may have suggested paths) + workingComposePath = data.composePath || ''; + workingEnvPath = data.envPath || ''; + // Show empty editors - user can browse for files + composeContent = ''; + rawEnvContent = ''; + loadError = null; + loading = false; // Important: stop loading spinner + + // Fetch containers for this stack to show what's running + try { + const stacksRes = await fetch(appendEnvParam('/api/stacks', envId)); + if (stacksRes.ok) { + const stacks = await stacksRes.json(); + const thisStack = stacks.find((s: any) => s.name === stackName); + if (thisStack?.containerDetails) { + stackContainers = thisStack.containerDetails.map((c: any) => ({ + name: c.name || 'unknown', + state: c.state || 'unknown', + image: c.image || 'unknown' + })); + } + } + } catch (e) { + console.error('Failed to fetch stack containers:', e); + } + return; + } throw new Error(data.error || 'Failed to load compose file'); } composeContent = data.content; - stackLocation = data.stackDir || null; + // Set working paths + workingComposePath = data.composePath || ''; + workingEnvPath = data.envPath || ''; + // Track original paths for detecting changes + originalComposePath = data.composePath || null; + originalEnvPath = data.envPath || null; - // Load environment variables (parsed) - const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId)); + // Load both env endpoints in parallel, then process results together + const [envResponse, rawEnvResponse] = await Promise.all([ + fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId)), + fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId)) + ]); + + // Process env vars from DB + let loadedVars: EnvVar[] = []; if (envResponse.ok) { const envData = await envResponse.json(); - envVars = envData.variables || []; - // Track if DB had any vars (for proper cleanup on clear-all) - hadExistingDbVars = envVars.length > 0; - // Track existing secret keys (secrets loaded from DB cannot have visibility toggled) + loadedVars = envData.variables || []; + hadExistingDbVars = loadedVars.length > 0; existingSecretKeys = new Set( - envVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim()) + loadedVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim()) ); } - // Load raw .env file content (for preserving comments) - const rawEnvResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId)); + // Process raw .env file content + let loadedRawContent = ''; if (rawEnvResponse.ok) { const rawEnvData = await rawEnvResponse.json(); - rawEnvContent = rawEnvData.content || ''; - console.log('[loadComposeFile] rawEnvContent loaded:', rawEnvContent); + loadedRawContent = rawEnvData.content || ''; } + + // Pass data directly to syncAfterLoad - no tick() needed + // This sets both envVars and rawEnvContent synchronously via the panel + loading = false; + await tick(); // Wait for panel ref to be available + envVarsPanelRef?.syncAfterLoad(loadedVars, loadedRawContent); + isDirty = false; + } catch (e: any) { loadError = e.message; - } finally { loading = false; - // Merge variables and rawContent after both are loaded - await tick(); - envVarsPanelRef?.mergeOnLoad(); - // Reset dirty flag after loading completes - isDirty = false; } } @@ -367,23 +820,36 @@ services: try { const envId = $currentEnvironment?.id ?? null; - // Create the stack (include env vars and raw content for .env file) + // Build request body + const requestBody: Record = { + name: newStackName.trim(), + compose: content, + start, + // Send raw env content (non-secrets only, preserves comments/formatting) + rawEnvContent: prepared.rawContent.trim() ? prepared.rawContent : undefined, + // Also send parsed vars for DB secret tracking (includes secrets) + envVars: prepared.variables.length > 0 ? prepared.variables.map(v => ({ + key: v.key.trim(), + value: v.value, + isSecret: v.isSecret + })) : undefined + }; + + // Include custom paths if specified + if (workingComposePath.trim()) { + requestBody.composePath = workingComposePath.trim(); + } + // Use working env path or suggested path + const envPathToSave = workingEnvPath.trim() || suggestedEnvPath || ''; + if (envPathToSave) { + requestBody.envPath = envPathToSave; + } + + // Create the stack const response = await fetch(appendEnvParam('/api/stacks', envId), { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: newStackName.trim(), - compose: content, - start, - // Send raw env content (non-secrets only, preserves comments/formatting) - rawEnvContent: prepared.rawContent.trim() ? prepared.rawContent : undefined, - // Also send parsed vars for DB secret tracking (includes secrets) - envVars: prepared.variables.length > 0 ? prepared.variables.map(v => ({ - key: v.key.trim(), - value: v.value, - isSecret: v.isSecret - })) : undefined - }) + body: JSON.stringify(requestBody) }); if (!response.ok) { @@ -404,14 +870,56 @@ services: } } - async function handleSave(restart = false) { + async function handleSave(restart = false, moveFromDir: string | null = null) { errors = {}; - if (!composeContent.trim()) { - errors.compose = 'Compose file content cannot be empty'; + // Validate compose content (unless file location is needed and we have a path) + if (!composeContent.trim() && !workingComposePath.trim()) { + errors.compose = 'Compose file content or path is required'; return; } + // If file location is needed, require a compose path + if (needsFileLocation && !workingComposePath.trim()) { + errors.compose = 'Please select a compose file location'; + return; + } + + const envId = $currentEnvironment?.id ?? null; + + // Check if directory has changed (edit mode only, and not already confirmed) + if (mode === 'edit' && !moveFromDir) { + const newComposePath = workingComposePath.trim() || null; + + // Only check if compose path changed (which means directory changed) + if (newComposePath && originalComposePath && newComposePath !== originalComposePath) { + try { + const checkResponse = await fetch( + appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/check-path-change`, envId), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ newComposePath }) + } + ); + if (checkResponse.ok) { + const checkData = await checkResponse.json(); + if (checkData.hasChanges && checkData.oldDir && checkData.fileCount > 0) { + // Show confirmation dialog + pathChangeOldDir = checkData.oldDir; + pathChangeFileCount = checkData.fileCount; + pendingSaveRestart = restart; + showPathChangeConfirm = true; + return; + } + } + } catch (e) { + console.warn('Failed to check path changes:', e); + // Continue with save even if check fails + } + } + } + saving = true; savingWithRestart = restart; error = null; @@ -419,19 +927,46 @@ services: // Prepare env vars for saving - syncs variables and rawContent const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] }; - try { - const envId = $currentEnvironment?.id ?? null; + // Resolve env path (use working or suggested) + const envPathToSave = workingEnvPath.trim() || suggestedEnvPath || ''; - // Save compose file + try { + // Build request body - include paths if they've been set/changed + const requestBody: Record = { + content: composeContent, + restart + }; + + // Include compose path if set (either custom path or user selected) + if (workingComposePath.trim()) { + requestBody.composePath = workingComposePath.trim(); + } + + // Include env path - empty string means "no env file", null/undefined means "use default" + if (envPathToSave) { + requestBody.envPath = envPathToSave; + } + + // Include old paths for file move/rename operations + if (originalComposePath && workingComposePath.trim() && originalComposePath !== workingComposePath.trim()) { + requestBody.oldComposePath = originalComposePath; + } + if (originalEnvPath && envPathToSave && originalEnvPath !== envPathToSave) { + requestBody.oldEnvPath = originalEnvPath; + } + + // Include old directory to move files from if user confirmed + if (moveFromDir) { + requestBody.moveFromDir = moveFromDir; + } + + // Save compose file (with optional paths) const response = await fetch( appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId), { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: composeContent, - restart - }) + body: JSON.stringify(requestBody) } ); @@ -505,6 +1040,19 @@ services: } } + // Handle path change confirmation - move files to new location and proceed + function confirmPathChangeAndMove() { + showPathChangeConfirm = false; + handleSave(pendingSaveRestart, pathChangeOldDir); + } + + // Handle path change - keep old files and proceed (just save without moving) + function confirmPathChangeKeepFiles() { + showPathChangeConfirm = false; + // Pass empty string to skip move check this time (not null, which means "not checked yet") + handleSave(pendingSaveRestart, ''); + } + function tryClose() { if (isDirty) { showConfirmClose = true; @@ -535,7 +1083,25 @@ services: showConfirmClose = false; codeEditorRef = null; operationError = null; - stackLocation = null; + // Reset path state + workingComposePath = ''; + workingEnvPath = ''; + originalComposePath = null; + originalEnvPath = null; + autoComputedComposePath = ''; + pathSource = null; + needsFileLocation = false; + stackContainers = []; + showFileBrowser = false; + // Reset path change confirmation state + showPathChangeConfirm = false; + pathChangeOldDir = null; + pathChangeFileCount = 0; + pendingSaveRestart = false; + // Reset browse confirmation state + showBrowseConfirm = false; + pendingBrowsePath = null; + pendingBrowseName = null; onClose(); } @@ -580,6 +1146,56 @@ services: return () => clearTimeout(timeout); }); + + // Auto-update default paths when stack name changes in create mode + $effect(() => { + if (mode !== 'create' || !open) return; + // Don't overwrite if user has browsed and selected a path + if (pathSource === 'browsed') return; + + const name = newStackName.trim(); + const location = $appSettings.primaryStackLocation; + + if (!name) { + // Clear paths when no name + workingComposePath = ''; + workingEnvPath = ''; + autoComputedComposePath = ''; + pathSource = null; + return; + } + + // Fetch the actual absolute path from the backend + const envId = $currentEnvironment?.id ?? null; + const fetchDefaultPath = async () => { + try { + const params = new URLSearchParams({ name }); + if (envId) params.set('env', String(envId)); + if (location) { + params.set('location', location); + } + const response = await fetch(`/api/stacks/default-path?${params}`); + if (response.ok) { + const data = await response.json(); + // Check if user has customized before updating auto-computed + // Compare current working path against OLD auto path (before we update it) + const userHasCustomized = workingComposePath !== '' && + workingComposePath !== autoComputedComposePath; + // Track the auto-computed path + autoComputedComposePath = data.composePath; + // Only update working paths if user hasn't customized + if (!userHasCustomized) { + workingComposePath = data.composePath; + workingEnvPath = data.envPath; + pathSource = data.source || 'default'; + } + } + } catch (e) { + console.error('Failed to fetch default path:', e); + } + }; + fetchDefaultPath(); + }); @@ -620,24 +1238,8 @@ services: {#if mode === 'create'} Create a new Docker Compose stack - {:else if stackLocation} - - - {stackLocation} - - {:else} - Edit compose file and view stack structure + Edit compose file and environment variables {/if}
@@ -704,82 +1306,160 @@ services: Loading compose file...
- {:else if mode === 'edit' && loadError} -
-
-
- -
-

Could not load compose file

-

{loadError}

-

- This stack may have been created outside of Dockhand or the compose file may have been moved. -

-
-
{:else} - + {#if mode === 'create'}
-
- - errors.stackName = undefined} - /> - {#if errors.stackName} -

{errors.stackName}

- {/if} +
+
+ + errors.stackName = undefined} + /> + {#if errors.stackName} +

{errors.stackName}

+ {/if} +
+
+
+ {/if} + + + {#if mode === 'edit' && needsFileLocation} +
+
+ +
+

+ Untracked stack. Select the compose file location to start managing this stack with Dockhand. +

+ {#if stackContainers.length > 0} +
+ Running containers: +
+ {#each stackContainers as container} + + + {container.name} + + {/each} +
+
+ {/if} +
{/if} -
+
{#if activeTab === 'editor'} - -
- {#if open} -
- -
- {/if} -
- -