diff --git a/package.json b/package.json index 8be348e..70822d1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.11", + "version": "1.0.12", "type": "module", "scripts": { "dev": "bunx --bun vite dev", @@ -75,7 +75,7 @@ "@layerstack/tailwind": "^1.0.1", "@lucide/svelte": "^0.562.0", "@playwright/test": "1.57.0", - "@sveltejs/kit": "2.49.5", + "@sveltejs/kit": "2.50.0", "@sveltejs/vite-plugin-svelte": "6.2.4", "@tailwindcss/vite": "^4.1.18", "@types/bun": "1.3.6", @@ -96,7 +96,7 @@ "lucide-svelte": "^0.562.0", "mode-watcher": "^1.1.0", "postcss": "^8.5.6", - "svelte": "5.46.4", + "svelte": "5.47.1", "svelte-adapter-bun": "1.0.1", "svelte-check": "^4.3.5", "svelte-easy-crop": "^5.0.0", diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 40e65f1..5ceed16 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,21 @@ [ + { + "version": "1.0.12", + "date": "2026-01-22", + "changes": [ + { "type": "feature", "text": "Add SKIP_DF_COLLECTION env var to disable slow disk usage collection on NAS devices" }, + { "type": "fix", "text": "Fix terminal/shell connections to direct TLS/mTLS and Hawser Standard environments" }, + { "type": "fix", "text": "Fix crash when Hawser agent is stopped from Dockhand" }, + { "type": "fix", "text": "Skip auto-update for SHA-pinned images (image@sha256:...)" }, + { "type": "fix", "text": "Fix pending updates not cleared when containers or stacks are deleted" }, + { "type": "fix", "text": "Fix adopted stacks using wrong .env path from internal directory instead of original location" }, + { "type": "fix", "text": "Improve /login audit logs information" }, + { "type": "fix", "text": "Fix login/logout screen refresh issue" }, + { "type": "fix", "text": "Fix password change not persisting" }, + { "type": "fix", "text": "Fix audit log page showing empty values" } + ], + "imageTag": "fnsys/dockhand:v1.0.12" + }, { "version": "1.0.11", "date": "2026-01-20", diff --git a/src/lib/data/dependencies.json b/src/lib/data/dependencies.json index dd43451..f943cde 100644 --- a/src/lib/data/dependencies.json +++ b/src/lib/data/dependencies.json @@ -71,12 +71,6 @@ "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", @@ -91,19 +85,13 @@ }, { "name": "@codemirror/search", - "version": "6.5.11", + "version": "6.6.0", "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", + "version": "6.5.4", "license": "MIT", "repository": "https://github.com/codemirror/state" }, @@ -115,13 +103,7 @@ }, { "name": "@codemirror/view", - "version": "6.38.8", - "license": "MIT", - "repository": "https://github.com/codemirror/view" - }, - { - "name": "@codemirror/view", - "version": "6.39.9", + "version": "6.39.11", "license": "MIT", "repository": "https://github.com/codemirror/view" }, @@ -245,12 +227,6 @@ "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", @@ -299,39 +275,9 @@ "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", + "version": "1.3.6", "license": "MIT", "repository": "https://github.com/oven-sh/bun" }, @@ -341,12 +287,6 @@ "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", @@ -401,27 +341,9 @@ "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", + "version": "5.6.2", "license": "MIT", "repository": "https://github.com/sveltejs/devalue" }, @@ -449,12 +371,6 @@ "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", @@ -467,66 +383,24 @@ "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", @@ -569,48 +443,12 @@ "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", @@ -653,18 +491,6 @@ "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", @@ -677,18 +503,6 @@ "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", @@ -707,36 +521,12 @@ "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", @@ -749,24 +539,12 @@ "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", @@ -775,7 +553,7 @@ }, { "name": "svelte", - "version": "5.46.1", + "version": "5.47.1", "license": "MIT", "repository": "https://github.com/sveltejs/svelte" }, @@ -791,42 +569,18 @@ "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", @@ -857,12 +611,6 @@ "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/audit-events.ts b/src/lib/server/audit-events.ts index 7747a5b..70c8d99 100644 --- a/src/lib/server/audit-events.ts +++ b/src/lib/server/audit-events.ts @@ -9,7 +9,9 @@ import type { AuditLogCreateData } from './db'; export interface AuditEventData extends AuditLogCreateData { id: number; - timestamp: string; + createdAt: string; + environmentName?: string | null; + environmentIcon?: string | null; } // Create a singleton event emitter for audit events diff --git a/src/lib/server/crypto-fallback.ts b/src/lib/server/crypto-fallback.ts index c95c1d7..b0839fc 100644 --- a/src/lib/server/crypto-fallback.ts +++ b/src/lib/server/crypto-fallback.ts @@ -10,6 +10,7 @@ import { existsSync, openSync, readSync, closeSync } from 'node:fs'; import os from 'node:os'; +import { randomBytes } from 'node:crypto'; // Cache kernel version check result let needsFallback: boolean | null = null; @@ -140,7 +141,6 @@ export function secureRandomBytes(size: number): Buffer { } // Use native crypto on modern kernels - const { randomBytes } = require('node:crypto'); return randomBytes(size); } diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 24e58d9..86f3363 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -3023,13 +3023,32 @@ export async function logAuditEvent(data: AuditLogCreateData): Promise { - const results = await db.select().from(auditLogs).where(eq(auditLogs.id, id)); +export async function getAuditLog(id: number): Promise<(AuditLogData & { environmentName?: string | null; environmentIcon?: string | null }) | undefined> { + const results = await db.select({ + id: auditLogs.id, + userId: auditLogs.userId, + username: auditLogs.username, + action: auditLogs.action, + entityType: auditLogs.entityType, + entityId: auditLogs.entityId, + entityName: auditLogs.entityName, + environmentId: auditLogs.environmentId, + description: auditLogs.description, + details: auditLogs.details, + ipAddress: auditLogs.ipAddress, + userAgent: auditLogs.userAgent, + createdAt: auditLogs.createdAt, + environmentName: environments.name, + environmentIcon: environments.icon + }) + .from(auditLogs) + .leftJoin(environments, eq(auditLogs.environmentId, environments.id)) + .where(eq(auditLogs.id, id)); if (!results[0]) return undefined; return { ...results[0], details: results[0].details ? JSON.parse(results[0].details) : null - } as AuditLogData; + } as AuditLogData & { environmentName?: string | null; environmentIcon?: string | null }; } export async function getAuditLogs(filters: AuditLogFilters = {}): Promise { @@ -4433,6 +4452,39 @@ export async function deleteStackEnvVars( } } +/** + * Update stack name in environment variables (for stack rename operations). + * @param oldStackName - Current stack name + * @param newStackName - New stack name + * @param environmentId - Optional environment ID (null = no environment, undefined = all environments) + */ +export async function updateStackEnvVarsName( + oldStackName: string, + newStackName: string, + environmentId?: number | null +): Promise { + if (environmentId === undefined) { + // Update all env vars for this stack (all environments) + await db.update(stackEnvironmentVariables) + .set({ stackName: newStackName }) + .where(eq(stackEnvironmentVariables.stackName, oldStackName)); + } else if (environmentId === null) { + await db.update(stackEnvironmentVariables) + .set({ stackName: newStackName }) + .where(and( + eq(stackEnvironmentVariables.stackName, oldStackName), + isNull(stackEnvironmentVariables.environmentId) + )); + } else { + await db.update(stackEnvironmentVariables) + .set({ stackName: newStackName }) + .where(and( + eq(stackEnvironmentVariables.stackName, oldStackName), + eq(stackEnvironmentVariables.environmentId, environmentId) + )); + } +} + /** * Get all stacks with their environment variable counts. * Useful for displaying env var badges in the stacks list. diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index 605fb88..e7bd2d4 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -1877,6 +1877,15 @@ export async function checkImageUpdateAvailable( envId?: number ): Promise { try { + // Skip update check for digest-pinned images + // If the user explicitly pins to a digest (image@sha256:...), they don't want auto-updates + if (isDigestBasedImage(imageName)) { + return { + hasUpdate: false, + currentDigest: imageName.split('@')[1] // Extract the digest part + }; + } + // Get current image info to get RepoDigests let currentImageInfo: any; try { diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 9f4316e..d54209d 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -384,11 +384,11 @@ export async function syncRepository(repoId: number): Promise { let currentCommit = ''; if (!existsSync(repoPath)) { - // Clone the repository (shallow clone) + // Clone the repository (blobless clone - fetches all commits but blobs on-demand) const repoUrl = buildRepoUrl(repo.url, credential); const result = await execGit( - ['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath], + ['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath], process.cwd(), env ); @@ -611,7 +611,7 @@ export async function syncGitStack(stackId: number): Promise { let currentCommit = ''; // Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.) - // Shallow clones are fast so this is acceptable + // Blobless clones fetch all commits (for git diff) but download blobs on-demand const previousCommit = await getPreviousCommit(repoPath, env); if (existsSync(repoPath)) { console.log(`${logPrefix} Removing existing clone for fresh sync...`); @@ -622,7 +622,7 @@ export async function syncGitStack(stackId: number): Promise { const repoUrl = buildRepoUrl(repo.url, credential); const result = await execGit( - ['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath], + ['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath], process.cwd(), env ); @@ -993,10 +993,10 @@ export async function deployGitStackWithProgress( const repoUrl = buildRepoUrl(repo.url, credential); - // Step 3: Fetching + // Step 3: Fetching (blobless clone - fetches all commits but blobs on-demand) onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps }); const cloneResult = await execGit( - ['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath], + ['clone', '--filter=blob:none', '--branch', repo.branch, repoUrl, repoPath], process.cwd(), env ); diff --git a/src/lib/server/host-path.ts b/src/lib/server/host-path.ts index 7eab340..e2dbf89 100644 --- a/src/lib/server/host-path.ts +++ b/src/lib/server/host-path.ts @@ -2,19 +2,21 @@ * 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. + * This module detects the host paths for ALL container mounts, enabling proper + * volume path resolution for compose stacks (both internal and adopted/external). * * Problem: * - Dockhand container has /app/data mounted from host (e.g., -v dockhand_data:/app/data) + * - User may also mount external directories (e.g., -v /host/stacks:/external-stacks) * - 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-compose resolves this to container path (e.g., /external-stacks/.../ca.pem) + * - Docker daemon on HOST receives this path, but /external-stacks 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 + * - Query Docker API to find ALL host source paths for our container mounts + * - Rewrite relative paths in compose files to use the correct host path + * - Works for both internal stacks (DATA_DIR) and adopted stacks (external mounts) */ import { readFileSync } from 'node:fs'; @@ -24,6 +26,9 @@ import { resolve } from 'node:path'; let cachedHostDataDir: string | null = null; let detectionAttempted = false; +// Cache ALL mounts for path translation (not just DATA_DIR) +let cachedMounts: Array<{ source: string; destination: string }> | null = null; + /** * Get our own container ID */ @@ -111,6 +116,13 @@ export async function detectHostDataDir(): Promise { }>; }; + // Cache ALL mounts for later path translation (used by rewriteComposeVolumePaths) + cachedMounts = (containerInfo.Mounts || []).map(m => ({ + source: m.Source, + destination: m.Destination + })); + console.log(`[HostPath] Cached ${cachedMounts.length} mount(s)`); + // Find the mount for our DATA_DIR const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir); @@ -168,6 +180,34 @@ export function translateToHostPath(containerPath: string): string { return containerPath; } +/** + * Translate any container path to host path using ALL cached mounts. + * This is more general than translateToHostPath() which only handles DATA_DIR. + * + * @param containerPath - Path inside the container (e.g., /external-stacks/mystack) + * @returns Host path if a matching mount is found, or null if no translation possible + */ +export function translateContainerPathViaMount(containerPath: string): string | null { + if (!cachedMounts || cachedMounts.length === 0) { + return null; + } + + // Sort mounts by destination length (longest first) to match most specific mount + const sortedMounts = [...cachedMounts].sort( + (a, b) => b.destination.length - a.destination.length + ); + + for (const mount of sortedMounts) { + if (containerPath.startsWith(mount.destination + '/') || + containerPath === mount.destination) { + const relativePath = containerPath.substring(mount.destination.length); + return mount.source + relativePath; + } + } + + return null; +} + /** * 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. @@ -180,24 +220,17 @@ export function translateToHostPath(containerPath: string): string { * @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) { + // Try to translate workingDir to host path using ANY cached mount + // This handles both DATA_DIR mounts and external mounts (e.g., /external-stacks) + const hostWorkingDir = translateContainerPathViaMount(workingDir); + + if (!hostWorkingDir) { + // Can't translate - workingDir is not under any known mount 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 diff --git a/src/lib/server/scheduler/tasks/container-update.ts b/src/lib/server/scheduler/tasks/container-update.ts index 2442364..b8f02a9 100644 --- a/src/lib/server/scheduler/tasks/container-update.ts +++ b/src/lib/server/scheduler/tasks/container-update.ts @@ -124,6 +124,18 @@ export async function runContainerUpdate( return; } + // Skip digest-pinned images - they are explicitly locked to a specific version + if (isDigestBasedImage(imageNameFromConfig)) { + log(`Skipping ${containerName} - image pinned to specific digest`); + await updateScheduleExecution(execution.id, { + status: 'skipped', + completedAt: new Date().toISOString(), + duration: Date.now() - startTime, + details: { reason: 'Image pinned to specific digest' } + }); + return; + } + // Get the actual image ID from inspect data const currentImageId = inspectData.Image; diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index c87ec46..cba9ae7 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -20,8 +20,12 @@ import { getGitStackByName, deleteGitStack, getStackSources, - deleteStackEnvVars + deleteStackEnvVars, + removePendingContainerUpdate, + deleteAutoUpdateSchedule, + getAutoUpdateSetting } from './db'; +import { unregisterSchedule } from './scheduler'; import { deleteGitStackFiles } from './git'; import { cleanPem } from '$lib/utils/pem'; import { rewriteComposeVolumePaths, getHostDataDir } from './host-path'; @@ -1534,18 +1538,24 @@ async function requireComposeFile( const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId); // Read non-secret vars from .env file - // For stacks with custom path, use the env path if set (and not empty string which means "no env file") - // Otherwise, use the .env file in the stack directory + // For stacks with custom composePath (adopted/external), derive envPath from same directory + // For internal stacks, use the default data directory let envFilePath: string | null = null; - if (composeResult.composePath && composeResult.envPath) { - // Custom compose path with explicit env path - envFilePath = composeResult.envPath; - } else if (composeResult.composePath && composeResult.envPath === '') { - // Custom compose path with explicit "no env file" - don't read any file - envFilePath = null; + if (composeResult.composePath) { + // Adopted/external stack with custom compose path + if (composeResult.envPath) { + // Explicit env path stored in database + envFilePath = composeResult.envPath; + } else if (composeResult.envPath === '') { + // Explicitly no env file (user selected "no .env") + envFilePath = null; + } else { + // envPath is null - look for .env next to the compose file + envFilePath = join(dirname(composeResult.composePath), '.env'); + } } else { - // Default location - look for .env in stack directory + // Internal stack - use default data directory location const stackDir = composeResult.stackDir || await findStackDir(stackName, envId) || await getStackDir(stackName, envId); envFilePath = join(stackDir, '.env'); } @@ -1699,6 +1709,9 @@ export async function removeStack( // Get compose file (may not exist for external stacks) const composeResult = await getStackComposeFile(stackName); + // Get stack containers BEFORE removing them (for cleanup later) + const stackContainers = await getStackContainers(stackName, envId); + // If compose file exists, run docker compose down first if (composeResult.success) { const envVars = await getNonSecretEnvVarsAsRecord(stackName, envId); @@ -1722,7 +1735,6 @@ export async function removeStack( } else { // External stack - remove containers directly in parallel const { removeContainer } = await import('./docker.js'); - const stackContainers = await getStackContainers(stackName, envId); const removalResults = await Promise.allSettled( stackContainers.map((container) => @@ -1746,12 +1758,70 @@ export async function removeStack( } } + // Clean up auto-update schedules and pending updates for stack containers + const envIdNum = typeof envId === 'number' ? envId : undefined; + for (const container of stackContainers) { + const containerName = container.names?.[0]?.replace(/^\//, '') || container.name; + const containerId = container.id; + + // Clean up auto-update schedule + try { + const setting = await getAutoUpdateSetting(containerName, envIdNum); + if (setting) { + unregisterSchedule(setting.id, 'container_update'); + await deleteAutoUpdateSchedule(containerName, envIdNum); + } + } catch { + // Ignore cleanup errors + } + + // Clean up pending container update + try { + if (envIdNum) { + await removePendingContainerUpdate(envIdNum, containerId); + } + } catch { + // Ignore cleanup errors + } + } + // Clean up database records - collect errors but don't stop const cleanupErrors: string[] = []; // Delete compose file and directory - const stackDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId); - if (existsSync(stackDir)) { + // Only delete files that are within Dockhand's data directory (stacks we created) + // Adopted/imported stacks have files outside DATA_DIR and should be preserved + const stackSource = await getStackSource(stackName, envId); + const stacksDir = getStacksDir(); + + // Determine what directory to delete (if any) + let stackDir: string | null = null; + + if (stackSource?.composePath) { + // Check if the compose path is within Dockhand's stacks directory + const customDir = dirname(stackSource.composePath); + const resolvedCustomDir = resolve(customDir); + const resolvedStacksDir = resolve(stacksDir); + + // Only delete if the directory is within DATA_DIR/stacks/ (files we created) + // AND it looks like a stack directory (contains stackName for safety) + if (resolvedCustomDir.startsWith(resolvedStacksDir) && + customDir.includes(stackName) && + existsSync(customDir)) { + stackDir = customDir; + } + } + + // Fall back to default paths (always within DATA_DIR/stacks/) + if (!stackDir) { + const defaultDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId); + if (existsSync(defaultDir)) { + stackDir = defaultDir; + } + } + + // Delete the directory if found + if (stackDir) { try { rmSync(stackDir, { recursive: true, force: true }); } catch (err: any) { diff --git a/src/lib/server/subprocesses/metrics-subprocess.ts b/src/lib/server/subprocesses/metrics-subprocess.ts index 84ade62..408fd93 100644 --- a/src/lib/server/subprocesses/metrics-subprocess.ts +++ b/src/lib/server/subprocesses/metrics-subprocess.ts @@ -422,10 +422,15 @@ async function start(): Promise { // Schedule regular collection collectInterval = setInterval(collectMetrics, COLLECT_INTERVAL); - // Start disk space checking (every 5 minutes) - console.log('[MetricsSubprocess] Starting disk space monitoring (every 5 minutes)'); - checkDiskSpace(); // Initial check - diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL); + // Start disk space checking (every 5 minutes) - can be disabled for Synology NAS + const skipDfCollection = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1'; + if (!skipDfCollection) { + console.log('[MetricsSubprocess] Starting disk space monitoring (every 5 minutes)'); + checkDiskSpace(); // Initial check + diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL); + } else { + console.log('[MetricsSubprocess] Disk space monitoring disabled (SKIP_DF_COLLECTION=true)'); + } // Listen for commands from main process process.on('message', (message: MainProcessCommand) => { diff --git a/src/lib/stores/audit-events.ts b/src/lib/stores/audit-events.ts index c074307..11c3fe7 100644 --- a/src/lib/stores/audit-events.ts +++ b/src/lib/stores/audit-events.ts @@ -2,18 +2,20 @@ import { writable, get } from 'svelte/store'; export interface AuditLogEntry { id: number; - user_id: number | null; + userId: number | null; username: string; action: string; - entity_type: string; - entity_id: string | null; - entity_name: string | null; - environment_id: number | null; + entityType: string; + entityId: string | null; + entityName: string | null; + environmentId: number | null; + environmentName: string | null; + environmentIcon: string | null; description: string | null; details: any | null; - ip_address: string | null; - user_agent: string | null; - timestamp: string; + ipAddress: string | null; + userAgent: string | null; + createdAt: string; } export type AuditEventCallback = (event: AuditLogEntry) => void; diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 984f0ca..7b5da82 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -1,4 +1,5 @@ import { writable, derived } from 'svelte/store'; +import { environments } from './environment'; export interface Permissions { containers: string[]; @@ -128,12 +129,15 @@ function createAuthStore() { try { await fetch('/api/auth/logout', { method: 'POST' }); } finally { + // Clear auth state set({ user: null, loading: false, authEnabled: true, // Keep authEnabled as we know it was on authenticated: false }); + // Clear environment data to prevent showing stale info on login screen + environments.clear(); } }, diff --git a/src/lib/stores/environment.ts b/src/lib/stores/environment.ts index b1c68b8..b3594a2 100644 --- a/src/lib/stores/environment.ts +++ b/src/lib/stores/environment.ts @@ -161,7 +161,18 @@ function createEnvironmentsStore() { refresh: fetchEnvironments, set, update, - loaded // Expose the loaded store for consumers to know when first fetch is complete + loaded, // Expose the loaded store for consumers to know when first fetch is complete + /** + * Clear all environment data (used on logout) + */ + clear: () => { + set([]); + loaded.set(false); + if (browser) { + localStorage.removeItem(STORAGE_KEY); + } + currentEnvironment.set(null); + } }; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 287c0c0..bcbe48d 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import '../app.css'; import { onMount } from 'svelte'; import { browser } from '$app/environment'; + import { page } from '$app/stores'; import { Toaster } from '$lib/components/ui/sonner'; import AppSidebar from '$lib/components/app-sidebar.svelte'; import ThemeToggle from '$lib/components/theme-toggle.svelte'; @@ -19,6 +20,9 @@ import { shouldShowWhatsNew } from '$lib/utils/version'; import { AlertTriangle, Search } from 'lucide-svelte'; + // Check if current route is login page (no sidebar needed) + const isLoginPage = $derived($page.url.pathname === '/login'); + let { children } = $props(); let envId = $state(null); let commandPaletteOpen = $state(false); @@ -116,60 +120,67 @@ Dockhand - Docker Management - - - -
-
- - -
-
- - {#if $licenseStore.isEnterprise && $daysUntilExpiry !== null && $daysUntilExpiry <= 30} - + {@render children?.()} + +{:else} + + + + +
+
+ + +
+
+
+
+ {@render children?.()}
-
-
- {@render children?.()} -
-
-
+ + - - + + +{/if} {#if showWhatsNewModal && currentVersion} { +export const POST: RequestHandler = async (event) => { + const { request, cookies, getClientAddress } = event; // Check if auth is enabled if (!(await isAuthEnabled())) { return json({ error: 'Authentication is not enabled' }, { status: 400 }); @@ -80,6 +82,12 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress const session = await createUserSession(user.id, authProviderType, cookies); clearRateLimit(rateLimitKey); + // Audit log + await auditAuth(event, 'login', user.username, { + provider: authProviderType, + mfa: true + }); + return json({ success: true, user: { @@ -97,6 +105,11 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress const session = await createUserSession(result.user.id, authProviderType, cookies); clearRateLimit(rateLimitKey); + // Audit log + await auditAuth(event, 'login', result.user.username, { + provider: authProviderType + }); + return json({ success: true, user: { diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts index b52a4e4..a82adf0 100644 --- a/src/routes/api/auth/logout/+server.ts +++ b/src/routes/api/auth/logout/+server.ts @@ -1,11 +1,22 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit'; import { destroySession } from '$lib/server/auth'; +import { authorize } from '$lib/server/authorize'; +import { auditAuth } from '$lib/server/audit'; // POST /api/auth/logout - End session -export const POST: RequestHandler = async ({ cookies }) => { +export const POST: RequestHandler = async (event) => { + const { cookies } = event; try { + // Get current user before destroying session for audit log + const auth = await authorize(cookies); + const username = auth.user?.username || 'unknown'; + await destroySession(cookies); + + // Audit log + await auditAuth(event, 'logout', username); + return json({ success: true }); } catch (error) { console.error('Logout error:', error); diff --git a/src/routes/api/auth/oidc/callback/+server.ts b/src/routes/api/auth/oidc/callback/+server.ts index 8010e6e..d20a536 100644 --- a/src/routes/api/auth/oidc/callback/+server.ts +++ b/src/routes/api/auth/oidc/callback/+server.ts @@ -1,9 +1,11 @@ import { json, redirect } from '@sveltejs/kit'; import type { RequestHandler } from '@sveltejs/kit'; import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth'; +import { auditAuth } from '$lib/server/audit'; // GET /api/auth/oidc/callback - Handle OIDC callback from IdP -export const GET: RequestHandler = async ({ url, cookies }) => { +export const GET: RequestHandler = async (event) => { + const { url, cookies } = event; // Check if auth is enabled if (!isAuthEnabled()) { throw redirect(302, '/login?error=auth_disabled'); @@ -38,6 +40,13 @@ export const GET: RequestHandler = async ({ url, cookies }) => { // Create session await createUserSession(result.user.id, 'oidc', cookies); + // Audit log + await auditAuth(event, 'login', result.user.username, { + provider: 'oidc', + providerId: result.providerId, + providerName: result.providerName + }); + // Redirect to the original destination or home const redirectUrl = result.redirectUrl || '/'; throw redirect(302, redirectUrl); diff --git a/src/routes/api/batch/+server.ts b/src/routes/api/batch/+server.ts index 940b2c3..234ff45 100644 --- a/src/routes/api/batch/+server.ts +++ b/src/routes/api/batch/+server.ts @@ -21,7 +21,7 @@ import { downStack, removeStack } from '$lib/server/stacks'; -import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db'; +import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db'; import { unregisterSchedule } from '$lib/server/scheduler'; // SSE Event types @@ -375,6 +375,15 @@ async function executeContainerOperation( } catch { // Ignore cleanup errors } + + // Clean up pending container update if exists + try { + if (envIdNum) { + await removePendingContainerUpdate(envIdNum, id); + } + } catch { + // Ignore cleanup errors + } break; default: throw new Error(`Unsupported container operation: ${operation}`); diff --git a/src/routes/api/containers/[id]/+server.ts b/src/routes/api/containers/[id]/+server.ts index 504168e..289dc00 100644 --- a/src/routes/api/containers/[id]/+server.ts +++ b/src/routes/api/containers/[id]/+server.ts @@ -4,7 +4,7 @@ import { removeContainer, getContainerLogs } from '$lib/server/docker'; -import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db'; +import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { auditContainer } from '$lib/server/audit'; import { unregisterSchedule } from '$lib/server/scheduler'; @@ -85,6 +85,16 @@ export const DELETE: RequestHandler = async (event) => { // Don't fail the deletion if schedule cleanup fails } + // Clean up pending container update if exists + try { + if (envIdNum) { + await removePendingContainerUpdate(envIdNum, params.id); + } + } catch (error) { + console.error('Failed to cleanup pending container update:', error); + // Don't fail the deletion if cleanup fails + } + return json({ success: true }); } catch (error) { console.error('Error removing container:', error); diff --git a/src/routes/api/containers/batch-update-stream/+server.ts b/src/routes/api/containers/batch-update-stream/+server.ts index bcd12b9..01be464 100644 --- a/src/routes/api/containers/batch-update-stream/+server.ts +++ b/src/routes/api/containers/batch-update-stream/+server.ts @@ -182,6 +182,22 @@ export const POST: RequestHandler = async (event) => { continue; } + // Skip digest-pinned images - they are explicitly locked to a specific version + if (isDigestBasedImage(imageName)) { + safeEnqueue({ + type: 'progress', + containerId, + containerName, + step: 'skipped', + current: i + 1, + total: containerIds.length, + success: true, + message: `Skipping ${containerName} - image pinned to specific digest` + }); + skippedCount++; + continue; + } + // Step 1: Pull latest image safeEnqueue({ type: 'progress', @@ -559,8 +575,18 @@ export const POST: RequestHandler = async (event) => { : `Updated ${successCount} of ${containerIds.length} containers` }); - clearInterval(keepaliveInterval); - controller.close(); + if (keepaliveInterval) { + clearInterval(keepaliveInterval); + } + if (!controllerClosed) { + try { + controller.close(); + controllerClosed = true; + } catch { + // Controller already closed - ignore + controllerClosed = true; + } + } }, cancel() { controllerClosed = true; diff --git a/src/routes/api/dashboard/stats/+server.ts b/src/routes/api/dashboard/stats/+server.ts index b91c34d..732bf0f 100644 --- a/src/routes/api/dashboard/stats/+server.ts +++ b/src/routes/api/dashboard/stats/+server.ts @@ -20,6 +20,9 @@ import { listComposeStacks } from '$lib/server/stacks'; import { authorize } from '$lib/server/authorize'; import { parseLabels } from '$lib/utils/label-colors'; +// Skip disk usage collection (Synology NAS performance fix) +const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1'; + // Helper to add timeout to promises function withTimeout(promise: Promise, ms: number, fallback: T): Promise { return Promise.race([ @@ -200,13 +203,14 @@ export const GET: RequestHandler = async ({ cookies, url }) => { envStats.online = true; // Fetch all data in parallel (with 10 second timeout per operation) + // Disk usage can be disabled with SKIP_DF_COLLECTION for Synology NAS devices const [containers, images, volumes, networks, stacks, diskUsage] = await Promise.all([ withTimeout(listContainers(true, env.id).catch(() => []), 10000, []), withTimeout(listImages(env.id).catch(() => []), 10000, []), withTimeout(listVolumes(env.id).catch(() => []), 10000, []), withTimeout(listNetworks(env.id).catch(() => []), 10000, []), withTimeout(listComposeStacks(env.id).catch(() => []), 10000, []), - withTimeout(getDiskUsage(env.id).catch(() => null), 10000, null) + SKIP_DF_COLLECTION ? Promise.resolve(null) : withTimeout(getDiskUsage(env.id).catch(() => null), 10000, null) ]); // Process containers diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts index b4d9d08..25389d5 100644 --- a/src/routes/api/dashboard/stats/stream/+server.ts +++ b/src/routes/api/dashboard/stats/stream/+server.ts @@ -21,6 +21,9 @@ import { authorize } from '$lib/server/authorize'; import type { EnvironmentStats } from '../+server'; import { parseLabels } from '$lib/utils/label-colors'; +// Skip disk usage collection (Synology NAS performance fix) +const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1'; + // Helper to add timeout to promises function withTimeout(promise: Promise, ms: number, fallback: T): Promise { return Promise.race([ @@ -31,6 +34,7 @@ function withTimeout(promise: Promise, ms: number, fallback: T): Promise { - if (diskUsage) { - // Update images with disk usage data (more accurate) - envStats.images.total = diskUsage.Images?.length || envStats.images.total; - envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || envStats.images.totalSize; - - // Volumes from disk usage - envStats.volumes.total = diskUsage.Volumes?.length || 0; - envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0; - - // Containers disk size - envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0; - - // Build cache - envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0; - } + // Can be disabled with SKIP_DF_COLLECTION env var for Synology NAS + const diskUsagePromise = SKIP_DF_COLLECTION + ? Promise.resolve(null).then(() => { envStats.loading!.volumes = false; envStats.loading!.diskUsage = false; - onPartialUpdate({ id: env.id, - images: { ...envStats.images }, volumes: { ...envStats.volumes }, - containersSize: envStats.containersSize, - buildCacheSize: envStats.buildCacheSize, loading: { ...envStats.loading! } }); + return null; + }) + : getCachedDiskUsage(env.id) + .then((diskUsage) => { + if (diskUsage) { + // Update images with disk usage data (more accurate) + envStats.images.total = diskUsage.Images?.length || envStats.images.total; + envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || envStats.images.totalSize; - return diskUsage; - }); + // Volumes from disk usage + envStats.volumes.total = diskUsage.Volumes?.length || 0; + envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0; + + // Containers disk size + envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0; + + // Build cache + envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0; + } + envStats.loading!.volumes = false; + envStats.loading!.diskUsage = false; + + onPartialUpdate({ + id: env.id, + images: { ...envStats.images }, + volumes: { ...envStats.volumes }, + containersSize: envStats.containersSize, + buildCacheSize: envStats.buildCacheSize, + loading: { ...envStats.loading! } + }); + + return diskUsage; + }); // PHASE 4: Top containers (slow - requires per-container stats) // Limited to TOP_CONTAINERS_LIMIT containers to reduce API calls diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts index 2ade909..8b9e3ec 100644 --- a/src/routes/api/events/+server.ts +++ b/src/routes/api/events/+server.ts @@ -36,11 +36,30 @@ export const GET: RequestHandler = async ({ url }) => { const stream = new ReadableStream({ async start(controller) { const encoder = new TextEncoder(); + let controllerClosed = false; + + // Safe close helper - prevents "Controller is already closed" errors + const safeClose = () => { + if (controllerClosed) return; + try { + controller.close(); + controllerClosed = true; + } catch { + // Controller already closed - ignore + controllerClosed = true; + } + }; // Send initial connection event const sendEvent = (type: string, data: any) => { - const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; - controller.enqueue(encoder.encode(event)); + if (controllerClosed) return; + try { + const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; + controller.enqueue(encoder.encode(event)); + } catch { + // Controller closed or errored - mark as closed + controllerClosed = true; + } }; // Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout) @@ -64,7 +83,7 @@ export const GET: RequestHandler = async ({ url }) => { if (!eventStream) { sendEvent('error', { message: 'Failed to connect to Docker events' }); clearInterval(heartbeatInterval); - controller.close(); + safeClose(); return; } @@ -108,11 +127,17 @@ export const GET: RequestHandler = async ({ url }) => { } } } catch (error: any) { - console.error('Docker event stream error:', error); - sendEvent('error', { message: error.message }); + // Don't log full stack trace for expected connection errors + const isConnectionError = error?.code === 'ECONNRESET' || error?.code === 'ECONNREFUSED'; + if (isConnectionError) { + // Silent - these are handled by event-subprocess reconnection logic + } else { + console.error('Docker event stream error:', error?.message || error); + } + sendEvent('error', { message: error?.message || 'Stream connection lost' }); } finally { clearInterval(heartbeatInterval); - controller.close(); + safeClose(); } }; @@ -122,11 +147,15 @@ export const GET: RequestHandler = async ({ url }) => { // Expected error when environment doesn't exist - don't spam logs sendEvent('error', { message: 'Environment not found' }); } else { - console.error('Failed to connect to Docker events:', error); - sendEvent('error', { message: error.message || 'Failed to connect to Docker' }); + // Don't log full stack trace for expected connection errors + const isConnectionError = error?.code === 'ECONNRESET' || error?.code === 'ECONNREFUSED'; + if (!isConnectionError) { + console.error('Failed to connect to Docker events:', error?.message || error); + } + sendEvent('error', { message: error?.message || 'Failed to connect to Docker' }); } clearInterval(heartbeatInterval); - controller.close(); + safeClose(); } } }); diff --git a/src/routes/api/git/stacks/[id]/+server.ts b/src/routes/api/git/stacks/[id]/+server.ts index bb499f7..479483c 100644 --- a/src/routes/api/git/stacks/[id]/+server.ts +++ b/src/routes/api/git/stacks/[id]/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName } from '$lib/server/db'; +import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName } from '$lib/server/db'; import { deleteGitStackFiles, deployGitStack } from '$lib/server/git'; import { authorize } from '$lib/server/authorize'; import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler'; @@ -71,9 +71,10 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => { webhookSecret: data.webhookSecret }); - // If stack name changed, update the stack_sources record too + // If stack name changed, update related records if (data.stackName && data.stackName !== oldStackName) { await updateStackSourceName(oldStackName, data.stackName, existing.environmentId); + await updateStackEnvVarsName(oldStackName, data.stackName, existing.environmentId); } // Register or unregister schedule with croner diff --git a/src/routes/api/system/disk/+server.ts b/src/routes/api/system/disk/+server.ts index 0f5eb2c..cee1538 100644 --- a/src/routes/api/system/disk/+server.ts +++ b/src/routes/api/system/disk/+server.ts @@ -3,6 +3,9 @@ import { getDiskUsage } from '$lib/server/docker'; import { authorize } from '$lib/server/authorize'; import type { RequestHandler } from './$types'; +// Skip disk usage collection (Synology NAS performance fix) +const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1'; + const DISK_USAGE_TIMEOUT = 15000; // 15 second timeout export const GET: RequestHandler = async ({ url, cookies }) => { @@ -23,6 +26,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => { return json({ error: 'Access denied to this environment' }, { status: 403 }); } + // Skip disk usage when disabled (Synology NAS performance fix) + if (SKIP_DF_COLLECTION) { + return json({ diskUsage: null }); + } + try { // Fetch disk usage with timeout const diskUsagePromise = getDiskUsage(envId); diff --git a/src/routes/audit/+page.svelte b/src/routes/audit/+page.svelte index 9516321..6dbc957 100644 --- a/src/routes/audit/+page.svelte +++ b/src/routes/audit/+page.svelte @@ -68,20 +68,20 @@ interface AuditLogEntry { id: number; - user_id: number | null; + userId: number | null; username: string; action: string; - entity_type: string; - entity_id: string | null; - entity_name: string | null; - environment_id: number | null; - environment_name: string | null; - environment_icon: string | null; + entityType: string; + entityId: string | null; + entityName: string | null; + environmentId: number | null; + environmentName: string | null; + environmentIcon: string | null; description: string | null; details: any | null; - ip_address: string | null; - user_agent: string | null; - timestamp: string; + ipAddress: string | null; + userAgent: string | null; + createdAt: string; } interface Environment { @@ -555,16 +555,16 @@ function handleNewAuditEvent(event: SSEAuditLogEntry) { // Check if event matches current filters if (filterUsernames.length > 0 && !filterUsernames.includes(event.username)) return; - if (filterEntityTypes.length > 0 && !filterEntityTypes.includes(event.entity_type)) return; + if (filterEntityTypes.length > 0 && !filterEntityTypes.includes(event.entityType)) return; if (filterActions.length > 0 && !filterActions.includes(event.action)) return; // Check date filters if (filterFromDate) { - const eventDate = new Date(event.timestamp).toISOString().split('T')[0]; + const eventDate = new Date(event.createdAt).toISOString().split('T')[0]; if (eventDate < filterFromDate) return; } if (filterToDate) { - const eventDate = new Date(event.timestamp).toISOString().split('T')[0]; + const eventDate = new Date(event.createdAt).toISOString().split('T')[0]; if (eventDate > filterToDate) return; } @@ -981,14 +981,14 @@ onkeydown={(e) => e.key === 'Enter' && showDetails(log)} >
- {formatTimestamp(log.timestamp)} + {formatTimestamp(log.createdAt)}
- {#if log.environment_name} - {@const LogEnvIcon = getIconComponent(log.environment_icon || 'globe')} + {#if log.environmentName} + {@const LogEnvIcon = getIconComponent(log.environmentIcon || 'globe')}
- {log.environment_name} + {log.environmentName}
{:else} - @@ -1007,17 +1007,17 @@
- - {log.entity_type} + + {log.entityType}
- - {log.entity_name || log.entity_id || '-'} + + {log.entityName || log.entityId || '-'}
- {log.ip_address || '-'} + {log.ipAddress || '-'}