Compare commits

...

1 Commits

Author SHA1 Message Date
jarek 0303f54e2b 1.0.12 2026-01-22 16:23:26 +01:00
32 changed files with 799 additions and 522 deletions
+3 -3
View File
@@ -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",
+17
View File
@@ -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",
+6 -258
View File
@@ -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",
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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);
}
+55 -3
View File
@@ -3023,13 +3023,32 @@ export async function logAuditEvent(data: AuditLogCreateData): Promise<AuditLogD
return auditLog!;
}
export async function getAuditLog(id: number): Promise<AuditLogData | undefined> {
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<AuditLogResult> {
@@ -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<void> {
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.
+9
View File
@@ -1877,6 +1877,15 @@ export async function checkImageUpdateAvailable(
envId?: number
): Promise<ImageUpdateCheckResult> {
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 {
+6 -6
View File
@@ -384,11 +384,11 @@ export async function syncRepository(repoId: number): Promise<SyncResult> {
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<SyncResult> {
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<SyncResult> {
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
);
+52 -19
View File
@@ -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<string | null> {
}>;
};
// 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
@@ -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;
+83 -13
View File
@@ -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) {
@@ -422,10 +422,15 @@ async function start(): Promise<void> {
// 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) => {
+10 -8
View File
@@ -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;
+4
View File
@@ -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();
}
},
+12 -1
View File
@@ -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);
}
};
}
+62 -51
View File
@@ -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<number | null>(null);
let commandPaletteOpen = $state(false);
@@ -116,60 +120,67 @@
<title>Dockhand - Docker Management</title>
</svelte:head>
<SidebarProvider>
<AppSidebar />
<MainContent>
<header class="h-14 shrink-0 flex items-center justify-between gap-4 border-b bg-background px-4">
<div class="flex items-center gap-2 min-w-0">
<SidebarTrigger class="md:hidden shrink-0" />
<HostInfo />
</div>
<div class="flex items-center gap-3 shrink-0">
<button
type="button"
onclick={() => commandPaletteOpen = true}
class="flex items-center gap-2 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border rounded-md hover:bg-muted/50 transition-colors"
>
<Search class="w-3.5 h-3.5" />
<span class="hidden sm:inline">Search...</span>
<kbd class="pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-2xs font-medium text-muted-foreground">
{#if isMac}
<span class="text-xs"></span>
{:else}
<span class="text-xs">Ctrl</span>
{/if}
K
</kbd>
</button>
{#if $licenseStore.isEnterprise && $daysUntilExpiry !== null && $daysUntilExpiry <= 30}
<a
href="/settings?tab=license"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors
{$daysUntilExpiry <= 7
? 'bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-amber-100 text-amber-800 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'}"
{#if isLoginPage}
<!-- Login page: no sidebar, no header -->
{@render children?.()}
<Toaster richColors position="bottom-right" />
{:else}
<!-- Main app: full layout with sidebar -->
<SidebarProvider>
<AppSidebar />
<MainContent>
<header class="h-14 shrink-0 flex items-center justify-between gap-4 border-b bg-background px-4">
<div class="flex items-center gap-2 min-w-0">
<SidebarTrigger class="md:hidden shrink-0" />
<HostInfo />
</div>
<div class="flex items-center gap-3 shrink-0">
<button
type="button"
onclick={() => commandPaletteOpen = true}
class="flex items-center gap-2 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border rounded-md hover:bg-muted/50 transition-colors"
>
<AlertTriangle class="w-3.5 h-3.5" />
{#if $daysUntilExpiry <= 0}
License expired
{:else if $daysUntilExpiry === 1}
License expires tomorrow
{:else}
License expires in {$daysUntilExpiry} days
{/if}
</a>
{/if}
<ThemeToggle />
<Search class="w-3.5 h-3.5" />
<span class="hidden sm:inline">Search...</span>
<kbd class="pointer-events-none hidden sm:inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-2xs font-medium text-muted-foreground">
{#if isMac}
<span class="text-xs"></span>
{:else}
<span class="text-xs">Ctrl</span>
{/if}
K
</kbd>
</button>
{#if $licenseStore.isEnterprise && $daysUntilExpiry !== null && $daysUntilExpiry <= 30}
<a
href="/settings?tab=license"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors
{$daysUntilExpiry <= 7
? 'bg-red-100 text-red-800 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
: 'bg-amber-100 text-amber-800 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:hover:bg-amber-900/50'}"
>
<AlertTriangle class="w-3.5 h-3.5" />
{#if $daysUntilExpiry <= 0}
License expired
{:else if $daysUntilExpiry === 1}
License expires tomorrow
{:else}
License expires in {$daysUntilExpiry} days
{/if}
</a>
{/if}
<ThemeToggle />
</div>
</header>
<div class="flex-1 min-h-0 h-[calc(100%-3.5rem)] overflow-auto py-2 px-3 flex flex-col">
{@render children?.()}
</div>
</header>
<div class="flex-1 min-h-0 h-[calc(100%-3.5rem)] overflow-auto py-2 px-3 flex flex-col">
{@render children?.()}
</div>
</MainContent>
</SidebarProvider>
</MainContent>
</SidebarProvider>
<Toaster richColors position="bottom-right" />
<CommandPalette bind:open={commandPaletteOpen} />
<Toaster richColors position="bottom-right" />
<CommandPalette bind:open={commandPaletteOpen} />
{/if}
{#if showWhatsNewModal && currentVersion}
<WhatsNewModal
+14 -1
View File
@@ -12,9 +12,11 @@ import {
isAuthEnabled
} from '$lib/server/auth';
import { getUser, getUserByUsername } from '$lib/server/db';
import { auditAuth } from '$lib/server/audit';
// POST /api/auth/login - Authenticate user
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
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: {
+12 -1
View File
@@ -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);
+10 -1
View File
@@ -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);
+10 -1
View File
@@ -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}`);
+11 -1
View File
@@ -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);
@@ -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;
+5 -1
View File
@@ -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<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
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
@@ -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<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
@@ -31,6 +34,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T
// Disk usage cache - getDiskUsage() is very slow (30s timeout) but data changes rarely
// Cache per environment with 5-minute TTL
// DISABLED when SKIP_DF_COLLECTION is set (kills Synology NAS devices)
interface DiskUsageCache {
data: any;
timestamp: number;
@@ -344,37 +348,49 @@ async function getEnvironmentStatsProgressive(
});
// PHASE 3: Disk usage (slow - includes volumes) - uses cache for better performance
const diskUsagePromise = 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;
// 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
+38 -9
View File
@@ -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();
}
}
});
+3 -2
View File
@@ -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
+8
View File
@@ -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);
+35 -35
View File
@@ -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)}
>
<div class="px-2 font-mono whitespace-nowrap">
{formatTimestamp(log.timestamp)}
{formatTimestamp(log.createdAt)}
</div>
<div class="px-2">
{#if log.environment_name}
{@const LogEnvIcon = getIconComponent(log.environment_icon || 'globe')}
{#if log.environmentName}
{@const LogEnvIcon = getIconComponent(log.environmentIcon || 'globe')}
<div class="flex items-center gap-1 truncate">
<LogEnvIcon class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{log.environment_name}</span>
<span class="truncate">{log.environmentName}</span>
</div>
{:else}
<span class="text-muted-foreground">-</span>
@@ -1007,17 +1007,17 @@
</div>
<div class="px-2">
<div class="flex items-center gap-1 truncate">
<svelte:component this={getEntityIcon(log.entity_type)} class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{log.entity_type}</span>
<svelte:component this={getEntityIcon(log.entityType)} class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{log.entityType}</span>
</div>
</div>
<div class="px-2">
<span class="truncate" title={log.entity_name || log.entity_id || '-'}>
{log.entity_name || log.entity_id || '-'}
<span class="truncate" title={log.entityName || log.entityId || '-'}>
{log.entityName || log.entityId || '-'}
</span>
</div>
<div class="px-2 font-mono text-muted-foreground">
{log.ip_address || '-'}
{log.ipAddress || '-'}
</div>
<div class="px-2 flex items-center justify-center">
<Button variant="ghost" size="sm" onclick={(e) => { e.stopPropagation(); showDetails(log); }}>
@@ -1060,7 +1060,7 @@
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-muted-foreground">Timestamp</label>
<p class="font-mono text-sm">{formatTimestamp(selectedLog.timestamp)}</p>
<p class="font-mono text-sm">{formatTimestamp(selectedLog.createdAt)}</p>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground">User</label>
@@ -1081,32 +1081,32 @@
<div>
<label class="text-sm font-medium text-muted-foreground">Entity type</label>
<p class="flex items-center gap-1">
<svelte:component this={getEntityIcon(selectedLog.entity_type)} class="w-4 h-4 text-muted-foreground" />
{selectedLog.entity_type}
<svelte:component this={getEntityIcon(selectedLog.entityType)} class="w-4 h-4 text-muted-foreground" />
{selectedLog.entityType}
</p>
</div>
{#if selectedLog.entity_name}
{#if selectedLog.entityName}
<div>
<label class="text-sm font-medium text-muted-foreground">Entity name</label>
<p>{selectedLog.entity_name}</p>
<p>{selectedLog.entityName}</p>
</div>
{/if}
{#if selectedLog.entity_id}
{#if selectedLog.entityId}
<div>
<label class="text-sm font-medium text-muted-foreground">Entity ID</label>
<p class="font-mono text-sm break-all">{selectedLog.entity_id}</p>
<p class="font-mono text-sm break-all">{selectedLog.entityId}</p>
</div>
{/if}
{#if selectedLog.environment_id}
{#if selectedLog.environmentId}
<div>
<label class="text-sm font-medium text-muted-foreground">Environment ID</label>
<p>{selectedLog.environment_id}</p>
<p>{selectedLog.environmentId}</p>
</div>
{/if}
{#if selectedLog.ip_address}
{#if selectedLog.ipAddress}
<div>
<label class="text-sm font-medium text-muted-foreground">IP address</label>
<p class="font-mono text-sm">{selectedLog.ip_address}</p>
<p class="font-mono text-sm">{selectedLog.ipAddress}</p>
</div>
{/if}
</div>
@@ -1118,10 +1118,10 @@
</div>
{/if}
{#if selectedLog.user_agent}
{#if selectedLog.userAgent}
<div>
<label class="text-sm font-medium text-muted-foreground">User agent</label>
<p class="text-xs text-muted-foreground break-all">{selectedLog.user_agent}</p>
<p class="text-xs text-muted-foreground break-all">{selectedLog.userAgent}</p>
</div>
{/if}
@@ -53,8 +53,8 @@
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
current_password: currentPassword,
new_password: newPassword
currentPassword: currentPassword,
newPassword: newPassword
})
});
+31 -30
View File
@@ -264,6 +264,16 @@
}
function resetForm() {
// Clear state BEFORE async loads to avoid race conditions
formError = '';
errors = {};
copiedWebhookUrl = false;
copiedWebhookSecret = false;
envFiles = [];
envVars = [];
fileEnvVars = {};
existingSecretKeys = new Set();
if (gitStack) {
formRepoMode = 'existing';
formRepositoryId = gitStack.repositoryId;
@@ -275,7 +285,7 @@
formWebhookEnabled = gitStack.webhookEnabled;
formWebhookSecret = gitStack.webhookSecret || '';
formDeployNow = false;
// Load env files and overrides for editing
// Load env files and overrides for editing (async - will populate envFiles, envVars, fileEnvVars)
loadEnvFiles();
loadEnvVarsOverrides();
if (gitStack.envFilePath) {
@@ -298,14 +308,6 @@
formWebhookSecret = '';
formDeployNow = false;
}
formError = '';
errors = {};
copiedWebhookUrl = false;
copiedWebhookSecret = false;
envFiles = [];
envVars = [];
fileEnvVars = {};
existingSecretKeys = new Set();
}
async function saveGitStack(deployAfterSave: boolean = false) {
@@ -392,30 +394,29 @@
return;
}
// Save environment variable overrides if we have any
// Save environment variable overrides (always save to ensure DB is in sync)
// This handles both adding new vars and clearing all vars
const definedVars = envVars.filter(v => v.key.trim());
if (definedVars.length > 0) {
try {
const envResponse = await fetch(
`/api/stacks/${encodeURIComponent(formStackName)}/env${environmentId ? `?env=${environmentId}` : ''}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variables: definedVars.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
}))
})
}
);
if (!envResponse.ok) {
console.error('Failed to save environment variables');
try {
const envResponse = await fetch(
`/api/stacks/${encodeURIComponent(formStackName)}/env${environmentId ? `?env=${environmentId}` : ''}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
variables: definedVars.map(v => ({
key: v.key.trim(),
value: v.value,
isSecret: v.isSecret
}))
})
}
} catch (e) {
console.error('Failed to save environment variables:', e);
);
if (!envResponse.ok) {
console.error('Failed to save environment variables');
}
} catch (e) {
console.error('Failed to save environment variables:', e);
}
onSaved();
+100 -23
View File
@@ -84,6 +84,9 @@
// Path source info (for hint display)
let pathSource = $state<'default' | 'custom' | 'browsed' | null>(null);
// Base directory when user browsed to a directory (without stack name yet)
let browsedBaseDirectory = $state<string | null>(null);
// UI state
let composePathCopied = $state(false);
let envPathCopied = $state(false);
@@ -112,7 +115,12 @@
// Derived: source hint text for the path bar (only in create mode)
const pathSourceHint = $derived.by(() => {
if (mode !== 'create' || !workingComposePath) return undefined;
if (mode !== 'create') return undefined;
// Show hint when user selected a directory but hasn't entered stack name yet
if (browsedBaseDirectory && !workingComposePath) {
return `Will create in ${browsedBaseDirectory}/`;
}
if (!workingComposePath) return undefined;
switch (pathSource) {
case 'browsed':
return 'Custom location';
@@ -372,25 +380,52 @@
if (isDirectory) {
const stackName = newStackName.trim();
// Store the base directory so effect can rebuild path if user changes stack name
browsedBaseDirectory = baseDir;
if (stackName) {
// If we have a stack name, include the subfolder
// If we have a stack name, build the full path with subfolder
finalPath = `${baseDir}/${stackName}/compose.yaml`;
} else {
// No stack name yet - just show the selected directory
finalPath = `${baseDir}/`;
// No stack name yet - path will be completed when stack name is entered
finalPath = ''; // Don't set incomplete path
pathSource = 'browsed';
showFileBrowser = false;
isDirty = true;
return; // Exit early - path will be completed when stack name is entered
}
} else {
browsedBaseDirectory = null; // Selected a file, not a directory
}
// In CREATE mode, we only want the content - don't store external paths
// Files will be saved to internal stack directory
if (mode === 'create') {
pathSource = 'browsed';
showFileBrowser = false;
// Load compose file content when selecting a file (not directory)
if (!isDirectory) {
// Build potential env path in same directory as compose file
const dir = finalPath.replace(/\/[^/]+$/, '');
const potentialEnvPath = `${dir}/.env`;
await loadFilesFromLocalFilesystem(finalPath, potentialEnvPath);
// Don't set workingComposePath/workingEnvPath - use internal defaults
workingComposePath = '';
workingEnvPath = '';
}
isDirty = true;
return;
}
// EDIT mode - store the selected path
workingComposePath = finalPath;
pathSource = 'browsed';
showFileBrowser = false;
// Auto-suggest .env in the same directory (only if we have a full path)
if (!isDirectory || newStackName.trim()) {
const dir = finalPath.replace(/\/[^/]+$/, '');
if (!workingEnvPath) {
workingEnvPath = `${dir}/.env`;
}
// Auto-suggest .env in the same directory
const dir = finalPath.replace(/\/[^/]+$/, '');
if (!workingEnvPath) {
workingEnvPath = `${dir}/.env`;
}
// Load compose file content when selecting a file (not directory)
@@ -427,7 +462,6 @@
finalPath = path.endsWith('/') ? `${path}.env` : `${path}/.env`;
}
workingEnvPath = finalPath;
showFileBrowser = false;
// Load env content when selecting a file (not directory)
@@ -445,6 +479,14 @@
console.error('Failed to load env file:', e);
}
}
// In CREATE mode, don't store external path - content will be saved to internal directory
// In EDIT mode, store the path for the file location
if (mode !== 'create') {
workingEnvPath = finalPath;
}
// If CREATE mode, workingEnvPath stays empty - will use internal default
isDirty = true;
}
@@ -456,7 +498,10 @@
if (composeResponse.ok) {
const composeData = await composeResponse.json();
composeContent = composeData.content || '';
workingComposePath = composeFilePath;
// Only set workingComposePath in EDIT mode - CREATE mode uses internal defaults
if (mode !== 'create') {
workingComposePath = composeFilePath;
}
// Clear the needsFileLocation flag since we now have content
needsFileLocation = false;
stackContainers = [];
@@ -465,18 +510,23 @@
console.error('Failed to load compose file:', err.error);
}
// Try to load .env file (only set workingEnvPath if it exists)
// Try to load .env file (only set workingEnvPath if it exists AND we're in edit mode)
if (envFilePath) {
const envResponse = await fetch(`/api/system/files/content?path=${encodeURIComponent(envFilePath)}`);
if (envResponse.ok) {
const envData = await envResponse.json();
rawEnvContent = envData.content || '';
workingEnvPath = envFilePath;
// Only set workingEnvPath in EDIT mode - CREATE mode uses internal defaults
if (mode !== 'create') {
workingEnvPath = envFilePath;
}
parseEnvVarsFromRaw(rawEnvContent);
} else {
// .env file not found - clear env path
rawEnvContent = '';
workingEnvPath = '';
if (mode !== 'create') {
workingEnvPath = '';
}
}
}
} catch (e) {
@@ -891,7 +941,7 @@ services:
}
}
async function handleSave(restart = false, moveFromDir: string | null = null) {
async function handleSave(restart = false, moveFromDir: string | null | undefined = undefined) {
errors = {};
// Validate compose content (unless file location is needed and we have a path)
@@ -909,7 +959,8 @@ services:
const envId = $currentEnvironment?.id ?? null;
// Check if directory has changed (edit mode only, and not already confirmed)
if (mode === 'edit' && !moveFromDir) {
// Use === undefined to distinguish "not checked yet" from "keep files" (empty string)
if (mode === 'edit' && moveFromDir === undefined) {
const newComposePath = workingComposePath.trim() || null;
// Only check if compose path changed (which means directory changed)
@@ -1070,7 +1121,7 @@ services:
// Handle path change - keep old files and proceed (just save without moving)
function confirmPathChangeKeepFiles() {
showPathChangeConfirm = false;
// Pass empty string to skip move check this time (not null, which means "not checked yet")
// Pass empty string to skip move check (undefined means "not checked yet")
handleSave(pendingSaveRestart, '');
}
@@ -1111,6 +1162,7 @@ services:
originalEnvPath = null;
autoComputedComposePath = '';
pathSource = null;
browsedBaseDirectory = null;
needsFileLocation = false;
stackContainers = [];
showFileBrowser = false;
@@ -1169,16 +1221,14 @@ services:
});
// Auto-update default paths when stack name changes in create mode
// This unified effect handles both default paths and browsed directory paths
$effect(() => {
if (mode !== 'create' || !open) return;
// Don't overwrite if user has browsed and selected a path
if (pathSource === 'browsed') return;
const name = newStackName.trim();
const location = $appSettings.primaryStackLocation;
// Case 1: No name entered yet - clear paths
if (!name) {
// Clear paths when no name
workingComposePath = '';
workingEnvPath = '';
autoComputedComposePath = '';
@@ -1186,8 +1236,35 @@ services:
return;
}
// Fetch the actual absolute path from the backend
// Case 2: User has browsed and selected a directory - use that as base
// Keep updating as user types (don't clear browsedBaseDirectory!)
if (browsedBaseDirectory) {
workingComposePath = `${browsedBaseDirectory}/${name}/compose.yaml`;
workingEnvPath = `${browsedBaseDirectory}/${name}/.env`;
pathSource = 'browsed';
return;
}
// Case 3: User already has a browsed path set (from previous name entry)
// Update the stack name portion in the existing path
if (pathSource === 'browsed' && workingComposePath) {
// Extract base directory from existing path and rebuild with new name
// Path format: {baseDir}/{stackName}/compose.yaml
const pathParts = workingComposePath.split('/');
pathParts.pop(); // remove 'compose.yaml'
pathParts.pop(); // remove old stack name
const baseDir = pathParts.join('/');
if (baseDir) {
workingComposePath = `${baseDir}/${name}/compose.yaml`;
workingEnvPath = `${baseDir}/${name}/.env`;
}
return;
}
// Case 4: Default path from settings/API
const location = $appSettings.primaryStackLocation;
const envId = $currentEnvironment?.id ?? null;
const fetchDefaultPath = async () => {
try {
const params = new URLSearchParams({ name });
+109 -22
View File
@@ -2,10 +2,86 @@ import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig, type Plugin } from 'vite';
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import { existsSync, readFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { Database } from 'bun:sqlite';
import { createDecipheriv } from 'node:crypto';
// ============ Encryption/Decryption for dev mode ============
const ENCRYPTED_PREFIX = 'enc:v1:';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
let _encryptionKey: Buffer | null = null;
function getEncryptionKey(): Buffer | null {
if (_encryptionKey) return _encryptionKey;
const dataDir = process.env.DATA_DIR || './data';
const keyPath = join(dataDir, '.encryption_key');
const envKey = process.env.ENCRYPTION_KEY;
// Try file first
if (existsSync(keyPath)) {
try {
_encryptionKey = readFileSync(keyPath);
return _encryptionKey;
} catch {
// Fall through
}
}
// Try env var
if (envKey) {
try {
_encryptionKey = Buffer.from(envKey, 'base64');
return _encryptionKey;
} catch {
// Fall through
}
}
return null;
}
function decrypt(value: string | null | undefined): string | null {
if (!value || !value.startsWith(ENCRYPTED_PREFIX)) {
return value as string | null;
}
const key = getEncryptionKey();
if (!key) {
console.error('[vite.config] Cannot decrypt: no encryption key available');
return value;
}
try {
const payload = value.substring(ENCRYPTED_PREFIX.length);
const combined = Buffer.from(payload, 'base64');
if (combined.length < IV_LENGTH + AUTH_TAG_LENGTH + 1) {
return value;
}
const iv = combined.subarray(0, IV_LENGTH);
const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]);
return decrypted.toString('utf8');
} catch (error) {
console.error('[vite.config] Decryption failed:', error);
return value;
}
}
const WS_PORT = 5174;
@@ -71,14 +147,20 @@ function resolveDockerTarget(
};
if (env.tls_ca) tls.ca = env.tls_ca;
if (env.tls_cert) tls.cert = env.tls_cert;
if (env.tls_key) tls.key = env.tls_key;
// tls_key is encrypted in database - decrypt it
if (env.tls_key) tls.key = decrypt(env.tls_key) || undefined;
}
// hawser_token is also encrypted
const hawserToken = env.connection_type === 'hawser-standard' && env.hawser_token
? decrypt(env.hawser_token) || undefined
: undefined;
return {
type: 'tcp',
host: env.host || 'localhost',
port: env.port || 2375,
hawserToken: env.connection_type === 'hawser-standard' ? env.hawser_token : undefined,
hawserToken,
tls
};
}
@@ -90,7 +172,9 @@ function buildExecStartHttpRequest(execId: string, target: DockerTarget): string
const tokenHeader = target.type === 'tcp' && target.hawserToken
? `X-Hawser-Token: ${target.hawserToken}\r\n`
: '';
return `POST /exec/${execId}/start HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\n${tokenHeader}Connection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${body.length}\r\n\r\n${body}`;
// Use actual host for proper routing through reverse proxies like Caddy
const host = target.host || 'localhost';
return `POST /exec/${execId}/start HTTP/1.1\r\nHost: ${host}\r\nContent-Type: application/json\r\n${tokenHeader}Connection: Upgrade\r\nUpgrade: tcp\r\nContent-Length: ${body.length}\r\n\r\n${body}`;
}
// ============ Stream Processing ============
@@ -504,7 +588,6 @@ function webSocketPlugin(): Plugin {
}
const target = getDockerTarget(envId);
console.log('[Terminal WS] Open connId:', connId, 'container:', containerId, 'target:', target.type);
try {
// Handle Hawser Edge mode differently - use WebSocket protocol
@@ -518,7 +601,6 @@ function webSocketPlugin(): Plugin {
// Generate unique exec ID
const execId = crypto.randomUUID();
console.log('[Terminal WS] Starting Edge exec:', execId, 'container:', containerId);
// Track this session
edgeExecSessions.set(execId, { ws, execId, environmentId: target.environmentId });
@@ -574,7 +656,19 @@ function webSocketPlugin(): Plugin {
ws.close();
}
},
error() {},
error(socket: any, error: any) {
console.error('[Terminal WS] Socket error:', error?.message || error);
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'error', message: `Connection error: ${error?.message || 'Unknown error'}` }));
}
},
connectError(socket: any, error: any) {
console.error('[Terminal WS] Connect error:', error?.message || error);
if (ws.readyState === 1) {
ws.send(JSON.stringify({ type: 'error', message: `Failed to connect: ${error?.message || 'Unknown error'}` }));
ws.close();
}
},
open(socket: any) {
// Send exec start request (using shared helper)
const httpRequest = buildExecStartHttpRequest(execId, target);
@@ -590,19 +684,20 @@ function webSocketPlugin(): Plugin {
const connectOpts: any = { hostname: target.host, port: target.port, socket: socketHandler };
if (target.tls) {
connectOpts.tls = {
ca: target.tls.ca,
cert: target.tls.cert,
key: target.tls.key,
sessionTimeout: 0, // Disable TLS session caching for mTLS
servername: target.host, // Required for SNI
rejectUnauthorized: target.tls.rejectUnauthorized ?? true
};
if (target.tls.ca) connectOpts.tls.ca = [target.tls.ca];
if (target.tls.cert) connectOpts.tls.cert = [target.tls.cert];
if (target.tls.key) connectOpts.tls.key = target.tls.key;
}
dockerStream = await Bun.connect(connectOpts);
}
dockerStreams.set(connId, { stream: dockerStream, execId, target, state, ws });
console.log('[Terminal WS] Stream stored for connId:', connId, 'total streams:', dockerStreams.size);
} catch (error: any) {
console.error('[Terminal WS] Error:', error.message);
console.error('[Terminal WS] Connection error:', error?.message || error);
ws.send(JSON.stringify({ type: 'error', message: error.message }));
ws.close();
}
@@ -610,7 +705,6 @@ function webSocketPlugin(): Plugin {
async message(ws, message) {
const url = new URL((ws.data as any).url, `http://localhost:${WS_PORT}`);
const connId = (ws.data as any).connId as string | undefined;
console.log('[WS Message] connId:', connId, 'edgeExecId:', (ws.data as any)?.edgeExecId, 'pathname:', url.pathname.slice(0, 50));
// Handle Hawser Edge messages
if (url.pathname === '/api/hawser/connect') {
@@ -689,16 +783,9 @@ function webSocketPlugin(): Plugin {
}
// Terminal message handling (direct Docker connection)
if (!connId) {
console.log('[Terminal WS] No connId for terminal message');
return;
}
if (!connId) return;
const d = dockerStreams.get(connId);
if (!d) {
console.log('[Terminal WS] No stream for connId:', connId, 'streams:', [...dockerStreams.keys()]);
return;
}
console.log('[Terminal WS] Found stream for connId:', connId);
if (!d) return;
try {
const msg = JSON.parse(message.toString());