From c185d00dc32fb41737d220f909d8df9819e7aa77 Mon Sep 17 00:00:00 2001 From: jarek Date: Sat, 17 Jan 2026 15:06:14 +0100 Subject: [PATCH] 1.0.9 --- Dockerfile | 2 + package.json | 24 +- src/hooks.server.ts | 62 ++- src/lib/components/host-info.svelte | 5 +- src/lib/data/changelog.json | 18 + src/lib/data/dependencies.json | 252 +++++++++++ src/lib/server/db.ts | 4 +- src/lib/server/db/schema/index.ts | 4 +- src/lib/server/db/schema/pg-schema.ts | 4 +- src/lib/server/docker.ts | 410 ++++++++++++------ src/lib/server/git.ts | 203 +++++---- src/lib/server/host-path.ts | 232 ++++++++++ src/lib/server/scanner.ts | 14 + .../scheduler/tasks/container-update.ts | 14 +- .../scheduler/tasks/env-update-check.ts | 12 +- .../server/scheduler/tasks/update-utils.ts | 26 ++ src/lib/server/stack-scanner.ts | 4 +- src/lib/server/stacks.ts | 278 ++++++++++-- .../server/subprocesses/event-subprocess.ts | 8 +- .../server/subprocesses/metrics-subprocess.ts | 22 +- src/lib/stores/dashboard.ts | 21 +- src/lib/stores/environment.ts | 7 +- src/lib/stores/stats.ts | 134 ------ src/lib/types.ts | 12 + src/lib/utils/shell-detection.ts | 79 ++++ src/routes/+layout.svelte | 5 - src/routes/+page.svelte | 93 +++- src/routes/activity/+page.svelte | 8 +- .../api/containers/[id]/shells/+server.ts | 99 +++++ .../api/containers/check-updates/+server.ts | 6 +- src/routes/api/dashboard/stats/+server.ts | 15 +- .../api/dashboard/stats/stream/+server.ts | 14 +- src/routes/api/git/stacks/+server.ts | 2 +- src/routes/api/registry/catalog/+server.ts | 46 +- src/routes/api/registry/image/+server.ts | 15 +- src/routes/api/registry/search/+server.ts | 150 +++++-- src/routes/api/registry/tags/+server.ts | 15 +- .../[name]/check-path-change/+server.ts | 14 +- src/routes/api/stacks/default-path/+server.ts | 2 +- src/routes/audit/+page.svelte | 30 +- src/routes/containers/+page.svelte | 381 ++++++++++++---- .../containers/AutoUpdateSettings.svelte | 98 +++-- .../containers/ContainerInspectModal.svelte | 14 +- .../containers/ContainerSettingsTab.svelte | 10 + .../containers/ContainerTerminal.svelte | 200 ++++++--- .../dashboard-container-stats.svelte | 33 +- src/routes/environments/+page.svelte | 2 +- src/routes/images/+page.svelte | 53 ++- src/routes/logs/+page.svelte | 15 +- src/routes/networks/+page.svelte | 50 ++- src/routes/registry/+page.svelte | 131 +++++- src/routes/schedules/+page.svelte | 2 +- src/routes/settings/+page.svelte | 6 +- .../environments/EnvironmentModal.svelte | 4 +- .../settings/general/ScanResultsModal.svelte | 2 +- src/routes/stacks/+page.svelte | 66 ++- src/routes/stacks/GitStackModal.svelte | 8 +- src/routes/stacks/StackModal.svelte | 53 ++- src/routes/terminal/+page.svelte | 207 ++++++--- src/routes/volumes/+page.svelte | 50 ++- 60 files changed, 2831 insertions(+), 919 deletions(-) create mode 100644 src/lib/server/host-path.ts delete mode 100644 src/lib/stores/stats.ts create mode 100644 src/lib/utils/shell-detection.ts create mode 100644 src/routes/api/containers/[id]/shells/+server.ts diff --git a/Dockerfile b/Dockerfile index 1e2e0c7..22fb948 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") " - tzdata" \ " - docker-cli" \ " - docker-compose" \ + " - docker-cli-buildx" \ " - sqlite" \ " - git" \ " - openssh-client" \ @@ -142,6 +143,7 @@ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ PGID=1001 # Create docker compose plugin symlink (we use `docker compose` syntax, Wolfi has standalone binary) +# Note: docker-cli-buildx package already creates the buildx symlink RUN mkdir -p /usr/libexec/docker/cli-plugins \ && ln -s /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose diff --git a/package.json b/package.json index 9bcddd6..8af77f4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.8", + "version": "1.0.3", "type": "module", "scripts": { "dev": "bunx --bun vite dev", @@ -50,10 +50,10 @@ "@codemirror/lang-xml": "6.1.0", "@codemirror/lang-yaml": "6.1.2", "@codemirror/language": "6.12.1", - "@codemirror/search": "6.5.11", - "@codemirror/state": "6.5.3", + "@codemirror/search": "6.6.0", + "@codemirror/state": "6.5.4", "@codemirror/theme-one-dark": "6.1.3", - "@codemirror/view": "6.39.9", + "@codemirror/view": "6.39.11", "@lezer/highlight": "1.2.3", "@lucide/lab": "^0.1.2", "codemirror": "6.0.2", @@ -75,12 +75,12 @@ "@layerstack/tailwind": "^1.0.1", "@lucide/svelte": "^0.562.0", "@playwright/test": "1.57.0", - "@sveltejs/kit": "^2.49.3", - "@sveltejs/vite-plugin-svelte": "^6.2.3", + "@sveltejs/kit": "2.49.5", + "@sveltejs/vite-plugin-svelte": "6.2.4", "@tailwindcss/vite": "^4.1.18", - "@types/bun": "^1.3.5", + "@types/bun": "1.3.6", "@types/js-yaml": "^4.0.9", - "@types/nodemailer": "^7.0.4", + "@types/nodemailer": "7.0.5", "@types/qrcode": "^1.5.6", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", @@ -96,7 +96,7 @@ "lucide-svelte": "^0.562.0", "mode-watcher": "^1.1.0", "postcss": "^8.5.6", - "svelte": "^5.46.1", + "svelte": "5.46.4", "svelte-adapter-bun": "1.0.1", "svelte-check": "^4.3.5", "svelte-easy-crop": "^5.0.0", @@ -109,9 +109,11 @@ "vite": "^7.3.1" }, "overrides": { - "@codemirror/state": "6.5.3", - "@codemirror/view": "6.39.9", + "@codemirror/state": "6.5.4", + "@codemirror/view": "6.39.11", "@codemirror/language": "6.12.1", + "@codemirror/commands": "6.10.1", + "@codemirror/search": "6.6.0", "@lezer/common": "1.5.0", "@lezer/highlight": "1.2.3" } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index c5bb24f..9fe2f29 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -5,9 +5,34 @@ 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 { detectHostDataDir } from '$lib/server/host-path'; +import { listContainers, removeContainer } from '$lib/server/docker'; +import { rmSync, readdirSync, existsSync } from 'fs'; +import { join } from 'path'; import type { HandleServerError, Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; +// Cleanup orphaned scanner version containers from previous runs +async function cleanupOrphanedScannerContainers() { + try { + const containers = await listContainers(true); + const orphaned = containers.filter(c => + c.name?.startsWith('dockhand-grype-version-') || + c.name?.startsWith('dockhand-trivy-version-') + ); + for (const c of orphaned) { + try { + await removeContainer(c.id, true); + } catch { /* ignore */ } + } + if (orphaned.length > 0) { + console.log(`[Startup] Cleaned up ${orphaned.length} orphaned scanner containers`); + } + } catch (error) { + // Silently ignore - Docker may not be available yet or no containers to clean + } +} + // License expiry check interval (24 hours) const LICENSE_CHECK_INTERVAL = 86400000; @@ -24,10 +49,46 @@ if (!initialized) { // Initialize crypto fallback first (detects old kernels and logs status) initCryptoFallback(); + // Cleanup orphaned TLS temp directories from previous crashes + const dataDir = process.env.DATA_DIR || './data'; + const tmpDir = join(dataDir, 'tmp'); + if (existsSync(tmpDir)) { + try { + const entries = readdirSync(tmpDir); + for (const entry of entries) { + if (entry.startsWith('tls-')) { + const path = join(tmpDir, entry); + try { + rmSync(path, { recursive: true, force: true }); + console.log(`[Startup] Cleaned orphaned TLS temp dir: ${entry}`); + } catch { /* ignore */ } + } + } + } catch { /* ignore */ } + } + setServerStartTime(); // Track when server started initDatabase(); // Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside) console.log('Hostname for license validation:', getHostname()); + + // Detect host data directory for path translation + // This allows Dockhand to translate container paths to host paths for compose volume mounts + detectHostDataDir().then(hostPath => { + if (hostPath) { + console.log(`[Startup] Host data directory detected: ${hostPath}`); + } else { + console.warn('[Startup] Could not detect host data path.'); + console.warn('[Startup] Git stacks with relative volume paths may not work correctly.'); + console.warn('[Startup] Consider setting HOST_DATA_DIR or using matching volume paths (-v /app/data:/app/data)'); + } + }).catch(err => { + console.error('[Startup] Failed to detect host data directory:', err); + }); + // Cleanup orphaned scanner containers from previous runs (non-blocking) + cleanupOrphanedScannerContainers().catch(err => { + console.error('Failed to cleanup orphaned scanner containers:', err); + }); // Start background subprocesses for metrics and event collection (isolated processes) startSubprocesses().catch(err => { console.error('Failed to start background subprocesses:', err); @@ -174,4 +235,3 @@ export const handleError: HandleServerError = ({ error, event }) => { code: 'INTERNAL_ERROR' }; }; -// CI trigger 1766327149 diff --git a/src/lib/components/host-info.svelte b/src/lib/components/host-info.svelte index d13820d..5ca3677 100644 --- a/src/lib/components/host-info.svelte +++ b/src/lib/components/host-info.svelte @@ -316,13 +316,10 @@ envAbortController = new AbortController(); fetchHostInfo(); fetchDiskUsage(); - const hostInterval = setInterval(fetchHostInfo, 30000); - const diskInterval = setInterval(fetchDiskUsage, 30000); + // No polling - only fetch on mount and environment switch document.addEventListener('click', handleClickOutside); return () => { abortPendingRequests(); // Abort on destroy - clearInterval(hostInterval); - clearInterval(diskInterval); document.removeEventListener('click', handleClickOutside); }; }); diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 5584409..19efecb 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,22 @@ [ + { + "version": "1.0.9", + "date": "2026-01-17", + "changes": [ + { "type": "feature", "text": "Shell: detect available shells in container before connecting" }, + { "type": "fix", "text": "Fix GHCR registry authentication with OAuth2 token flow" }, + { "type": "fix", "text": "Add page titles for browser tab updates on navigation" }, + { "type": "fix", "text": "Add stack name conflict warning" }, + { "type": "feature", "text": "Add docker-buildx plugin to container image" }, + { "type": "fix", "text": "Fix relative paths not working for adopted/imported stacks" }, + { "type": "fix", "text": "Fix TLS certificates not passed to docker-compose for direct connections" }, + { "type": "fix", "text": "Fix registry queries for images with docker.io prefix" }, + { "type": "fix", "text": "Fix compose editor issues when editing near env var references" }, + { "type": "fix", "text": "Fix branch switching causing unknown revision error in git stacks" }, + { "type": "fix", "text": "Fix SSE connection leak" } + ], + "imageTag": "fnsys/dockhand:v1.0.9" + }, { "version": "1.0.8", "date": "2026-01-13", diff --git a/src/lib/data/dependencies.json b/src/lib/data/dependencies.json index 61afae1..dd43451 100644 --- a/src/lib/data/dependencies.json +++ b/src/lib/data/dependencies.json @@ -71,6 +71,12 @@ "license": "MIT", "repository": "https://github.com/codemirror/lang-yaml" }, + { + "name": "@codemirror/language", + "version": "6.11.3", + "license": "MIT", + "repository": "https://github.com/codemirror/language" + }, { "name": "@codemirror/language", "version": "6.12.1", @@ -89,6 +95,12 @@ "license": "MIT", "repository": "https://github.com/codemirror/search" }, + { + "name": "@codemirror/state", + "version": "6.5.2", + "license": "MIT", + "repository": "https://github.com/codemirror/state" + }, { "name": "@codemirror/state", "version": "6.5.3", @@ -101,6 +113,12 @@ "license": "MIT", "repository": "https://github.com/codemirror/theme-one-dark" }, + { + "name": "@codemirror/view", + "version": "6.38.8", + "license": "MIT", + "repository": "https://github.com/codemirror/view" + }, { "name": "@codemirror/view", "version": "6.39.9", @@ -227,6 +245,12 @@ "license": "MIT", "repository": "https://github.com/sveltejs/acorn-typescript" }, + { + "name": "@types/better-sqlite3", + "version": "7.6.13", + "license": "MIT", + "repository": "https://github.com/DefinitelyTyped/DefinitelyTyped" + }, { "name": "@types/estree", "version": "1.0.8", @@ -275,6 +299,36 @@ "license": "Apache-2.0", "repository": "https://github.com/A11yance/axobject-query" }, + { + "name": "base64-js", + "version": "1.5.1", + "license": "MIT", + "repository": "https://github.com/beatgammit/base64-js" + }, + { + "name": "better-sqlite3", + "version": "12.5.0", + "license": "MIT", + "repository": "https://github.com/WiseLibs/better-sqlite3" + }, + { + "name": "bindings", + "version": "1.5.0", + "license": "MIT", + "repository": "https://github.com/TooTallNate/node-bindings" + }, + { + "name": "bl", + "version": "4.1.0", + "license": "MIT", + "repository": "https://github.com/rvagg/bl" + }, + { + "name": "buffer", + "version": "5.7.1", + "license": "MIT", + "repository": "https://github.com/feross/buffer" + }, { "name": "bun-types", "version": "1.3.5", @@ -287,6 +341,12 @@ "license": "MIT", "repository": "https://github.com/sindresorhus/camelcase" }, + { + "name": "chownr", + "version": "1.1.4", + "license": "ISC", + "repository": "https://github.com/isaacs/chownr" + }, { "name": "cliui", "version": "6.0.0", @@ -341,6 +401,24 @@ "license": "MIT", "repository": "https://github.com/sindresorhus/decamelize" }, + { + "name": "decompress-response", + "version": "6.0.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/decompress-response" + }, + { + "name": "deep-extend", + "version": "0.6.0", + "license": "MIT", + "repository": "https://github.com/unclechu/node-deep-extend" + }, + { + "name": "detect-libc", + "version": "2.1.2", + "license": "Apache-2.0", + "repository": "https://github.com/lovell/detect-libc" + }, { "name": "devalue", "version": "5.5.0", @@ -371,6 +449,12 @@ "license": "MIT", "repository": "https://github.com/mathiasbynens/emoji-regex" }, + { + "name": "end-of-stream", + "version": "1.4.5", + "license": "MIT", + "repository": "https://github.com/mafintosh/end-of-stream" + }, { "name": "esm-env", "version": "1.2.2", @@ -383,24 +467,66 @@ "license": "MIT", "repository": "https://github.com/sveltejs/esrap" }, + { + "name": "expand-template", + "version": "2.0.3", + "license": "(MIT OR WTFPL)", + "repository": "https://github.com/ralphtheninja/expand-template" + }, + { + "name": "file-uri-to-path", + "version": "1.0.0", + "license": "MIT", + "repository": "https://github.com/TooTallNate/file-uri-to-path" + }, { "name": "find-up", "version": "4.1.0", "license": "MIT", "repository": "https://github.com/sindresorhus/find-up" }, + { + "name": "fs-constants", + "version": "1.0.0", + "license": "MIT", + "repository": "https://github.com/mafintosh/fs-constants" + }, { "name": "get-caller-file", "version": "2.0.5", "license": "ISC", "repository": "https://github.com/stefanpenner/get-caller-file" }, + { + "name": "github-from-package", + "version": "0.0.0", + "license": "MIT", + "repository": "https://github.com/substack/github-from-package" + }, { "name": "hash-wasm", "version": "4.12.0", "license": "MIT", "repository": "https://github.com/Daninet/hash-wasm" }, + { + "name": "ieee754", + "version": "1.2.1", + "license": "BSD-3-Clause", + "repository": "https://github.com/feross/ieee754" + }, + { + "name": "inherits", + "version": "2.0.4", + "license": "ISC", + "repository": "https://github.com/isaacs/inherits" + }, + { + "name": "ini", + "version": "1.3.8", + "license": "ISC", + "repository": "https://github.com/isaacs/ini" + }, { "name": "is-fullwidth-code-point", "version": "3.0.0", @@ -443,12 +569,48 @@ "license": "MIT", "repository": "https://github.com/Rich-Harris/magic-string" }, + { + "name": "mimic-response", + "version": "3.1.0", + "license": "MIT", + "repository": "https://github.com/sindresorhus/mimic-response" + }, + { + "name": "minimist", + "version": "1.2.8", + "license": "MIT", + "repository": "https://github.com/minimistjs/minimist" + }, + { + "name": "mkdirp-classic", + "version": "0.5.3", + "license": "MIT", + "repository": "https://github.com/mafintosh/mkdirp-classic" + }, + { + "name": "napi-build-utils", + "version": "2.0.0", + "license": "MIT", + "repository": "https://github.com/inspiredware/napi-build-utils" + }, + { + "name": "node-abi", + "version": "3.85.0", + "license": "MIT", + "repository": "https://github.com/electron/node-abi" + }, { "name": "nodemailer", "version": "7.0.12", "license": "MIT-0", "repository": "https://github.com/nodemailer/nodemailer" }, + { + "name": "once", + "version": "1.4.0", + "license": "ISC", + "repository": "https://github.com/isaacs/once" + }, { "name": "otpauth", "version": "9.4.1", @@ -491,6 +653,18 @@ "license": "Unlicense", "repository": "https://github.com/porsager/postgres" }, + { + "name": "prebuild-install", + "version": "7.1.3", + "license": "MIT", + "repository": "https://github.com/prebuild/prebuild-install" + }, + { + "name": "pump", + "version": "3.0.3", + "license": "MIT", + "repository": "https://github.com/mafintosh/pump" + }, { "name": "punycode", "version": "2.3.1", @@ -503,6 +677,18 @@ "license": "MIT", "repository": "https://github.com/soldair/node-qrcode" }, + { + "name": "rc", + "version": "1.2.8", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "repository": "https://github.com/dominictarr/rc" + }, + { + "name": "readable-stream", + "version": "3.6.2", + "license": "MIT", + "repository": "https://github.com/nodejs/readable-stream" + }, { "name": "require-directory", "version": "2.1.1", @@ -521,12 +707,36 @@ "license": "MIT", "repository": "https://github.com/svecosystem/runed" }, + { + "name": "safe-buffer", + "version": "5.2.1", + "license": "MIT", + "repository": "https://github.com/feross/safe-buffer" + }, + { + "name": "semver", + "version": "7.7.3", + "license": "ISC", + "repository": "https://github.com/npm/node-semver" + }, { "name": "set-blocking", "version": "2.0.0", "license": "ISC", "repository": "https://github.com/yargs/set-blocking" }, + { + "name": "simple-concat", + "version": "1.0.1", + "license": "MIT", + "repository": "https://github.com/feross/simple-concat" + }, + { + "name": "simple-get", + "version": "4.0.1", + "license": "MIT", + "repository": "https://github.com/feross/simple-get" + }, { "name": "strict-event-emitter-types", "version": "2.0.0", @@ -539,12 +749,24 @@ "license": "MIT", "repository": "https://github.com/sindresorhus/string-width" }, + { + "name": "string_decoder", + "version": "1.3.0", + "license": "MIT", + "repository": "https://github.com/nodejs/string_decoder" + }, { "name": "strip-ansi", "version": "6.0.1", "license": "MIT", "repository": "https://github.com/chalk/strip-ansi" }, + { + "name": "strip-json-comments", + "version": "2.0.1", + "license": "MIT", + "repository": "https://github.com/sindresorhus/strip-json-comments" + }, { "name": "style-mod", "version": "4.1.3", @@ -569,18 +791,42 @@ "license": "MIT", "repository": "https://github.com/wobsoriano/svelte-sonner" }, + { + "name": "tar-fs", + "version": "2.1.4", + "license": "MIT", + "repository": "https://github.com/mafintosh/tar-fs" + }, + { + "name": "tar-stream", + "version": "2.2.0", + "license": "MIT", + "repository": "https://github.com/mafintosh/tar-stream" + }, { "name": "tr46", "version": "6.0.0", "license": "MIT", "repository": "https://github.com/jsdom/tr46" }, + { + "name": "tunnel-agent", + "version": "0.6.0", + "license": "Apache-2.0", + "repository": "https://github.com/mikeal/tunnel-agent" + }, { "name": "undici-types", "version": "7.16.0", "license": "MIT", "repository": "https://github.com/nodejs/undici" }, + { + "name": "util-deprecate", + "version": "1.0.2", + "license": "MIT", + "repository": "https://github.com/TooTallNate/util-deprecate" + }, { "name": "w3c-keyname", "version": "2.2.8", @@ -611,6 +857,12 @@ "license": "MIT", "repository": "https://github.com/chalk/wrap-ansi" }, + { + "name": "wrappy", + "version": "1.0.2", + "license": "ISC", + "repository": "https://github.com/npm/wrappy" + }, { "name": "y18n", "version": "4.0.3", diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index f5f16e6..72de2d0 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -1911,7 +1911,7 @@ export async function createGitRepository(data: { name: data.name, url: data.url, branch: data.branch || 'main', - composePath: data.composePath || 'docker-compose.yml', + composePath: data.composePath || 'compose.yaml', credentialId: data.credentialId || null, environmentId: data.environmentId || null, autoUpdate: data.autoUpdate || false, @@ -2325,7 +2325,7 @@ export async function createGitStack(data: { stackName: data.stackName, environmentId: data.environmentId ?? null, repositoryId: data.repositoryId, - composePath: data.composePath || 'docker-compose.yml', + composePath: data.composePath || 'compose.yaml', envFilePath: data.envFilePath || null, autoUpdate: data.autoUpdate || false, autoUpdateSchedule: data.autoUpdateSchedule || 'daily', diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 274531e..396e84e 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -288,7 +288,7 @@ export const gitRepositories = sqliteTable('git_repositories', { url: text('url').notNull(), branch: text('branch').default('main'), credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }), - composePath: text('compose_path').default('docker-compose.yml'), + composePath: text('compose_path').default('compose.yaml'), environmentId: integer('environment_id'), autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false), autoUpdateSchedule: text('auto_update_schedule').default('daily'), @@ -308,7 +308,7 @@ export const gitStacks = sqliteTable('git_stacks', { stackName: text('stack_name').notNull(), environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }), - composePath: text('compose_path').default('docker-compose.yml'), + composePath: text('compose_path').default('compose.yaml'), envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod") autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false), autoUpdateSchedule: text('auto_update_schedule').default('daily'), diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts index 2aad1ca..6c08051 100644 --- a/src/lib/server/db/schema/pg-schema.ts +++ b/src/lib/server/db/schema/pg-schema.ts @@ -291,7 +291,7 @@ export const gitRepositories = pgTable('git_repositories', { url: text('url').notNull(), branch: text('branch').default('main'), credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }), - composePath: text('compose_path').default('docker-compose.yml'), + composePath: text('compose_path').default('compose.yaml'), environmentId: integer('environment_id'), autoUpdate: boolean('auto_update').default(false), autoUpdateSchedule: text('auto_update_schedule').default('daily'), @@ -311,7 +311,7 @@ export const gitStacks = pgTable('git_stacks', { stackName: text('stack_name').notNull(), environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }), repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }), - composePath: text('compose_path').default('docker-compose.yml'), + composePath: text('compose_path').default('compose.yaml'), envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod") autoUpdate: boolean('auto_update').default(false), autoUpdateSchedule: text('auto_update_schedule').default('daily'), diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 050953e..fb2b235 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import type { Environment } from './db'; import { getStackEnvVarsAsRecord } from './db'; +import { isSystemContainer } from './scheduler/tasks/update-utils'; /** * Custom error for when an environment is not found. @@ -736,7 +737,8 @@ export async function listContainers(all = true, envId?: number | null): Promise restartCount: restartCounts.get(container.Id) || 0, mounts, labels: container.Labels || {}, - command: container.Command || '' + command: container.Command || '', + systemContainer: isSystemContainer(container.Image || '') }; }); } @@ -1204,6 +1206,12 @@ function parseImageReference(imageName: string): { registry: string; repo: strin } } + // Normalize docker.io to index.docker.io (Docker Hub's actual registry host) + // docker.io redirects to www.docker.com, while index.docker.io is the real API + if (registry === 'docker.io') { + registry = 'index.docker.io'; + } + // Docker Hub requires library/ prefix for official images if (registry === 'index.docker.io' && !repo.includes('/')) { repo = `library/${repo}`; @@ -1349,6 +1357,141 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise { + try { + // Normalize URL + let baseUrl = registryUrl; + if (!baseUrl.startsWith('http')) { + baseUrl = `https://${baseUrl}`; + } + baseUrl = baseUrl.replace(/\/$/, ''); + + // Step 1: Challenge request to /v2/ + const challengeResponse = await fetch(`${baseUrl}/v2/`, { + method: 'GET', + headers: { 'User-Agent': 'Dockhand/1.0' } + }); + + // If 200, no auth needed + if (challengeResponse.ok) { + return null; + } + + // If not 401, something else is wrong + if (challengeResponse.status !== 401) { + console.error(`Registry challenge failed: ${challengeResponse.status}`); + return null; + } + + // Step 2: Parse WWW-Authenticate header + const wwwAuth = challengeResponse.headers.get('WWW-Authenticate') || ''; + const challenge = wwwAuth.toLowerCase(); + + if (challenge.startsWith('basic')) { + // Basic auth - use credentials if we have them + if (credentials) { + const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + return `Basic ${basicAuth}`; + } + return null; + } + + if (!challenge.startsWith('bearer')) { + console.error(`Unsupported auth type: ${wwwAuth}`); + return null; + } + + // Parse bearer challenge: Bearer realm="...",service="...",scope="..." + const realmMatch = wwwAuth.match(/realm="([^"]+)"/i); + const serviceMatch = wwwAuth.match(/service="([^"]+)"/i); + + if (!realmMatch) { + console.error('No realm in WWW-Authenticate header'); + return null; + } + + const realm = realmMatch[1]; + const service = serviceMatch ? serviceMatch[1] : ''; + + // Step 3: Request token from realm (with credentials if available) + const tokenUrl = new URL(realm); + if (service) tokenUrl.searchParams.set('service', service); + tokenUrl.searchParams.set('scope', scope); + + const tokenHeaders: Record = { 'User-Agent': 'Dockhand/1.0' }; + + // Add Basic auth header if we have credentials + if (credentials) { + const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64'); + tokenHeaders['Authorization'] = `Basic ${basicAuth}`; + } + + const tokenResponse = await fetch(tokenUrl.toString(), { + headers: tokenHeaders + }); + + if (!tokenResponse.ok) { + const errorBody = await tokenResponse.text().catch(() => ''); + console.error(`Token request failed: ${tokenResponse.status} - ${errorBody}`); + return null; + } + + const tokenData = await tokenResponse.json() as { token?: string; access_token?: string }; + const token = tokenData.token || tokenData.access_token || null; + + return token ? `Bearer ${token}` : null; + + } catch (e) { + console.error('Failed to get registry auth header:', e); + return null; + } +} + +/** + * Helper to get normalized registry URL and auth header for registry API requests. + * Combines URL normalization, credential extraction, and token flow in one call. + * + * @param registry - Registry object from database + * @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull') + * @returns { baseUrl, authHeader } - Normalized URL and auth header (or null) + */ +export async function getRegistryAuth( + registry: { url: string; username?: string | null; password?: string | null }, + scope: string +): Promise<{ baseUrl: string; authHeader: string | null }> { + // Normalize URL + let baseUrl = registry.url; + if (!baseUrl.startsWith('http')) { + baseUrl = `https://${baseUrl}`; + } + baseUrl = baseUrl.replace(/\/$/, ''); + + // Get auth header using proper token flow + const credentials = registry.username && registry.password + ? { username: registry.username, password: registry.password } + : null; + + const authHeader = await getRegistryAuthHeader(baseUrl, scope, credentials); + + return { baseUrl, authHeader }; +} + /** * Check the registry for the current manifest digest of an image. * Simple HEAD request to get Docker-Content-Digest header. @@ -2161,14 +2304,15 @@ export async function runContainer(options: { binds?: string[]; env?: string[]; name?: string; - autoRemove?: boolean; envId?: number | null; }): Promise<{ stdout: string; stderr: string }> { // Add random suffix to avoid naming conflicts const baseName = options.name || `dockhand-temp-${Date.now()}`; const containerName = `${baseName}-${randomSuffix()}`; - // Create container + // Create container - disable AutoRemove since we fetch logs after exit + // and clean up manually. AutoRemove causes race condition where container + // is removed before we can fetch logs. const containerConfig: any = { Image: options.image, Cmd: options.cmd, @@ -2176,7 +2320,7 @@ export async function runContainer(options: { Tty: false, HostConfig: { Binds: options.binds || [], - AutoRemove: options.autoRemove !== false + AutoRemove: false // Never use AutoRemove - we clean up manually after fetching logs } }; @@ -2190,15 +2334,21 @@ export async function runContainer(options: { ); const containerId = createResult.Id; + console.log(`[runContainer] Created container ${containerId} for image ${options.image}`); try { // Start container + console.log(`[runContainer] Starting container ${containerId}...`); await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId); // Wait for container to finish - await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId); + console.log(`[runContainer] Waiting for container ${containerId} to finish...`); + const waitResponse = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId); + const waitResult = await waitResponse.json().catch(() => ({})); + console.log(`[runContainer] Container ${containerId} finished with exit code:`, waitResult?.StatusCode); - // Get logs + // Get logs - container is stopped but NOT removed yet since AutoRemove is false + console.log(`[runContainer] Fetching logs for container ${containerId}...`); const logsResponse = await dockerFetch( `/containers/${containerId}/logs?stdout=true&stderr=true`, {}, @@ -2206,15 +2356,20 @@ export async function runContainer(options: { ); const buffer = Buffer.from(await logsResponse.arrayBuffer()); - return demuxDockerStream(buffer, { separateStreams: true }) as { stdout: string; stderr: string }; + console.log(`[runContainer] Got logs buffer, size: ${buffer.length} bytes`); + + const result = demuxDockerStream(buffer, { separateStreams: true }) as { stdout: string; stderr: string }; + console.log(`[runContainer] Demuxed: stdout=${result.stdout.length} chars, stderr=${result.stderr.length} chars`); + if (result.stdout.length === 0 && result.stderr.length === 0 && buffer.length > 0) { + console.log(`[runContainer] WARNING: Buffer has data but demux returned empty. First 100 bytes:`, buffer.slice(0, 100)); + } + return result; } finally { - // Cleanup container if not auto-removed - if (options.autoRemove === false) { - try { - await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId); - } catch { - // Ignore cleanup errors - } + // Always cleanup container manually + try { + await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId); + } catch { + // Ignore cleanup errors } } } @@ -2230,11 +2385,10 @@ export async function runContainerWithStreaming(options: { onStdout?: (data: string) => void; onStderr?: (data: string) => void; }): Promise { - // Add random suffix to avoid naming conflicts const baseName = options.name || `dockhand-stream-${Date.now()}`; const containerName = `${baseName}-${randomSuffix()}`; - // Create container + // Create container WITHOUT AutoRemove - we need to fetch logs after it exits const containerConfig: any = { Image: options.image, Cmd: options.cmd, @@ -2242,123 +2396,135 @@ export async function runContainerWithStreaming(options: { Tty: false, HostConfig: { Binds: options.binds || [], - AutoRemove: true + AutoRemove: false } }; - // Try to create container, handle 409 conflict by removing stale container - let createResult: { Id: string }; - try { - createResult = await dockerJsonRequest<{ Id: string }>( - `/containers/create?name=${encodeURIComponent(containerName)}`, - { - method: 'POST', - body: JSON.stringify(containerConfig) - }, - options.envId - ); - } catch (error: any) { - // Check for 409 conflict (container name already in use) - if (error?.message?.includes('409') || error?.status === 409) { - console.log(`[Docker] Container name conflict for ${containerName}, attempting cleanup...`); - // Try to force remove the conflicting container - try { - await dockerFetch(`/containers/${containerName}?force=true`, { method: 'DELETE' }, options.envId); - console.log(`[Docker] Removed stale container ${containerName}`); - } catch (removeError) { - console.error(`[Docker] Failed to remove stale container:`, removeError); - } - // Retry with a new random suffix - const retryName = `${baseName}-${randomSuffix()}`; - createResult = await dockerJsonRequest<{ Id: string }>( - `/containers/create?name=${encodeURIComponent(retryName)}`, - { - method: 'POST', - body: JSON.stringify(containerConfig) - }, - options.envId - ); - } else { - throw error; - } - } - - const containerId = createResult.Id; - - // Start container - await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId); - - // Check if this is an edge environment for streaming approach - const config = await getDockerConfig(options.envId ?? undefined); - - // Stream logs while container is running - if (config.connectionType === 'hawser-edge' && config.environmentId) { - // Edge mode: use sendEdgeStreamRequest for real-time streaming - return new Promise((resolve, reject) => { - let stdout = ''; - let buffer: Buffer = Buffer.alloc(0); - - const { cancel } = sendEdgeStreamRequest( - config.environmentId!, - 'GET', - `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true`, - { - onData: (data: string) => { - try { - // Data is base64 encoded from edge agent - const decoded = Buffer.from(data, 'base64'); - buffer = Buffer.concat([buffer, decoded]); - - // Process Docker stream frames - const result = processStreamFrames(buffer, options.onStdout, options.onStderr); - stdout += result.stdout; - buffer = result.remaining; - } catch { - // If not base64, try as raw data - const result = processStreamFrames(Buffer.from(data), options.onStdout, options.onStderr); - stdout += result.stdout; - } - }, - onEnd: () => { - resolve(stdout); - }, - onError: (error: string) => { - // If container finished, treat as success - if (error.includes('container') && (error.includes('exited') || error.includes('not running'))) { - resolve(stdout); - } else { - reject(new Error(error)); - } - } - } - ); - }); - } - - // Non-edge mode: use regular streaming - const logsResponse = await dockerFetch( - `/containers/${containerId}/logs?stdout=true&stderr=true&follow=true`, - { streaming: true }, + const createResult = await dockerJsonRequest<{ Id: string }>( + `/containers/create?name=${encodeURIComponent(containerName)}`, + { method: 'POST', body: JSON.stringify(containerConfig) }, options.envId ); - let stdout = ''; - const reader = logsResponse.body?.getReader(); - if (reader) { - let buffer: Buffer = Buffer.alloc(0); + const containerId = createResult.Id; + const config = await getDockerConfig(options.envId ?? undefined); - while (true) { - const { done, value } = await reader.read(); - if (done) break; + try { + // Start container + await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId); - buffer = Buffer.concat([buffer, Buffer.from(value)]); - const result = processStreamFrames(buffer, options.onStdout, options.onStderr); - stdout += result.stdout; - buffer = result.remaining; + // Stream stderr for real-time progress while container runs + if (config.connectionType === 'hawser-edge' && config.environmentId) { + await streamEdgeStderr(config.environmentId, containerId, options.onStderr); + } else { + await streamLocalStderr(containerId, options.envId, options.onStderr); + } + + // Container has exited. Now fetch stdout reliably (no race condition). + const stdout = await fetchContainerStdout(containerId, config, options.envId); + return stdout; + } finally { + // Always cleanup container + try { + await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId); + } catch { + // Ignore cleanup errors } } +} - return stdout; +// Stream only stderr for real-time progress (local/standard mode) +async function streamLocalStderr( + containerId: string, + envId: number | null | undefined, + onStderr?: (data: string) => void +): Promise { + const response = await dockerFetch( + `/containers/${containerId}/logs?stdout=false&stderr=true&follow=true`, + { streaming: true }, + envId + ); + + const reader = response.body?.getReader(); + if (!reader) return; + + let buffer: Buffer = Buffer.alloc(0); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer = Buffer.concat([buffer, Buffer.from(value)]); + const result = processStreamFrames(buffer, undefined, onStderr); + buffer = result.remaining; + } +} + +// Stream only stderr for real-time progress (edge mode) +async function streamEdgeStderr( + environmentId: number, + containerId: string, + onStderr?: (data: string) => void +): Promise { + return new Promise((resolve, reject) => { + let buffer: Buffer = Buffer.alloc(0); + + sendEdgeStreamRequest( + environmentId, + 'GET', + `/containers/${containerId}/logs?stdout=false&stderr=true&follow=true`, + { + onData: (data: string) => { + try { + const decoded = Buffer.from(data, 'base64'); + buffer = Buffer.concat([buffer, decoded]); + const result = processStreamFrames(buffer, undefined, onStderr); + buffer = result.remaining; + } catch { + // Ignore decode errors + } + }, + onEnd: () => resolve(), + onError: (error: string) => { + // Container exited = success + if (error.includes('container') && (error.includes('exited') || error.includes('not running'))) { + resolve(); + } else { + reject(new Error(error)); + } + } + } + ); + }); +} + +// Fetch stdout after container has exited (reliable, no race) +async function fetchContainerStdout( + containerId: string, + config: Awaited>, + envId: number | null | undefined +): Promise { + if (config.connectionType === 'hawser-edge' && config.environmentId) { + const response = await sendEdgeRequest( + config.environmentId, + 'GET', + `/containers/${containerId}/logs?stdout=true&stderr=false&follow=false` + ); + if (!response.body) return ''; + const bodyData = typeof response.body === 'string' + ? Buffer.from(response.body, 'base64') + : Buffer.from(response.body); + const result = processStreamFrames(bodyData, undefined, undefined); + return result.stdout; + } + + // Local/standard mode + const response = await dockerFetch( + `/containers/${containerId}/logs?stdout=true&stderr=false&follow=false`, + {}, + envId + ); + const buffer = Buffer.from(await response.arrayBuffer()); + const result = processStreamFrames(buffer, undefined, undefined); + return result.stdout; } // Push image to registry diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 2bb1c64..407221b 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs'; -import { join, resolve, dirname } from 'node:path'; +import { join, resolve, dirname, basename, relative } from 'node:path'; import { getGitRepository, getGitCredential, @@ -11,7 +11,7 @@ import { type GitCredential, type GitStackWithRepo } from './db'; -import { deployStack } from './stacks'; +import { deployStack, getStackDir } from './stacks'; // Directory for storing cloned repositories const GIT_REPOS_DIR = process.env.GIT_REPOS_DIR || './data/git-repos'; @@ -142,8 +142,10 @@ export interface SyncResult { commit?: string; composeContent?: string; composeDir?: string; // Directory containing the compose file (for copying all files) + composeFileName?: string; // Filename of the compose file (e.g., "docker-compose.yaml") envFileVars?: Record; // Variables from .env file in repo envFileContent?: string; // Raw .env file content (for Hawser deployments) + envFileName?: string; // Filename of env file relative to composeDir (e.g., ".env" or "../.env") error?: string; updated?: boolean; } @@ -505,6 +507,22 @@ function getStackRepoPath(stackId: number): string { return join(GIT_REPOS_DIR, `stack-${stackId}`); } +/** + * Get the current commit hash from a repo path (if it exists). + * Used to detect if repo was updated after re-clone. + */ +async function getPreviousCommit(repoPath: string, env: GitEnv): Promise { + if (!existsSync(repoPath)) { + return null; + } + try { + const result = await execGit(['rev-parse', 'HEAD'], repoPath, env); + return result.code === 0 ? result.stdout.trim() : null; + } catch { + return null; + } +} + export async function syncGitStack(stackId: number): Promise { const gitStack = await getGitStack(stackId); if (!gitStack) { @@ -551,55 +569,40 @@ export async function syncGitStack(stackId: number): Promise { let updated = false; let currentCommit = ''; - if (!existsSync(repoPath)) { - console.log(`${logPrefix} Repo doesn't exist locally, cloning...`); - // Clone the repository (shallow clone) - const repoUrl = buildRepoUrl(repo.url, credential); - - const result = await execGit( - ['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath], - process.cwd(), - env - ); - console.log(`${logPrefix} Clone exit code:`, result.code); - if (result.stdout) console.log(`${logPrefix} Clone stdout:`, result.stdout); - if (result.stderr) console.log(`${logPrefix} Clone stderr:`, result.stderr); - - if (result.code !== 0) { - // Clean up partial clone directory on failure - if (existsSync(repoPath)) { - rmSync(repoPath, { recursive: true, force: true }); - } - throw new Error(`Git clone failed: ${result.stderr}`); - } - - updated = true; - } else { - console.log(`${logPrefix} Repo exists, pulling latest...`); - // Get current commit before pull - const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); - const beforeCommit = beforeResult.stdout; - console.log(`${logPrefix} Commit before pull:`, beforeCommit.substring(0, 7)); - - // Pull latest changes - const result = await execGit(['pull', 'origin', repo.branch], repoPath, env); - console.log(`${logPrefix} Pull exit code:`, result.code); - if (result.stdout) console.log(`${logPrefix} Pull stdout:`, result.stdout); - if (result.stderr) console.log(`${logPrefix} Pull stderr:`, result.stderr); - - if (result.code !== 0) { - throw new Error(`Git pull failed: ${result.stderr}`); - } - - // Get commit after pull - const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); - const afterCommit = afterResult.stdout; - console.log(`${logPrefix} Commit after pull:`, afterCommit.substring(0, 7)); - - updated = beforeCommit !== afterCommit; - console.log(`${logPrefix} Repo updated:`, updated); + // Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.) + // Shallow clones are fast so this is acceptable + const previousCommit = await getPreviousCommit(repoPath, env); + if (existsSync(repoPath)) { + console.log(`${logPrefix} Removing existing clone for fresh sync...`); + rmSync(repoPath, { recursive: true, force: true }); } + console.log(`${logPrefix} Cloning repository...`); + const repoUrl = buildRepoUrl(repo.url, credential); + + const result = await execGit( + ['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath], + process.cwd(), + env + ); + console.log(`${logPrefix} Clone exit code:`, result.code); + if (result.stdout) console.log(`${logPrefix} Clone stdout:`, result.stdout); + if (result.stderr) console.log(`${logPrefix} Clone stderr:`, result.stderr); + + if (result.code !== 0) { + // Clean up partial clone directory on failure + if (existsSync(repoPath)) { + rmSync(repoPath, { recursive: true, force: true }); + } + throw new Error(`Git clone failed: ${result.stderr}`); + } + + // Check if commit changed + const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + const newCommit = newCommitResult.stdout.trim(); + updated = previousCommit !== newCommit; + console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, updated: ${updated}`); + // Get current commit hash const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); currentCommit = commitResult.stdout.substring(0, 7); @@ -618,13 +621,16 @@ export async function syncGitStack(stackId: number): Promise { console.log(`${logPrefix} Compose content:`); console.log(composeContent); - // Determine the compose directory (for copying all files) + // Determine the compose directory and filename (for copying all files) const composeDir = dirname(composePath); + const composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml" console.log(`${logPrefix} Compose directory:`, composeDir); + console.log(`${logPrefix} Compose filename:`, composeFileName); // Read env file if configured (optional - don't fail if missing) let envFileVars: Record | undefined; let envFileContent: string | undefined; + let envFileName: string | undefined; if (gitStack.envFilePath) { const envFilePath = join(repoPath, gitStack.envFilePath); console.log(`${logPrefix} Looking for env file at:`, envFilePath); @@ -634,6 +640,11 @@ export async function syncGitStack(stackId: number): Promise { envFileContent = await Bun.file(envFilePath).text(); envFileVars = parseEnvFileContent(envFileContent, gitStack.stackName); console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length); + + // Compute env file path relative to compose directory + // This is needed for --env-file flag after files are copied to stack directory + envFileName = relative(composeDir, envFilePath); + console.log(`${logPrefix} Env filename relative to compose dir:`, envFileName); } catch (err) { // Log but don't fail - env file is optional console.warn(`${logPrefix} Failed to read env file ${gitStack.envFilePath}:`, err); @@ -668,7 +679,9 @@ export async function syncGitStack(stackId: number): Promise { commit: currentCommit, composeContent, composeDir, + composeFileName, envFileVars, + envFileName, updated }; } catch (error: any) { @@ -735,12 +748,17 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea // Note: Without this, docker compose only detects compose file changes, not env var changes console.log(`${logPrefix} Calling deployStack...`); console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir); + console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName); + console.log(`${logPrefix} Env filename:`, syncResult.envFileName ?? '(none)'); + const result = await deployStack({ name: gitStack.stackName, compose: syncResult.composeContent!, envId: gitStack.environmentId, envFileVars: syncResult.envFileVars, sourceDir: syncResult.composeDir, // Copy entire directory from git repo + composeFileName: syncResult.composeFileName, // Use original compose filename from repo + envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional) forceRecreate }); @@ -752,13 +770,21 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea if (result.error) console.log(`${logPrefix} Error:`, result.error); if (result.success) { - // Record the stack source + // Record the stack source with resolved compose path for consistency + const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId); + const resolvedComposePath = syncResult.composeFileName + ? join(stackDir, syncResult.composeFileName) + : undefined; + + console.log(`${logPrefix} Resolved compose path for stack_sources:`, resolvedComposePath); + await upsertStackSource({ stackName: gitStack.stackName, environmentId: gitStack.environmentId, sourceType: 'git', gitRepositoryId: gitStack.repositoryId, - gitStackId: stackId + gitStackId: stackId, + composePath: resolvedComposePath }); } @@ -873,54 +899,39 @@ export async function deployGitStackWithProgress( let updated = false; let currentCommit = ''; - if (!existsSync(repoPath)) { - // Step 2: Cloning - onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps }); + // Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.) + // Shallow clones are fast so this is acceptable + const previousCommit = await getPreviousCommit(repoPath, env); - const repoUrl = buildRepoUrl(repo.url, credential); + // Step 2: Cloning + onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps }); - // Step 3: Fetching - onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps }); - const result = await execGit( - ['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath], - process.cwd(), - env - ); - if (result.code !== 0) { - // Clean up partial clone directory on failure - if (existsSync(repoPath)) { - rmSync(repoPath, { recursive: true, force: true }); - } - throw new Error(`Git clone failed: ${result.stderr}`); - } - - updated = true; - } else { - // Step 2-3: Fetching and resetting to latest (works with shallow clones) - onProgress({ status: 'fetching', message: 'Fetching latest changes...', step: 2, totalSteps }); - - const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); - const beforeCommit = beforeResult.stdout; - - // Fetch the latest from origin (shallow fetch) - const fetchResult = await execGit(['fetch', '--depth=1', 'origin', repo.branch], repoPath, env); - if (fetchResult.code !== 0) { - throw new Error(`Git fetch failed: ${fetchResult.stderr}`); - } - - // Reset to the fetched commit (this works reliably with shallow clones) - onProgress({ status: 'fetching', message: 'Updating to latest...', step: 3, totalSteps }); - const resetResult = await execGit(['reset', '--hard', `origin/${repo.branch}`], repoPath, env); - if (resetResult.code !== 0) { - throw new Error(`Git reset failed: ${resetResult.stderr}`); - } - - const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); - const afterCommit = afterResult.stdout; - - updated = beforeCommit !== afterCommit; + if (existsSync(repoPath)) { + rmSync(repoPath, { recursive: true, force: true }); } + const repoUrl = buildRepoUrl(repo.url, credential); + + // Step 3: Fetching + onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps }); + const cloneResult = await execGit( + ['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath], + process.cwd(), + env + ); + if (cloneResult.code !== 0) { + // Clean up partial clone directory on failure + if (existsSync(repoPath)) { + rmSync(repoPath, { recursive: true, force: true }); + } + throw new Error(`Git clone failed: ${cloneResult.stderr}`); + } + + // Check if commit changed + const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); + const newCommit = newCommitResult.stdout.trim(); + updated = previousCommit !== newCommit; + // Get current commit hash const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env); currentCommit = commitResult.stdout.substring(0, 7); diff --git a/src/lib/server/host-path.ts b/src/lib/server/host-path.ts new file mode 100644 index 0000000..7eab340 --- /dev/null +++ b/src/lib/server/host-path.ts @@ -0,0 +1,232 @@ +/** + * Host Path Resolution Module + * + * Dockhand runs inside a Docker container where paths differ from the host. + * This module detects the host path for the DATA_DIR mount, enabling proper + * volume path resolution for compose stacks. + * + * Problem: + * - Dockhand container has /app/data mounted from host (e.g., -v dockhand_data:/app/data) + * - Compose file says: ./ca.pem:/ca.pem (relative path) + * - docker-compose resolves this to /app/data/stacks/.../ca.pem + * - Docker daemon on HOST receives this path, but /app/data doesn't exist on host! + * - Docker creates a directory instead of mounting the file + * + * Solution: + * - Query Docker API to find the host source path for our /app/data mount + * - Rewrite relative paths in compose files to use the host path + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +// Cache the host data dir to avoid repeated API calls +let cachedHostDataDir: string | null = null; +let detectionAttempted = false; + +/** + * Get our own container ID + */ +function getOwnContainerId(): string | null { + // Method 1: From cgroup (works in most cases) + try { + const cgroup = readFileSync('/proc/self/cgroup', 'utf-8'); + // Look for docker container ID (64 hex chars) + const match = cgroup.match(/[a-f0-9]{64}/); + if (match) { + return match[0]; + } + } catch { + // Can't read cgroup + } + + // Method 2: From mountinfo + try { + const mountinfo = readFileSync('/proc/self/mountinfo', 'utf-8'); + const match = mountinfo.match(/\/docker\/containers\/([a-f0-9]{64})/); + if (match) { + return match[1]; + } + } catch { + // Can't read mountinfo + } + + // Method 3: HOSTNAME might be container ID (short form) + const hostname = process.env.HOSTNAME; + if (hostname && /^[a-f0-9]{12}$/.test(hostname)) { + return hostname; + } + + return null; +} + +/** + * Get the host path for our DATA_DIR mount by inspecting our own container + */ +export async function detectHostDataDir(): Promise { + // Return cached value if already detected + if (detectionAttempted) { + return cachedHostDataDir; + } + detectionAttempted = true; + + // Check if user explicitly set HOST_DATA_DIR + if (process.env.HOST_DATA_DIR) { + cachedHostDataDir = process.env.HOST_DATA_DIR; + console.log(`[HostPath] Using HOST_DATA_DIR from environment: ${cachedHostDataDir}`); + return cachedHostDataDir; + } + + const containerId = getOwnContainerId(); + if (!containerId) { + console.warn('[HostPath] Running in Docker but could not detect container ID'); + return null; + } + + console.log(`[HostPath] Detected container ID: ${containerId.substring(0, 12)}`); + + // Get DATA_DIR (inside container) + const dataDir = resolve(process.env.DATA_DIR || '/app/data'); + + try { + // Query Docker API to inspect our own container + const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock'; + + // Use fetch with unix socket + const response = await fetch(`http://localhost/containers/${containerId}/json`, { + // @ts-ignore - Bun supports unix sockets + unix: socketPath + }); + + if (!response.ok) { + console.warn(`[HostPath] Failed to inspect container: ${response.status}`); + return null; + } + + const containerInfo = await response.json() as { + Mounts?: Array<{ + Type: string; + Source: string; + Destination: string; + }>; + }; + + // Find the mount for our DATA_DIR + const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir); + + if (dataMount) { + cachedHostDataDir = dataMount.Source; + console.log(`[HostPath] Detected host path for ${dataDir}: ${cachedHostDataDir}`); + return cachedHostDataDir; + } + + // Check if DATA_DIR is a subdirectory of a mount + for (const mount of containerInfo.Mounts || []) { + if (dataDir.startsWith(mount.Destination + '/') || dataDir === mount.Destination) { + const relativePath = dataDir.substring(mount.Destination.length); + cachedHostDataDir = mount.Source + relativePath; + console.log(`[HostPath] Detected host path for ${dataDir} via parent mount: ${cachedHostDataDir}`); + return cachedHostDataDir; + } + } + + console.warn(`[HostPath] Could not find mount for ${dataDir} in container mounts`); + return null; + } catch (err) { + console.warn(`[HostPath] Failed to query Docker API: ${err}`); + return null; + } +} + +/** + * Get the cached host data dir (call detectHostDataDir first during startup) + */ +export function getHostDataDir(): string | null { + return cachedHostDataDir; +} + +/** + * Translate a container path to host path + * + * @param containerPath - Path inside the container (e.g., /app/data/stacks/mystack/file.txt) + * @returns Host path if translation is needed, or original path if not + */ +export function translateToHostPath(containerPath: string): string { + const hostDataDir = getHostDataDir(); + if (!hostDataDir) { + return containerPath; + } + + const dataDir = resolve(process.env.DATA_DIR || '/app/data'); + + // Check if the path is under DATA_DIR + if (containerPath.startsWith(dataDir + '/') || containerPath === dataDir) { + const relativePath = containerPath.substring(dataDir.length); + return hostDataDir + relativePath; + } + + return containerPath; +} + +/** + * Rewrite relative volume paths in a compose file to use absolute host paths. + * This is necessary when Dockhand runs inside Docker with a mounted data volume. + * + * Transforms: + * ./config.toml:/config.toml -> /host/path/to/stack/config.toml:/config.toml + * + * @param composeContent - The compose file content + * @param workingDir - The working directory (container path) where the compose file is located + * @returns Modified compose content with absolute host paths, or original if no translation needed + */ +export function rewriteComposeVolumePaths(composeContent: string, workingDir: string): { content: string; modified: boolean; changes: string[] } { + const hostDataDir = getHostDataDir(); + const changes: string[] = []; + + if (!hostDataDir) { + return { content: composeContent, modified: false, changes }; + } + + const dataDir = resolve(process.env.DATA_DIR || '/app/data'); + + // Check if workingDir is under DATA_DIR + if (!workingDir.startsWith(dataDir + '/') && workingDir !== dataDir) { + return { content: composeContent, modified: false, changes }; + } + + // Calculate the host working directory + const relativePath = workingDir.substring(dataDir.length); + const hostWorkingDir = hostDataDir + relativePath; + + // Parse compose content line by line to find and rewrite volume mounts + // We look for patterns like: + // - ./something:/container/path + // - "./something:/container/path" + // - './something:/container/path' + const lines = composeContent.split('\n'); + const modifiedLines: string[] = []; + + for (const line of lines) { + // Match volume mount patterns with relative paths + // Handles: - ./path:/dest, - "./path:/dest", - './path:/dest' + const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\/[^'":\s]+)(\2)(:.+)$/); + + if (volumeMatch) { + const [, prefix, quote, relativeSrc, , destPart] = volumeMatch; + // Convert relative path to absolute host path + const absoluteHostPath = hostWorkingDir + '/' + relativeSrc.substring(2); // Remove ./ + + const newLine = `${prefix}${absoluteHostPath}${destPart}`; + modifiedLines.push(newLine); + changes.push(` ${relativeSrc} -> ${absoluteHostPath}`); + } else { + modifiedLines.push(line); + } + } + + return { + content: modifiedLines.join('\n'), + modified: changes.length > 0, + changes + }; +} diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts index 14d4212..4d34470 100644 --- a/src/lib/server/scanner.ts +++ b/src/lib/server/scanner.ts @@ -497,6 +497,12 @@ export async function scanWithGrype( } ); + // Defensive logging for empty output + console.log(`[Grype] Scanner container output received, length: ${output.length}`); + if (output.length === 0) { + console.error('[Grype] WARNING: Empty output from scanner container - possible race condition'); + } + onProgress?.({ stage: 'parsing', message: 'Parsing scan results...', @@ -589,6 +595,12 @@ export async function scanWithTrivy( } ); + // Defensive logging for empty output + console.log(`[Trivy] Scanner container output received, length: ${output.length}`); + if (output.length === 0) { + console.error('[Trivy] WARNING: Empty output from scanner container - possible race condition'); + } + onProgress?.({ stage: 'parsing', message: 'Parsing scan results...', @@ -731,6 +743,7 @@ async function getScannerVersion( // Create temporary container to get version const versionCmd = scannerType === 'grype' ? ['version'] : ['--version']; + console.log(`[Scanner] Getting ${scannerType} version with cmd:`, versionCmd); const { stdout, stderr } = await runContainer({ image: scannerImage, cmd: versionCmd, @@ -738,6 +751,7 @@ async function getScannerVersion( envId }); + console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`); const output = stdout || stderr; // Parse version from output diff --git a/src/lib/server/scheduler/tasks/container-update.ts b/src/lib/server/scheduler/tasks/container-update.ts index c1b7b32..c0f6d48 100644 --- a/src/lib/server/scheduler/tasks/container-update.ts +++ b/src/lib/server/scheduler/tasks/container-update.ts @@ -31,7 +31,7 @@ import { } from '../../docker'; import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner'; import { sendEventNotification } from '../../notifications'; -import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from './update-utils'; +import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; /** * Execute a container auto-update. @@ -98,14 +98,18 @@ export async function runContainerUpdate( return; } - // Prevent Dockhand from updating itself - if (isDockhandContainer(imageNameFromConfig)) { - log(`Skipping Dockhand container - cannot auto-update self`); + // Prevent system containers (Dockhand/Hawser) from being updated + const systemContainerType = isSystemContainer(imageNameFromConfig); + if (systemContainerType) { + const reason = systemContainerType === 'dockhand' + ? 'Cannot auto-update Dockhand itself' + : 'Cannot auto-update Hawser agent'; + log(`Skipping ${systemContainerType} container - ${reason}`); await updateScheduleExecution(execution.id, { status: 'skipped', completedAt: new Date().toISOString(), duration: Date.now() - startTime, - details: { reason: 'Cannot auto-update Dockhand itself' } + details: { reason } }); return; } diff --git a/src/lib/server/scheduler/tasks/env-update-check.ts b/src/lib/server/scheduler/tasks/env-update-check.ts index 87fe915..bb59474 100644 --- a/src/lib/server/scheduler/tasks/env-update-check.ts +++ b/src/lib/server/scheduler/tasks/env-update-check.ts @@ -33,7 +33,7 @@ import { } from '../../docker'; import { sendEventNotification } from '../../notifications'; import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner'; -import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from './update-utils'; +import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils'; interface UpdateInfo { containerId: string; @@ -224,9 +224,13 @@ export async function runEnvUpdateCheckJob( const blockedContainers: { name: string; reason: string; scannerResults?: { scanner: string; critical: number; high: number; medium: number; low: number }[] }[] = []; for (const update of updatesAvailable) { - // Skip Dockhand container - cannot update itself - if (isDockhandContainer(update.imageName)) { - await log(`\n[${update.containerName}] Skipping - cannot auto-update Dockhand itself`); + // Skip system containers (Dockhand/Hawser) - cannot update themselves + const systemContainerType = isSystemContainer(update.imageName); + if (systemContainerType) { + const reason = systemContainerType === 'dockhand' + ? 'cannot auto-update Dockhand itself' + : 'cannot auto-update Hawser agent'; + await log(`\n[${update.containerName}] Skipping - ${reason}`); continue; } diff --git a/src/lib/server/scheduler/tasks/update-utils.ts b/src/lib/server/scheduler/tasks/update-utils.ts index b3ebe4f..be48b1d 100644 --- a/src/lib/server/scheduler/tasks/update-utils.ts +++ b/src/lib/server/scheduler/tasks/update-utils.ts @@ -99,6 +99,32 @@ export function isDockhandContainer(imageName: string): boolean { return imageName.toLowerCase().includes('fnsys/dockhand'); } +/** + * Check if a container is a Hawser agent. + * Official image: ghcr.io/finsys/hawser + */ +export function isHawserContainer(imageName: string): boolean { + const lower = imageName.toLowerCase(); + return lower.includes('finsys/hawser') || lower.includes('ghcr.io/finsys/hawser'); +} + +/** + * System container type - containers that cannot be updated from within Dockhand. + */ +export type SystemContainerType = 'dockhand' | 'hawser'; + +/** + * Check if a container is a system container (Dockhand or Hawser). + * System containers cannot be updated from within Dockhand because: + * - Dockhand: Would need to stop itself to update + * - Hawser: Would disconnect from the environment it's managing + */ +export function isSystemContainer(imageName: string): SystemContainerType | null { + if (isDockhandContainer(imageName)) return 'dockhand'; + if (isHawserContainer(imageName)) return 'hawser'; + return null; +} + /** * Combine multiple scan summaries by taking the maximum of each severity level. */ diff --git a/src/lib/server/stack-scanner.ts b/src/lib/server/stack-scanner.ts index c185524..138f58c 100644 --- a/src/lib/server/stack-scanner.ts +++ b/src/lib/server/stack-scanner.ts @@ -9,8 +9,8 @@ import { readdirSync, existsSync, statSync } from 'node:fs'; import { join, basename, dirname, resolve } from 'node:path'; import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db'; -// Compose file patterns to detect (in order of priority) -const COMPOSE_PATTERNS = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml']; +// Compose file patterns to detect (in order of priority - prefer new style first) +const COMPOSE_PATTERNS = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml']; // Directories to skip during scanning const SKIP_DIRECTORIES = ['.git', 'node_modules', '.docker', '__pycache__', '.venv', 'venv']; diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index c8a88e5..248d966 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -23,11 +23,23 @@ import { deleteStackEnvVars } from './db'; import { deleteGitStackFiles } from './git'; +import { cleanPem } from '$lib/utils/pem'; +import { rewriteComposeVolumePaths, getHostDataDir } from './host-path'; // ============================================================================= // TYPES // ============================================================================= +/** + * TLS configuration for remote Docker connections + */ +interface TlsConfig { + ca?: string; + cert?: string; + key?: string; + skipVerify?: boolean; +} + /** * Stack source types */ @@ -82,6 +94,10 @@ export interface DeployStackOptions { envFileVars?: Record; sourceDir?: string; // Directory to copy all files from (for git stacks) forceRecreate?: boolean; + composePath?: string; // Custom compose file path (for adopted/imported stacks) + envPath?: string; // Custom env file path (for adopted/imported stacks) + composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks + envFileName?: string; // Env filename relative to compose dir (e.g., ".env") for git stacks } // ============================================================================= @@ -114,6 +130,24 @@ let _stacksDir: string | null = null; // Per-stack locking mechanism to prevent race conditions during concurrent operations const stackLocks = new Map>(); +// Track active TLS temp directories for cleanup on unexpected process exit +const activeTlsDirs = new Set(); + +// Register cleanup handlers once at module load +if (typeof process !== 'undefined') { + const cleanupTlsDirs = () => { + for (const dir of activeTlsDirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { /* ignore */ } + } + activeTlsDirs.clear(); + }; + process.on('exit', cleanupTlsDirs); + process.on('SIGINT', () => { cleanupTlsDirs(); process.exit(130); }); + process.on('SIGTERM', () => { cleanupTlsDirs(); process.exit(143); }); +} + /** * Execute a function with exclusive lock on a stack. * Prevents race conditions when multiple operations target the same stack. @@ -287,9 +321,9 @@ export function listManagedStacks(): string[] { return readdirSync(stacksDir, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .filter((dirent) => { - const composeYml = join(stacksDir, dirent.name, 'docker-compose.yml'); - const composeYaml = join(stacksDir, dirent.name, 'docker-compose.yaml'); - return existsSync(composeYml) || existsSync(composeYaml); + // Check all valid compose filenames + const composeNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml']; + return composeNames.some(name => existsSync(join(stacksDir, dirent.name, name))); }) .map((dirent) => dirent.name); } @@ -381,8 +415,8 @@ export async function getStackComposeFile( 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']; + // Check all common compose file names (prefer new style first) + const composeFileNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml']; for (const fileName of composeFileNames) { const actualComposePath = join(stackDir, fileName); @@ -617,7 +651,7 @@ export async function saveStackComposeFile( stackDir = existingDir; } - const composeFile = join(stackDir, 'docker-compose.yml'); + const composeFile = join(stackDir, 'compose.yaml'); const exists = existsSync(stackDir); if (create) { @@ -720,11 +754,14 @@ interface ComposeCommandOptions { workingDir?: string; /** Full path to the compose file (for imported stacks, to avoid writing to internal dir) */ composePath?: string; + /** Full path to the env file (for --env-file flag, supports custom names) */ + envPath?: string; } /** * Execute a docker compose command locally via Bun.spawn. * + * @param tlsConfig - TLS configuration for remote Docker connections (certs written to temp files) * @param envVars - Non-secret environment variables (from .env file, passed for backward compat) * @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk) * @param workingDir - Optional working directory for compose execution (for imported stacks) @@ -735,13 +772,15 @@ async function executeLocalCompose( stackName: string, composeContent: string, dockerHost?: string, + tlsConfig?: TlsConfig, envVars?: Record, secretVars?: Record, forceRecreate?: boolean, removeVolumes?: boolean, envId?: number | null, workingDir?: string, - customComposePath?: string + customComposePath?: string, + customEnvPath?: string ): Promise { const logPrefix = `[Stack:${stackName}]`; @@ -752,21 +791,45 @@ async function executeLocalCompose( let composeFile: string; if (customComposePath && workingDir) { - // Imported stack: use original location, don't copy files + // Custom compose path provided - use the provided working directory and compose file + // This applies to: + // - Imported/adopted stacks: files exist at original location, no copying needed + // - Git stacks: files were already copied to workingDir by deployStack(), use them in-place + // In both cases, we don't write the compose file - it already exists stackDir = workingDir; composeFile = customComposePath; - // Don't write to the compose file - it already exists at the custom location - // The user manages this file externally } else { // Internal stack: use default data directory stackDir = operation === 'up' ? await getStackDir(stackName, envId) : (await findStackDir(stackName, envId) || await getStackDir(stackName, envId)); mkdirSync(stackDir, { recursive: true }); - composeFile = join(stackDir, 'docker-compose.yml'); + composeFile = join(stackDir, 'compose.yaml'); await Bun.write(composeFile, composeContent); } + // Rewrite relative volume paths for host path translation (in memory only, not saved to disk) + // This is needed when Dockhand runs inside Docker - the Docker daemon on the host + // can't see container paths like /app/data/..., so we translate them to host paths + // Only do this for local Docker (no dockerHost) - for remote Docker the paths wouldn't make sense + let finalComposeContent = composeContent; + if (!dockerHost && getHostDataDir()) { + const rewriteResult = rewriteComposeVolumePaths(composeContent, stackDir); + if (rewriteResult.modified) { + finalComposeContent = rewriteResult.content; + console.log(`${logPrefix} [HostPath] Translating relative volume paths for Docker host:`); + for (const change of rewriteResult.changes) { + console.log(`${logPrefix} [HostPath]${change}`); + } + console.log(`${logPrefix} [HostPath] Translated compose content:`); + console.log(`${logPrefix} [HostPath] ----------------------------------------`); + for (const line of finalComposeContent.split('\n')) { + console.log(`${logPrefix} [HostPath] ${line}`); + } + console.log(`${logPrefix} [HostPath] ----------------------------------------`); + } + } + // Build spawn environment: // 1. Start with process.env // 2. Add DOCKER_HOST if specified @@ -785,8 +848,58 @@ async function executeLocalCompose( Object.assign(spawnEnv, secretVars); } + // Handle TLS certificates for remote Docker connections + // Docker CLI requires file paths, so we write certs to a temp directory + let tlsCertDir: string | undefined; + + if (tlsConfig && (tlsConfig.ca || tlsConfig.cert)) { + // Create temp directory for TLS certs in DATA_DIR (guaranteed writable in Docker) + // Use resolve() to get absolute path - docker compose runs from a different working dir + const dataDir = resolve(process.env.DATA_DIR || './data'); + tlsCertDir = join(dataDir, 'tmp', `tls-${stackName}-${Date.now()}`); + mkdirSync(tlsCertDir, { recursive: true }); + + // Track for cleanup on unexpected process exit + activeTlsDirs.add(tlsCertDir); + + // Write certs to files (docker-compose expects specific filenames) + if (tlsConfig.ca) { + const cleanedCa = cleanPem(tlsConfig.ca); + if (cleanedCa) await Bun.write(join(tlsCertDir, 'ca.pem'), cleanedCa); + } + if (tlsConfig.cert) { + const cleanedCert = cleanPem(tlsConfig.cert); + if (cleanedCert) await Bun.write(join(tlsCertDir, 'cert.pem'), cleanedCert); + } + if (tlsConfig.key) { + const cleanedKey = cleanPem(tlsConfig.key); + if (cleanedKey) await Bun.write(join(tlsCertDir, 'key.pem'), cleanedKey); + } + + // Set Docker TLS environment variables + spawnEnv.DOCKER_TLS = '1'; + spawnEnv.DOCKER_CERT_PATH = tlsCertDir; + spawnEnv.DOCKER_TLS_VERIFY = tlsConfig.skipVerify ? '0' : '1'; + + console.log(`${logPrefix} TLS enabled: DOCKER_CERT_PATH=${tlsCertDir}, DOCKER_TLS_VERIFY=${spawnEnv.DOCKER_TLS_VERIFY}`); + } + // Build command based on operation - const args = ['docker', 'compose', '-p', stackName, '-f', composeFile]; + // If we have modified compose content (host path translation), use stdin instead of file + const useStdin = finalComposeContent !== composeContent; + const args = ['docker', 'compose', '-p', stackName, '-f', useStdin ? '-' : composeFile]; + + // Add --env-file flag if env file exists + // This makes Docker Compose load the .env file automatically (like Portainer) + // Uses custom path if provided, otherwise defaults to .env in stack directory + const envFilePath = customEnvPath || join(stackDir, '.env'); + if (existsSync(envFilePath)) { + args.push('--env-file', envFilePath); + } + + if (useStdin) { + console.log(`${logPrefix} [HostPath] Using stdin for compose content (paths translated)`); + } switch (operation) { case 'up': @@ -836,10 +949,17 @@ async function executeLocalCompose( const proc = Bun.spawn(args, { cwd: stackDir, env: spawnEnv, + stdin: useStdin ? 'pipe' : 'inherit', stdout: 'pipe', stderr: 'pipe' }); + // If using stdin (host path translation), write the modified compose content + if (useStdin && proc.stdin) { + proc.stdin.write(finalComposeContent); + proc.stdin.end(); + } + // Set up timeout with SIGTERM -> SIGKILL escalation let timedOut = false; const timeoutId = setTimeout(() => { @@ -909,6 +1029,17 @@ async function executeLocalCompose( output: '', error: `Failed to run docker compose ${operation}: ${err.message}` }; + } finally { + // Cleanup TLS temp directory (always runs, even on exception) + if (tlsCertDir) { + activeTlsDirs.delete(tlsCertDir); + try { + rmSync(tlsCertDir, { recursive: true, force: true }); + console.log(`${logPrefix} Cleaned up TLS temp directory: ${tlsCertDir}`); + } catch { + // Ignore cleanup errors + } + } } } @@ -1063,7 +1194,7 @@ async function executeComposeCommand( envVars?: Record, secretVars?: Record ): Promise { - const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath } = options; + const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath } = options; // Get environment configuration const env = envId ? await getEnvironment(envId) : null; @@ -1074,14 +1205,16 @@ async function executeComposeCommand( operation, stackName, composeContent, - undefined, + undefined, // dockerHost + undefined, // tlsConfig envVars, secretVars, forceRecreate, removeVolumes, envId, workingDir, - composePath + composePath, + envPath ); } @@ -1103,18 +1236,29 @@ async function executeComposeCommand( case 'direct': { const port = env.port || 2375; const dockerHost = `tcp://${env.host}:${port}`; + + // Build TLS config if using HTTPS + const tlsConfig: TlsConfig | undefined = env.protocol === 'https' ? { + ca: env.tlsCa || undefined, + cert: env.tlsCert || undefined, + key: env.tlsKey || undefined, + skipVerify: env.tlsSkipVerify ?? false + } : undefined; + return executeLocalCompose( operation, stackName, composeContent, dockerHost, + tlsConfig, envVars, secretVars, forceRecreate, removeVolumes, envId, workingDir, - composePath + composePath, + envPath ); } @@ -1124,14 +1268,16 @@ async function executeComposeCommand( operation, stackName, composeContent, - undefined, + undefined, // dockerHost + undefined, // tlsConfig envVars, secretVars, forceRecreate, removeVolumes, envId, workingDir, - composePath + composePath, + envPath ); } } @@ -1437,7 +1583,8 @@ async function requireComposeFile( envVars, secretVars, stackDir: composeResult.stackDir, - composePath: composeResult.composePath + composePath: composeResult.composePath ?? undefined, + envPath: envFilePath ?? undefined }; } @@ -1458,7 +1605,7 @@ export async function startStack( return executeComposeCommand( 'up', - { stackName, envId, workingDir: result.stackDir, composePath: result.composePath }, + { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, result.envVars, result.secretVars @@ -1482,7 +1629,7 @@ export async function stopStack( return executeComposeCommand( 'stop', - { stackName, envId, workingDir: result.stackDir, composePath: result.composePath }, + { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, result.envVars, result.secretVars @@ -1506,7 +1653,7 @@ export async function restartStack( return executeComposeCommand( 'restart', - { stackName, envId, workingDir: result.stackDir, composePath: result.composePath }, + { stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, result.envVars, result.secretVars @@ -1531,7 +1678,7 @@ export async function downStack( return executeComposeCommand( 'down', - { stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath }, + { stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath }, result.content!, result.envVars, result.secretVars @@ -1561,7 +1708,8 @@ export async function removeStack( stackName, envId, workingDir: composeResult.stackDir, - composePath: composeResult.composePath + composePath: composeResult.composePath ?? undefined, + envPath: composeResult.envPath ?? undefined }, composeResult.content!, envVars, @@ -1671,7 +1819,7 @@ export async function removeStack( * Uses stack locking to prevent concurrent deployments. */ export async function deployStack(options: DeployStackOptions): Promise { - const { name, compose, envId, envFileVars, sourceDir, forceRecreate } = options; + const { name, compose, envId, envFileVars, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options; const logPrefix = `[Stack:${name}]`; console.log(`${logPrefix} ========================================`); @@ -1680,6 +1828,10 @@ export async function deployStack(options: DeployStackOptions): Promise 0) { console.log(`${logPrefix} Env file var keys:`, Object.keys(envFileVars).join(', ')); @@ -1698,31 +1850,56 @@ export async function deployStack(options: DeployStackOptions): Promise { - const stackDir = await getStackDir(name, envId); - - // Read all files from source directory if provided (for Hawser deployments) + // Determine working directory: use custom composePath directory if provided, + // otherwise fall back to internal stack directory + let workingDir: string; + let actualComposePath: string | undefined; + let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks) let stackFiles: Record | undefined; - if (sourceDir && existsSync(sourceDir)) { + + if (composePath) { + // Adopted/imported stack: use the original compose file location + // This ensures relative paths in the compose file resolve correctly + // Files are NOT copied - we use them in-place at their original location + workingDir = dirname(composePath); + actualComposePath = composePath; + console.log(`${logPrefix} Using custom compose path, workingDir:`, workingDir); + } else if (sourceDir && existsSync(sourceDir)) { + // Git stack: copy entire source directory to internal stack directory + workingDir = await getStackDir(name, envId); + + // Set actualComposePath using the provided compose filename from git stack config + if (composeFileName) { + actualComposePath = join(workingDir, composeFileName); + console.log(`${logPrefix} Using compose filename from git config:`, composeFileName); + console.log(`${logPrefix} Actual compose path will be:`, actualComposePath); + } + + // Set actualEnvPath using the provided env filename from git stack config + // Only if envFileName is provided (env file is optional for git stacks) + if (envFileName) { + actualEnvPath = join(workingDir, envFileName); + console.log(`${logPrefix} Using env filename from git config:`, envFileName); + console.log(`${logPrefix} Actual env path will be:`, actualEnvPath); + } + + // Read all files for Hawser deployments stackFiles = await readDirFilesAsMap(sourceDir); console.log(`${logPrefix} Read ${Object.keys(stackFiles).length} files from source directory`); console.log(`${logPrefix} Files:`, Object.keys(stackFiles).join(', ')); - } - // Handle stack directory setup - if (sourceDir && existsSync(sourceDir)) { - // Copy entire source directory to stack directory (for git stacks) + // Copy source to stack directory console.log(`${logPrefix} Copying source directory to stack directory...`); - if (existsSync(stackDir)) { - rmSync(stackDir, { recursive: true, force: true }); + if (existsSync(workingDir)) { + rmSync(workingDir, { recursive: true, force: true }); } - cpSync(sourceDir, stackDir, { recursive: true }); - console.log(`${logPrefix} Copied ${sourceDir} -> ${stackDir}`); + cpSync(sourceDir, workingDir, { recursive: true }); + console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`); } else { - // Traditional behavior: create directory and write compose file only - mkdirSync(stackDir, { recursive: true }); - const composeFile = join(stackDir, 'docker-compose.yml'); - await Bun.write(composeFile, compose); - console.log(`${logPrefix} Compose file written to:`, composeFile); + // Internal stack: compose file should already exist (written by saveStackComposeFile) + // Just determine the working directory + workingDir = await getStackDir(name, envId); + console.log(`${logPrefix} Using internal stack directory:`, workingDir); } console.log(`${logPrefix} Compose content length:`, compose.length, 'chars'); @@ -1746,7 +1923,20 @@ export async function deployStack(options: DeployStackOptions): Promise { 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); + const message = error instanceof Error ? error.message : String(error); + console.error(`[EventSubprocess] Failed to load settings, using defaults: ${message}`); } // Start collectors for all environments diff --git a/src/lib/server/subprocesses/metrics-subprocess.ts b/src/lib/server/subprocesses/metrics-subprocess.ts index 74669c0..84ade62 100644 --- a/src/lib/server/subprocesses/metrics-subprocess.ts +++ b/src/lib/server/subprocesses/metrics-subprocess.ts @@ -132,7 +132,8 @@ async function collectEnvMetrics(env: { id: number; name: string; host?: string; } } catch (error) { // Skip this environment if it fails (might be offline) - console.error(`[MetricsSubprocess] Failed to collect metrics for ${env.name}:`, error); + const message = error instanceof Error ? error.message : String(error); + console.warn(`[MetricsSubprocess] Failed to collect metrics for ${env.name}: ${message}`); } } @@ -165,12 +166,14 @@ async function collectMetrics() { if (result.status === 'fulfilled' && result.value === null) { console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics timed out after ${ENV_METRICS_TIMEOUT}ms`); } else if (result.status === 'rejected') { - console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed:`, result.reason); + const reason = result.reason instanceof Error ? result.reason.message : String(result.reason); + console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed: ${reason}`); } }); } catch (error) { - console.error('[MetricsSubprocess] Metrics collection error:', error); - send({ type: 'error', message: `Metrics collection error: ${error}` }); + const message = error instanceof Error ? error.message : String(error); + console.error(`[MetricsSubprocess] Metrics collection error: ${message}`); + send({ type: 'error', message: `Metrics collection error: ${message}` }); } } @@ -308,7 +311,8 @@ async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics } } catch (error) { // Skip this environment if it fails - console.error(`[MetricsSubprocess] Failed to check disk space for ${env.name}:`, error); + const message = error instanceof Error ? error.message : String(error); + console.warn(`[MetricsSubprocess] Failed to check disk space for ${env.name}: ${message}`); } } @@ -341,12 +345,14 @@ async function checkDiskSpace() { if (result.status === 'fulfilled' && result.value === null) { console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check timed out after ${ENV_DISK_TIMEOUT}ms`); } else if (result.status === 'rejected') { - console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check failed:`, result.reason); + const reason = result.reason instanceof Error ? result.reason.message : String(result.reason); + console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check failed: ${reason}`); } }); } catch (error) { - console.error('[MetricsSubprocess] Disk space check error:', error); - send({ type: 'error', message: `Disk space check error: ${error}` }); + const message = error instanceof Error ? error.message : String(error); + console.error(`[MetricsSubprocess] Disk space check error: ${message}`); + send({ type: 'error', message: `Disk space check error: ${message}` }); } } diff --git a/src/lib/stores/dashboard.ts b/src/lib/stores/dashboard.ts index 7c157d5..3339dd2 100644 --- a/src/lib/stores/dashboard.ts +++ b/src/lib/stores/dashboard.ts @@ -74,19 +74,26 @@ function createDashboardDataStore() { lastFetchTime: Date.now() })); }, - // Partial update for progressive loading - merges into existing stats + // Partial update for progressive loading - deep merges into existing stats + // This preserves nested object properties (like containers.pendingUpdates) updateTilePartial: (id: number, partialStats: Partial) => { update(data => ({ ...data, tiles: data.tiles.map(t => { if (t.id === id && t.stats) { - return { - ...t, - stats: { - ...t.stats, - ...partialStats + // Deep merge: for nested objects, merge properties instead of replacing + const mergedStats = { ...t.stats }; + for (const [key, value] of Object.entries(partialStats)) { + const existing = (mergedStats as any)[key]; + // Deep merge for plain objects (not arrays or null) + if (existing && typeof existing === 'object' && !Array.isArray(existing) && + value && typeof value === 'object' && !Array.isArray(value)) { + (mergedStats as any)[key] = { ...existing, ...value }; + } else if (value !== undefined) { + (mergedStats as any)[key] = value; } - }; + } + return { ...t, stats: mergedStats }; } return t; }), diff --git a/src/lib/stores/environment.ts b/src/lib/stores/environment.ts index 7087b60..b1c68b8 100644 --- a/src/lib/stores/environment.ts +++ b/src/lib/stores/environment.ts @@ -88,6 +88,7 @@ export function appendEnvParam(url: string, envId: number | null | undefined): s // Store for environments list with auto-refresh capability function createEnvironmentsStore() { const { subscribe, set, update } = writable([]); + const loaded = writable(false); // Tracks if environments have been fetched at least once let loading = false; async function fetchEnvironments() { @@ -98,6 +99,7 @@ function createEnvironmentsStore() { if (response.ok) { const data: Environment[] = await response.json(); set(data); + loaded.set(true); // Auto-select environment if none selected or current one no longer exists const current = get(currentEnvironment); @@ -133,6 +135,7 @@ function createEnvironmentsStore() { } else { // Clear environments on permission denied or other errors set([]); + loaded.set(true); // Mark as loaded even on error - we've completed the fetch // Also clear the current environment from localStorage localStorage.removeItem(STORAGE_KEY); currentEnvironment.set(null); @@ -140,6 +143,7 @@ function createEnvironmentsStore() { } catch (error) { console.error('Failed to fetch environments:', error); set([]); + loaded.set(true); // Mark as loaded even on error - we've completed the fetch localStorage.removeItem(STORAGE_KEY); currentEnvironment.set(null); } finally { @@ -156,7 +160,8 @@ function createEnvironmentsStore() { subscribe, refresh: fetchEnvironments, set, - update + update, + loaded // Expose the loaded store for consumers to know when first fetch is complete }; } diff --git a/src/lib/stores/stats.ts b/src/lib/stores/stats.ts deleted file mode 100644 index c7f147c..0000000 --- a/src/lib/stores/stats.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { writable, get } from 'svelte/store'; -import { currentEnvironment, appendEnvParam } from './environment'; - -export interface ContainerStats { - id: string; - name: string; - cpuPercent: number; - memoryUsage: number; - memoryLimit: number; - memoryPercent: number; -} - -export interface HostInfo { - hostname: string; - ipAddress: string; - platform: string; - arch: string; - cpus: number; - totalMemory: number; - freeMemory: number; - uptime: number; - dockerVersion: string; - dockerContainers: number; - dockerContainersRunning: number; - dockerImages: number; -} - -export interface HostMetric { - cpu_percent: number; - memory_percent: number; - memory_used: number; - memory_total: number; - timestamp: string; -} - -// Historical data settings -const MAX_HISTORY = 60; // 10 minutes at 10s intervals (server collects every 10s) -const POLL_INTERVAL = 5000; // 5 seconds - -// Stores -export const cpuHistory = writable([]); -export const memoryHistory = writable([]); -export const containerStats = writable([]); -export const hostInfo = writable(null); -export const lastUpdated = writable(new Date()); -export const isCollecting = writable(false); - -let pollInterval: ReturnType | null = null; -let envId: number | null = null; -let initialFetchDone = false; - -// Subscribe to environment changes -currentEnvironment.subscribe((env) => { - envId = env?.id ?? null; - // Reset history when environment changes - if (initialFetchDone) { - cpuHistory.set([]); - memoryHistory.set([]); - initialFetchDone = false; - } -}); - -// Helper for fetch with timeout -async function fetchWithTimeout(url: string, timeout = 5000): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - try { - const response = await fetch(url, { signal: controller.signal }); - clearTimeout(timeoutId); - return response.json(); - } catch { - clearTimeout(timeoutId); - return null; - } -} - -async function fetchStats() { - // Don't fetch if no environment is selected - if (!envId) return; - - // Fire all fetches independently - don't block on slow ones - fetchWithTimeout(appendEnvParam('/api/containers/stats?limit=5', envId), 5000).then(data => { - if (Array.isArray(data)) { - containerStats.set(data); - } - }); - - fetchWithTimeout(appendEnvParam('/api/host', envId), 5000).then(data => { - if (data && !data.error) { - hostInfo.set(data); - } - }); - - fetchWithTimeout(appendEnvParam('/api/metrics?limit=60', envId), 5000).then(data => { - if (data?.metrics && data.metrics.length > 0) { - const metrics: HostMetric[] = data.metrics; - const cpuValues = metrics.map(m => m.cpu_percent); - const memValues = metrics.map(m => m.memory_percent); - - cpuHistory.set(cpuValues.slice(-MAX_HISTORY)); - memoryHistory.set(memValues.slice(-MAX_HISTORY)); - initialFetchDone = true; - } - }); - - lastUpdated.set(new Date()); -} - -export function startStatsCollection() { - if (pollInterval) return; // Already running - - isCollecting.set(true); - fetchStats(); // Initial fetch - pollInterval = setInterval(fetchStats, POLL_INTERVAL); -} - -export function stopStatsCollection() { - if (pollInterval) { - clearInterval(pollInterval); - pollInterval = null; - } - isCollecting.set(false); -} - -// Get current values -export function getCurrentCpu(): number { - const history = get(cpuHistory); - return history.length > 0 ? history[history.length - 1] : 0; -} - -export function getCurrentMemory(): number { - const history = get(memoryHistory); - return history.length > 0 ? history[history.length - 1] : 0; -} diff --git a/src/lib/types.ts b/src/lib/types.ts index a9309d2..0a6674b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,5 +1,10 @@ // Shared types that can be used in both client and server code +/** + * System container type - containers that cannot be updated from within Dockhand. + */ +export type SystemContainerType = 'dockhand' | 'hawser'; + export interface ContainerInfo { id: string; name: string; @@ -24,6 +29,13 @@ export interface ContainerInfo { }>; networkMode: string; networks: string[]; + /** + * Identifies system containers (Dockhand, Hawser) that cannot be updated from within Dockhand. + * - 'dockhand': The Dockhand container itself + * - 'hawser': A Hawser remote agent container + * - null/undefined: Regular container + */ + systemContainer?: SystemContainerType | null; } export interface ImageInfo { diff --git a/src/lib/utils/shell-detection.ts b/src/lib/utils/shell-detection.ts new file mode 100644 index 0000000..fe23d9b --- /dev/null +++ b/src/lib/utils/shell-detection.ts @@ -0,0 +1,79 @@ +import { appendEnvParam } from '$lib/stores/environment'; + +export interface ShellInfo { + path: string; + label: string; + available: boolean; +} + +export const SHELL_OPTIONS: Omit[] = [ + { path: '/bin/bash', label: 'Bash' }, + { path: '/bin/sh', label: 'Shell (sh)' }, + { path: '/bin/zsh', label: 'Zsh' }, + { path: '/bin/ash', label: 'Ash (Alpine)' } +]; + +export const USER_OPTIONS = [ + { value: 'root', label: 'root' }, + { value: 'nobody', label: 'nobody' }, + { value: '', label: 'Container default' } +]; + +export interface ShellDetectionResult { + shells: string[]; + defaultShell: string | null; + allShells: ShellInfo[]; + error?: string; +} + +/** + * Detect available shells in a container + */ +export async function detectShells( + containerId: string, + envId: number | null +): Promise { + try { + const response = await fetch( + appendEnvParam(`/api/containers/${containerId}/shells`, envId) + ); + const data = await response.json(); + return { + shells: data.shells || [], + defaultShell: data.defaultShell || null, + allShells: data.allShells || SHELL_OPTIONS.map(s => ({ ...s, available: false })), + error: data.error + }; + } catch (error) { + console.error('Failed to detect shells:', error); + return { + shells: [], + defaultShell: null, + allShells: SHELL_OPTIONS.map(s => ({ ...s, available: false })), + error: 'Failed to detect available shells' + }; + } +} + +/** + * Get the best available shell from the detection result + * Returns the user's preferred shell if available, otherwise the default + */ +export function getBestShell( + result: ShellDetectionResult, + preferredShell: string +): string | null { + // If preferred shell is available, use it + if (result.shells.includes(preferredShell)) { + return preferredShell; + } + // Otherwise use the default shell + return result.defaultShell; +} + +/** + * Check if any shell is available + */ +export function hasAvailableShell(result: ShellDetectionResult): boolean { + return result.shells.length > 0; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9c90fbc..287c0c0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -10,7 +10,6 @@ import CommandPalette from '$lib/components/CommandPalette.svelte'; import WhatsNewModal from '$lib/components/WhatsNewModal.svelte'; import { SidebarProvider, SidebarTrigger } from '$lib/components/ui/sidebar'; - import { startStatsCollection, stopStatsCollection } from '$lib/stores/stats'; import { connectSSE, disconnectSSE } from '$lib/stores/events'; import { currentEnvironment, environments } from '$lib/stores/environment'; import { licenseStore, daysUntilExpiry } from '$lib/stores/license'; @@ -70,9 +69,6 @@ // Initialize grid preferences gridPreferencesStore.init(); - // Start global stats collection for CPU/Memory graphs - startStatsCollection(); - // Connect to SSE for real-time Docker events (global) connectSSE(envId); @@ -86,7 +82,6 @@ checkWhatsNew(); return () => { - stopStatsCollection(); disconnectSSE(); }; }); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 3c29030..27bc5c8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,3 +1,7 @@ + + Dashboard - Dockhand + +
-
+
@@ -1400,7 +1480,7 @@ {/if} Check for updates - {#if batchUpdateContainerIds.length > 0} + {#if updatableContainersCount > 0} {/if} {#if $canAccess('containers', 'remove')} @@ -1457,13 +1537,14 @@
- - {#if selectedContainers.size > 0} -
+ +
+ {#if selectedContainers.size > 0} +
{selectedInFilter.length} selected
- {/if} +
+ {/if} +
- {#if $environments.length === 0 || !$currentEnvironment} + {#if !loading && ($environments.length === 0 || !$currentEnvironment)} {:else if !loading && containers.length === 0} { @@ -1640,15 +1722,98 @@ {@const ports = formatPorts(container.ports)} {@const stack = getComposeProject(container.labels)} {#if column.id === 'name'} - +
+ + {#if container.systemContainer} + {@const hasUpdate = containersWithUpdatesSet.has(container.id)} + + + + {#if container.systemContainer === 'dockhand'} + + {:else} + + {/if} + {container.systemContainer === 'dockhand' ? 'Dockhand' : 'Hawser'} + {#if hasUpdate} + + {/if} + + + + {#if container.systemContainer === 'dockhand'} + {#if hasUpdate} + {@const composeCmd = 'docker compose pull && docker compose up -d'} + {@const dockerCmd = `docker stop ${container.name} && docker pull fnsys/dockhand:latest && docker start ${container.name}`} +
+

+ + Update available +

+

Cannot be updated from within Dockhand. Update manually:

+
+

Using Compose:

+
+ {composeCmd} + +
+

Using Docker CLI:

+
+ {dockerCmd} + +
+
+
+ {:else} +

Dockhand management container

+ {/if} + {:else} + {#if hasUpdate} +
+

+ + Update available +

+

Update on the remote host where Hawser runs.

+ e.stopPropagation()} + > + + Update instructions on GitHub + +
+ {:else} +

Hawser remote agent

+ {/if} + {/if} +
+
+ {/if} +
{:else if column.id === 'image'} -
- {#if containersWithUpdatesSet.has(container.id)} +
+ {#if containersWithUpdatesSet.has(container.id) && !container.systemContainer} @@ -1799,7 +1964,7 @@ {/if} {:else if column.id === 'actions'}
- {#if containersWithUpdatesSet.has(container.id)} + {#if containersWithUpdatesSet.has(container.id) && !container.systemContainer} {:else} - { terminalPopoverStates[container.id] = open; }}> + { + terminalPopoverStates[container.id] = open; + if (open) detectContainerShells(container.id); + }}> e.stopPropagation()} class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer" @@ -1948,46 +2116,66 @@ {container.name}
-
-
- - - - - {shellOptions.find(o => o.value === terminalShell)?.label || 'Select'} - - - {#each shellOptions as option} - - - {option.label} - - {/each} - - + {#if detectingShellsFor === container.id} +
+ +

Detecting shells...

-
- - - - - {userOptions.find(o => o.value === terminalUser)?.label || 'Select'} - - - {#each userOptions as option} - - - {option.label} - - {/each} - - + {:else if !anyShellAvailableFor(container.id)} +
+ +

No shell available

+

This container has no shell installed.

- -
+ {:else} +
+
+ + + + + {shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'} + + + {#if shellDetectionCache[container.id]} + {#each shellDetectionCache[container.id].allShells as option} + + + + {option.label} + {#if !option.available} + (unavailable) + {/if} + + + {/each} + {/if} + + +
+
+ + + + + {userOptions.find(o => o.value === terminalUser)?.label || 'Select'} + + + {#each userOptions as option} + + + {option.label} + + {/each} + + +
+ +
+ {/if} {/if} @@ -2197,5 +2385,6 @@ border-radius: 4px; box-shadow: 0 0 8px rgb(245 158 11 / 0.4); } + diff --git a/src/routes/containers/AutoUpdateSettings.svelte b/src/routes/containers/AutoUpdateSettings.svelte index 731a4cc..53c441e 100644 --- a/src/routes/containers/AutoUpdateSettings.svelte +++ b/src/routes/containers/AutoUpdateSettings.svelte @@ -4,11 +4,14 @@ import CronEditor from '$lib/components/cron-editor.svelte'; import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; import { currentEnvironment } from '$lib/stores/environment'; + import { Ship, Cable, ExternalLink, AlertTriangle } from 'lucide-svelte'; + import type { SystemContainerType } from '$lib/types'; interface Props { enabled: boolean; cronExpression: string; vulnerabilityCriteria: VulnerabilityCriteria; + systemContainer?: SystemContainerType | null; onenablechange?: (enabled: boolean) => void; oncronchange?: (cron: string) => void; oncriteriachange?: (criteria: VulnerabilityCriteria) => void; @@ -18,6 +21,7 @@ enabled = $bindable(), cronExpression = $bindable(), vulnerabilityCriteria = $bindable(), + systemContainer = null, onenablechange, oncronchange, oncriteriachange @@ -47,35 +51,69 @@ } -
-
- - onenablechange?.(value)} - /> -
- - {#if enabled} - { - cronExpression = cron; - oncronchange?.(cron); - }} - /> - - {#if envHasScanning} -
- - oncriteriachange?.(v)} - /> -

- Block auto-updates if new image has vulnerabilities matching this criteria -

+{#if systemContainer} + +
+
+ +
+ {#if systemContainer === 'dockhand'} +

Auto-updates not available

+

+ Dockhand cannot update itself. To update, run on the host: +

+ + docker compose pull && docker compose up -d + + {:else} +

Auto-updates not available

+

+ Hawser agents must be updated on their remote host. +

+ + + View update instructions on GitHub + + {/if}
+
+
+{:else} +
+
+ + onenablechange?.(value)} + /> +
+ + {#if enabled} + { + cronExpression = cron; + oncronchange?.(cron); + }} + /> + + {#if envHasScanning} +
+ + oncriteriachange?.(v)} + /> +

+ Block auto-updates if new image has vulnerabilities matching this criteria +

+
+ {/if} {/if} - {/if} -
+
+{/if} diff --git a/src/routes/containers/ContainerInspectModal.svelte b/src/routes/containers/ContainerInspectModal.svelte index e30c461..abaf737 100644 --- a/src/routes/containers/ContainerInspectModal.svelte +++ b/src/routes/containers/ContainerInspectModal.svelte @@ -978,7 +978,7 @@ {#if containerData.Config?.Env && containerData.Config.Env.length > 0}
- {#each containerData.Config.Env as envVar} + {#each [...containerData.Config.Env].sort((a, b) => a.split('=')[0].localeCompare(b.split('=')[0])) as envVar} {@const [key, ...valueParts] = envVar.split('=')} {@const value = valueParts.join('=')}
@@ -1214,10 +1214,10 @@ - + {#if containerData.State?.Health} -
-
+
+

Status

@@ -1231,9 +1231,9 @@
{#if containerData.State.Health.Log && containerData.State.Health.Log.length > 0} -
-

Health check log

-
+
+

Health check log

+
{#each containerData.State.Health.Log.slice(-5) as log}
diff --git a/src/routes/containers/ContainerSettingsTab.svelte b/src/routes/containers/ContainerSettingsTab.svelte index 90ba559..eb6fa4d 100644 --- a/src/routes/containers/ContainerSettingsTab.svelte +++ b/src/routes/containers/ContainerSettingsTab.svelte @@ -9,6 +9,15 @@ import { Badge } from '$lib/components/ui/badge'; import AutoUpdateSettings from './AutoUpdateSettings.svelte'; import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte'; + import type { SystemContainerType } from '$lib/types'; + + // Detect system containers (must match server-side logic in update-utils.ts) + function detectSystemContainer(imageName: string): SystemContainerType | null { + const lower = imageName.toLowerCase(); + if (lower.includes('fnsys/dockhand')) return 'dockhand'; + if (lower.includes('finsys/hawser') || lower.includes('ghcr.io/finsys/hawser')) return 'hawser'; + return null; + } // Protocol options for ports const protocolOptions = [ @@ -1263,6 +1272,7 @@ bind:enabled={autoUpdateEnabled} bind:cronExpression={autoUpdateCronExpression} bind:vulnerabilityCriteria={vulnerabilityCriteria} + systemContainer={detectSystemContainer(image)} />
diff --git a/src/routes/containers/ContainerTerminal.svelte b/src/routes/containers/ContainerTerminal.svelte index 37f83ed..d2a451f 100644 --- a/src/routes/containers/ContainerTerminal.svelte +++ b/src/routes/containers/ContainerTerminal.svelte @@ -4,7 +4,8 @@ import * as Select from '$lib/components/ui/select'; import { Button } from '$lib/components/ui/button'; import { Label } from '$lib/components/ui/label'; - import { Terminal as TerminalIcon, X, ExternalLink, Shell, User } from 'lucide-svelte'; + import { Terminal as TerminalIcon, X, ExternalLink, Shell, User, Loader2, AlertCircle } from 'lucide-svelte'; + import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellDetectionResult } from '$lib/utils/shell-detection'; // Dynamic imports for browser-only xterm let Terminal: any; @@ -16,10 +17,11 @@ open: boolean; containerId: string; containerName: string; + envId?: number | null; onClose: () => void; } - let { open = $bindable(), containerId, containerName, onClose }: Props = $props(); + let { open = $bindable(), containerId, containerName, envId = null, onClose }: Props = $props(); let terminalRef: HTMLDivElement; let terminal: Terminal | null = null; @@ -28,19 +30,14 @@ let connected = $state(false); let error = $state(null); - // Shell options - const shellOptions = [ - { value: '/bin/bash', label: 'Bash' }, - { value: '/bin/sh', label: 'Shell (sh)' }, - { value: '/bin/zsh', label: 'Zsh' }, - { value: '/bin/ash', label: 'Ash (Alpine)' } - ]; + // Shell detection state + let shellDetection = $state(null); + let detectingShells = $state(false); - const userOptions = [ - { value: 'root', label: 'root' }, - { value: 'nobody', label: 'nobody' }, - { value: '', label: 'Container default' } - ]; + // Derived: check if any shell is available + const anyShellAvailable = $derived( + !shellDetection || hasAvailableShell(shellDetection) + ); let selectedShell = $state('/bin/bash'); let selectedUser = $state('root'); @@ -108,7 +105,10 @@ error = null; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/api/containers/${containerId}/exec?shell=${encodeURIComponent(selectedShell)}&user=${encodeURIComponent(selectedUser)}`; + let wsUrl = `${protocol}//${window.location.host}/api/containers/${containerId}/exec?shell=${encodeURIComponent(selectedShell)}&user=${encodeURIComponent(selectedUser)}`; + if (envId) { + wsUrl += `&envId=${envId}`; + } terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`); terminal.writeln(`\x1b[90mShell: ${selectedShell}, User: ${selectedUser || 'default'}\x1b[0m`); @@ -162,7 +162,7 @@ } function startSession() { - if (!xtermLoaded) return; + if (!xtermLoaded || !anyShellAvailable) return; showConfig = false; // Wait for DOM update then init terminal setTimeout(() => { @@ -176,7 +176,7 @@ user: selectedUser, name: containerName }); - const url = `/terminal/${containerId}?${params.toString()}`; + const url = `/terminal?container=${containerId}`; window.open(url, `terminal_${containerId}`, 'width=900,height=600,resizable=yes,scrollbars=no'); handleClose(); } @@ -212,6 +212,27 @@ } } + // Detect shells when dialog opens + async function detectContainerShells() { + if (!containerId) return; + + detectingShells = true; + shellDetection = null; + try { + shellDetection = await detectShells(containerId, envId); + + // Auto-select best available shell if current is not available + const bestShell = getBestShell(shellDetection, selectedShell); + if (bestShell && bestShell !== selectedShell) { + selectedShell = bestShell; + } + } catch (error) { + console.error('Failed to detect shells:', error); + } finally { + detectingShells = false; + } + } + onMount(async () => { window.addEventListener('resize', handleResize); @@ -235,10 +256,13 @@ cleanup(); }); - // Reset when dialog closes + // Detect shells when dialog opens, reset when it closes $effect(() => { - if (!open) { + if (open) { + detectContainerShells(); + } else { cleanup(); + shellDetection = null; } }); @@ -268,63 +292,95 @@ {#if showConfig}
-
+ {#if detectingShells}
- -

Open terminal session

-

- Configure the shell and user for this session + +

Detecting available shells...

+
+ {:else if !anyShellAvailable} +
+ +

No shell available

+

+ This container does not have any shell installed.

-
- -
-
- - - - - {shellOptions.find(o => o.value === selectedShell)?.label || 'Select shell'} - - - {#each shellOptions as option} - - - {option.label} - - {/each} - - -
- -
- - - - - {userOptions.find(o => o.value === selectedUser)?.label || 'Select user'} - - - {#each userOptions as option} - - - {option.label} - - {/each} - - -
-
- -
- -
-
+ {:else} +
+
+ +

Open terminal session

+

+ Configure the shell and user for this session +

+
+ +
+
+ + + + + {shellDetection?.allShells.find(o => o.path === selectedShell)?.label || 'Select shell'} + + + {#if shellDetection} + {#each shellDetection.allShells as option} + + + + {option.label} + {#if !option.available} + (unavailable) + {/if} + + + {/each} + {/if} + + +
+ +
+ + + + + {USER_OPTIONS.find(o => o.value === selectedUser)?.label || 'Select user'} + + + {#each USER_OPTIONS as option} + + + {option.label} + + {/each} + + +
+
+ +
+ + +
+
+ {/if}
{:else}
diff --git a/src/routes/dashboard/dashboard-container-stats.svelte b/src/routes/dashboard/dashboard-container-stats.svelte index c536c81..250c11e 100644 --- a/src/routes/dashboard/dashboard-container-stats.svelte +++ b/src/routes/dashboard/dashboard-container-stats.svelte @@ -5,6 +5,7 @@ Pause, RefreshCw, AlertTriangle, + ArrowUpCircle, Loader2 } from 'lucide-svelte'; @@ -14,6 +15,7 @@ paused: number; restarting: number; unhealthy: number; + pendingUpdates: number; total: number; } @@ -54,10 +56,14 @@
+
+ +
+
{:else if showSkeleton} -
+
@@ -78,6 +84,10 @@
+
+ +
+
Total
@@ -106,10 +116,14 @@ {containers.unhealthy}
+
+ + {containers.pendingUpdates} +
{:else} -
+
{containers.running} @@ -130,6 +144,10 @@ {containers.unhealthy}
+
+ + {containers.pendingUpdates} +
Total {containers.total} @@ -147,4 +165,15 @@ background-size: 200% 100%; animation: shimmer 1.5s infinite; } + @keyframes pending-pulse { + 0%, 100% { + filter: drop-shadow(0 0 2px rgba(251, 191, 36, 0.4)); + } + 50% { + filter: drop-shadow(0 0 3px rgba(251, 191, 36, 0.6)) drop-shadow(0 0 5px rgba(251, 191, 36, 0.3)); + } + } + :global(.pending-glow) { + animation: pending-pulse 2s ease-in-out infinite; + } diff --git a/src/routes/environments/+page.svelte b/src/routes/environments/+page.svelte index 7c677c3..0e87002 100644 --- a/src/routes/environments/+page.svelte +++ b/src/routes/environments/+page.svelte @@ -245,7 +245,7 @@
-
+
{environments.length} total diff --git a/src/routes/images/+page.svelte b/src/routes/images/+page.svelte index ed9db55..ad7bda6 100644 --- a/src/routes/images/+page.svelte +++ b/src/routes/images/+page.svelte @@ -1,3 +1,7 @@ + + Images - Dockhand + +
-
+
confirmPrune = open} + unstyled > {#snippet children({ open })} confirmPruneUnused = open} + unstyled > {#snippet children({ open })}
- - {#if selectedImages.size > 0} -
+ +
+ {#if selectedImages.size > 0} +
{selectedInFilter.length} selected {/if} -
- {/if} +
+ {/if} +
{#if !loading && ($environments.length === 0 || !$currentEnvironment)} @@ -1027,7 +1050,7 @@ itemType="image" itemName={tagInfo.fullRef} title="Remove" - onConfirm={() => removeImage(tagInfo.fullRef, tagInfo.fullRef)} + onConfirm={() => removeImage(tagInfo.imageId, tagInfo.fullRef)} onOpenChange={(open) => confirmDeleteId = open ? tagInfo.fullRef : null} > {#snippet children({ open })} diff --git a/src/routes/logs/+page.svelte b/src/routes/logs/+page.svelte index f32fad9..2baa1ae 100644 --- a/src/routes/logs/+page.svelte +++ b/src/routes/logs/+page.svelte @@ -1,3 +1,7 @@ + + Logs - Dockhand + + diff --git a/src/routes/networks/+page.svelte b/src/routes/networks/+page.svelte index 608488d..3853359 100644 --- a/src/routes/networks/+page.svelte +++ b/src/routes/networks/+page.svelte @@ -1,3 +1,7 @@ + + Networks - Dockhand + +
-
+
@@ -512,6 +531,7 @@ position="left" onConfirm={pruneNetworks} onOpenChange={(open) => confirmPrune = open} + unstyled > {#snippet children({ open })} @@ -542,13 +562,14 @@
- - {#if selectedNetworks.size > 0} -
+ +
+ {#if selectedNetworks.size > 0} +
{selectedInFilter.length} selected
- {/if} +
+ {/if} +
{#if !loading && ($environments.length === 0 || !$currentEnvironment)} diff --git a/src/routes/registry/+page.svelte b/src/routes/registry/+page.svelte index a7b5404..42eafd5 100644 --- a/src/routes/registry/+page.svelte +++ b/src/routes/registry/+page.svelte @@ -57,13 +57,26 @@ let selectedRegistryId = $state(null); let searchTerm = $state(''); + let browseFilter = $state(''); let results = $state([]); let loading = $state(false); let browsing = $state(false); + let loadingMore = $state(false); let searched = $state(false); let browseMode = $state(false); let errorMessage = $state(''); + // Pagination state for browse mode + let hasMoreResults = $state(false); + let nextPageCursor = $state(null); + + // Filtered results for browse mode + let filteredResults = $derived( + browseMode && browseFilter.trim() + ? results.filter(r => r.name.toLowerCase().includes(browseFilter.toLowerCase())) + : results + ); + // Copy to registry modal state let showCopyModal = $state(false); let copyImageName = $state(''); @@ -174,28 +187,60 @@ } } - async function browse() { + async function browse(loadMore = false) { if (!selectedRegistryId) return; - browsing = true; - searched = true; - browseMode = true; + if (loadMore) { + loadingMore = true; + } else { + browsing = true; + searched = true; + browseMode = true; + results = []; + hasMoreResults = false; + nextPageCursor = null; + } errorMessage = ''; + try { - const response = await fetch(`/api/registry/catalog?registry=${selectedRegistryId}`); + let url = `/api/registry/catalog?registry=${selectedRegistryId}`; + if (loadMore && nextPageCursor) { + url += `&last=${encodeURIComponent(nextPageCursor)}`; + } + + const response = await fetch(url); if (response.ok) { - results = await response.json(); + const data = await response.json(); + + // Handle both old array format and new paginated format + if (Array.isArray(data)) { + // Old format (backwards compat) + results = loadMore ? [...results, ...data] : data; + hasMoreResults = false; + nextPageCursor = null; + } else { + // New paginated format + const newResults = data.repositories || []; + results = loadMore ? [...results, ...newResults] : newResults; + hasMoreResults = data.pagination?.hasMore || false; + nextPageCursor = data.pagination?.nextLast || null; + } } else { const data = await response.json(); errorMessage = data.error || 'Failed to browse registry'; - results = []; + if (!loadMore) { + results = []; + } } } catch (error) { console.error('Failed to browse registry:', error); errorMessage = 'Failed to browse registry'; - results = []; + if (!loadMore) { + results = []; + } } finally { browsing = false; + loadingMore = false; } } @@ -221,8 +266,11 @@ results = []; searched = false; browseMode = false; + browseFilter = ''; errorMessage = ''; expandedImages = {}; + hasMoreResults = false; + nextPageCursor = null; } async function toggleImageExpansion(imageName: string) { @@ -433,7 +481,7 @@
-
+
{#if $canAccess('registries', 'edit')} @@ -446,14 +494,17 @@
{ selectedRegistryId = Number(v); handleRegistryChange(); }}> - + {@const selected = registries.find(r => r.id === selectedRegistryId)} {#if selected && isDockerHub(selected)} - + {:else} - + + {/if} + {selected ? selected.name : 'Select registry'} + {#if selected?.hasCredentials} + auth {/if} - {selected ? `${selected.name}${selected.hasCredentials ? ' (auth)' : ''}` : 'Select registry'} {#each registries as registry} @@ -490,7 +541,7 @@ Search {#if supportsBrowsing()} - and use the filter to find images. +

+ {/if} +
{:else if results.length > 0} + + {#if browseMode} +
+
+ + +
+ + {filteredResults.length === results.length + ? `${results.length} images` + : `${filteredResults.length} of ${results.length} images`} + +
+ {/if}
- {#each results as result (result.name)} + {#each filteredResults as result (result.name)} {@const isExpanded = !!expandedImages[result.name]} {@const expandState = expandedImages[result.name]} @@ -684,6 +761,24 @@
+ + {#if browseMode && hasMoreResults} +
+ +
+ {/if} {:else}
diff --git a/src/routes/schedules/+page.svelte b/src/routes/schedules/+page.svelte index 4420138..2eb0d60 100644 --- a/src/routes/schedules/+page.svelte +++ b/src/routes/schedules/+page.svelte @@ -894,7 +894,7 @@
-
+
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index f0595c5..ffcefba 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1,3 +1,7 @@ + + Settings - Dockhand + +
-
+
diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte index c0ccfd7..9e31362 100644 --- a/src/routes/settings/environments/EnvironmentModal.svelte +++ b/src/routes/settings/environments/EnvironmentModal.svelte @@ -1002,7 +1002,7 @@ } // Refresh scanner status after pull - await checkScannerImages(); + await loadScannerVersionsAsync(environment?.id); grypeUpdateStatus = 'up-to-date'; setTimeout(() => { grypeUpdateStatus = 'idle'; }, 3000); } catch (error) { @@ -1043,7 +1043,7 @@ } // Refresh scanner status after pull - await checkScannerImages(); + await loadScannerVersionsAsync(environment?.id); trivyUpdateStatus = 'up-to-date'; setTimeout(() => { trivyUpdateStatus = 'idle'; }, 3000); } catch (error) { diff --git a/src/routes/settings/general/ScanResultsModal.svelte b/src/routes/settings/general/ScanResultsModal.svelte index 52bcad0..fdbb028 100644 --- a/src/routes/settings/general/ScanResultsModal.svelte +++ b/src/routes/settings/general/ScanResultsModal.svelte @@ -555,7 +555,7 @@

No Docker Compose files found in the configured paths.

-

Make sure your paths contain docker-compose.yml, compose.yml, or similar files.

+

Make sure your paths contain compose.yaml, compose.yml, or similar files.

{/if} diff --git a/src/routes/stacks/+page.svelte b/src/routes/stacks/+page.svelte index 7885d92..035dbb8 100644 --- a/src/routes/stacks/+page.svelte +++ b/src/routes/stacks/+page.svelte @@ -1,3 +1,7 @@ + + Stacks - Dockhand + +
-
+
{#if stacks.length > 0}
- - {#if selectedStacks.size > 0} -
+ +
+ {#if selectedStacks.size > 0} +
{selectedInFilter.length} selected
- {/if} +
+ {/if} +
{#if !loading && ($environments.length === 0 || !$currentEnvironment)} diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index e0ede93..35e72cb 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -79,7 +79,7 @@ // Form state - stack deployment config let formStackName = $state(''); let formStackNameUserModified = $state(false); - let formComposePath = $state('docker-compose.yml'); + let formComposePath = $state('compose.yaml'); let formAutoUpdate = $state(false); let formAutoUpdateCron = $state('0 3 * * *'); let formWebhookEnabled = $state(false); @@ -290,7 +290,7 @@ formNewRepoCredentialId = null; formStackName = ''; formStackNameUserModified = false; - formComposePath = 'docker-compose.yml'; + formComposePath = 'compose.yaml'; formEnvFilePath = null; formAutoUpdate = false; formAutoUpdateCron = '0 3 * * *'; @@ -344,7 +344,7 @@ try { let body: any = { stackName: formStackName, - composePath: formComposePath || 'docker-compose.yml', + composePath: formComposePath || 'compose.yaml', envFilePath: formEnvFilePath, environmentId: environmentId, autoUpdate: formAutoUpdate, @@ -661,7 +661,7 @@
- +

Path to the compose file within the repository

diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index 2bccc78..75129be 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -65,6 +65,9 @@ // Error dialog state let operationError = $state<{ title: string; message: string; details?: string } | null>(null); + // Stack exists warning dialog state + let showExistsWarning = $state(false); + // ─── Path State (Simplified) ───────────────────────────────────────────────── // Working paths: what we're currently editing (always strings, never null) @@ -197,7 +200,7 @@ // Get the current compose filename const currentComposePath = workingComposePath; - const composeFilename = currentComposePath ? currentComposePath.split('/').pop() : 'docker-compose.yml'; + const composeFilename = currentComposePath ? currentComposePath.split('/').pop() : 'compose.yaml'; // Build new paths: create a subfolder with the stack name inside selected directory const displayName = mode === 'edit' ? stackName : newStackName; @@ -371,7 +374,7 @@ const stackName = newStackName.trim(); if (stackName) { // If we have a stack name, include the subfolder - finalPath = `${baseDir}/${stackName}/docker-compose.yml`; + finalPath = `${baseDir}/${stackName}/compose.yaml`; } else { // No stack name yet - just show the selected directory finalPath = `${baseDir}/`; @@ -811,6 +814,26 @@ services: if (hasErrors) return; + const envId = $currentEnvironment?.id ?? null; + + // Check if stack already exists + try { + const stacksResponse = await fetch(appendEnvParam('/api/stacks', envId)); + if (stacksResponse.ok) { + const stacks = await stacksResponse.json(); + const existingStack = stacks.find((s: { name: string }) => + s.name.toLowerCase() === newStackName.trim().toLowerCase() + ); + if (existingStack) { + showExistsWarning = true; + return; + } + } + } catch (e) { + console.warn('Failed to check for existing stacks:', e); + // Continue with creation if check fails + } + saving = true; error = null; @@ -818,8 +841,6 @@ services: const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] }; try { - const envId = $currentEnvironment?.id ?? null; - // Build request body const requestBody: Record = { name: newStackName.trim(), @@ -1365,7 +1386,7 @@ services: copyToClipboard(workingComposePath, (v) => composePathCopied = v)} onBrowse={openComposeBrowser} @@ -1457,7 +1478,7 @@ services: existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()} onchange={() => { markDirty(); debouncedValidate(); }} theme={editorTheme} - infoText="These variables will be written to a .env file in the stack directory." + infoText="These variables will be written to a .env file in the stack directory and passed to the compose command." />
@@ -1671,6 +1692,26 @@ services: + + + + + + + Stack already exists + + + A stack named "{newStackName}" already exists. Please choose a different name. + + +
+ +
+
+
+ {#if operationError} {@const errorDialogOpen = true} diff --git a/src/routes/terminal/+page.svelte b/src/routes/terminal/+page.svelte index 4dd3fc2..b165b41 100644 --- a/src/routes/terminal/+page.svelte +++ b/src/routes/terminal/+page.svelte @@ -1,3 +1,7 @@ + + Terminal - Dockhand + +
-
+
@@ -431,6 +450,7 @@ position="left" onConfirm={pruneVolumes} onOpenChange={(open) => confirmPrune = open} + unstyled > {#snippet children({ open })} @@ -458,13 +478,14 @@
- - {#if selectedVolumes.size > 0} -
+ +
+ {#if selectedVolumes.size > 0} +
{selectedInFilter.length} selected
- {/if} +
+ {/if} +
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}