mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-18 03:20:43 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0303f54e2b |
+3
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user