diff --git a/Dockerfile b/Dockerfile
index 902b76f..1e2e0c7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,8 +7,9 @@
# - Reproducible builds from open-source Wolfi packages
# - Minimal attack surface with only required packages
#
-# Bun is copied from the official oven/bun image (app-builder stage) to ensure
-# compatibility with all x86_64 CPUs (including those without AVX2 like Celeron).
+# Bun is copied from the official oven/bun image (app-builder stage).
+# For CPUs without AVX support (Celeron, Atom, pre-Haswell), build with:
+# docker build --build-arg BUN_VARIANT=baseline -t dockhand:baseline .
# =============================================================================
# -----------------------------------------------------------------------------
@@ -75,10 +76,15 @@ RUN apko build apko.yaml dockhand-base:latest output.tar \
# Alpine's musl libc causes rayon/tokio thread pool panics during svelte-adapter-bun build
FROM oven/bun:1.3.5-debian AS app-builder
+# Build argument for Bun variant (regular or baseline)
+# baseline is for CPUs without AVX support (Celeron, Atom, pre-Haswell)
+ARG BUN_VARIANT=regular
+ARG TARGETARCH
+
WORKDIR /app
# Install build dependencies
-RUN apt-get update && apt-get install -y --no-install-recommends jq git && rm -rf /var/lib/apt/lists/*
+RUN apt-get update && apt-get install -y --no-install-recommends jq git curl unzip ca-certificates && rm -rf /var/lib/apt/lists/*
# Copy package files and install ALL dependencies (needed for build)
COPY package.json bun.lock* bunfig.toml ./
@@ -95,6 +101,19 @@ RUN NODE_OPTIONS="--max-old-space-size=8192 --max-semi-space-size=128" bun run b
RUN rm -rf node_modules && bun install --production --frozen-lockfile \
&& rm -rf node_modules/@types node_modules/bun-types
+# Download baseline Bun binary if BUN_VARIANT=baseline (for CPUs without AVX)
+# Only applies to amd64 - ARM64 doesn't have AVX concept
+ARG BUN_VERSION=1.3.5
+RUN if [ "$BUN_VARIANT" = "baseline" ] && [ "$TARGETARCH" = "amd64" ]; then \
+ echo "Downloading Bun baseline binary for CPUs without AVX support..." && \
+ curl -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64-baseline.zip" -o /tmp/bun.zip && \
+ unzip -o /tmp/bun.zip -d /tmp && \
+ cp /tmp/bun-linux-x64-baseline/bun /usr/local/bin/bun && \
+ chmod +x /usr/local/bin/bun && \
+ rm -rf /tmp/bun.zip /tmp/bun-linux-x64-baseline && \
+ echo "Bun baseline binary installed successfully"; \
+ fi
+
# -----------------------------------------------------------------------------
# Stage 3: Final Image (Scratch + Custom Wolfi OS)
# -----------------------------------------------------------------------------
@@ -105,6 +124,8 @@ COPY --from=os-builder /work/rootfs/ /
# Copy Bun from official image - ensures compatibility with all x86_64 CPUs (no AVX2 requirement)
# Wolfi's bun package requires AVX2 which breaks on Celeron/Atom CPUs
+# For baseline builds (BUN_VARIANT=baseline), this contains the baseline binary (no AVX requirement)
+# For regular builds, this contains the standard oven/bun binary
COPY --from=app-builder /usr/local/bin/bun /usr/bin/bun
WORKDIR /app
diff --git a/drizzle-pg/meta/_journal.json b/drizzle-pg/meta/_journal.json
index b439adc..590bb1f 100644
--- a/drizzle-pg/meta/_journal.json
+++ b/drizzle-pg/meta/_journal.json
@@ -22,6 +22,13 @@
"when": 1766763867484,
"tag": "0002_add_pending_container_updates",
"breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "7",
+ "when": 1767687362730,
+ "tag": "0003_add_stack_paths",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 868151f..f4192f3 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -22,6 +22,13 @@
"when": 1766763860091,
"tag": "0002_add_pending_container_updates",
"breakpoints": true
+ },
+ {
+ "idx": 3,
+ "version": "6",
+ "when": 1767689000000,
+ "tag": "0003_add_stack_paths",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/package.json b/package.json
index 24cc896..033063d 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
- "version": "1.0.4",
+ "version": "1.0.3",
"type": "module",
"scripts": {
"dev": "bunx --bun vite dev",
@@ -39,7 +39,7 @@
},
"dependencies": {
"@codemirror/autocomplete": "6.20.0",
- "@codemirror/commands": "6.10.0",
+ "@codemirror/commands": "6.10.1",
"@codemirror/lang-css": "6.3.1",
"@codemirror/lang-html": "6.4.11",
"@codemirror/lang-javascript": "6.2.4",
@@ -48,63 +48,71 @@
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-sql": "6.10.0",
"@codemirror/lang-xml": "6.1.0",
- "@codemirror/language": "6.11.3",
+ "@codemirror/lang-yaml": "6.1.2",
+ "@codemirror/language": "6.12.1",
"@codemirror/search": "6.5.11",
+ "@codemirror/state": "6.5.3",
+ "@codemirror/theme-one-dark": "6.1.3",
+ "@codemirror/view": "6.39.9",
"@lezer/highlight": "1.2.3",
"@lucide/lab": "^0.1.2",
+ "codemirror": "6.0.2",
"croner": "9.1.0",
"cronstrue": "3.9.0",
- "drizzle-orm": "0.45.0",
+ "drizzle-orm": "0.45.1",
+ "hash-wasm": "4.12.0",
"js-yaml": "^4.1.1",
- "ldapts": "^8.0.9",
- "nodemailer": "^7.0.11",
+ "ldapts": "^8.1.3",
+ "nodemailer": "^7.0.12",
"otpauth": "^9.4.1",
- "postgres": "3.4.7",
+ "postgres": "3.4.8",
"qrcode": "^1.5.4",
- "svelte-dnd-action": "0.9.68",
+ "svelte-dnd-action": "0.9.69",
"svelte-sonner": "1.0.7"
},
"devDependencies": {
- "@codemirror/lang-yaml": "^6.1.2",
- "@codemirror/state": "^6.5.2",
- "@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.38.8",
- "@internationalized/date": "^3.10.0",
+ "@internationalized/date": "^3.10.1",
"@layerstack/tailwind": "^1.0.1",
- "@lucide/svelte": "^0.544.0",
+ "@lucide/svelte": "^0.562.0",
"@playwright/test": "1.57.0",
- "@sveltejs/kit": "^2.48.5",
- "@sveltejs/vite-plugin-svelte": "^6.2.1",
- "@tailwindcss/vite": "^4.1.17",
- "@types/bun": "^1.2.5",
+ "@sveltejs/kit": "^2.49.3",
+ "@sveltejs/vite-plugin-svelte": "^6.2.3",
+ "@tailwindcss/vite": "^4.1.18",
+ "@types/bun": "^1.3.5",
"@types/js-yaml": "^4.0.9",
"@types/nodemailer": "^7.0.4",
"@types/qrcode": "^1.5.6",
- "@xterm/addon-fit": "^0.10.0",
- "@xterm/addon-web-links": "^0.11.0",
- "@xterm/xterm": "^5.5.0",
- "autoprefixer": "^10.4.22",
- "bits-ui": "^2.14.4",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-web-links": "^0.12.0",
+ "@xterm/xterm": "^6.0.0",
+ "autoprefixer": "^10.4.23",
+ "bits-ui": "^2.15.4",
"clsx": "^2.1.1",
- "codemirror": "^6.0.2",
"cytoscape": "^3.33.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"drizzle-kit": "0.31.8",
- "layerchart": "^1.0.12",
- "lucide-svelte": "^0.555.0",
+ "layerchart": "^1.0.13",
+ "lucide-svelte": "^0.562.0",
"mode-watcher": "^1.1.0",
"postcss": "^8.5.6",
- "svelte": "^5.43.8",
+ "svelte": "^5.46.1",
"svelte-adapter-bun": "1.0.1",
- "svelte-check": "^4.3.4",
+ "svelte-check": "^4.3.5",
"svelte-easy-crop": "^5.0.0",
"svelte-virtual-scroll-list": "^1.3.0",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
- "tailwindcss": "^4.1.17",
+ "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
- "vite": "^7.2.2"
+ "vite": "^7.3.1"
+ },
+ "overrides": {
+ "@codemirror/state": "6.5.3",
+ "@codemirror/view": "6.39.9",
+ "@codemirror/language": "6.12.1",
+ "@lezer/common": "1.5.0",
+ "@lezer/highlight": "1.2.3"
}
}
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index 10be1d1..c5bb24f 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -4,6 +4,7 @@ import { startScheduler } from '$lib/server/scheduler';
import { isAuthEnabled, validateSession } from '$lib/server/auth';
import { setServerStartTime } from '$lib/server/uptime';
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
+import { initCryptoFallback } from '$lib/server/crypto-fallback';
import type { HandleServerError, Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
@@ -20,6 +21,9 @@ let initialized = false;
if (!initialized) {
try {
+ // Initialize crypto fallback first (detects old kernels and logs status)
+ initCryptoFallback();
+
setServerStartTime(); // Track when server started
initDatabase();
// Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside)
@@ -68,7 +72,8 @@ const PUBLIC_PATHS = [
'/api/auth/oidc',
'/api/license',
'/api/changelog',
- '/api/dependencies'
+ '/api/dependencies',
+ '/api/health'
];
// Check if path is public
diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte
index 25b0c68..0e76cb9 100644
--- a/src/lib/components/CodeEditor.svelte
+++ b/src/lib/components/CodeEditor.svelte
@@ -225,6 +225,9 @@
// Mutable ref for callback - allows updating without recreating editor
let onchangeRef: ((value: string) => void) | undefined = onchange;
+ // Flag to suppress onchange during programmatic value sync
+ let isSyncingExternalValue = false;
+
// Keep callback ref updated when prop changes
$effect(() => {
onchangeRef = onchange;
@@ -660,8 +663,9 @@
view.update(trs);
// Check if any transaction changed the document
+ // Skip onchange during programmatic value sync (only fire for user edits)
const lastChangingTr = trs.findLast(tr => tr.docChanged);
- if (lastChangingTr && onchangeRef) {
+ if (lastChangingTr && onchangeRef && !isSyncingExternalValue) {
// Defer callback to next microtask to avoid blocking input handling
// This allows key repeat to work properly
const newContent = lastChangingTr.newDoc.toString();
@@ -801,9 +805,12 @@
// Only update if the external value differs from editor content
// This prevents feedback loops from editor changes
if (externalValue !== currentContent) {
+ // Suppress onchange during programmatic sync - only user edits should trigger it
+ isSyncingExternalValue = true;
view.dispatch({
changes: { from: 0, to: currentContent.length, insert: externalValue }
});
+ isSyncingExternalValue = false;
}
}
});
diff --git a/src/lib/components/StackEnvVarsEditor.svelte b/src/lib/components/StackEnvVarsEditor.svelte
index 54a7b10..e0e4ff3 100644
--- a/src/lib/components/StackEnvVarsEditor.svelte
+++ b/src/lib/components/StackEnvVarsEditor.svelte
@@ -104,7 +104,7 @@
- {#each variables as variable, index (index)}
+ {#each variables as variable, index (`${index}-${variable.key}`)}
{@const source = getSource(variable.key)}
{@const isVarRequired = isRequired(variable.key)}
{@const isVarOptional = isOptional(variable.key)}
diff --git a/src/lib/components/StackEnvVarsPanel.svelte b/src/lib/components/StackEnvVarsPanel.svelte
index 30391b5..df98404 100644
--- a/src/lib/components/StackEnvVarsPanel.svelte
+++ b/src/lib/components/StackEnvVarsPanel.svelte
@@ -46,38 +46,35 @@
let confirmClearOpen = $state(false);
let contentAreaRef: HTMLDivElement;
let parseWarnings = $state
([]);
- let hasMergedOnLoad = $state(false);
// Count of secrets (for display in hint)
const secretCount = $derived(variables.filter(v => v.isSecret && v.key.trim()).length);
/**
- * Merge variables and rawContent on initial load.
- * Called by parent after setting both variables and rawContent.
- * This ensures both are in sync regardless of which view mode is active.
+ * Sync variables with rawContent after initial load.
+ * Pass the loaded data directly to avoid timing issues with bindable props.
+ * Merges: secrets from loadedVars (DB) + non-secrets from loadedRaw (file).
*/
- export function mergeOnLoad() {
- if (hasMergedOnLoad) return;
- hasMergedOnLoad = true;
-
- // If rawContent exists, parse it and merge with variables (which may have secrets from DB)
- if (rawContent.trim()) {
- const { vars: rawVars } = parseRawContent(rawContent);
- const rawVarsByKey = new Map(rawVars.map(v => [v.key, v]));
-
- // Secrets come from variables (DB), non-secrets come from rawContent (file)
- // But if a var exists in variables but not in rawContent, keep it (could be new)
- const secrets = variables.filter(v => v.isSecret);
- const nonSecretsFromRaw = rawVars;
-
- // Also keep non-secrets from variables that aren't in raw (new vars added before first save)
- const rawKeys = new Set(rawVars.map(v => v.key));
- const newNonSecrets = variables.filter(v => !v.isSecret && v.key.trim() && !rawKeys.has(v.key));
-
- variables = [...nonSecretsFromRaw, ...newNonSecrets, ...secrets];
+ export function syncAfterLoad(loadedVars: EnvVar[], loadedRaw: string) {
+ if (!loadedRaw.trim()) {
+ // No raw content - just use the loaded variables as-is
+ variables = loadedVars;
+ rawContent = '';
+ return;
}
- // If no rawContent, variables is already correct (from DB), just need to generate raw
- // for when user switches to text view (done in handleViewModeChange)
+
+ const { vars: rawVars } = parseRawContent(loadedRaw);
+
+ // Secrets come from loadedVars (DB), non-secrets come from loadedRaw (file)
+ const secrets = loadedVars.filter(v => v.isSecret);
+
+ // Also keep non-secrets from loadedVars that aren't in raw (new vars added before first save)
+ const rawKeys = new Set(rawVars.map(v => v.key));
+ const newNonSecrets = loadedVars.filter(v => !v.isSecret && v.key.trim() && !rawKeys.has(v.key));
+
+ // Set both at once to avoid any intermediate states
+ variables = [...rawVars, ...newNonSecrets, ...secrets];
+ rawContent = loadedRaw;
}
/**
diff --git a/src/lib/components/TimezoneSelector.svelte b/src/lib/components/TimezoneSelector.svelte
index bb28fe4..0866fdd 100644
--- a/src/lib/components/TimezoneSelector.svelte
+++ b/src/lib/components/TimezoneSelector.svelte
@@ -111,7 +111,7 @@
{/snippet}
-
+
diff --git a/src/lib/components/data-grid/DataGrid.svelte b/src/lib/components/data-grid/DataGrid.svelte
index 2a3ddf4..6b9cf3b 100644
--- a/src/lib/components/data-grid/DataGrid.svelte
+++ b/src/lib/components/data-grid/DataGrid.svelte
@@ -496,34 +496,32 @@
});
// Row state cache to prevent creating new objects on every scroll
+ // Use $derived to track dependencies synchronously (unlike $effect which is async)
let rowStateCache = new WeakMap();
- let rowStateCacheDataRef: T[] | null = null;
- let rowStateCacheExpandedRef: Set | null = null;
- let rowStateCacheSelectedRef: Set | null = null;
- // Clear row state cache when data or selection/expansion state changes
- $effect(() => {
- if (data !== rowStateCacheDataRef ||
- expandedKeys !== rowStateCacheExpandedRef ||
- selectedKeys !== rowStateCacheSelectedRef) {
- rowStateCache = new WeakMap();
- rowStateCacheDataRef = data;
- rowStateCacheExpandedRef = expandedKeys;
- rowStateCacheSelectedRef = selectedKeys;
- }
- });
+ // Track cache invalidation keys - when these change, cache is stale
+ let cachedSelectedKeysRef: Set | null = null;
+ let cachedExpandedKeysRef: Set | null = null;
+ let cachedHighlightedKeyRef: unknown = undefined;
// Helper to get row state (memoized via WeakMap)
+ // Cache is invalidated synchronously when selection/expansion changes
function getRowState(item: T, index: number): DataGridRowState {
const actualIndex = virtualScroll ? startIndex + index : index;
+ // Check if cache needs to be cleared (synchronous check)
+ if (selectedKeys !== cachedSelectedKeysRef ||
+ expandedKeys !== cachedExpandedKeysRef ||
+ highlightedKey !== cachedHighlightedKeyRef) {
+ rowStateCache = new WeakMap();
+ cachedSelectedKeysRef = selectedKeys;
+ cachedExpandedKeysRef = expandedKeys;
+ cachedHighlightedKeyRef = highlightedKey;
+ }
+
// Try to get cached state
const cached = rowStateCache.get(item as object);
if (cached && cached.index === actualIndex) {
- // Update mutable fields that may have changed
- cached.isSelected = isSelected(item[keyField]);
- cached.isHighlighted = highlightedKey === item[keyField];
- cached.isExpanded = isExpanded(item[keyField]);
return cached;
}
@@ -761,7 +759,7 @@
e.stopPropagation();
toggleSelection(item[keyField]);
}}
- class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
+ class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
@@ -870,7 +868,7 @@
{ e.stopPropagation(); toggleSelection(item[keyField]); }}
- class="flex items-center justify-center transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
+ class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
diff --git a/src/lib/components/icon-picker.svelte b/src/lib/components/icon-picker.svelte
index 0ea4a53..d38b23e 100644
--- a/src/lib/components/icon-picker.svelte
+++ b/src/lib/components/icon-picker.svelte
@@ -37,7 +37,7 @@
-
+
{placeholder}
{/if}
-
+
=3.17): Uses Bun's native password API (faster)
+ * On old kernels (<3.17): Uses hash-wasm (WASM-based, no getrandom dependency)
+ *
* Argon2id is the recommended variant - resistant to both side-channel and GPU attacks
*/
export async function hashPassword(password: string): Promise {
+ // On old kernels, Bun.password.hash() crashes because it internally uses getrandom()
+ // Use hash-wasm as a fallback which is pure WASM and doesn't depend on the syscall
+ if (usingFallback()) {
+ const salt = secureRandomBytes(ARGON2_SALT_LENGTH);
+ return argon2id({
+ password,
+ salt,
+ iterations: ARGON2_TIME_COST,
+ parallelism: ARGON2_PARALLELISM,
+ memorySize: ARGON2_MEMORY_COST,
+ hashLength: ARGON2_HASH_LENGTH,
+ outputType: 'encoded' // Returns PHC format: $argon2id$v=19$m=65536,t=3,p=1$...
+ });
+ }
+
+ // Modern kernels: use Bun's native implementation (faster)
return Bun.password.hash(password, {
algorithm: 'argon2id',
- memoryCost: 65536, // 64 MB
- timeCost: 3 // 3 iterations
+ memoryCost: ARGON2_MEMORY_COST,
+ timeCost: ARGON2_TIME_COST
});
}
/**
* Verify a password against a hash
* Uses constant-time comparison internally
+ *
+ * Both Bun.password and hash-wasm use the same PHC format, so hashes are compatible
*/
export async function verifyPassword(password: string, hash: string): Promise {
try {
+ // On old kernels, use hash-wasm for verification
+ if (usingFallback()) {
+ return await argon2Verify({ password, hash });
+ }
+
+ // Modern kernels: use Bun's native implementation
return await Bun.password.verify(password, hash);
} catch {
return false;
@@ -130,7 +166,7 @@ export async function verifyPassword(password: string, hash: string): Promise {
+ const results = await db.select().from(environments).where(eq(environments.name, name));
+ return results[0];
+}
+
export async function createEnvironment(env: Omit): Promise {
const result = await db.insert(environments).values({
name: env.name,
@@ -2487,6 +2492,8 @@ export interface StackSourceData {
sourceType: StackSourceType;
gitRepositoryId: number | null;
gitStackId: number | null;
+ composePath: string | null;
+ envPath: string | null;
createdAt: string;
updatedAt: string;
}
@@ -2527,9 +2534,10 @@ export async function getStackSource(stackName: string, environmentId?: number |
export async function getStackSources(environmentId?: number | null): Promise {
let results;
- if (environmentId !== undefined) {
+ if (environmentId !== undefined && environmentId !== null) {
+ // Only get stacks for the specific environment
results = await db.select().from(stackSources)
- .where(or(eq(stackSources.environmentId, environmentId), isNull(stackSources.environmentId)))
+ .where(eq(stackSources.environmentId, environmentId))
.orderBy(asc(stackSources.stackName));
} else {
results = await db.select().from(stackSources).orderBy(asc(stackSources.stackName));
@@ -2563,6 +2571,8 @@ export async function upsertStackSource(data: {
sourceType: StackSourceType;
gitRepositoryId?: number | null;
gitStackId?: number | null;
+ composePath?: string | null;
+ envPath?: string | null;
}): Promise {
const existing = await getStackSource(data.stackName, data.environmentId);
@@ -2572,6 +2582,8 @@ export async function upsertStackSource(data: {
sourceType: data.sourceType,
gitRepositoryId: data.gitRepositoryId || null,
gitStackId: data.gitStackId || null,
+ composePath: data.composePath ?? null,
+ envPath: data.envPath ?? null,
updatedAt: new Date().toISOString()
})
.where(eq(stackSources.id, existing.id));
@@ -2582,12 +2594,33 @@ export async function upsertStackSource(data: {
environmentId: data.environmentId ?? null,
sourceType: data.sourceType,
gitRepositoryId: data.gitRepositoryId || null,
- gitStackId: data.gitStackId || null
+ gitStackId: data.gitStackId || null,
+ composePath: data.composePath ?? null,
+ envPath: data.envPath ?? null
});
return getStackSource(data.stackName, data.environmentId) as Promise;
}
}
+export async function updateStackSource(
+ stackName: string,
+ environmentId: number | null,
+ updates: { composePath?: string | null; envPath?: string | null }
+): Promise {
+ const existing = await getStackSource(stackName, environmentId);
+ if (!existing) return false;
+
+ await db.update(stackSources)
+ .set({
+ composePath: updates.composePath !== undefined ? updates.composePath : existing.composePath,
+ envPath: updates.envPath !== undefined ? updates.envPath : existing.envPath,
+ updatedAt: new Date().toISOString()
+ })
+ .where(eq(stackSources.id, existing.id));
+
+ return true;
+}
+
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise {
// Delete matching record (either with specific envId or NULL)
await db.delete(stackSources)
@@ -3083,10 +3116,8 @@ export interface ContainerEventResult {
}
export async function logContainerEvent(data: ContainerEventCreateData): Promise {
- // Timestamp is always a string with nanosecond precision (stored as text in both SQLite and PostgreSQL)
- // For PostgreSQL, we convert to Date since the schema uses native timestamp type
- const timestamp = isPostgres ? new Date(data.timestamp) : data.timestamp;
-
+ // Timestamp is already an ISO-8601 string from event-subprocess
+ // Both SQLite and PostgreSQL schemas use mode: 'string' so we pass it directly
const result = await db.insert(containerEvents).values({
environmentId: data.environmentId ?? null,
containerId: data.containerId,
@@ -3094,7 +3125,7 @@ export async function logContainerEvent(data: ContainerEventCreateData): Promise
image: data.image ?? null,
action: data.action,
actorAttributes: data.actorAttributes ? JSON.stringify(data.actorAttributes) : null,
- timestamp
+ timestamp: data.timestamp
}).returning();
return getContainerEvent(result[0].id) as Promise;
@@ -3896,6 +3927,73 @@ export async function setEventCleanupEnabled(enabled: boolean): Promise {
}
}
+// =============================================================================
+// EXTERNAL STACK PATHS
+// =============================================================================
+
+const EXTERNAL_STACK_PATHS_KEY = 'external_stack_paths';
+
+export async function getExternalStackPaths(): Promise {
+ const result = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
+ if (result[0]) {
+ try {
+ const parsed = JSON.parse(result[0].value);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ }
+ return [];
+}
+
+export async function setExternalStackPaths(paths: string[]): Promise {
+ const jsonValue = JSON.stringify(paths);
+ const existing = await db.select().from(settings).where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
+ if (existing.length > 0) {
+ await db.update(settings)
+ .set({ value: jsonValue, updatedAt: new Date().toISOString() })
+ .where(eq(settings.key, EXTERNAL_STACK_PATHS_KEY));
+ } else {
+ await db.insert(settings).values({
+ key: EXTERNAL_STACK_PATHS_KEY,
+ value: jsonValue
+ });
+ }
+}
+
+// =============================================================================
+// PRIMARY STACK LOCATION
+// =============================================================================
+
+const PRIMARY_STACK_LOCATION_KEY = 'primary_stack_location';
+
+export async function getPrimaryStackLocation(): Promise {
+ const result = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
+ if (result[0]?.value) {
+ return result[0].value;
+ }
+ return null;
+}
+
+export async function setPrimaryStackLocation(path: string | null): Promise {
+ const existing = await db.select().from(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
+ if (path === null) {
+ // Delete the setting if path is null
+ if (existing.length > 0) {
+ await db.delete(settings).where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
+ }
+ } else if (existing.length > 0) {
+ await db.update(settings)
+ .set({ value: path, updatedAt: new Date().toISOString() })
+ .where(eq(settings.key, PRIMARY_STACK_LOCATION_KEY));
+ } else {
+ await db.insert(settings).values({
+ key: PRIMARY_STACK_LOCATION_KEY,
+ value: path
+ });
+ }
+}
+
// =============================================================================
// ENVIRONMENT UPDATE CHECK SETTINGS
// =============================================================================
@@ -3988,6 +4086,66 @@ export async function setDefaultTimezone(timezone: string): Promise {
await setSetting('default_timezone', timezone);
}
+// =============================================================================
+// BACKGROUND MONITORING SETTINGS
+// =============================================================================
+
+/**
+ * Get event collection mode ('stream' or 'poll').
+ * Defaults to 'stream' for real-time event streaming.
+ */
+export async function getEventCollectionMode(): Promise<'stream' | 'poll'> {
+ const value = await getSetting('event_collection_mode');
+ return value || 'stream';
+}
+
+/**
+ * Set event collection mode.
+ */
+export async function setEventCollectionMode(mode: 'stream' | 'poll'): Promise {
+ await setSetting('event_collection_mode', mode);
+}
+
+/**
+ * Get event poll interval in milliseconds.
+ * Defaults to 60000ms (60 seconds).
+ */
+export async function getEventPollInterval(): Promise {
+ const value = await getSetting('event_poll_interval');
+ return value || 60000;
+}
+
+/**
+ * Set event poll interval in milliseconds.
+ * Valid range: 30000ms (30s) to 300000ms (5min).
+ */
+export async function setEventPollInterval(interval: number): Promise {
+ if (interval < 30000 || interval > 300000) {
+ throw new Error('Event poll interval must be between 30s and 300s');
+ }
+ await setSetting('event_poll_interval', interval);
+}
+
+/**
+ * Get metrics collection interval in milliseconds.
+ * Defaults to 30000ms (30 seconds) - changed from hardcoded 10s.
+ */
+export async function getMetricsCollectionInterval(): Promise {
+ const value = await getSetting('metrics_collection_interval');
+ return value || 30000;
+}
+
+/**
+ * Set metrics collection interval in milliseconds.
+ * Valid range: 10000ms (10s) to 300000ms (5min).
+ */
+export async function setMetricsCollectionInterval(interval: number): Promise {
+ if (interval < 10000 || interval > 300000) {
+ throw new Error('Metrics collection interval must be between 10s and 300s');
+ }
+ await setSetting('metrics_collection_interval', interval);
+}
+
// =============================================================================
// STACK ENVIRONMENT VARIABLES OPERATIONS
// =============================================================================
diff --git a/src/lib/server/db/drizzle.ts b/src/lib/server/db/drizzle.ts
index a1e08d3..8eb7eeb 100644
--- a/src/lib/server/db/drizzle.ts
+++ b/src/lib/server/db/drizzle.ts
@@ -604,21 +604,21 @@ async function initializeDatabase() {
logHeader('DATABASE INITIALIZATION');
if (isPostgres) {
- // PostgreSQL via Bun.sql
+ // PostgreSQL via postgres-js (more stable than bun:sql for concurrent queries)
validatePostgresUrl(config.databaseUrl!);
logInfo(`Database: PostgreSQL`);
logInfo(`Connection: ${maskPassword(config.databaseUrl!)}`);
- const { drizzle } = await import('drizzle-orm/bun-sql');
- const { SQL } = await import('bun');
+ const { drizzle } = await import('drizzle-orm/postgres-js');
+ const postgres = (await import('postgres')).default;
// Import PostgreSQL schema
schema = await import('./schema/pg-schema.js');
if (verbose) logStep('Connecting to PostgreSQL...');
try {
- rawClient = new SQL(config.databaseUrl!);
+ rawClient = postgres(config.databaseUrl!);
db = drizzle({ client: rawClient, schema });
logSuccess('PostgreSQL connection established');
} catch (error) {
diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts
index aeab9e6..274531e 100644
--- a/src/lib/server/db/schema/index.ts
+++ b/src/lib/server/db/schema/index.ts
@@ -332,6 +332,8 @@ export const stackSources = sqliteTable('stack_sources', {
sourceType: text('source_type').notNull().default('internal'),
gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }),
gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }),
+ composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
+ envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
diff --git a/src/lib/server/db/schema/pg-schema.ts b/src/lib/server/db/schema/pg-schema.ts
index 6b37bf6..2aad1ca 100644
--- a/src/lib/server/db/schema/pg-schema.ts
+++ b/src/lib/server/db/schema/pg-schema.ts
@@ -335,6 +335,8 @@ export const stackSources = pgTable('stack_sources', {
sourceType: text('source_type').notNull().default('internal'),
gitRepositoryId: integer('git_repository_id').references(() => gitRepositories.id, { onDelete: 'set null' }),
gitStackId: integer('git_stack_id').references(() => gitStacks.id, { onDelete: 'set null' }),
+ composePath: text('compose_path'), // Custom path to compose file (for stacks with non-default location)
+ envPath: text('env_path'), // Custom path to .env file (for stacks with non-default location)
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow()
}, (table) => ({
diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts
index 0b76f45..050953e 100644
--- a/src/lib/server/docker.ts
+++ b/src/lib/server/docker.ts
@@ -469,13 +469,16 @@ export async function dockerFetch(
streaming ? 300000 : 30000 // 5 min for streaming, 30s for normal requests
);
const elapsed = Date.now() - startTime;
- if (elapsed > 5000) {
+ // Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
+ if (elapsed > 5000 && !path.includes('/stats')) {
console.warn(`[Docker] Edge env ${config.environmentId}: ${method} ${path} took ${elapsed}ms`);
}
return edgeResponseToResponse(edgeResponse);
- } catch (error) {
+ } catch (error: any) {
const elapsed = Date.now() - startTime;
- console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms:`, error);
+ // Log error message only, not full stack trace
+ const msg = error?.message || String(error);
+ console.error(`[Docker] Edge env ${config.environmentId}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
throw DockerConnectionError.fromError(error);
}
}
@@ -491,13 +494,16 @@ export async function dockerFetch(
...bunOptions
});
const elapsed = Date.now() - startTime;
- if (elapsed > 5000) {
+ // Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
+ if (elapsed > 5000 && !path.includes('/stats')) {
console.warn(`[Docker] Socket: ${method} ${path} took ${elapsed}ms`);
}
return response;
- } catch (error) {
+ } catch (error: any) {
const elapsed = Date.now() - startTime;
- console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms:`, error);
+ // Log error message only, not full stack trace
+ const msg = error?.message || String(error);
+ console.error(`[Docker] Socket: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
throw DockerConnectionError.fromError(error);
}
} else {
@@ -516,21 +522,30 @@ export async function dockerFetch(
}
// For HTTPS with TLS certificates, we need to configure TLS
- // IMPORTANT: Bun requires certificates as Buffer objects, not strings
+ // Pass certificate strings directly to Bun's fetch - no temp files needed
if (config.type === 'https') {
const tlsOptions: Record = {};
- // CA certificate - must be array of Buffers for Bun
+ // DISABLE TLS SESSION CACHING: Bun reuses TLS sessions across different hosts,
+ // which causes client certificate mismatches in mTLS scenarios. By setting
+ // sessionTimeout to 0, we force a fresh TLS handshake for every connection.
+ tlsOptions.sessionTimeout = 0;
+
+ // Set explicit servername for SNI - helps isolate TLS contexts per host
+ tlsOptions.servername = config.host;
+
+ // Load CA certificate (just this environment's CA, not composite)
+ // The sessionTimeout=0 should prevent session reuse across hosts
if (config.ca) {
- tlsOptions.ca = [Buffer.from(config.ca)];
+ tlsOptions.ca = [config.ca];
}
- // Client certificate and key for mTLS - must be Buffers
+ // Client cert and key for mTLS authentication
if (config.cert) {
- tlsOptions.cert = Buffer.from(config.cert);
+ tlsOptions.cert = [config.cert];
}
if (config.key) {
- tlsOptions.key = Buffer.from(config.key);
+ tlsOptions.key = config.key;
}
// Skip verification (self-signed without CA)
@@ -541,8 +556,27 @@ export async function dockerFetch(
}
if (Object.keys(tlsOptions).length > 0) {
- // @ts-ignore - Bun supports tls options with Buffer certs
+ // @ts-ignore - Bun supports tls options with string certs
finalOptions.tls = tlsOptions;
+ // Force new connection for each request to prevent Bun from reusing
+ // a TLS session with wrong client certificates (pool key doesn't include certs)
+ // @ts-ignore - Bun supports keepalive option
+ finalOptions.keepalive = false;
+ }
+
+ // Explicitly close connection to prevent TLS session reuse issues
+ // But only for non-streaming requests (logs, events, exec need keep-alive)
+ if (!streaming) {
+ finalOptions.headers = {
+ ...finalOptions.headers,
+ 'Connection': 'close'
+ };
+ }
+
+ // Optional verbose TLS debugging
+ if (process.env.DEBUG_TLS) {
+ // @ts-ignore - Bun-specific verbose option
+ finalOptions.verbose = true;
}
}
@@ -550,13 +584,16 @@ export async function dockerFetch(
try {
const response = await fetch(url, { ...finalOptions, ...bunOptions });
const elapsed = Date.now() - startTime;
- if (elapsed > 5000) {
+ // Only warn for slow requests, but skip /stats which is expected to be slow (5-10s)
+ if (elapsed > 5000 && !path.includes('/stats')) {
console.warn(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} took ${elapsed}ms`);
}
return response;
- } catch (error) {
+ } catch (error: any) {
const elapsed = Date.now() - startTime;
- console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms:`, error);
+ // Log error message only, not full stack trace
+ const msg = error?.message || String(error);
+ console.error(`[Docker] ${config.connectionType || 'direct'} ${config.host}: ${method} ${path} failed after ${elapsed}ms: ${msg}`);
throw DockerConnectionError.fromError(error);
}
}
@@ -994,12 +1031,31 @@ export async function updateContainer(id: string, options: CreateContainerOption
// Image operations
export async function listImages(envId?: number | null): Promise {
- const images = await dockerJsonRequest('/images/json', {}, envId);
+ // Fetch images and containers in parallel
+ const [images, containers] = await Promise.all([
+ dockerJsonRequest('/images/json', {}, envId),
+ dockerJsonRequest('/containers/json?all=true', {}, envId).catch(() => [] as any[])
+ ]);
+
+ // Build a map of imageId -> container count
+ // Docker may return -1 for Containers field on some hosts, so we compute it ourselves
+ const imageContainerCount = new Map();
+ for (const container of containers) {
+ const imageId = container.ImageID || container.Image;
+ if (imageId) {
+ imageContainerCount.set(imageId, (imageContainerCount.get(imageId) || 0) + 1);
+ }
+ }
+
return images.map((image) => ({
id: image.Id,
+ repoTags: image.RepoTags || [],
tags: image.RepoTags || [],
size: image.Size,
- created: image.Created
+ virtualSize: image.VirtualSize || image.Size,
+ created: image.Created,
+ labels: image.Labels || {},
+ containers: imageContainerCount.get(image.Id) || 0
}));
}
@@ -1943,7 +1999,10 @@ export async function pruneContainers(envId?: number | null) {
}
export async function pruneImages(dangling = true, envId?: number | null) {
- const filters = dangling ? '{"dangling":["true"]}' : '{}';
+ // dangling=true: only remove untagged images (default Docker behavior)
+ // dangling=false: remove ALL unused images including tagged ones
+ // Docker API quirk: to remove all unused, we pass dangling=false filter
+ const filters = dangling ? '{"dangling":["true"]}' : '{"dangling":["false"]}';
return dockerJsonRequest(`/images/prune?filters=${encodeURIComponent(filters)}`, { method: 'POST' }, envId);
}
@@ -2042,18 +2101,30 @@ export async function execInContainer(
}
// Get Docker events as a stream (for SSE)
+// For streaming mode: call with just filters
+// For polling mode: call with since and until to get a finite window of events
export async function getDockerEvents(
filters: Record,
- envId?: number | null
+ envId?: number | null,
+ options?: { since?: string; until?: string }
): Promise | null> {
const filterJson = JSON.stringify(filters);
+ // Build query string with optional since/until for polling mode
+ let queryString = `filters=${encodeURIComponent(filterJson)}`;
+ if (options?.since) {
+ queryString += `&since=${encodeURIComponent(options.since)}`;
+ }
+ if (options?.until) {
+ queryString += `&until=${encodeURIComponent(options.until)}`;
+ }
+
try {
// Note: We use streaming: true to disable Bun's idle timeout for this long-lived connection.
// The Docker events API keeps the connection open indefinitely, sending events as they occur.
// Without streaming: true, Bun would terminate the connection after ~5 seconds of inactivity.
const response = await dockerFetch(
- `/events?filters=${encodeURIComponent(filterJson)}`,
+ `/events?${queryString}`,
{ streaming: true },
envId
);
@@ -3114,8 +3185,12 @@ async function cleanupStaleVolumeHelpersForEnv(envId?: number | null): Promise {
const timeoutHandle = setTimeout(() => {
@@ -614,7 +612,7 @@ export function sendEdgeStreamRequest(
return { requestId: '', cancel: () => {} };
}
- const requestId = crypto.randomUUID();
+ const requestId = secureRandomUUID();
// Initialize pendingStreamRequests if not present (can happen in dev mode due to HMR)
if (!connection.pendingStreamRequests) {
diff --git a/src/lib/server/metrics-collector.ts b/src/lib/server/metrics-collector.ts
deleted file mode 100644
index dbccbff..0000000
--- a/src/lib/server/metrics-collector.ts
+++ /dev/null
@@ -1,271 +0,0 @@
-import { saveHostMetric, getEnvironments, getEnvSetting } from './db';
-import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from './docker';
-import { sendEventNotification } from './notifications';
-import os from 'node:os';
-
-const COLLECT_INTERVAL = 10000; // 10 seconds
-const DISK_CHECK_INTERVAL = 300000; // 5 minutes
-const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings
-
-let collectorInterval: ReturnType | null = null;
-let diskCheckInterval: ReturnType | null = null;
-
-// Track last disk warning sent per environment to avoid spamming
-const lastDiskWarning: Map = new Map();
-const DISK_WARNING_COOLDOWN = 3600000; // 1 hour between warnings
-
-/**
- * Collect metrics for a single environment
- */
-async function collectEnvMetrics(env: { id: number; name: string; collectMetrics?: boolean }) {
- try {
- // Skip environments where metrics collection is disabled
- if (env.collectMetrics === false) {
- return;
- }
-
- // Get running containers
- const containers = await listContainers(false, env.id); // Only running
- let totalCpuPercent = 0;
- let totalMemUsed = 0;
-
- // Get stats for each running container
- const statsPromises = containers.map(async (container) => {
- try {
- const stats = await getContainerStats(container.id, env.id) as any;
-
- // Calculate CPU percentage
- const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
- const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
- const cpuCount = stats.cpu_stats.online_cpus || os.cpus().length;
-
- let cpuPercent = 0;
- if (systemDelta > 0 && cpuDelta > 0) {
- cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100;
- }
-
- // Get container memory usage
- const memUsage = stats.memory_stats?.usage || 0;
- const memCache = stats.memory_stats?.stats?.cache || 0;
- // Subtract cache from usage to get actual memory used by the container
- const actualMemUsed = memUsage - memCache;
-
- return { cpu: cpuPercent, mem: actualMemUsed > 0 ? actualMemUsed : memUsage };
- } catch {
- return { cpu: 0, mem: 0 };
- }
- });
-
- const statsResults = await Promise.all(statsPromises);
- totalCpuPercent = statsResults.reduce((sum, v) => sum + v.cpu, 0);
- totalMemUsed = statsResults.reduce((sum, v) => sum + v.mem, 0);
-
- // Get host total memory from Docker info (this is the remote host's memory)
- const info = await getDockerInfo(env.id) as any;
- const memTotal = info.MemTotal || os.totalmem();
-
- // Calculate memory percentage based on container usage vs host total
- const memPercent = memTotal > 0 ? (totalMemUsed / memTotal) * 100 : 0;
-
- // Normalize CPU by number of cores from the remote host
- const cpuCount = info.NCPU || os.cpus().length;
- const normalizedCpu = totalCpuPercent / cpuCount;
-
- // Save to database
- await saveHostMetric(
- normalizedCpu,
- memPercent,
- totalMemUsed,
- memTotal,
- env.id
- );
- } catch (error) {
- // Skip this environment if it fails (might be offline)
- console.error(`Failed to collect metrics for ${env.name}:`, error);
- }
-}
-
-async function collectMetrics() {
- try {
- const environments = await getEnvironments();
-
- // Filter enabled environments and collect metrics in parallel
- const enabledEnvs = environments.filter(env => env.collectMetrics !== false);
-
- // Process all environments in parallel for better performance
- await Promise.all(enabledEnvs.map(env => collectEnvMetrics(env)));
- } catch (error) {
- console.error('Metrics collection error:', error);
- }
-}
-
-/**
- * Check disk space for a single environment
- */
-async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics?: boolean }) {
- try {
- // Skip environments where metrics collection is disabled
- if (env.collectMetrics === false) {
- return;
- }
-
- // Check if we're in cooldown for this environment
- const lastWarningTime = lastDiskWarning.get(env.id);
- if (lastWarningTime && Date.now() - lastWarningTime < DISK_WARNING_COOLDOWN) {
- return; // Skip this environment, still in cooldown
- }
-
- // Get Docker disk usage data
- const diskData = await getDiskUsage(env.id) as any;
- if (!diskData) return;
-
- // Calculate total Docker disk usage using reduce for cleaner code
- let totalUsed = 0;
- if (diskData.Images) {
- totalUsed += diskData.Images.reduce((sum: number, img: any) => sum + (img.Size || 0), 0);
- }
- if (diskData.Containers) {
- totalUsed += diskData.Containers.reduce((sum: number, c: any) => sum + (c.SizeRw || 0), 0);
- }
- if (diskData.Volumes) {
- totalUsed += diskData.Volumes.reduce((sum: number, v: any) => sum + (v.UsageData?.Size || 0), 0);
- }
- if (diskData.BuildCache) {
- totalUsed += diskData.BuildCache.reduce((sum: number, bc: any) => sum + (bc.Size || 0), 0);
- }
-
- // Get Docker root filesystem info from Docker info
- const info = await getDockerInfo(env.id) as any;
- const driverStatus = info?.DriverStatus;
-
- // Try to find "Data Space Total" from driver status
- let dataSpaceTotal = 0;
- let diskPercentUsed = 0;
-
- if (driverStatus) {
- for (const [key, value] of driverStatus) {
- if (key === 'Data Space Total' && typeof value === 'string') {
- dataSpaceTotal = parseSize(value);
- break;
- }
- }
- }
-
- // If we found total disk space, calculate percentage
- if (dataSpaceTotal > 0) {
- diskPercentUsed = (totalUsed / dataSpaceTotal) * 100;
- } else {
- // Fallback: just report absolute usage if we can't determine percentage
- const GB = 1024 * 1024 * 1024;
- if (totalUsed > 50 * GB) {
- await sendEventNotification('disk_space_warning', {
- title: 'High Docker disk usage',
- message: `Environment "${env.name}" is using ${formatSize(totalUsed)} of Docker disk space`,
- type: 'warning'
- }, env.id);
- lastDiskWarning.set(env.id, Date.now());
- }
- return;
- }
-
- // Check against threshold
- const threshold = await getEnvSetting('disk_warning_threshold', env.id) || DEFAULT_DISK_THRESHOLD;
- if (diskPercentUsed >= threshold) {
- console.log(`[Metrics] Docker disk usage for ${env.name}: ${diskPercentUsed.toFixed(1)}% (threshold: ${threshold}%)`);
-
- await sendEventNotification('disk_space_warning', {
- title: 'Disk space warning',
- message: `Environment "${env.name}" Docker disk usage is at ${diskPercentUsed.toFixed(1)}% (${formatSize(totalUsed)} used)`,
- type: 'warning'
- }, env.id);
-
- lastDiskWarning.set(env.id, Date.now());
- }
- } catch (error) {
- // Skip this environment if it fails
- console.error(`Failed to check disk space for ${env.name}:`, error);
- }
-}
-
-/**
- * Check Docker disk usage and send warnings if above threshold
- */
-async function checkDiskSpace() {
- try {
- const environments = await getEnvironments();
-
- // Filter enabled environments and check disk space in parallel
- const enabledEnvs = environments.filter(env => env.collectMetrics !== false);
-
- // Process all environments in parallel for better performance
- await Promise.all(enabledEnvs.map(env => checkEnvDiskSpace(env)));
- } catch (error) {
- console.error('Disk space check error:', error);
- }
-}
-
-/**
- * Parse size string like "107.4GB" to bytes
- */
-function parseSize(sizeStr: string): number {
- const units: Record = {
- 'B': 1,
- 'KB': 1024,
- 'MB': 1024 * 1024,
- 'GB': 1024 * 1024 * 1024,
- 'TB': 1024 * 1024 * 1024 * 1024
- };
-
- const match = sizeStr.match(/^([\d.]+)\s*([KMGT]?B)$/i);
- if (!match) return 0;
-
- const value = parseFloat(match[1]);
- const unit = match[2].toUpperCase();
- return value * (units[unit] || 1);
-}
-
-/**
- * Format bytes to human readable string
- */
-function formatSize(bytes: number): string {
- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
- let unitIndex = 0;
- let size = bytes;
-
- while (size >= 1024 && unitIndex < units.length - 1) {
- size /= 1024;
- unitIndex++;
- }
-
- return `${size.toFixed(1)} ${units[unitIndex]}`;
-}
-
-export function startMetricsCollector() {
- if (collectorInterval) return; // Already running
-
- console.log('Starting server-side metrics collector (every 10s)');
-
- // Initial collection
- collectMetrics();
-
- // Schedule regular collection
- collectorInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
-
- // Start disk space checking (every 5 minutes)
- console.log('Starting disk space monitoring (every 5 minutes)');
- checkDiskSpace(); // Initial check
- diskCheckInterval = setInterval(checkDiskSpace, DISK_CHECK_INTERVAL);
-}
-
-export function stopMetricsCollector() {
- if (collectorInterval) {
- clearInterval(collectorInterval);
- collectorInterval = null;
- }
- if (diskCheckInterval) {
- clearInterval(diskCheckInterval);
- diskCheckInterval = null;
- }
- lastDiskWarning.clear();
- console.log('Metrics collector stopped');
-}
diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts
index 35d1021..ad4dddb 100644
--- a/src/lib/server/stacks.ts
+++ b/src/lib/server/stacks.ts
@@ -5,8 +5,8 @@
* All lifecycle operations use docker compose commands.
*/
-import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync } from 'node:fs';
-import { join, resolve } from 'node:path';
+import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs';
+import { join, resolve, dirname } from 'node:path';
import {
getEnvironment,
getStackEnvVarsAsRecord,
@@ -88,22 +88,6 @@ export interface DeployStackOptions {
// ERRORS
// =============================================================================
-/**
- * Error for operations on external stacks without compose files
- */
-export class ExternalStackError extends Error {
- public readonly stackName: string;
-
- constructor(stackName: string) {
- super(
- `Stack "${stackName}" was created outside of Dockhand. ` +
- `To manage this stack, first import it by clicking the Import button in the stack menu.`
- );
- this.name = 'ExternalStackError';
- this.stackName = stackName;
- }
-}
-
/**
* Error when compose file is missing for a managed stack
*/
@@ -232,6 +216,65 @@ export function getStacksDir(): string {
return _stacksDir;
}
+/**
+ * Get stack directory path for a specific environment.
+ * New stacks use: $DATA_DIR/stacks///
+ * Legacy stacks (no env): $DATA_DIR/stacks//
+ *
+ * Automatically looks up environment name from database.
+ */
+export async function getStackDir(stackName: string, envId?: number | null): Promise {
+ const stacksDir = getStacksDir();
+ if (envId) {
+ const env = await getEnvironment(envId);
+ if (env) {
+ return join(stacksDir, env.name, stackName);
+ }
+ }
+ // Legacy path for stacks without environment
+ return join(stacksDir, stackName);
+}
+
+/**
+ * Find stack directory, checking paths in order:
+ * 1. New path (envName): $DATA_DIR/stacks///
+ * 2. ID-based path (envId): $DATA_DIR/stacks///
+ * 3. Legacy path: $DATA_DIR/stacks//
+ *
+ * Automatically looks up environment name from database.
+ * Always checks legacy path for backwards compatibility with pre-env stacks.
+ */
+export async function findStackDir(stackName: string, envId?: number | null): Promise {
+ const stacksDir = getStacksDir();
+
+ // Look up environment name if we have an ID
+ if (envId) {
+ const env = await getEnvironment(envId);
+
+ // 1. Check new path (with envName)
+ if (env) {
+ const namePath = join(stacksDir, env.name, stackName);
+ if (existsSync(namePath)) {
+ return namePath;
+ }
+ }
+
+ // 2. Check ID-based path
+ const idPath = join(stacksDir, String(envId), stackName);
+ if (existsSync(idPath)) {
+ return idPath;
+ }
+ }
+
+ // 3. Always check legacy path (stacks created before env-scoping was added)
+ const legacyPath = join(stacksDir, stackName);
+ if (existsSync(legacyPath)) {
+ return legacyPath;
+ }
+
+ return null;
+}
+
/**
* List stacks that have compose files stored locally
*/
@@ -256,31 +299,116 @@ export function listManagedStacks(): string[] {
// =============================================================================
/**
- * Get compose file content for a stack
+ * Result type for getStackComposeFile
+ */
+export interface GetComposeFileResult {
+ success: boolean;
+ content?: string;
+ stackDir?: string;
+ error?: string;
+ needsFileLocation?: boolean;
+ composePath?: string | null;
+ envPath?: string | null;
+ suggestedEnvPath?: string;
+}
+
+/**
+ * Get compose file content for a stack.
+ *
+ * Unified logic for all stacks:
+ * - If composePath is set in DB → use custom path
+ * - If composePath is NULL → use default location (data/stacks/{env}/{name}/)
+ * - If no source record and no files found → return needsFileLocation: true
*/
export async function getStackComposeFile(
- stackName: string
-): Promise<{ success: boolean; content?: string; stackDir?: string; error?: string }> {
- const stacksDir = getStacksDir();
- const stackDir = join(stacksDir, stackName);
+ stackName: string,
+ envId?: number | null
+): Promise {
+ const source = await getStackSource(stackName, envId);
- // Check all common compose file names (Docker Compose v1 and v2 naming conventions)
- const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
+ // Case 1: Stack not in database = untracked (discovered from Docker but not imported)
+ // User must select the compose file location - don't guess from default location
+ if (!source) {
+ return {
+ success: false,
+ needsFileLocation: true,
+ error: `Select the compose file location for stack "${stackName}"`
+ };
+ }
+
+ // Case 2: Stack has custom composePath set - use it
+ if (source.composePath) {
+ try {
+ if (!existsSync(source.composePath)) {
+ return {
+ success: false,
+ error: `Compose file no longer accessible at ${source.composePath}. The file may have been moved or deleted.`,
+ composePath: source.composePath,
+ envPath: source.envPath
+ };
+ }
+
+ const content = await Bun.file(source.composePath).text();
+ const stackDir = dirname(source.composePath);
+
+ // For custom paths, suggest .env next to compose if envPath not set
+ let suggestedEnvPath: string | undefined;
+ if (source.envPath === null) {
+ suggestedEnvPath = source.composePath.replace(/\/[^/]+$/, '/.env');
+ }
- for (const fileName of composeFileNames) {
- const file = Bun.file(join(stackDir, fileName));
- if (await file.exists()) {
return {
success: true,
- content: await file.text(),
- stackDir
+ content,
+ stackDir,
+ composePath: source.composePath,
+ envPath: source.envPath,
+ suggestedEnvPath
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Unknown error';
+ return {
+ success: false,
+ error: `Failed to read compose file: ${message}`,
+ composePath: source.composePath,
+ envPath: source.envPath
};
}
}
+ // Case 3: Stack is in DB but no custom path - check default location
+ // This is for stacks created in Dockhand using the default data directory
+ const stackDir = await findStackDir(stackName, envId);
+
+ if (stackDir) {
+ // Check all common compose file names
+ const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
+
+ for (const fileName of composeFileNames) {
+ const actualComposePath = join(stackDir, fileName);
+ const file = Bun.file(actualComposePath);
+ if (await file.exists()) {
+ // Check for .env file in the same directory
+ const envFilePath = join(stackDir, '.env');
+ const envExists = existsSync(envFilePath);
+
+ return {
+ success: true,
+ content: await file.text(),
+ stackDir,
+ // Always return the actual resolved paths for display
+ composePath: actualComposePath,
+ envPath: envExists ? envFilePath : null
+ };
+ }
+ }
+ }
+
+ // Case 4: Stack is in DB but compose file not found - need user to specify location
return {
success: false,
- error: `Compose file not found for stack "${stackName}". The stack may have been created outside of Dockhand.`
+ needsFileLocation: true,
+ error: `Select the compose file location for stack "${stackName}"`
};
}
@@ -289,22 +417,206 @@ export async function getStackComposeFile(
* @param name - Stack name
* @param content - Compose file content
* @param create - If true, creates a new stack (fails if exists). If false, updates existing (fails if not exists).
+ * @param envId - Environment ID for path scoping
*/
export async function saveStackComposeFile(
name: string,
content: string,
- create = false
+ create = false,
+ envId?: number | null,
+ options?: {
+ composePath?: string; // Custom compose file path
+ envPath?: string | null; // Custom env path (null = default, '' = none)
+ moveFromDir?: string; // Old directory to move all files from when path changes
+ oldComposePath?: string; // Old compose file path for renaming
+ oldEnvPath?: string; // Old env file path for renaming
+ }
): Promise<{ success: boolean; error?: string }> {
- // Validate stack name
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
+ // Validate stack name - Docker Compose requires lowercase alphanumeric, hyphens, underscores
+ // Must also start with a letter or number
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
return {
success: false,
- error: 'Stack name can only contain letters, numbers, hyphens, and underscores'
+ error: 'Stack name must be lowercase, start with a letter or number, and contain only letters, numbers, hyphens, and underscores'
};
}
- const stacksDir = getStacksDir();
- const stackDir = join(stacksDir, name);
+ // Check if this stack has a custom compose path configured, or if one was provided
+ const source = await getStackSource(name, envId);
+ const composePath = options?.composePath || source?.composePath;
+
+ // Handle compose file move/rename when path changes
+ if (options?.oldComposePath && options?.composePath &&
+ options.oldComposePath !== options.composePath &&
+ existsSync(options.oldComposePath)) {
+ const newDir = dirname(options.composePath);
+
+ // Ensure target directory exists
+ if (!existsSync(newDir)) {
+ try {
+ mkdirSync(newDir, { recursive: true });
+ } catch (err: any) {
+ console.warn(`[Stack] Failed to create directory ${newDir}: ${err.message}`);
+ }
+ }
+
+ // Move/rename the compose file to new location
+ try {
+ renameSync(options.oldComposePath, options.composePath);
+ console.log(`[Stack] Moved compose file: ${options.oldComposePath} -> ${options.composePath}`);
+ } catch (renameErr: any) {
+ // If rename fails (e.g., cross-filesystem), try copy+delete
+ if (renameErr.code === 'EXDEV') {
+ try {
+ const data = readFileSync(options.oldComposePath);
+ writeFileSync(options.composePath, data);
+ unlinkSync(options.oldComposePath);
+ console.log(`[Stack] Copied compose file (cross-fs): ${options.oldComposePath} -> ${options.composePath}`);
+ } catch (err: any) {
+ console.warn(`[Stack] Failed to copy compose file: ${err.message}`);
+ }
+ } else {
+ console.warn(`[Stack] Failed to move compose file: ${renameErr.message}`);
+ }
+ }
+ }
+
+ // Handle env file move/rename when path changes
+ if (options?.oldEnvPath && options?.envPath &&
+ options.oldEnvPath !== options.envPath &&
+ existsSync(options.oldEnvPath)) {
+ const newDir = dirname(options.envPath);
+
+ // Ensure target directory exists
+ if (!existsSync(newDir)) {
+ try {
+ mkdirSync(newDir, { recursive: true });
+ } catch (err: any) {
+ console.warn(`[Stack] Failed to create directory ${newDir}: ${err.message}`);
+ }
+ }
+
+ // Move/rename the env file to new location
+ try {
+ renameSync(options.oldEnvPath, options.envPath);
+ console.log(`[Stack] Moved env file: ${options.oldEnvPath} -> ${options.envPath}`);
+ } catch (renameErr: any) {
+ // If rename fails (e.g., cross-filesystem), try copy+delete
+ if (renameErr.code === 'EXDEV') {
+ try {
+ const data = readFileSync(options.oldEnvPath);
+ writeFileSync(options.envPath, data);
+ unlinkSync(options.oldEnvPath);
+ console.log(`[Stack] Copied env file (cross-fs): ${options.oldEnvPath} -> ${options.envPath}`);
+ } catch (err: any) {
+ console.warn(`[Stack] Failed to copy env file: ${err.message}`);
+ }
+ } else {
+ console.warn(`[Stack] Failed to move env file: ${renameErr.message}`);
+ }
+ }
+ }
+
+ // Move all files from old directory to new directory when path changes
+ // Get the new directory from composePath
+ const newDir = options?.composePath ? dirname(options.composePath) : null;
+
+ if (options?.moveFromDir && newDir && options.moveFromDir !== newDir && existsSync(options.moveFromDir)) {
+ try {
+ // Ensure new directory exists
+ if (!existsSync(newDir)) {
+ mkdirSync(newDir, { recursive: true });
+ }
+
+ // Move all files from old directory to new directory
+ const files = readdirSync(options.moveFromDir);
+ for (const file of files) {
+ const oldFilePath = join(options.moveFromDir, file);
+ const newFilePath = join(newDir, file);
+
+ try {
+ // Use rename for atomic move (same filesystem) or copy+delete for cross-filesystem
+ renameSync(oldFilePath, newFilePath);
+ console.log(`[Stack] Moved file: ${oldFilePath} -> ${newFilePath}`);
+ } catch (renameErr: any) {
+ // If rename fails (e.g., cross-filesystem), try copy+delete
+ if (renameErr.code === 'EXDEV') {
+ const stat = statSync(oldFilePath);
+ if (stat.isDirectory()) {
+ // For directories, use recursive copy
+ cpSync(oldFilePath, newFilePath, { recursive: true });
+ rmSync(oldFilePath, { recursive: true, force: true });
+ } else {
+ // For files, read and write
+ const data = readFileSync(oldFilePath);
+ writeFileSync(newFilePath, data);
+ unlinkSync(oldFilePath);
+ }
+ console.log(`[Stack] Copied file (cross-fs): ${oldFilePath} -> ${newFilePath}`);
+ } else {
+ throw renameErr;
+ }
+ }
+ }
+
+ // Remove old directory if it's now empty
+ try {
+ const remaining = readdirSync(options.moveFromDir);
+ if (remaining.length === 0) {
+ rmSync(options.moveFromDir, { recursive: true, force: true });
+ console.log(`[Stack] Removed empty old directory: ${options.moveFromDir}`);
+ }
+ } catch {
+ // Ignore errors when checking/removing old directory
+ }
+ } catch (err: any) {
+ console.warn(`[Stack] Failed to move files from ${options.moveFromDir} to ${newDir}: ${err.message}`);
+ // Continue with save even if move fails - new files will be written anyway
+ }
+ }
+
+ // If a custom composePath is being set (new or update), save it to the database
+ if (options?.composePath || options?.envPath !== undefined) {
+ await upsertStackSource({
+ stackName: name,
+ environmentId: envId ?? null,
+ sourceType: 'internal',
+ composePath: options?.composePath || source?.composePath || null,
+ envPath: options?.envPath !== undefined ? options.envPath : (source?.envPath ?? null)
+ });
+ }
+
+ if (composePath) {
+ // Write directly to the custom compose file path
+ // Ensure parent directory exists for custom paths
+ const parentDir = dirname(composePath);
+ if (!existsSync(parentDir)) {
+ try {
+ mkdirSync(parentDir, { recursive: true });
+ } catch (err: any) {
+ return { success: false, error: `Failed to create directory for compose file: ${err.message}` };
+ }
+ }
+ try {
+ await Bun.write(composePath, content);
+ return { success: true };
+ } catch (err: any) {
+ return { success: false, error: `Failed to save compose file: ${err.message}` };
+ }
+ }
+
+ // For creates, use new path; for updates, find existing path first
+ let stackDir: string;
+ if (create) {
+ stackDir = await getStackDir(name, envId);
+ } else {
+ const existingDir = await findStackDir(name, envId);
+ if (!existingDir) {
+ return { success: false, error: `Stack "${name}" not found` };
+ }
+ stackDir = existingDir;
+ }
+
const composeFile = join(stackDir, 'docker-compose.yml');
const exists = existsSync(stackDir);
@@ -323,11 +635,6 @@ export async function saveStackComposeFile(
} catch (err: any) {
return { success: false, error: `Failed to create stack directory: ${err.message}` };
}
- } else {
- // Updating existing stack - must exist
- if (!exists) {
- return { success: false, error: `Stack "${name}" not found` };
- }
}
try {
@@ -338,6 +645,68 @@ export async function saveStackComposeFile(
}
}
+// =============================================================================
+// REGISTRY AUTHENTICATION
+// =============================================================================
+
+/**
+ * Login to all configured Docker registries before running compose commands.
+ * This ensures that `docker compose up` can pull images from private registries.
+ */
+async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]'): Promise {
+ const { getRegistries } = await import('./db.js');
+ const registries = await getRegistries();
+
+ if (registries.length === 0) {
+ return;
+ }
+
+ const spawnEnv: Record = { ...(process.env as Record) };
+ if (dockerHost) {
+ spawnEnv.DOCKER_HOST = dockerHost;
+ }
+
+ for (const reg of registries) {
+ if (!reg.username || !reg.password) {
+ continue; // Skip registries without credentials
+ }
+
+ try {
+ // Extract registry host from URL
+ const url = new URL(reg.url);
+ const registryHost = url.host;
+
+ console.log(`${logPrefix} Logging into registry: ${registryHost}`);
+
+ const proc = Bun.spawn(
+ ['docker', 'login', '-u', reg.username, '--password-stdin', registryHost],
+ {
+ env: spawnEnv,
+ stdin: 'pipe',
+ stdout: 'pipe',
+ stderr: 'pipe'
+ }
+ );
+
+ // Write password to stdin
+ const writer = proc.stdin.getWriter();
+ await writer.write(new TextEncoder().encode(reg.password));
+ await writer.close();
+
+ const exitCode = await proc.exited;
+
+ if (exitCode === 0) {
+ console.log(`${logPrefix} Successfully logged into ${registryHost}`);
+ } else {
+ const stderr = await new Response(proc.stderr).text();
+ console.error(`${logPrefix} Failed to login to ${registryHost}: ${stderr}`);
+ }
+ } catch (e) {
+ console.error(`${logPrefix} Error logging into registry ${reg.name}:`, e);
+ }
+ }
+}
+
// =============================================================================
// COMPOSE COMMAND EXECUTION
// =============================================================================
@@ -364,11 +733,14 @@ async function executeLocalCompose(
envVars?: Record,
secretVars?: Record,
forceRecreate?: boolean,
- removeVolumes?: boolean
+ removeVolumes?: boolean,
+ envId?: number | null
): Promise {
const logPrefix = `[Stack:${stackName}]`;
- const stacksDir = getStacksDir();
- const stackDir = join(stacksDir, stackName);
+ // For operations that write (up), use getStackDir; for others, try to find existing first
+ const stackDir = operation === 'up'
+ ? await getStackDir(stackName, envId)
+ : (await findStackDir(stackName, envId) || await getStackDir(stackName, envId));
mkdirSync(stackDir, { recursive: true });
const composeFile = join(stackDir, 'docker-compose.yml');
@@ -433,6 +805,11 @@ async function executeLocalCompose(
console.log(`${logPrefix} Env vars being injected (masked):`, JSON.stringify(maskSecrets(envVars), null, 2));
}
+ // Login to registries before pulling images
+ if (operation === 'up' || operation === 'pull') {
+ await loginToRegistries(dockerHost, logPrefix);
+ }
+
try {
console.log(`${logPrefix} Spawning docker compose process...`);
const proc = Bun.spawn(args, {
@@ -577,6 +954,20 @@ async function executeComposeViaHawser(
}
}
+ // Fetch registry credentials for Hawser to use for docker login
+ const { getRegistries } = await import('./db.js');
+ const allRegistries = await getRegistries();
+ const registries = allRegistries
+ .filter(r => r.username && r.password)
+ .map(r => ({
+ url: r.url,
+ username: r.username!,
+ password: r.password!
+ }));
+ if (registries.length > 0) {
+ console.log(`${logPrefix} Sending ${registries.length} registry credentials to Hawser`);
+ }
+
const body = JSON.stringify({
operation,
projectName: stackName,
@@ -584,7 +975,8 @@ async function executeComposeViaHawser(
envVars: allEnvVars, // All vars (including secrets) - Hawser injects via shell env
files, // Files including .env (secrets NOT in .env file)
forceRecreate: forceRecreate || false,
- removeVolumes: removeVolumes || false
+ removeVolumes: removeVolumes || false,
+ registries // Registry credentials for docker login
});
console.log(`${logPrefix} Sending request to Hawser agent...`);
@@ -665,7 +1057,8 @@ async function executeComposeCommand(
envVars,
secretVars,
forceRecreate,
- removeVolumes
+ removeVolumes,
+ envId
);
}
@@ -695,7 +1088,8 @@ async function executeComposeCommand(
envVars,
secretVars,
forceRecreate,
- removeVolumes
+ removeVolumes,
+ envId
);
}
@@ -709,7 +1103,8 @@ async function executeComposeCommand(
envVars,
secretVars,
forceRecreate,
- removeVolumes
+ removeVolumes,
+ envId
);
}
}
@@ -806,6 +1201,37 @@ async function getStackContainers(stackName: string, envId?: number | null): Pro
return containers.filter((c) => c.labels['com.docker.compose.project'] === stackName);
}
+/**
+ * Extract path hints from Docker container labels for a stack.
+ * Docker Compose adds labels like:
+ * - com.docker.compose.project.working_dir: /path/to/stack
+ * - com.docker.compose.project.config_files: /path/to/docker-compose.yml[,...]
+ */
+export async function getStackPathHints(
+ stackName: string,
+ envId?: number | null
+): Promise<{
+ workingDir: string | null;
+ configFiles: string[] | null;
+}> {
+ const containers = await getStackContainers(stackName, envId);
+
+ if (containers.length === 0) {
+ return { workingDir: null, configFiles: null };
+ }
+
+ // Get labels from first container (all containers in stack have same project labels)
+ const labels = containers[0].labels || {};
+
+ const workingDir = labels['com.docker.compose.project.working_dir'] || null;
+ const configFilesRaw = labels['com.docker.compose.project.config_files'] || null;
+
+ // Config files can be comma-separated if multiple compose files were used
+ const configFiles = configFilesRaw ? configFilesRaw.split(',').map((f: string) => f.trim()) : null;
+
+ return { workingDir, configFiles };
+}
+
/**
* Helper to perform container-based operations for external stacks
* Used as fallback when no compose file exists.
@@ -878,12 +1304,25 @@ async function withContainerFallback(
// =============================================================================
/**
- * Ensure we have a compose file for operations, throw appropriate error if not.
+ * Result type for requireComposeFile - can indicate stack needs file location
+ */
+export interface RequireComposeResult {
+ success: boolean;
+ content?: string;
+ envVars?: Record;
+ secretVars?: Record;
+ needsFileLocation?: boolean;
+ error?: string;
+}
+
+/**
+ * Get compose file and env vars for stack operations.
*
* Returns:
* - content: The compose file content
* - envVars: Non-secret variables (from .env file, with DB fallback)
* - secretVars: Secret variables (from DB only, for shell injection)
+ * - needsFileLocation: true if stack needs user to specify file paths
*
* SECURITY: Secrets are NEVER written to .env files. They are stored in the database
* and injected via shell environment variables at runtime.
@@ -891,16 +1330,22 @@ async function withContainerFallback(
async function requireComposeFile(
stackName: string,
envId?: number | null
-): Promise<{ content: string; envVars: Record; secretVars: Record }> {
- const composeResult = await getStackComposeFile(stackName);
+): Promise {
+ const composeResult = await getStackComposeFile(stackName, envId);
+ // If compose file not found, return info about what's needed
if (!composeResult.success) {
- // Check if this is an external stack
- const source = await getStackSource(stackName, envId);
- if (!source || source.sourceType === 'external') {
- throw new ExternalStackError(stackName);
+ if (composeResult.needsFileLocation) {
+ return {
+ success: false,
+ needsFileLocation: true,
+ error: composeResult.error
+ };
}
- throw new ComposeFileNotFoundError(stackName);
+ return {
+ success: false,
+ error: composeResult.error || `Compose file not found for stack "${stackName}"`
+ };
}
// Get SECRET variables from database (for shell injection at runtime)
@@ -910,12 +1355,26 @@ async function requireComposeFile(
// Get non-secret variables from database (for backward compatibility)
const dbNonSecretVars = await getNonSecretEnvVarsAsRecord(stackName, envId);
- // Read non-secret vars from .env file (user can edit this file manually)
- const stackDir = join(getStacksDir(), stackName);
- const envFilePath = join(stackDir, '.env');
+ // Read non-secret vars from .env file
+ // For stacks with custom path, use the env path if set (and not empty string which means "no env file")
+ // Otherwise, use the .env file in the stack directory
+ let envFilePath: string | null = null;
+
+ if (composeResult.composePath && composeResult.envPath) {
+ // Custom compose path with explicit env path
+ envFilePath = composeResult.envPath;
+ } else if (composeResult.composePath && composeResult.envPath === '') {
+ // Custom compose path with explicit "no env file" - don't read any file
+ envFilePath = null;
+ } else {
+ // Default location - look for .env in stack directory
+ const stackDir = composeResult.stackDir || await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
+ envFilePath = join(stackDir, '.env');
+ }
+
let fileEnvVars: Record = {};
- if (existsSync(envFilePath)) {
+ if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
for (const line of content.split('\n')) {
@@ -941,85 +1400,80 @@ async function requireComposeFile(
// This ensures external edits to .env are respected during deployment
const envVars = { ...dbNonSecretVars, ...fileEnvVars };
- return { content: composeResult.content!, envVars, secretVars };
+ return { success: true, content: composeResult.content!, envVars, secretVars };
}
/**
* Start a stack using docker compose up
- * Falls back to individual container start for external stacks
+ * Falls back to individual container start for stacks without compose files
*/
export async function startStack(
stackName: string,
envId?: number | null
): Promise {
- try {
- const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
- return executeComposeCommand('up', { stackName, envId }, content, envVars, secretVars);
- } catch (err) {
- if (err instanceof ExternalStackError) {
- return withContainerFallback(stackName, envId, 'start');
- }
- throw err;
+ const result = await requireComposeFile(stackName, envId);
+
+ if (!result.success) {
+ // No compose file - fall back to container-based operations
+ return withContainerFallback(stackName, envId, 'start');
}
+
+ return executeComposeCommand('up', { stackName, envId }, result.content!, result.envVars, result.secretVars);
}
/**
* Stop a stack using docker compose stop
- * Falls back to individual container stop for external stacks
+ * Falls back to individual container stop for stacks without compose files
*/
export async function stopStack(
stackName: string,
envId?: number | null
): Promise {
- try {
- const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
- return executeComposeCommand('stop', { stackName, envId }, content, envVars, secretVars);
- } catch (err) {
- if (err instanceof ExternalStackError) {
- return withContainerFallback(stackName, envId, 'stop');
- }
- throw err;
+ const result = await requireComposeFile(stackName, envId);
+
+ if (!result.success) {
+ // No compose file - fall back to container-based operations
+ return withContainerFallback(stackName, envId, 'stop');
}
+
+ return executeComposeCommand('stop', { stackName, envId }, result.content!, result.envVars, result.secretVars);
}
/**
* Restart a stack using docker compose restart
- * Falls back to individual container restart for external stacks
+ * Falls back to individual container restart for stacks without compose files
*/
export async function restartStack(
stackName: string,
envId?: number | null
): Promise {
- try {
- const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
- return executeComposeCommand('restart', { stackName, envId }, content, envVars, secretVars);
- } catch (err) {
- if (err instanceof ExternalStackError) {
- return withContainerFallback(stackName, envId, 'restart');
- }
- throw err;
+ const result = await requireComposeFile(stackName, envId);
+
+ if (!result.success) {
+ // No compose file - fall back to container-based operations
+ return withContainerFallback(stackName, envId, 'restart');
}
+
+ return executeComposeCommand('restart', { stackName, envId }, result.content!, result.envVars, result.secretVars);
}
/**
* Down a stack using docker compose down (removes containers, keeps files)
- * For external stacks, this is equivalent to stop (no compose file to "down")
+ * For stacks without compose files, this is equivalent to stop
*/
export async function downStack(
stackName: string,
envId?: number | null,
removeVolumes = false
): Promise {
- try {
- const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
- return executeComposeCommand('down', { stackName, envId, removeVolumes }, content, envVars, secretVars);
- } catch (err) {
- if (err instanceof ExternalStackError) {
- // For external stacks, down is the same as stop (no compose file to tear down)
- return withContainerFallback(stackName, envId, 'stop');
- }
- throw err;
+ const result = await requireComposeFile(stackName, envId);
+
+ if (!result.success) {
+ // No compose file - down is the same as stop
+ return withContainerFallback(stackName, envId, 'stop');
}
+
+ return executeComposeCommand('down', { stackName, envId, removeVolumes }, result.content!, result.envVars, result.secretVars);
}
/**
@@ -1080,8 +1534,7 @@ export async function removeStack(
const cleanupErrors: string[] = [];
// Delete compose file and directory
- const stacksDir = getStacksDir();
- const stackDir = join(stacksDir, stackName);
+ const stackDir = await findStackDir(stackName, envId) || await getStackDir(stackName, envId);
if (existsSync(stackDir)) {
try {
rmSync(stackDir, { recursive: true, force: true });
@@ -1166,19 +1619,19 @@ export async function deployStack(options: DeployStackOptions): Promise {
- const stacksDir = getStacksDir();
- const stackDir = join(stacksDir, name);
+ const stackDir = await getStackDir(name, envId);
// Read all files from source directory if provided (for Hawser deployments)
let stackFiles: Record | undefined;
@@ -1248,9 +1701,16 @@ export async function pullStackImages(
stackName: string,
envId?: number | null
): Promise<{ success: boolean; output?: string; error?: string }> {
- const { content, envVars, secretVars } = await requireComposeFile(stackName, envId);
+ const result = await requireComposeFile(stackName, envId);
- return executeComposeCommand('pull', { stackName, envId }, content, envVars, secretVars);
+ if (!result.success) {
+ return {
+ success: false,
+ error: result.error || 'Compose file not found'
+ };
+ }
+
+ return executeComposeCommand('pull', { stackName, envId }, result.content!, result.envVars, result.secretVars);
}
// =============================================================================
@@ -1279,10 +1739,19 @@ export async function saveStackEnvVarsToDb(
*/
export async function writeStackEnvFile(
stackName: string,
- variables: { key: string; value: string; isSecret?: boolean }[]
+ variables: { key: string; value: string; isSecret?: boolean }[],
+ envId?: number | null,
+ customEnvPath?: string
): Promise {
- const stacksDir = getStacksDir();
- const envFilePath = join(stacksDir, stackName, '.env');
+ const envFilePath = customEnvPath
+ ? customEnvPath
+ : join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
+
+ // Ensure parent directory exists
+ const dir = dirname(envFilePath);
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
+ }
// SECURITY: Only write non-secret variables to .env file
// Secrets are stored in DB and injected via shell environment at runtime
@@ -1302,17 +1771,20 @@ export async function writeStackEnvFile(
*/
export async function writeRawStackEnvFile(
stackName: string,
- rawContent: string
+ rawContent: string,
+ envId?: number | null,
+ customEnvPath?: string
): Promise {
- const stacksDir = getStacksDir();
- const stackDir = join(stacksDir, stackName);
+ const envFilePath = customEnvPath
+ ? customEnvPath
+ : join(await findStackDir(stackName, envId) || await getStackDir(stackName, envId), '.env');
- // Ensure stack directory exists
- if (!existsSync(stackDir)) {
- mkdirSync(stackDir, { recursive: true });
+ // Ensure parent directory exists
+ const dir = dirname(envFilePath);
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true });
}
- const envFilePath = join(stackDir, '.env');
await Bun.write(envFilePath, rawContent);
}
@@ -1329,12 +1801,13 @@ export async function writeRawStackEnvFile(
export async function saveStackEnvVars(
stackName: string,
variables: { key: string; value: string; isSecret?: boolean }[],
- envId?: number | null
+ envId?: number | null,
+ customEnvPath?: string
): Promise {
// Save to database for secret tracking
await saveStackEnvVarsToDb(stackName, variables, envId);
// Write .env file to disk for Docker Compose
- await writeStackEnvFile(stackName, variables);
+ await writeStackEnvFile(stackName, variables, envId, customEnvPath);
}
// =============================================================================
diff --git a/src/lib/server/subprocess-manager.ts b/src/lib/server/subprocess-manager.ts
index 6db4ec5..f29c770 100644
--- a/src/lib/server/subprocess-manager.ts
+++ b/src/lib/server/subprocess-manager.ts
@@ -102,7 +102,12 @@ export interface ShutdownCommand {
type: 'shutdown';
}
-export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand;
+export interface UpdateIntervalCommand {
+ type: 'update_interval';
+ intervalMs: number;
+}
+
+export type MainProcessCommand = RefreshEnvironmentsCommand | ShutdownCommand | UpdateIntervalCommand;
// Subprocess configuration
interface SubprocessConfig {
@@ -198,6 +203,20 @@ class SubprocessManager {
this.sendToEvents({ type: 'refresh_environments' });
}
+ /**
+ * Send message to metrics subprocess
+ */
+ sendToMetricsSubprocess(message: MainProcessCommand): void {
+ this.sendToMetrics(message);
+ }
+
+ /**
+ * Send message to events subprocess
+ */
+ sendToEventsSubprocess(message: MainProcessCommand): void {
+ this.sendToEvents(message);
+ }
+
/**
* Start the metrics collection subprocess
*/
@@ -591,3 +610,21 @@ export function refreshSubprocessEnvironments(): void {
manager.refreshEnvironments();
}
}
+
+/**
+ * Send message to event subprocess
+ */
+export function sendToEventSubprocess(message: MainProcessCommand): void {
+ if (manager) {
+ manager.sendToEventsSubprocess(message);
+ }
+}
+
+/**
+ * Send message to metrics subprocess
+ */
+export function sendToMetricsSubprocess(message: MainProcessCommand): void {
+ if (manager) {
+ manager.sendToMetricsSubprocess(message);
+ }
+}
diff --git a/src/lib/server/subprocesses/event-subprocess.ts b/src/lib/server/subprocesses/event-subprocess.ts
index bf37d58..5abfc68 100644
--- a/src/lib/server/subprocesses/event-subprocess.ts
+++ b/src/lib/server/subprocesses/event-subprocess.ts
@@ -7,7 +7,7 @@
* Communication with main process via IPC (process.send).
*/
-import { getEnvironments, type ContainerEventAction } from '../db';
+import { getEnvironments, getEventCollectionMode, getEventPollInterval, type ContainerEventAction } from '../db';
import { getDockerEvents } from '../docker';
import type { MainProcessCommand } from '../subprocess-manager';
@@ -19,9 +19,15 @@ const MAX_RECONNECT_DELAY = 60000; // 1 minute max
// Only send notifications on status CHANGES, not on every reconnect attempt
const environmentOnlineStatus: Map = new Map();
-// Active collectors per environment
+// Active collectors per environment (for streaming mode)
const collectors: Map = new Map();
+// Poll intervals per environment (for polling mode)
+const pollIntervals: Map> = new Map();
+
+// Last poll timestamp per environment (for polling mode)
+const lastPollTime: Map = new Map();
+
// Recent event cache for deduplication (key: timeNano-containerId-action)
const recentEvents: Map = new Map();
const DEDUP_WINDOW_MS = 5000; // 5 second window for deduplication
@@ -30,6 +36,10 @@ const CACHE_CLEANUP_INTERVAL_MS = 30000; // Clean up cache every 30 seconds
let cacheCleanupInterval: ReturnType | null = null;
let isShuttingDown = false;
+// Track current settings to detect changes
+let currentPollInterval: number = 60000;
+let currentMode: 'stream' | 'poll' = 'stream';
+
// Actions we care about for container activity
const CONTAINER_ACTIONS: ContainerEventAction[] = [
'create',
@@ -211,6 +221,76 @@ function processEvent(event: DockerEvent, envId: number) {
});
}
+/**
+ * Poll events for a specific environment (polling mode)
+ */
+async function pollEnvironmentEvents(envId: number, envName: string) {
+ try {
+ // Calculate 'since' timestamp (use last poll time, or start from 30s ago if first poll)
+ const now = Math.floor(Date.now() / 1000); // Unix timestamp in seconds
+ const since = lastPollTime.get(envId) || (now - 30); // Default to 30s ago on first poll
+
+ // Fetch events since last check until now
+ // IMPORTANT: 'until' is required for polling mode, otherwise Docker keeps the connection open
+ const eventStream = await getDockerEvents(
+ { type: ['container'] },
+ envId,
+ { since: since.toString(), until: now.toString() }
+ );
+
+ if (!eventStream) {
+ console.error(`[EventSubprocess] Failed to fetch events for ${envName}`);
+ updateEnvironmentStatus(envId, envName, false, 'Failed to fetch Docker events');
+ return;
+ }
+
+ // Mark environment as online
+ updateEnvironmentStatus(envId, envName, true);
+
+ // Read and process all events
+ const reader = eventStream.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (line.trim()) {
+ try {
+ const event = JSON.parse(line) as DockerEvent;
+ processEvent(event, envId);
+ } catch {
+ // Ignore parse errors
+ }
+ }
+ }
+ }
+ } finally {
+ try {
+ reader.releaseLock();
+ } catch {
+ // Reader already released
+ }
+ }
+
+ // Update last poll time
+ lastPollTime.set(envId, now);
+
+ } catch (error: any) {
+ if (!isShuttingDown) {
+ console.error(`[EventSubprocess] Poll error for ${envName}:`, error.message);
+ updateEnvironmentStatus(envId, envName, false, error.message);
+ }
+ }
+}
+
/**
* Start collecting events for a specific environment
*/
@@ -332,7 +412,42 @@ async function startEnvironmentCollector(envId: number, envName: string) {
}
/**
- * Stop collecting events for a specific environment
+ * Start polling mode for a specific environment
+ */
+async function startEnvironmentPoller(envId: number, envName: string, interval: number) {
+ // Stop existing poller if any
+ stopEnvironmentPoller(envId);
+
+ console.log(`[EventSubprocess] Starting poller for ${envName} (every ${interval / 1000}s)`);
+
+ // Initial poll immediately
+ await pollEnvironmentEvents(envId, envName);
+
+ // Set up interval for subsequent polls
+ const intervalId = setInterval(async () => {
+ if (!isShuttingDown) {
+ await pollEnvironmentEvents(envId, envName);
+ }
+ }, interval);
+
+ pollIntervals.set(envId, intervalId);
+}
+
+/**
+ * Stop polling for a specific environment
+ */
+function stopEnvironmentPoller(envId: number) {
+ const intervalId = pollIntervals.get(envId);
+ if (intervalId) {
+ clearInterval(intervalId);
+ pollIntervals.delete(envId);
+ lastPollTime.delete(envId);
+ environmentOnlineStatus.delete(envId);
+ }
+}
+
+/**
+ * Stop collecting events for a specific environment (streaming mode)
*/
function stopEnvironmentCollector(envId: number) {
const controller = collectors.get(envId);
@@ -351,6 +466,21 @@ async function refreshEventCollectors() {
try {
const environments = await getEnvironments();
+ const mode = await getEventCollectionMode();
+ const pollInterval = await getEventPollInterval();
+
+ // Detect if settings changed
+ const modeChanged = mode !== currentMode;
+ const intervalChanged = pollInterval !== currentPollInterval;
+
+ if (modeChanged) {
+ console.log(`[EventSubprocess] Mode changed from ${currentMode} to ${mode}`);
+ currentMode = mode;
+ }
+ if (intervalChanged) {
+ console.log(`[EventSubprocess] Poll interval changed from ${currentPollInterval}ms to ${pollInterval}ms`);
+ currentPollInterval = pollInterval;
+ }
// Filter: only collect for environments with activity enabled AND not Hawser Edge
const activeEnvIds = new Set(
@@ -362,18 +492,55 @@ async function refreshEventCollectors() {
// Stop collectors for removed environments or those with collection disabled
for (const envId of collectors.keys()) {
if (!activeEnvIds.has(envId)) {
- console.log(`[EventSubprocess] Stopping collector for environment ${envId}`);
+ console.log(`[EventSubprocess] Stopping stream collector for environment ${envId}`);
stopEnvironmentCollector(envId);
}
}
- // Start collectors for environments with collection enabled
+ // Stop pollers for removed environments or those with collection disabled
+ // Also restart all pollers if interval changed
+ for (const envId of pollIntervals.keys()) {
+ if (!activeEnvIds.has(envId)) {
+ console.log(`[EventSubprocess] Stopping poller for environment ${envId}`);
+ stopEnvironmentPoller(envId);
+ } else if (intervalChanged && mode === 'poll') {
+ // Restart poller with new interval
+ console.log(`[EventSubprocess] Restarting poller for environment ${envId} with new interval`);
+ stopEnvironmentPoller(envId);
+ }
+ }
+
+ // Start collectors based on mode
for (const env of environments) {
// Skip Hawser Edge (handled by main process)
if (env.connectionType === 'hawser-edge') continue;
- if (env.collectActivity && !collectors.has(env.id)) {
- startEnvironmentCollector(env.id, env.name);
+ // Skip if activity collection is disabled
+ if (!env.collectActivity) continue;
+
+ const hasStreamCollector = collectors.has(env.id);
+ const hasPoller = pollIntervals.has(env.id);
+
+ if (mode === 'stream') {
+ // Switch from polling to streaming if needed
+ if (hasPoller) {
+ console.log(`[EventSubprocess] Switching ${env.name} from poll to stream`);
+ stopEnvironmentPoller(env.id);
+ }
+ // Start stream if not already running
+ if (!hasStreamCollector) {
+ startEnvironmentCollector(env.id, env.name);
+ }
+ } else if (mode === 'poll') {
+ // Switch from streaming to polling if needed
+ if (hasStreamCollector) {
+ console.log(`[EventSubprocess] Switching ${env.name} from stream to poll`);
+ stopEnvironmentCollector(env.id);
+ }
+ // Start poller if not already running (will also restart after interval change above)
+ if (!hasPoller) {
+ startEnvironmentPoller(env.id, env.name, pollInterval);
+ }
}
}
} catch (error) {
@@ -392,6 +559,13 @@ function handleCommand(command: MainProcessCommand): void {
refreshEventCollectors();
break;
+ case 'update_interval':
+ // This is used by metrics subprocess, but we handle it here too for consistency
+ // Event subprocess re-reads interval from DB on refresh
+ console.log('[EventSubprocess] Interval update - refreshing collectors...');
+ refreshEventCollectors();
+ break;
+
case 'shutdown':
console.log('[EventSubprocess] Shutdown requested');
shutdown();
@@ -411,11 +585,16 @@ function shutdown(): void {
cacheCleanupInterval = null;
}
- // Stop all environment collectors
+ // Stop all environment stream collectors
for (const envId of collectors.keys()) {
stopEnvironmentCollector(envId);
}
+ // Stop all environment pollers
+ for (const envId of pollIntervals.keys()) {
+ stopEnvironmentPoller(envId);
+ }
+
// Clear the deduplication cache
recentEvents.clear();
@@ -429,6 +608,15 @@ function shutdown(): void {
async function start(): Promise {
console.log('[EventSubprocess] Starting container event collection...');
+ // Initialize current settings from database
+ try {
+ currentMode = await getEventCollectionMode();
+ currentPollInterval = await getEventPollInterval();
+ console.log(`[EventSubprocess] Initial mode: ${currentMode}, poll interval: ${currentPollInterval}ms`);
+ } catch (error) {
+ console.error('[EventSubprocess] Failed to load settings, using defaults:', error);
+ }
+
// Start collectors for all environments
await refreshEventCollectors();
diff --git a/src/lib/server/subprocesses/metrics-subprocess.ts b/src/lib/server/subprocesses/metrics-subprocess.ts
index 139e6fa..74669c0 100644
--- a/src/lib/server/subprocesses/metrics-subprocess.ts
+++ b/src/lib/server/subprocesses/metrics-subprocess.ts
@@ -7,12 +7,12 @@
* Communication with main process via IPC (process.send).
*/
-import { getEnvironments, getEnvSetting } from '../db';
+import { getEnvironments, getEnvSetting, getMetricsCollectionInterval } from '../db';
import { listContainers, getContainerStats, getDockerInfo, getDiskUsage } from '../docker';
import os from 'node:os';
import type { MainProcessCommand } from '../subprocess-manager';
-const COLLECT_INTERVAL = 10000; // 10 seconds
+let COLLECT_INTERVAL = 30000; // 30 seconds (default, will be loaded from settings)
const DISK_CHECK_INTERVAL = 300000; // 5 minutes
const DEFAULT_DISK_THRESHOLD = 80; // 80% threshold for disk warnings
const ENV_METRICS_TIMEOUT = 15000; // 15 seconds timeout per environment for metrics
@@ -82,12 +82,16 @@ async function collectEnvMetrics(env: { id: number; name: string; host?: string;
cpuPercent = (cpuDelta / systemDelta) * cpuCount * 100;
}
- // Get container memory usage (subtract cache for actual usage)
+ // Get container memory usage using the same formula as Docker CLI
+ // Docker subtracts cache (inactive_file) from total usage
+ // - cgroup v2: uses 'inactive_file'
+ // - cgroup v1: uses 'total_inactive_file'
const memUsage = stats.memory_stats?.usage || 0;
- const memCache = stats.memory_stats?.stats?.cache || 0;
- const actualMemUsed = memUsage - memCache;
+ const memStats = stats.memory_stats?.stats || {};
+ const memCache = memStats.inactive_file ?? memStats.total_inactive_file ?? 0;
+ const actualMemUsed = memCache > 0 && memCache < memUsage ? memUsage - memCache : memUsage;
- return { cpuPercent, memUsage: actualMemUsed > 0 ? actualMemUsed : memUsage };
+ return { cpuPercent, memUsage: actualMemUsed };
} catch {
return { cpuPercent: 0, memUsage: 0 };
}
@@ -356,6 +360,16 @@ function handleCommand(command: MainProcessCommand): void {
// The next collection cycle will pick up the new environments
break;
+ case 'update_interval':
+ console.log(`[MetricsSubprocess] Updating collection interval to ${command.intervalMs}ms`);
+ COLLECT_INTERVAL = command.intervalMs;
+ // Clear existing interval and restart with new timing
+ if (collectInterval) {
+ clearInterval(collectInterval);
+ collectInterval = setInterval(collectMetrics, COLLECT_INTERVAL);
+ }
+ break;
+
case 'shutdown':
console.log('[MetricsSubprocess] Shutdown requested');
shutdown();
@@ -386,8 +400,15 @@ function shutdown(): void {
/**
* Start the metrics collector
*/
-function start(): void {
- console.log('[MetricsSubprocess] Starting metrics collection (every 10s)...');
+async function start(): Promise {
+ // Load interval from settings
+ try {
+ COLLECT_INTERVAL = await getMetricsCollectionInterval();
+ console.log(`[MetricsSubprocess] Starting metrics collection (every ${COLLECT_INTERVAL / 1000}s)...`);
+ } catch (error) {
+ console.error('[MetricsSubprocess] Failed to load interval from settings, using default 30s');
+ COLLECT_INTERVAL = 30000;
+ }
// Initial collection
collectMetrics();
diff --git a/src/lib/stores/grid-preferences.ts b/src/lib/stores/grid-preferences.ts
index 3c174af..cdcb276 100644
--- a/src/lib/stores/grid-preferences.ts
+++ b/src/lib/stores/grid-preferences.ts
@@ -70,8 +70,21 @@ function createGridPreferencesStore() {
return getDefaultColumnPreferences(gridId);
}
- // Return columns in saved order, filtering to visible ones
- return gridPrefs.columns.filter((col) => col.visible);
+ // Merge with defaults to ensure new columns are included
+ const defaults = getDefaultColumnPreferences(gridId);
+ const savedIds = new Set(gridPrefs.columns.map((c) => c.id));
+
+ // Start with saved visible columns
+ const result = gridPrefs.columns.filter((col) => col.visible);
+
+ // Add any new default columns that aren't in saved preferences
+ for (const def of defaults) {
+ if (!savedIds.has(def.id) && def.visible) {
+ result.push(def);
+ }
+ }
+
+ return result;
},
// Get all columns for a grid (visible and hidden, in order)
diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts
index d067e7f..d88e1ce 100644
--- a/src/lib/stores/settings.ts
+++ b/src/lib/stores/settings.ts
@@ -4,6 +4,7 @@ import { browser } from '$app/environment';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export type DownloadFormat = 'tar' | 'tar.gz';
+export type EventCollectionMode = 'stream' | 'poll';
export interface AppSettings {
confirmDestructive: boolean;
@@ -22,6 +23,11 @@ export interface AppSettings {
eventCleanupEnabled: boolean;
logBufferSizeKb: number;
defaultTimezone: string;
+ eventCollectionMode: EventCollectionMode;
+ eventPollInterval: number;
+ metricsCollectionInterval: number;
+ externalStackPaths: string[];
+ primaryStackLocation: string | null;
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -40,7 +46,12 @@ const DEFAULT_SETTINGS: AppSettings = {
scheduleCleanupEnabled: true,
eventCleanupEnabled: true,
logBufferSizeKb: 500,
- defaultTimezone: 'UTC'
+ defaultTimezone: 'UTC',
+ eventCollectionMode: 'stream',
+ eventPollInterval: 60000,
+ metricsCollectionInterval: 30000,
+ externalStackPaths: [],
+ primaryStackLocation: null
};
// Create a writable store for app settings
@@ -73,7 +84,12 @@ function createSettingsStore() {
scheduleCleanupEnabled: settings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
eventCleanupEnabled: settings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
logBufferSizeKb: settings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
- defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone
+ defaultTimezone: settings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
+ eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
+ eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
+ metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
+ externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
+ primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
});
}
} catch {
@@ -109,7 +125,12 @@ function createSettingsStore() {
scheduleCleanupEnabled: updatedSettings.scheduleCleanupEnabled ?? DEFAULT_SETTINGS.scheduleCleanupEnabled,
eventCleanupEnabled: updatedSettings.eventCleanupEnabled ?? DEFAULT_SETTINGS.eventCleanupEnabled,
logBufferSizeKb: updatedSettings.logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
- defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone
+ defaultTimezone: updatedSettings.defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
+ eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode,
+ eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
+ metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
+ externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
+ primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
});
}
} catch (error) {
@@ -248,6 +269,41 @@ function createSettingsStore() {
return newSettings;
});
},
+ setEventCollectionMode: (value: EventCollectionMode) => {
+ update((current) => {
+ const newSettings = { ...current, eventCollectionMode: value };
+ saveSettings({ eventCollectionMode: value });
+ return newSettings;
+ });
+ },
+ setEventPollInterval: (value: number) => {
+ update((current) => {
+ const newSettings = { ...current, eventPollInterval: value };
+ saveSettings({ eventPollInterval: value });
+ return newSettings;
+ });
+ },
+ setMetricsCollectionInterval: (value: number) => {
+ update((current) => {
+ const newSettings = { ...current, metricsCollectionInterval: value };
+ saveSettings({ metricsCollectionInterval: value });
+ return newSettings;
+ });
+ },
+ setExternalStackPaths: (value: string[]) => {
+ update((current) => {
+ const newSettings = { ...current, externalStackPaths: value };
+ saveSettings({ externalStackPaths: value });
+ return newSettings;
+ });
+ },
+ setPrimaryStackLocation: (value: string | null) => {
+ update((current) => {
+ const newSettings = { ...current, primaryStackLocation: value };
+ saveSettings({ primaryStackLocation: value });
+ return newSettings;
+ });
+ },
// Manual refresh from database
refresh: loadSettings
};
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 17bf013..a9309d2 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -34,6 +34,7 @@ export interface ImageInfo {
size: number;
virtualSize: number;
labels: Record;
+ containers: number; // Number of containers using this image
}
export interface VolumeUsage {
@@ -90,7 +91,9 @@ export interface ContainerStats {
id: string;
name: string;
cpuPercent: number;
- memoryUsage: number;
+ memoryUsage: number; // Actual usage (total - cache), same as docker stats
+ memoryRaw: number; // Raw total usage before cache subtraction
+ memoryCache: number; // File cache (inactive_file)
memoryLimit: number;
memoryPercent: number;
networkRx: number;
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index a589049..83c36c3 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -335,7 +335,7 @@
connectionType: env.connectionType || 'socket',
labels: env.labels || [],
scannerEnabled: false,
- online: false,
+ online: undefined, // undefined = connecting, false = offline, true = online
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
diff --git a/src/routes/activity/+page.svelte b/src/routes/activity/+page.svelte
index c859190..172f39d 100644
--- a/src/routes/activity/+page.svelte
+++ b/src/routes/activity/+page.svelte
@@ -30,7 +30,9 @@
Loader2,
FileX,
Heart,
- Search
+ Search,
+ Wifi,
+ Radio
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { currentEnvironment, environments as environmentsStore } from '$lib/stores/environment';
@@ -38,7 +40,7 @@
import { canAccess } from '$lib/stores/auth';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { toast } from 'svelte-sonner';
- import { formatDateTime } from '$lib/stores/settings';
+ import { formatDateTime, appSettings } from '$lib/stores/settings';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { DataGrid } from '$lib/components/data-grid';
@@ -643,7 +645,20 @@
-
0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" />
+
+
0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" />
+
+ {#if ($appSettings.eventCollectionMode || 'stream') === 'stream'}
+
+ Stream
+ {:else if ($appSettings.eventCollectionMode || 'stream') === 'poll'}
+
+ Poll ({($appSettings.eventPollInterval || 60000) / 1000}s)
+ {:else}
+ Off
+ {/if}
+
+
diff --git a/src/routes/api/containers/[id]/logs/stream/+server.ts b/src/routes/api/containers/[id]/logs/stream/+server.ts
index 9e2b77d..fd0d83a 100644
--- a/src/routes/api/containers/[id]/logs/stream/+server.ts
+++ b/src/routes/api/containers/[id]/logs/stream/+server.ts
@@ -285,11 +285,19 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
const inspectHeaders: Record
= {};
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
- inspectResponse = await fetch(inspectUrl, {
- headers: inspectHeaders,
- // @ts-ignore
- tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
- });
+ const fetchOpts: any = { headers: inspectHeaders };
+ if (config.type === 'https') {
+ fetchOpts.tls = {
+ sessionTimeout: 0, // Disable TLS session caching for mTLS
+ servername: config.host,
+ rejectUnauthorized: true
+ };
+ if (config.ca) fetchOpts.tls.ca = [config.ca];
+ if (config.cert) fetchOpts.tls.cert = [config.cert];
+ if (config.key) fetchOpts.tls.key = config.key;
+ fetchOpts.keepalive = false;
+ }
+ inspectResponse = await fetch(inspectUrl, fetchOpts);
}
if (inspectResponse.ok) {
@@ -341,12 +349,22 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
const logsHeaders: Record = {};
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
- response = await fetch(logsUrl, {
+ const fetchOpts: any = {
headers: logsHeaders,
- signal: abortController?.signal,
- // @ts-ignore
- tls: config.type === 'https' ? { ca: config.ca, cert: config.cert, key: config.key } : undefined
- });
+ signal: abortController?.signal
+ };
+ if (config.type === 'https') {
+ fetchOpts.tls = {
+ sessionTimeout: 0, // Disable TLS session caching for mTLS
+ servername: config.host,
+ rejectUnauthorized: true
+ };
+ if (config.ca) fetchOpts.tls.ca = [config.ca];
+ if (config.cert) fetchOpts.tls.cert = [config.cert];
+ if (config.key) fetchOpts.tls.key = config.key;
+ fetchOpts.keepalive = false;
+ }
+ response = await fetch(logsUrl, fetchOpts);
}
if (!response.ok) {
diff --git a/src/routes/api/containers/[id]/stats/+server.ts b/src/routes/api/containers/[id]/stats/+server.ts
index 5690d85..c06e99c 100644
--- a/src/routes/api/containers/[id]/stats/+server.ts
+++ b/src/routes/api/containers/[id]/stats/+server.ts
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
-import { getContainerStats } from '$lib/server/docker';
+import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
@@ -47,6 +47,28 @@ function calculateBlockIO(stats: any): { read: number; write: number } {
return { read, write };
}
+/**
+ * Calculate memory usage the same way Docker CLI does.
+ * Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
+ * - cgroup v2: subtract inactive_file from stats
+ * - cgroup v1: subtract total_inactive_file from stats
+ * See: https://docs.docker.com/engine/containers/runmetrics/
+ *
+ * Returns: { usage: actual memory (minus cache), raw: total usage, cache: file cache }
+ */
+function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
+ const raw = memoryStats?.usage || 0;
+ const stats = memoryStats?.stats || {};
+
+ // cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
+ const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
+
+ // Only subtract cache if it's less than raw usage (sanity check)
+ const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
+
+ return { usage, raw, cache };
+}
+
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
@@ -67,15 +89,17 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const stats = await getContainerStats(params.id, envIdNum) as any;
const cpuPercent = calculateCpuPercent(stats);
- const memoryUsage = stats.memory_stats?.usage || 0;
+ const memory = calculateMemoryUsage(stats.memory_stats);
const memoryLimit = stats.memory_stats?.limit || 1;
- const memoryPercent = (memoryUsage / memoryLimit) * 100;
+ const memoryPercent = (memory.usage / memoryLimit) * 100;
const networkIO = calculateNetworkIO(stats);
const blockIO = calculateBlockIO(stats);
return json({
cpuPercent: Math.round(cpuPercent * 100) / 100,
- memoryUsage,
+ memoryUsage: memory.usage,
+ memoryRaw: memory.raw,
+ memoryCache: memory.cache,
memoryLimit,
memoryPercent: Math.round(memoryPercent * 100) / 100,
networkRx: networkIO.rx,
@@ -85,6 +109,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
timestamp: Date.now()
});
} catch (error: any) {
+ // Return 404 for deleted environments so client can clear stale cache
+ if (error instanceof EnvironmentNotFoundError) {
+ return json({ error: 'Environment not found' }, { status: 404 });
+ }
console.error('Failed to get container stats:', error);
return json({ error: error.message || 'Failed to get stats' }, { status: 500 });
}
diff --git a/src/routes/api/containers/stats/+server.ts b/src/routes/api/containers/stats/+server.ts
index 60b78a5..ed1bf6e 100644
--- a/src/routes/api/containers/stats/+server.ts
+++ b/src/routes/api/containers/stats/+server.ts
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
-import { listContainers, getContainerStats } from '$lib/server/docker';
+import { listContainers, getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import type { ContainerStats } from '$lib/types';
@@ -48,6 +48,28 @@ function calculateBlockIO(stats: any): { read: number; write: number } {
return { read, write };
}
+/**
+ * Calculate memory usage the same way Docker CLI does.
+ * Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
+ * - cgroup v2: subtract inactive_file from stats
+ * - cgroup v1: subtract total_inactive_file from stats
+ * See: https://docs.docker.com/engine/containers/runmetrics/
+ *
+ * Returns: { usage: actual memory (minus cache), raw: total usage, cache: file cache }
+ */
+function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
+ const raw = memoryStats?.usage || 0;
+ const stats = memoryStats?.stats || {};
+
+ // cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
+ const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
+
+ // Only subtract cache if it's less than raw usage (sanity check)
+ const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
+
+ return { usage, raw, cache };
+}
+
// Helper to add timeout to promises
function withTimeout(promise: Promise, ms: number, fallback: T): Promise {
return Promise.race([
@@ -112,10 +134,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
if (!stats) return null;
const cpuPercent = calculateCpuPercent(stats);
- // Use raw memory usage (total memory attributed to container)
- const memoryUsage = stats.memory_stats?.usage || 0;
+ // Calculate memory usage the same way Docker CLI does (excludes cache)
+ const memory = calculateMemoryUsage(stats.memory_stats);
const memoryLimit = stats.memory_stats?.limit || 1;
- const memoryPercent = (memoryUsage / memoryLimit) * 100;
+ const memoryPercent = (memory.usage / memoryLimit) * 100;
const networkIO = calculateNetworkIO(stats);
const blockIO = calculateBlockIO(stats);
@@ -123,7 +145,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
id: container.id,
name: container.name,
cpuPercent: Math.round(cpuPercent * 100) / 100,
- memoryUsage,
+ memoryUsage: memory.usage,
+ memoryRaw: memory.raw,
+ memoryCache: memory.cache,
memoryLimit,
memoryPercent: Math.round(memoryPercent * 100) / 100,
networkRx: networkIO.rx,
@@ -142,6 +166,10 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
return json(validStats);
} catch (error: any) {
+ // Return 404 for deleted environments so client can clear stale cache
+ if (error instanceof EnvironmentNotFoundError) {
+ return json({ error: 'Environment not found' }, { status: 404 });
+ }
console.error('Failed to get container stats:', error);
return json([], { status: 200 }); // Return empty array instead of error
}
diff --git a/src/routes/api/dashboard/stats/+server.ts b/src/routes/api/dashboard/stats/+server.ts
index 02bfe99..50ec6e3 100644
--- a/src/routes/api/dashboard/stats/+server.ts
+++ b/src/routes/api/dashboard/stats/+server.ts
@@ -52,7 +52,7 @@ export interface EnvironmentStats {
updateCheckAutoUpdate: boolean;
labels?: string[];
connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
- online: boolean;
+ online?: boolean; // undefined = connecting, false = offline, true = online
error?: string;
containers: {
total: number;
diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts
index 4fc1f8b..e8b35b4 100644
--- a/src/routes/api/dashboard/stats/stream/+server.ts
+++ b/src/routes/api/dashboard/stats/stream/+server.ts
@@ -12,7 +12,6 @@ import {
listContainers,
listImages,
listNetworks,
- getDockerInfo,
getContainerStats,
getDiskUsage
} from '$lib/server/docker';
@@ -95,6 +94,28 @@ function calculateCpuPercent(stats: any): number {
return 0;
}
+/**
+ * Calculate memory usage the same way Docker CLI does.
+ * Docker subtracts cache (inactive_file) from total usage to show actual memory consumption.
+ * - cgroup v2: subtract inactive_file from stats
+ * - cgroup v1: subtract total_inactive_file from stats
+ * See: https://docs.docker.com/engine/containers/runmetrics/
+ */
+function calculateMemoryUsage(memoryStats: any): number {
+ const usage = memoryStats?.usage || 0;
+ const stats = memoryStats?.stats || {};
+
+ // cgroup v2 uses 'inactive_file', cgroup v1 uses 'total_inactive_file'
+ const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
+
+ // Only subtract cache if it's less than usage (sanity check)
+ if (cache > 0 && cache < usage) {
+ return usage - cache;
+ }
+
+ return usage;
+}
+
// Progressive stats loading - returns stats object and emits partial updates via callback
async function getEnvironmentStatsProgressive(
env: any,
@@ -150,23 +171,9 @@ async function getEnvironmentStatsProgressive(
envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate;
}
- // Check if Docker is accessible (with 5 second timeout)
- const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null);
- if (!dockerInfo) {
- envStats.error = 'Connection timeout or Docker not accessible';
- envStats.loading = undefined; // Clear loading states on error
- // Send offline status to client
- onPartialUpdate({
- id: env.id,
- online: false,
- error: envStats.error,
- loading: undefined
- });
- return envStats;
- }
- envStats.online = true;
-
// Get all database stats in parallel for better performance
+ // NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others
+ // Instead, we determine online status from whether listContainers succeeds
const [latestMetrics, eventStats, recentEventsResult, metricsHistory] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
@@ -204,10 +211,9 @@ async function getEnvironmentStatsProgressive(
}));
}
- // Send initial update with DB data and online status
+ // Send initial update with DB data (online status determined later by Docker API success)
onPartialUpdate({
id: env.id,
- online: true,
metrics: envStats.metrics,
events: envStats.events,
recentEvents: envStats.recentEvents,
@@ -223,9 +229,22 @@ async function getEnvironmentStatsProgressive(
return size && size > 0 ? size : 0;
};
- // PHASE 1: Containers (usually fast)
- const containersPromise = withTimeout(listContainers(true, env.id).catch(() => []), 10000, [])
+ // Track if Docker API is accessible - determined by listContainers success
+ let dockerApiAccessible = false;
+ let dockerApiError: string | null = null;
+
+ // PHASE 1: Containers (usually fast) - this determines online status
+ // Use 10s timeout - this is the critical path that determines if env is online
+ const containersPromise = withTimeout(listContainers(true, env.id), 10000, null)
.then(async (containers) => {
+ // Timeout returns null
+ if (containers === null) {
+ throw new Error('Connection timeout');
+ }
+ // If we got here, Docker API is accessible
+ dockerApiAccessible = true;
+ envStats.online = true;
+
envStats.containers.total = containers.length;
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
@@ -236,11 +255,39 @@ async function getEnvironmentStatsProgressive(
onPartialUpdate({
id: env.id,
+ online: true,
containers: { ...envStats.containers },
loading: { ...envStats.loading! }
});
return containers;
+ })
+ .catch((error) => {
+ // Docker API failed - mark as offline
+ dockerApiAccessible = false;
+ const errorStr = String(error);
+ if (errorStr.includes('not connected') || errorStr.includes('Edge agent')) {
+ dockerApiError = 'Agent not connected';
+ } else if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
+ dockerApiError = 'Docker socket not accessible';
+ } else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
+ dockerApiError = 'Connection lost';
+ } else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
+ dockerApiError = 'Connection timeout';
+ } else {
+ dockerApiError = 'Connection error';
+ }
+ envStats.error = dockerApiError;
+ envStats.loading!.containers = false;
+
+ onPartialUpdate({
+ id: env.id,
+ online: false,
+ error: dockerApiError,
+ loading: { ...envStats.loading! }
+ });
+
+ return [] as any[];
});
// PHASE 2: Images, Networks, Stacks (medium speed) - run in parallel
@@ -339,7 +386,7 @@ async function getEnvironmentStatsProgressive(
if (!stats) return null;
const cpuPercent = calculateCpuPercent(stats);
- const memoryUsage = stats.memory_stats?.usage || 0;
+ const memoryUsage = calculateMemoryUsage(stats.memory_stats);
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memoryUsage / memoryLimit) * 100;
diff --git a/src/routes/api/environments/+server.ts b/src/routes/api/environments/+server.ts
index d97c978..46b8d71 100644
--- a/src/routes/api/environments/+server.ts
+++ b/src/routes/api/environments/+server.ts
@@ -1,9 +1,10 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
-import { getEnvironments, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
+import { getEnvironments, getEnvironmentByName, createEnvironment, assignUserRole, getRoleByName, getEnvironmentPublicIps, setEnvironmentPublicIp, getEnvUpdateCheckSettings, getEnvironmentTimezone, type Environment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
+import { cleanPem } from '$lib/utils/pem';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -69,6 +70,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
return json({ error: 'Name is required' }, { status: 400 });
}
+ // Check if environment with this name already exists
+ const existing = await getEnvironmentByName(data.name);
+ if (existing) {
+ return json({ error: 'An environment with this name already exists' }, { status: 409 });
+ }
+
// Host is required for direct and hawser-standard connections
const connectionType = data.connectionType || 'socket';
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
@@ -83,9 +90,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
host: data.host,
port: data.port || 2375,
protocol: data.protocol || 'http',
- tlsCa: data.tlsCa,
- tlsCert: data.tlsCert,
- tlsKey: data.tlsKey,
+ tlsCa: cleanPem(data.tlsCa),
+ tlsCert: cleanPem(data.tlsCert),
+ tlsKey: cleanPem(data.tlsKey),
tlsSkipVerify: data.tlsSkipVerify || false,
icon: data.icon || 'globe',
socketPath: data.socketPath || '/var/run/docker.sock',
@@ -124,7 +131,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
return json(env);
} catch (error) {
console.error('Failed to create environment:', error);
- const message = error instanceof Error ? error.message : 'Failed to create environment';
- return json({ error: message }, { status: 500 });
+ return json({ error: 'Failed to create environment' }, { status: 500 });
}
};
diff --git a/src/routes/api/environments/[id]/+server.ts b/src/routes/api/environments/[id]/+server.ts
index 52c4ac6..1f34bd9 100644
--- a/src/routes/api/environments/[id]/+server.ts
+++ b/src/routes/api/environments/[id]/+server.ts
@@ -6,6 +6,7 @@ import { deleteGitStackFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { refreshSubprocessEnvironments } from '$lib/server/subprocess-manager';
import { serializeLabels, parseLabels, MAX_LABELS } from '$lib/utils/label-colors';
+import { cleanPem } from '$lib/utils/pem';
import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser';
@@ -62,9 +63,9 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
host: data.host,
port: data.port,
protocol: data.protocol,
- tlsCa: data.tlsCa,
- tlsCert: data.tlsCert,
- tlsKey: data.tlsKey,
+ tlsCa: cleanPem(data.tlsCa),
+ tlsCert: cleanPem(data.tlsCert),
+ tlsKey: cleanPem(data.tlsKey),
tlsSkipVerify: data.tlsSkipVerify,
icon: data.icon,
socketPath: data.socketPath,
diff --git a/src/routes/api/environments/test/+server.ts b/src/routes/api/environments/test/+server.ts
index f7221ff..7222c8b 100644
--- a/src/routes/api/environments/test/+server.ts
+++ b/src/routes/api/environments/test/+server.ts
@@ -60,50 +60,54 @@ export const POST: RequestHandler = async ({ request }) => {
headers['X-Hawser-Token'] = config.hawserToken;
}
- // For HTTPS with custom CA or skip verification, use subprocess to avoid Vite dev server TLS issues
- if (protocol === 'https' && (config.tlsCa || config.tlsSkipVerify)) {
- const fs = await import('node:fs');
- let tempCaPath = '';
+ // For HTTPS with custom CA, client certs, or skip verification, use subprocess to avoid Vite dev server TLS issues
+ if (protocol === 'https' && (config.tlsCa || config.tlsCert || config.tlsSkipVerify)) {
+ // Clean PEM content (remove extra whitespace)
+ const cleanPem = (pem: string) => pem
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ .join('\n');
- // Clean the certificate - remove leading/trailing whitespace from each line
- let cleanedCa = '';
- if (config.tlsCa && !config.tlsSkipVerify) {
- cleanedCa = config.tlsCa
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line.length > 0)
- .join('\n');
-
- tempCaPath = `/tmp/dockhand-ca-${Date.now()}.pem`;
- fs.writeFileSync(tempCaPath, cleanedCa);
- }
-
- // Build Bun script that runs outside Vite's process (Vite interferes with TLS)
- const tlsConfig = config.tlsSkipVerify
- ? `tls: { rejectUnauthorized: false }`
- : `tls: { ca: await Bun.file('${tempCaPath}').text() }`;
+ // Pass config as base64-encoded JSON to avoid escaping issues
+ const tlsConfig = {
+ url: `https://${host}:${port}/info`,
+ headers,
+ tlsSkipVerify: config.tlsSkipVerify || false,
+ ca: config.tlsCa && !config.tlsSkipVerify ? cleanPem(config.tlsCa) : null,
+ cert: config.tlsCert ? cleanPem(config.tlsCert) : null,
+ key: config.tlsKey ? cleanPem(config.tlsKey) : null,
+ host
+ };
+ const configBase64 = Buffer.from(JSON.stringify(tlsConfig)).toString('base64');
+ // Inline script with config embedded (bun -e doesn't pass argv correctly)
const scriptContent = `
-const response = await fetch('https://${host}:${port}/info', {
- headers: ${JSON.stringify(headers)},
- ${tlsConfig}
-});
-const body = await response.text();
-console.log(JSON.stringify({ status: response.status, body }));
+const config = JSON.parse(Buffer.from('${configBase64}', 'base64').toString());
+try {
+ const tls = {
+ sessionTimeout: 0,
+ servername: config.host,
+ rejectUnauthorized: !config.tlsSkipVerify
+ };
+ if (config.ca) tls.ca = [config.ca];
+ if (config.cert) tls.cert = [config.cert];
+ if (config.key) tls.key = config.key;
+ const response = await fetch(config.url, {
+ headers: config.headers,
+ tls,
+ keepalive: false
+ });
+ const body = await response.text();
+ console.log(JSON.stringify({ status: response.status, body }));
+} catch (e) {
+ console.log(JSON.stringify({ error: e.message }));
+}
`;
- const scriptPath = `/tmp/dockhand-test-${Date.now()}.ts`;
- fs.writeFileSync(scriptPath, scriptContent);
-
- const proc = Bun.spawn(['bun', scriptPath], { stdout: 'pipe', stderr: 'pipe' });
+ const proc = Bun.spawn(['bun', '-e', scriptContent], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
- // Cleanup temp files
- if (tempCaPath) {
- try { fs.unlinkSync(tempCaPath); } catch {}
- }
- try { fs.unlinkSync(scriptPath); } catch {}
-
if (!output.trim()) {
throw new Error(stderr || 'Empty response from TLS test subprocess');
}
diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts
index caeb8c0..2ade909 100644
--- a/src/routes/api/events/+server.ts
+++ b/src/routes/api/events/+server.ts
@@ -1,5 +1,5 @@
import type { RequestHandler } from './$types';
-import { getDockerEvents } from '$lib/server/docker';
+import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker';
import { getEnvironment } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
@@ -118,8 +118,13 @@ export const GET: RequestHandler = async ({ url }) => {
processEvents();
} catch (error: any) {
- console.error('Failed to connect to Docker events:', error);
- sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
+ if (error instanceof EnvironmentNotFoundError) {
+ // Expected error when environment doesn't exist - don't spam logs
+ sendEvent('error', { message: 'Environment not found' });
+ } else {
+ console.error('Failed to connect to Docker events:', error);
+ sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
+ }
clearInterval(heartbeatInterval);
controller.close();
}
diff --git a/src/routes/api/git/stacks/+server.ts b/src/routes/api/git/stacks/+server.ts
index 22e5f28..f769413 100644
--- a/src/routes/api/git/stacks/+server.ts
+++ b/src/routes/api/git/stacks/+server.ts
@@ -11,7 +11,7 @@ import {
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule } from '$lib/server/scheduler';
-import crypto from 'node:crypto';
+import { secureRandomBytes } from '$lib/server/crypto-fallback';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
@@ -94,7 +94,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Generate webhook secret if webhook is enabled
let webhookSecret = data.webhookSecret;
if (data.webhookEnabled && !webhookSecret) {
- webhookSecret = crypto.randomBytes(32).toString('hex');
+ webhookSecret = secureRandomBytes(32).toString('hex');
}
const gitStack = await createGitStack({
diff --git a/src/routes/api/registry/tags/+server.ts b/src/routes/api/registry/tags/+server.ts
index cba2360..b19312a 100644
--- a/src/routes/api/registry/tags/+server.ts
+++ b/src/routes/api/registry/tags/+server.ts
@@ -16,7 +16,16 @@ function isDockerHub(url: string): boolean {
lower.includes('registry.hub.docker.com');
}
-async function fetchDockerHubTags(imageName: string): Promise {
+interface PaginatedTags {
+ tags: TagInfo[];
+ total: number;
+ page: number;
+ pageSize: number;
+ hasNext: boolean;
+ hasPrev: boolean;
+}
+
+async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize: number = 20): Promise {
// Docker Hub uses a different API
// For official images: https://hub.docker.com/v2/repositories/library//tags
// For user images: https://hub.docker.com/v2/repositories///tags
@@ -27,7 +36,7 @@ async function fetchDockerHubTags(imageName: string): Promise {
repoPath = `library/${imageName}`;
}
- const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=100&ordering=last_updated`;
+ const url = `https://hub.docker.com/v2/repositories/${repoPath}/tags?page_size=${pageSize}&page=${page}&ordering=last_updated`;
const response = await fetch(url, {
headers: {
@@ -45,12 +54,21 @@ async function fetchDockerHubTags(imageName: string): Promise {
const data = await response.json();
const results = data.results || [];
- return results.map((tag: any) => ({
+ const tags = results.map((tag: any) => ({
name: tag.name,
size: tag.full_size || tag.images?.[0]?.size,
lastUpdated: tag.last_updated || tag.tag_last_pushed,
digest: tag.images?.[0]?.digest
}));
+
+ return {
+ tags,
+ total: data.count || 0,
+ page,
+ pageSize,
+ hasNext: !!data.next,
+ hasPrev: !!data.previous
+ };
}
async function fetchRegistryTags(registry: any, imageName: string): Promise {
@@ -104,16 +122,18 @@ export const GET: RequestHandler = async ({ url }) => {
try {
const registryId = url.searchParams.get('registry');
const imageName = url.searchParams.get('image');
+ const page = parseInt(url.searchParams.get('page') || '1');
+ const pageSize = parseInt(url.searchParams.get('pageSize') || '20');
if (!imageName) {
return json({ error: 'Image name is required' }, { status: 400 });
}
- let tags: TagInfo[];
+ let result: PaginatedTags;
if (!registryId) {
// No registry specified, assume Docker Hub
- tags = await fetchDockerHubTags(imageName);
+ result = await fetchDockerHubTags(imageName, page, pageSize);
} else {
const registry = await getRegistry(parseInt(registryId));
if (!registry) {
@@ -121,13 +141,22 @@ export const GET: RequestHandler = async ({ url }) => {
}
if (isDockerHub(registry.url)) {
- tags = await fetchDockerHubTags(imageName);
+ result = await fetchDockerHubTags(imageName, page, pageSize);
} else {
- tags = await fetchRegistryTags(registry, imageName);
+ // V2 registries don't support pagination well, return all tags
+ const tags = await fetchRegistryTags(registry, imageName);
+ result = {
+ tags,
+ total: tags.length,
+ page: 1,
+ pageSize: tags.length,
+ hasNext: false,
+ hasPrev: false
+ };
}
}
- return json(tags);
+ return json(result);
} catch (error: any) {
console.error('Error fetching tags:', error);
diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts
index 6cd65ba..012429d 100644
--- a/src/routes/api/settings/general/+server.ts
+++ b/src/routes/api/settings/general/+server.ts
@@ -15,14 +15,26 @@ import {
getEventCleanupEnabled,
setEventCleanupEnabled,
getDefaultTimezone,
- setDefaultTimezone
+ setDefaultTimezone,
+ getEventCollectionMode,
+ setEventCollectionMode,
+ getEventPollInterval,
+ setEventPollInterval,
+ getMetricsCollectionInterval,
+ setMetricsCollectionInterval,
+ getExternalStackPaths,
+ setExternalStackPaths,
+ getPrimaryStackLocation,
+ setPrimaryStackLocation
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSystemJobs } from '$lib/server/scheduler';
+import { sendToEventSubprocess, sendToMetricsSubprocess, type UpdateIntervalCommand } from '$lib/server/subprocess-manager';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export type DownloadFormat = 'tar' | 'tar.gz';
+export type EventCollectionMode = 'stream' | 'poll';
export interface GeneralSettings {
confirmDestructive: boolean;
@@ -41,6 +53,10 @@ export interface GeneralSettings {
eventCleanupEnabled: boolean;
logBufferSizeKb: number;
defaultTimezone: string;
+ // Background monitoring settings
+ eventCollectionMode: EventCollectionMode;
+ eventPollInterval: number;
+ metricsCollectionInterval: number;
// Theme settings (for when auth is disabled)
lightTheme: string;
darkTheme: string;
@@ -48,6 +64,10 @@ export interface GeneralSettings {
fontSize: string;
gridFontSize: string;
terminalFont: string;
+ // External stack paths
+ externalStackPaths: string[];
+ // Primary stack location
+ primaryStackLocation: string | null;
}
const DEFAULT_SETTINGS: Omit = {
@@ -61,6 +81,9 @@ const DEFAULT_SETTINGS: Omit {
eventCleanupEnabled,
logBufferSizeKb,
defaultTimezone,
+ eventCollectionMode,
+ eventPollInterval,
+ metricsCollectionInterval,
lightTheme,
darkTheme,
font,
fontSize,
gridFontSize,
- terminalFont
+ terminalFont,
+ externalStackPaths,
+ primaryStackLocation
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -127,12 +155,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
getEventCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
+ getEventCollectionMode(),
+ getEventPollInterval(),
+ getMetricsCollectionInterval(),
getSetting('theme_light'),
getSetting('theme_dark'),
getSetting('theme_font'),
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
- getSetting('theme_terminal_font')
+ getSetting('theme_terminal_font'),
+ getExternalStackPaths(),
+ getPrimaryStackLocation()
]);
const settings: GeneralSettings = {
@@ -152,12 +185,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
eventCleanupEnabled,
logBufferSizeKb: logBufferSizeKb ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: defaultTimezone ?? DEFAULT_SETTINGS.defaultTimezone,
+ eventCollectionMode: (eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
+ eventPollInterval: eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
+ metricsCollectionInterval: metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
lightTheme: lightTheme ?? DEFAULT_SETTINGS.lightTheme,
darkTheme: darkTheme ?? DEFAULT_SETTINGS.darkTheme,
font: font ?? DEFAULT_SETTINGS.font,
fontSize: fontSize ?? DEFAULT_SETTINGS.fontSize,
gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize,
- terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont
+ terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
+ externalStackPaths,
+ primaryStackLocation
};
return json(settings);
@@ -175,7 +213,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
- const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont } = body;
+ const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, externalStackPaths, primaryStackLocation } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -228,6 +266,25 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Refresh system jobs to use the new timezone
await refreshSystemJobs();
}
+ if (eventCollectionMode !== undefined && (eventCollectionMode === 'stream' || eventCollectionMode === 'poll')) {
+ await setEventCollectionMode(eventCollectionMode);
+ // Notify event subprocess to refresh collectors with new mode
+ sendToEventSubprocess({ type: 'refresh_environments' });
+ }
+ if (eventPollInterval !== undefined && typeof eventPollInterval === 'number') {
+ // Validate: 30s - 300s (30 seconds to 5 minutes)
+ const validatedInterval = Math.max(30000, Math.min(300000, eventPollInterval));
+ await setEventPollInterval(validatedInterval);
+ // Notify event subprocess to refresh collectors with new interval
+ sendToEventSubprocess({ type: 'refresh_environments' });
+ }
+ if (metricsCollectionInterval !== undefined && typeof metricsCollectionInterval === 'number') {
+ // Validate: 10s - 300s (10 seconds to 5 minutes)
+ const validatedInterval = Math.max(10000, Math.min(300000, metricsCollectionInterval));
+ await setMetricsCollectionInterval(validatedInterval);
+ // Notify metrics subprocess to update its collection interval
+ sendToMetricsSubprocess({ type: 'update_interval', intervalMs: validatedInterval });
+ }
if (lightTheme !== undefined && VALID_LIGHT_THEMES.includes(lightTheme)) {
await setSetting('theme_light', lightTheme);
}
@@ -246,6 +303,20 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (terminalFont !== undefined && VALID_TERMINAL_FONTS.includes(terminalFont)) {
await setSetting('theme_terminal_font', terminalFont);
}
+ if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
+ // Filter to valid non-empty strings
+ const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
+ await setExternalStackPaths(validPaths);
+ }
+ if (primaryStackLocation !== undefined) {
+ // Accept string or null
+ if (primaryStackLocation === null || (typeof primaryStackLocation === 'string' && primaryStackLocation.trim())) {
+ await setPrimaryStackLocation(primaryStackLocation);
+ } else if (primaryStackLocation === '') {
+ // Empty string means clear the setting
+ await setPrimaryStackLocation(null);
+ }
+ }
// Fetch all settings in parallel for the response
const [
@@ -265,12 +336,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupEnabledVal,
logBufferSizeKbVal,
defaultTimezoneVal,
+ eventCollectionModeVal,
+ eventPollIntervalVal,
+ metricsCollectionIntervalVal,
lightThemeVal,
darkThemeVal,
fontVal,
fontSizeVal,
gridFontSizeVal,
- terminalFontVal
+ terminalFontVal,
+ externalStackPathsVal,
+ primaryStackLocationVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -288,12 +364,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getEventCleanupEnabled(),
getSetting('log_buffer_size_kb'),
getDefaultTimezone(),
+ getEventCollectionMode(),
+ getEventPollInterval(),
+ getMetricsCollectionInterval(),
getSetting('theme_light'),
getSetting('theme_dark'),
getSetting('theme_font'),
getSetting('theme_font_size'),
getSetting('theme_grid_font_size'),
- getSetting('theme_terminal_font')
+ getSetting('theme_terminal_font'),
+ getExternalStackPaths(),
+ getPrimaryStackLocation()
]);
const settings: GeneralSettings = {
@@ -313,12 +394,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupEnabled: eventCleanupEnabledVal,
logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb,
defaultTimezone: defaultTimezoneVal ?? DEFAULT_SETTINGS.defaultTimezone,
+ eventCollectionMode: (eventCollectionModeVal ?? DEFAULT_SETTINGS.eventCollectionMode) as EventCollectionMode,
+ eventPollInterval: eventPollIntervalVal ?? DEFAULT_SETTINGS.eventPollInterval,
+ metricsCollectionInterval: metricsCollectionIntervalVal ?? DEFAULT_SETTINGS.metricsCollectionInterval,
lightTheme: lightThemeVal ?? DEFAULT_SETTINGS.lightTheme,
darkTheme: darkThemeVal ?? DEFAULT_SETTINGS.darkTheme,
font: fontVal ?? DEFAULT_SETTINGS.font,
fontSize: fontSizeVal ?? DEFAULT_SETTINGS.fontSize,
gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize,
- terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont
+ terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
+ externalStackPaths: externalStackPathsVal,
+ primaryStackLocation: primaryStackLocationVal
};
return json(settings);
diff --git a/src/routes/api/stacks/+server.ts b/src/routes/api/stacks/+server.ts
index 39a675e..14c245d 100644
--- a/src/routes/api/stacks/+server.ts
+++ b/src/routes/api/stacks/+server.ts
@@ -35,11 +35,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const existingNames = new Set(stacks.map((s) => s.name));
for (const source of stackSources) {
- // Only add internal/git stacks that aren't already in the list
- if (
- !existingNames.has(source.stackName) &&
- (source.sourceType === 'internal' || source.sourceType === 'git')
- ) {
+ // Add stacks from database that aren't already in the Docker list
+ // This includes internal, git, and external (adopted) stacks that are currently down
+ if (!existingNames.has(source.stackName)) {
stacks.push({
name: source.stackName,
containers: [],
@@ -78,7 +76,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
try {
const body = await request.json();
- const { name, compose, start, envVars, rawEnvContent } = body;
+ const { name, compose, start, envVars, rawEnvContent, composePath, envPath } = body;
if (!name || typeof name !== 'string') {
return json({ error: 'Stack name is required' }, { status: 400 });
@@ -90,7 +88,10 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
// If start is false, only create the compose file without deploying
if (start === false) {
- const result = await saveStackComposeFile(name, compose, true);
+ const result = await saveStackComposeFile(name, compose, true, envIdNum, {
+ composePath: composePath || undefined,
+ envPath: envPath || undefined
+ });
if (!result.success) {
return json({ error: result.error }, { status: 400 });
}
@@ -100,7 +101,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
if (rawEnvContent) {
// Write raw content to .env file (should NOT contain secrets)
- await writeRawStackEnvFile(name, rawEnvContent);
+ await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
@@ -108,14 +109,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
- await saveStackEnvVars(name, envVars, envIdNum);
+ await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
}
- // Record the stack as internally created
+ // Record the stack as internally created with custom paths if provided
await upsertStackSource({
stackName: name,
environmentId: envIdNum,
- sourceType: 'internal'
+ sourceType: 'internal',
+ composePath: composePath || undefined,
+ envPath: envPath || undefined
});
return json({ success: true, started: false });
@@ -124,13 +127,16 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
// Save environment variables BEFORE deploying so they're available during start
if (rawEnvContent || (envVars && Array.isArray(envVars) && envVars.length > 0)) {
// First ensure the stack directory exists by saving compose file
- await saveStackComposeFile(name, compose, true);
+ await saveStackComposeFile(name, compose, true, envIdNum, {
+ composePath: composePath || undefined,
+ envPath: envPath || undefined
+ });
// - rawEnvContent: non-secret vars with comments → .env file
// - envVars: ALL vars → DB (secrets stored for shell injection, non-secrets for metadata)
if (rawEnvContent) {
// Write raw content to .env file (should NOT contain secrets)
- await writeRawStackEnvFile(name, rawEnvContent);
+ await writeRawStackEnvFile(name, rawEnvContent, envIdNum, envPath || undefined);
}
// Save ALL vars to DB (secrets for shell injection at runtime)
if (envVars && Array.isArray(envVars) && envVars.length > 0) {
@@ -138,7 +144,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
}
// Fallback: if no rawEnvContent, generate .env from non-secret vars
if (!rawEnvContent && envVars && Array.isArray(envVars) && envVars.length > 0) {
- await saveStackEnvVars(name, envVars, envIdNum);
+ await saveStackEnvVars(name, envVars, envIdNum, envPath || undefined);
}
}
@@ -146,18 +152,22 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
const result = await deployStack({
name,
compose,
- envId: envIdNum
+ envId: envIdNum,
+ composePath: composePath || undefined,
+ envPath: envPath || undefined
});
if (!result.success) {
return json({ error: result.error, output: result.output }, { status: 400 });
}
- // Record the stack as internally created
+ // Record the stack as internally created with custom paths if provided
await upsertStackSource({
stackName: name,
environmentId: envIdNum,
- sourceType: 'internal'
+ sourceType: 'internal',
+ composePath: composePath || undefined,
+ envPath: envPath || undefined
});
return json({ success: true, started: true, output: result.output });
diff --git a/src/routes/api/stacks/[name]/+server.ts b/src/routes/api/stacks/[name]/+server.ts
index 38098b5..9ed0945 100644
--- a/src/routes/api/stacks/[name]/+server.ts
+++ b/src/routes/api/stacks/[name]/+server.ts
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
-import { removeStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
+import { removeStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
@@ -34,9 +34,6 @@ export const DELETE: RequestHandler = async (event) => {
}
return json({ success: true });
} catch (error) {
- if (error instanceof ExternalStackError) {
- return json({ error: error.message }, { status: 400 });
- }
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
diff --git a/src/routes/api/stacks/[name]/compose/+server.ts b/src/routes/api/stacks/[name]/compose/+server.ts
index fa2664a..750838f 100644
--- a/src/routes/api/stacks/[name]/compose/+server.ts
+++ b/src/routes/api/stacks/[name]/compose/+server.ts
@@ -4,22 +4,36 @@ import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/ser
import { authorize } from '$lib/server/authorize';
// GET /api/stacks/[name]/compose - Get compose file content
-export const GET: RequestHandler = async ({ params, cookies }) => {
+export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('stacks', 'view'))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const { name } = params;
+ const envId = url.searchParams.get('env');
+ const envIdNum = envId ? parseInt(envId) : undefined;
try {
- const result = await getStackComposeFile(name);
+ const result = await getStackComposeFile(name, envIdNum);
if (!result.success) {
- return json({ error: result.error }, { status: 404 });
+ // Return info about what's needed - unified response for all missing compose files
+ return json({
+ error: result.error,
+ needsFileLocation: result.needsFileLocation || false,
+ composePath: result.composePath,
+ envPath: result.envPath
+ }, { status: 404 });
}
- return json({ content: result.content, stackDir: result.stackDir });
+ return json({
+ content: result.content,
+ stackDir: result.stackDir,
+ composePath: result.composePath,
+ envPath: result.envPath,
+ suggestedEnvPath: result.suggestedEnvPath
+ });
} catch (error: any) {
console.error(`Error getting compose file for stack ${name}:`, error);
return json({ error: error.message || 'Failed to get compose file' }, { status: 500 });
@@ -41,16 +55,29 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
try {
const body = await request.json();
- const { content, restart = false } = body;
+ const { content, restart = false, composePath, envPath, moveFromDir, oldComposePath, oldEnvPath } = body;
if (!content || typeof content !== 'string') {
return json({ error: 'Compose file content is required' }, { status: 400 });
}
+ // Build options object for custom paths, move operation, and file renames
+ const pathOptions = (composePath || envPath !== undefined || moveFromDir || oldComposePath || oldEnvPath)
+ ? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath }
+ : undefined;
+
let result;
if (restart) {
// Deploy with docker compose up -d --force-recreate
// Force recreate ensures env var changes are applied
+ // Note: deployStack uses requireComposeFile which will use saved paths
+ // Save paths first if provided
+ if (pathOptions) {
+ const saveResult = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
+ if (!saveResult.success) {
+ return json({ error: saveResult.error }, { status: 500 });
+ }
+ }
result = await deployStack({
name,
compose: content,
@@ -58,8 +85,8 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
forceRecreate: true
});
} else {
- // Just save the file without restarting
- result = await saveStackComposeFile(name, content);
+ // Just save the file without restarting (update operation, not create)
+ result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
}
if (!result.success) {
diff --git a/src/routes/api/stacks/[name]/down/+server.ts b/src/routes/api/stacks/[name]/down/+server.ts
index 1995f71..19f6aa0 100644
--- a/src/routes/api/stacks/[name]/down/+server.ts
+++ b/src/routes/api/stacks/[name]/down/+server.ts
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
-import { downStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
+import { downStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
@@ -42,9 +42,6 @@ export const POST: RequestHandler = async (event) => {
}
return json({ success: true, output: result.output });
} catch (error) {
- if (error instanceof ExternalStackError) {
- return json({ error: error.message }, { status: 400 });
- }
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
diff --git a/src/routes/api/stacks/[name]/env/+server.ts b/src/routes/api/stacks/[name]/env/+server.ts
index 4105e12..0265db6 100644
--- a/src/routes/api/stacks/[name]/env/+server.ts
+++ b/src/routes/api/stacks/[name]/env/+server.ts
@@ -1,9 +1,9 @@
import { json } from '@sveltejs/kit';
-import { getStackEnvVars, setStackEnvVars } from '$lib/server/db';
-import { getStacksDir } from '$lib/server/stacks';
+import { getStackEnvVars, setStackEnvVars, getStackSource } from '$lib/server/db';
+import { findStackDir } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { existsSync } from 'node:fs';
-import { join } from 'node:path';
+import { join, dirname } from 'node:path';
import type { RequestHandler } from './$types';
/**
@@ -61,12 +61,37 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const dbVariables = await getStackEnvVars(stackName, envIdNum, true);
const dbByKey = new Map(dbVariables.map(v => [v.key, v]));
- // Try to read .env file from stack directory (only contains non-secrets)
- const stacksDir = getStacksDir();
- const envFilePath = join(stacksDir, stackName, '.env');
+ // Check if this stack has a custom compose path configured
+ const source = await getStackSource(stackName, envIdNum);
+
+ // Determine the env file path based on path resolution rules:
+ // - envPath = '' (empty string) → explicitly no env file
+ // - envPath = '/path/.env' → use custom path
+ // - envPath = null with composePath → suggest .env next to compose (but don't auto-load)
+ // - envPath = null without composePath → use default location
+ let envFilePath: string | null = null;
+
+ if (source?.envPath === '') {
+ // Empty string = explicitly no env file
+ envFilePath = null;
+ } else if (source?.envPath) {
+ // Custom env path specified
+ envFilePath = source.envPath;
+ } else if (source?.composePath) {
+ // Custom compose path but no env path - suggest .env next to compose
+ // For loading, check if it exists (but don't fail if it doesn't)
+ envFilePath = join(dirname(source.composePath), '.env');
+ } else {
+ // Default location - .env in stack directory
+ const stackDir = await findStackDir(stackName, envIdNum);
+ if (stackDir) {
+ envFilePath = join(stackDir, '.env');
+ }
+ }
+
let fileVars: Record = {};
- if (existsSync(envFilePath)) {
+ if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
fileVars = parseEnvFile(content);
diff --git a/src/routes/api/stacks/[name]/env/raw/+server.ts b/src/routes/api/stacks/[name]/env/raw/+server.ts
index e5fac4d..6fbc3c5 100644
--- a/src/routes/api/stacks/[name]/env/raw/+server.ts
+++ b/src/routes/api/stacks/[name]/env/raw/+server.ts
@@ -1,8 +1,9 @@
import { json } from '@sveltejs/kit';
-import { getStacksDir } from '$lib/server/stacks';
+import { findStackDir, getStackDir } from '$lib/server/stacks';
+import { getStackSource } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { existsSync, rmSync } from 'node:fs';
-import { join } from 'node:path';
+import { join, dirname } from 'node:path';
import type { RequestHandler } from './$types';
/**
@@ -26,11 +27,36 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const stackName = decodeURIComponent(params.name);
- const stacksDir = getStacksDir();
- const envFilePath = join(stacksDir, stackName, '.env');
+
+ // Check if this stack has custom paths configured
+ const source = await getStackSource(stackName, envIdNum);
+
+ // Determine the env file path based on path resolution rules:
+ // - envPath = '' (empty string) → explicitly no env file
+ // - envPath = '/path/.env' → use custom path
+ // - envPath = null with composePath → suggest .env next to compose
+ // - envPath = null without composePath → use default location
+ let envFilePath: string | null = null;
+
+ if (source?.envPath === '') {
+ // Empty string = explicitly no env file
+ return json({ content: '', noEnvFile: true });
+ } else if (source?.envPath) {
+ // Custom env path specified
+ envFilePath = source.envPath;
+ } else if (source?.composePath) {
+ // Custom compose path but no env path - suggest .env next to compose
+ envFilePath = join(dirname(source.composePath), '.env');
+ } else {
+ // Default location - .env in stack directory
+ const stackDir = await findStackDir(stackName, envIdNum);
+ if (stackDir) {
+ envFilePath = join(stackDir, '.env');
+ }
+ }
let content = '';
- if (existsSync(envFilePath)) {
+ if (envFilePath && existsSync(envFilePath)) {
try {
content = await Bun.file(envFilePath).text();
} catch {
@@ -73,12 +99,35 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
return json({ error: 'Invalid request body: content string required' }, { status: 400 });
}
- const stacksDir = getStacksDir();
- const stackDir = join(stacksDir, stackName);
- const envFilePath = join(stackDir, '.env');
+ // Check if this stack has custom paths configured
+ const source = await getStackSource(stackName, envIdNum);
- // Only write if stack directory exists
- if (!existsSync(stackDir)) {
+ // Determine the env file path based on path resolution rules:
+ // - envPath = '' (empty string) → explicitly no env file, don't write
+ // - envPath = '/path/.env' → use custom path
+ // - envPath = null with composePath → suggest .env next to compose
+ // - envPath = null without composePath → use default location
+ let envFilePath: string | null = null;
+
+ if (source?.envPath === '') {
+ // Empty string = explicitly no env file - don't allow writes
+ return json({ success: true, noEnvFile: true });
+ } else if (source?.envPath) {
+ // Custom env path specified
+ envFilePath = source.envPath;
+ } else if (source?.composePath) {
+ // Custom compose path but no env path - suggest .env next to compose
+ envFilePath = join(dirname(source.composePath), '.env');
+ } else {
+ // Default location - .env in stack directory
+ const stackDir = await findStackDir(stackName, envIdNum);
+ if (stackDir) {
+ envFilePath = join(stackDir, '.env');
+ }
+ }
+
+ // Only write if we have a valid path
+ if (!envFilePath) {
return json({ error: 'Stack directory not found' }, { status: 404 });
}
diff --git a/src/routes/api/stacks/[name]/restart/+server.ts b/src/routes/api/stacks/[name]/restart/+server.ts
index 29ecbf5..b4d9ce3 100644
--- a/src/routes/api/stacks/[name]/restart/+server.ts
+++ b/src/routes/api/stacks/[name]/restart/+server.ts
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
-import { restartStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
+import { restartStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
}
return json({ success: true, output: result.output });
} catch (error) {
- if (error instanceof ExternalStackError) {
- return json({ error: error.message }, { status: 400 });
- }
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
diff --git a/src/routes/api/stacks/[name]/start/+server.ts b/src/routes/api/stacks/[name]/start/+server.ts
index 7e5fc5f..928841e 100644
--- a/src/routes/api/stacks/[name]/start/+server.ts
+++ b/src/routes/api/stacks/[name]/start/+server.ts
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
-import { startStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
+import { startStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
}
return json({ success: true, output: result.output });
} catch (error) {
- if (error instanceof ExternalStackError) {
- return json({ error: error.message }, { status: 400 });
- }
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
diff --git a/src/routes/api/stacks/[name]/stop/+server.ts b/src/routes/api/stacks/[name]/stop/+server.ts
index d57fa7b..2c2a0fe 100644
--- a/src/routes/api/stacks/[name]/stop/+server.ts
+++ b/src/routes/api/stacks/[name]/stop/+server.ts
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
-import { stopStack, ExternalStackError, ComposeFileNotFoundError } from '$lib/server/stacks';
+import { stopStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import type { RequestHandler } from './$types';
@@ -33,9 +33,6 @@ export const POST: RequestHandler = async (event) => {
}
return json({ success: true, output: result.output });
} catch (error) {
- if (error instanceof ExternalStackError) {
- return json({ error: error.message }, { status: 400 });
- }
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
diff --git a/src/routes/api/stacks/sources/+server.ts b/src/routes/api/stacks/sources/+server.ts
index d0688d9..a2f1a17 100644
--- a/src/routes/api/stacks/sources/+server.ts
+++ b/src/routes/api/stacks/sources/+server.ts
@@ -18,10 +18,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const sources = await getStackSources(envIdNum);
// Convert to a map for easier lookup in the frontend
- const sourceMap: Record = {};
+ const sourceMap: Record = {};
for (const source of sources) {
sourceMap[source.stackName] = {
sourceType: source.sourceType,
+ composePath: source.composePath,
repository: source.repository
};
}
diff --git a/src/routes/api/system/+server.ts b/src/routes/api/system/+server.ts
index 04c369a..118fb4f 100644
--- a/src/routes/api/system/+server.ts
+++ b/src/routes/api/system/+server.ts
@@ -186,6 +186,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
nodeVersion: process.version,
platform: os.platform(),
arch: os.arch(),
+ kernel: os.release(),
memory: bunInfo.memory,
container: containerRuntime,
ownContainer
diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte
index 438ad50..5984f2e 100644
--- a/src/routes/containers/+page.svelte
+++ b/src/routes/containers/+page.svelte
@@ -1,6 +1,7 @@
-
+
@@ -450,7 +501,7 @@
-
+
{#if loading}
@@ -461,7 +512,7 @@
{:else if containerData}
-
+
showLogs = false}>Overview
showLogs = true}>Logs
showLogs = false}>Layers
@@ -470,6 +521,7 @@
showLogs = false}>Mounts
showLogs = false}>Files
showLogs = false}>Environment
+ showLogs = false}>Labels
showLogs = false}>Security
showLogs = false}>Resources
showLogs = false}>Health
@@ -642,23 +694,6 @@
{/if}
-
- {#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
-
-
- Labels ({Object.keys(containerData.Config.Labels).length})
-
-
- {#each Object.entries(containerData.Config.Labels) as [key, value]}
-
- {key}
- =
- {value}
-
- {/each}
-
-
- {/if}
@@ -845,8 +880,22 @@
{#each Object.entries(containerData.NetworkSettings.Ports) as [containerPort, hostBindings]}
{#if hostBindings && hostBindings.length > 0}
{#each hostBindings as binding}
+ {@const url = getPortUrl(parseInt(binding.HostPort))}
-
{binding.HostIp || '0.0.0.0'}:{binding.HostPort}
+ {#if url}
+
+ {binding.HostIp || '0.0.0.0'}:{binding.HostPort}
+
+
+ {:else}
+
{binding.HostIp || '0.0.0.0'}:{binding.HostPort}
+ {/if}
→
{containerPort}
@@ -944,6 +993,37 @@
{/if}
+
+
+ {#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
+
+ {#each Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0])) as [key, value]}
+
+
+ {key}
+ =
+ {value}
+
+
copyLabel(key, value)}
+ class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
+ title={copiedLabel === key ? 'Copied!' : 'Copy label'}
+ >
+ {#if copiedLabel === key}
+
+ {:else}
+
+ {/if}
+
+
+ {/each}
+
+ {:else}
+ No labels
+ {/if}
+
+
@@ -1187,7 +1267,7 @@
-
+
diff --git a/src/routes/containers/ContainerSettingsTab.svelte b/src/routes/containers/ContainerSettingsTab.svelte
index ee0c604..90ba559 100644
--- a/src/routes/containers/ContainerSettingsTab.svelte
+++ b/src/routes/containers/ContainerSettingsTab.svelte
@@ -35,12 +35,12 @@
id: number;
name: string;
description?: string;
- env_vars?: { key: string; value: string }[];
+ envVars?: { key: string; value: string }[];
labels?: { key: string; value: string }[];
ports?: { hostPort: string; containerPort: string; protocol: string }[];
volumes?: { hostPath: string; containerPath: string; mode: string }[];
- network_mode: string;
- restart_policy: string;
+ networkMode: string;
+ restartPolicy: string;
}
interface DockerNetwork {
@@ -315,15 +315,15 @@
const configSet = configSets.find((c) => c.id === parseInt(configSetId));
if (!configSet) return;
- if (configSet.env_vars && configSet.env_vars.length > 0) {
+ if (configSet.envVars && configSet.envVars.length > 0) {
if (mode === 'edit') {
// Merge mode for edit
const existingKeys = new Set(envVars.map(e => e.key).filter(k => k));
- const newEnvVars = configSet.env_vars.filter(e => !existingKeys.has(e.key));
+ const newEnvVars = configSet.envVars.filter(e => !existingKeys.has(e.key));
envVars = [...envVars.filter(e => e.key), ...newEnvVars.map(e => ({ ...e }))];
if (envVars.length === 0) envVars = [{ key: '', value: '' }];
} else {
- envVars = configSet.env_vars.map((e) => ({ ...e }));
+ envVars = configSet.envVars.map((e) => ({ ...e }));
}
}
if (configSet.labels && configSet.labels.length > 0) {
@@ -356,11 +356,11 @@
volumeMappings = configSet.volumes.map((v) => ({ ...v }));
}
}
- if (configSet.network_mode) {
- networkMode = configSet.network_mode;
+ if (configSet.networkMode) {
+ networkMode = configSet.networkMode;
}
- if (configSet.restart_policy) {
- restartPolicy = configSet.restart_policy;
+ if (configSet.restartPolicy) {
+ restartPolicy = configSet.restartPolicy;
}
}
diff --git a/src/routes/containers/CreateContainerModal.svelte b/src/routes/containers/CreateContainerModal.svelte
index 0620e78..71893e8 100644
--- a/src/routes/containers/CreateContainerModal.svelte
+++ b/src/routes/containers/CreateContainerModal.svelte
@@ -49,12 +49,12 @@
id: number;
name: string;
description?: string;
- env_vars?: { key: string; value: string }[];
+ envVars?: { key: string; value: string }[];
labels?: { key: string; value: string }[];
ports?: { hostPort: string; containerPort: string; protocol: string }[];
volumes?: { hostPath: string; containerPath: string; mode: string }[];
- network_mode: string;
- restart_policy: string;
+ networkMode: string;
+ restartPolicy: string;
}
@@ -564,7 +564,7 @@
isOpen && focusFirstInput()}>
-
+
Create new container
isOpen && focusFirstInput()}>
-
+
Edit container
@@ -944,7 +944,7 @@
{#if loadingData}
-
+
Loading container data...
diff --git a/src/routes/containers/FileBrowserModal.svelte b/src/routes/containers/FileBrowserModal.svelte
index bb0253d..872d9a9 100644
--- a/src/routes/containers/FileBrowserModal.svelte
+++ b/src/routes/containers/FileBrowserModal.svelte
@@ -22,7 +22,7 @@
-
+
diff --git a/src/routes/containers/FileBrowserPanel.svelte b/src/routes/containers/FileBrowserPanel.svelte
index 935deba..d6e87a6 100644
--- a/src/routes/containers/FileBrowserPanel.svelte
+++ b/src/routes/containers/FileBrowserPanel.svelte
@@ -69,9 +69,25 @@
initialPath?: string;
canEdit?: boolean;
onUsageChange?: (usage: VolumeUsageInfo[], isInUse: boolean) => void;
+ // File selection mode - when true, clicking a file selects it instead of opening viewer
+ selectMode?: boolean;
+ // Regex to filter which files can be selected (used with selectMode)
+ selectFilter?: RegExp;
+ // Callback when a file is selected (in selectMode)
+ onFileSelect?: (path: string, name: string) => void;
}
- let { containerId, volumeName, envId, initialPath = '/', canEdit = true, onUsageChange }: Props = $props();
+ let {
+ containerId,
+ volumeName,
+ envId,
+ initialPath = '/',
+ canEdit = true,
+ onUsageChange,
+ selectMode = false,
+ selectFilter,
+ onFileSelect
+ }: Props = $props();
// For volume mode, track whether volume is in use (controls editing ability)
let volumeIsInUse = $state(false);
@@ -110,6 +126,15 @@
// Track if this container uses busybox (doesn't support --time-style=iso)
let useSimpleLs = $state(false);
+ // Selection mode state
+ let selectedFilePath = $state(null);
+
+ // Check if a file matches the select filter (for highlighting)
+ function matchesSelectFilter(name: string): boolean {
+ if (!selectMode || !selectFilter) return false;
+ return selectFilter.test(name);
+ }
+
// Sort state
let sortField = $state('name');
let sortDirection = $state('asc');
@@ -696,6 +721,11 @@
if (entry.type === 'directory') {
const newPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
navigateTo(newPath);
+ } else if (selectMode && entry.type === 'file') {
+ // In select mode, clicking a file selects it
+ const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
+ selectedFilePath = filePath;
+ onFileSelect?.(filePath, entry.name);
}
// Symlinks are not navigable - target path is displayed for reference
}
@@ -924,8 +954,11 @@
{#each displayEntries() as entry (entry.name)}
{@const Icon = getIcon(entry)}
- {@const isClickable = entry.type === 'directory'}
-
+ {@const isClickable = entry.type === 'directory' || (selectMode && entry.type === 'file')}
+ {@const isSelectable = selectMode && entry.type === 'file' && matchesSelectFilter(entry.name)}
+ {@const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`}
+ {@const isSelected = selectMode && selectedFilePath === filePath}
+
import * as Card from '$lib/components/ui/card';
- import { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp } from 'lucide-svelte';
+ import { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp, Loader2 } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { getIconComponent } from '$lib/utils/icons';
import { goto } from '$app/navigation';
@@ -45,10 +45,11 @@
// Helper flags
const isMini = $derived(is1x1 || is2x1);
const isWide = $derived(width >= 2);
- // Show offline when online is explicitly false
- // Only delay showing offline if online is undefined (truly unknown state during initial load)
+ // Show offline only when online is explicitly false (confirmed offline)
+ // Show connecting when online is undefined (initial load, not yet determined)
const isStillLoading = $derived(stats.loading && Object.values(stats.loading).some(v => v === true));
- const showOffline = $derived(stats.online === false || (!stats.online && !isStillLoading));
+ const showOffline = $derived(stats.online === false);
+ const showConnecting = $derived(stats.online === undefined);
{stats.name}
- {#if !showOffline}
-
- {:else}
+ {#if showConnecting}
+
+ {:else if showOffline}
+ {:else}
+
{/if}
@@ -138,13 +141,13 @@
- {#if !showOffline}
+ {#if showOffline}
+
+ {:else}
-
+
- {:else}
-
{/if}
@@ -178,10 +181,12 @@
{stats.name}
- {#if !showOffline}
-
- {:else}
+ {#if showConnecting}
+
+ {:else if showOffline}
+ {:else}
+
{/if}
@@ -232,10 +237,12 @@
- {#if !showOffline}
+ {#if showOffline}
+
+ {:else}
-
+
{#if stats.recentEvents}
@@ -244,8 +251,6 @@
{/if}
- {:else}
-
{/if}
@@ -279,10 +284,12 @@
{stats.name}
- {#if !showOffline}
-
- {:else}
+ {#if showConnecting}
+
+ {:else if showOffline}
+ {:else}
+
{/if}
@@ -333,9 +340,11 @@
- {#if !showOffline}
+ {#if showOffline}
+
+ {:else}
-
+
{#if stats.collectMetrics && stats.metrics}
@@ -343,8 +352,6 @@
- {:else}
-
{/if}
@@ -378,10 +385,12 @@
{stats.name}
- {#if !showOffline}
-
- {:else}
+ {#if showConnecting}
+
+ {:else if showOffline}
+ {:else}
+
{/if}
@@ -432,9 +441,11 @@
- {#if !showOffline}
+ {#if showOffline}
+
+ {:else}
-
+
{#if stats.collectMetrics && stats.metrics}
@@ -445,8 +456,6 @@
{/if}
- {:else}
-
{/if}
@@ -480,10 +489,12 @@
{stats.name}
- {#if !showOffline}
-
- {:else}
+ {#if showConnecting}
+
+ {:else if showOffline}
+ {:else}
+
{/if}
@@ -534,9 +545,11 @@
- {#if !showOffline}
+ {#if showOffline}
+
+ {:else}
-
+
{#if stats.collectMetrics && stats.metrics}
@@ -546,10 +559,8 @@
{#if stats.recentEvents}
{/if}
-
+
- {:else}
-
{/if}
@@ -577,11 +588,13 @@
- {#if !showOffline}
+ {#if showOffline}
+
+ {:else}
-
+
{#if stats.metrics}
@@ -591,11 +604,9 @@
-
+
- {:else}
-
{/if}
@@ -623,11 +634,13 @@
- {#if !showOffline}
+ {#if showOffline}
+
+ {:else}
-
+
{#if stats.metrics}
@@ -640,14 +653,12 @@
-
+
{#if stats.collectMetrics && stats.metrics && stats.metricsHistory}
{/if}
- {:else}
-
{/if}
@@ -675,11 +686,13 @@
- {#if !showOffline}
+ {#if showOffline}
+
+ {:else}
-
+
{#if stats.metrics}
@@ -689,18 +702,16 @@
{#if stats.recentEvents}
{/if}
-
+
{#if stats.collectMetrics && stats.metrics && stats.metricsHistory}
{/if}
-
+
- {:else}
-
{/if}
{/if}
diff --git a/src/routes/dashboard/dashboard-header.svelte b/src/routes/dashboard/dashboard-header.svelte
index c0ef83a..2bc35eb 100644
--- a/src/routes/dashboard/dashboard-header.svelte
+++ b/src/routes/dashboard/dashboard-header.svelte
@@ -11,7 +11,8 @@
Unplug,
Icon,
CircleArrowUp,
- CircleFadingArrowUp
+ CircleFadingArrowUp,
+ Loader2
} from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { getIconComponent } from '$lib/utils/icons';
@@ -27,7 +28,7 @@
port?: number | null;
icon: string;
socketPath?: string;
- online: boolean;
+ online?: boolean; // undefined = connecting, false = offline, true = online
scannerEnabled: boolean;
collectActivity: boolean;
collectMetrics: boolean;
@@ -40,6 +41,10 @@
compact?: boolean;
}
+ // Derived states for connecting/offline
+ const showConnecting = $derived(online === undefined);
+ const showOffline = $derived(online === false);
+
let {
name,
host,
@@ -88,10 +93,12 @@
{name}
- {#if online}
-
- {:else}
+ {#if showConnecting}
+
+ {:else if showOffline}
+ {:else}
+
{/if}
{hostDisplay}
@@ -124,10 +131,12 @@
{name}
- {#if online}
-
- {:else}
+ {#if showConnecting}
+
+ {:else if showOffline}
+ {:else}
+
{/if}
{hostDisplay}
diff --git a/src/routes/dashboard/index.ts b/src/routes/dashboard/index.ts
index f49ca3d..6cd54f9 100644
--- a/src/routes/dashboard/index.ts
+++ b/src/routes/dashboard/index.ts
@@ -11,4 +11,5 @@ export { default as DashboardTopContainers } from './dashboard-top-containers.sv
export { default as DashboardDiskUsage } from './dashboard-disk-usage.svelte';
export { default as DashboardCpuMemoryCharts } from './dashboard-cpu-memory-charts.svelte';
export { default as DashboardOfflineState } from './dashboard-offline-state.svelte';
+export { default as DashboardConnectingState } from './dashboard-connecting-state.svelte';
export { default as DashboardStatusIcons } from './dashboard-status-icons.svelte';
diff --git a/src/routes/images/+page.svelte b/src/routes/images/+page.svelte
index 279a33c..ed9db55 100644
--- a/src/routes/images/+page.svelte
+++ b/src/routes/images/+page.svelte
@@ -7,7 +7,7 @@
import { Label } from '$lib/components/ui/label';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
- import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-svelte';
+ import { Trash2, Upload, RefreshCw, Play, Search, Layers, Server, ShieldCheck, CheckSquare, Square, Tag, Check, XCircle, Icon, AlertTriangle, X, Images, Copy, Download, ChevronRight, ChevronDown, Loader2, ArrowUp, ArrowDown, ArrowUpDown, CircleDashed } from 'lucide-svelte';
import { broom, whale } from '@lucide/lab';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
@@ -46,10 +46,12 @@
imageId: string;
size: number;
created: number;
+ containers: number;
}>;
totalSize: number;
latestCreated: number;
imageIds: Set
;
+ containers: number;
}
// Check if a registry is Docker Hub
@@ -115,6 +117,8 @@
// Prune state
let confirmPrune = $state(false);
let pruneStatus = $state<'idle' | 'pruning' | 'success' | 'error'>('idle');
+ let confirmPruneUnused = $state(false);
+ let pruneUnusedStatus = $state<'idle' | 'pruning' | 'success' | 'error'>('idle');
// Multi-select state
let selectedImages = $state>(new Set());
@@ -179,7 +183,8 @@
tags: [],
totalSize: 0,
latestCreated: 0,
- imageIds: new Set()
+ imageIds: new Set(),
+ containers: 0
});
}
const group = groups.get(key)!;
@@ -188,11 +193,13 @@
fullRef: image.id,
imageId: image.id,
size: image.size,
- created: image.created
+ created: image.created,
+ containers: image.containers
});
group.totalSize = Math.max(group.totalSize, image.size);
group.latestCreated = Math.max(group.latestCreated, image.created);
group.imageIds.add(image.id);
+ group.containers += image.containers;
} else {
for (const fullTag of image.tags) {
const colonIndex = fullTag.lastIndexOf(':');
@@ -205,7 +212,8 @@
tags: [],
totalSize: 0,
latestCreated: 0,
- imageIds: new Set()
+ imageIds: new Set(),
+ containers: 0
});
}
@@ -217,11 +225,16 @@
fullRef: fullTag,
imageId: image.id,
size: image.size,
- created: image.created
+ created: image.created,
+ containers: image.containers
});
}
group.totalSize = Math.max(group.totalSize, image.size);
group.latestCreated = Math.max(group.latestCreated, image.created);
+ // Only add containers count once per unique image ID
+ if (!group.imageIds.has(image.id)) {
+ group.containers += image.containers;
+ }
group.imageIds.add(image.id);
}
}
@@ -432,7 +445,7 @@
const response = await fetch(appendEnvParam('/api/prune/images', envId), { method: 'POST' });
if (response.ok) {
pruneStatus = 'success';
- toast.success('Unused images pruned');
+ toast.success('Dangling images pruned');
await fetchImages();
} else {
pruneStatus = 'error';
@@ -445,6 +458,26 @@
pendingTimeouts.push(setTimeout(() => { pruneStatus = 'idle'; }, 3000));
}
+ async function pruneUnusedImages() {
+ pruneUnusedStatus = 'pruning';
+ confirmPruneUnused = false;
+ try {
+ const response = await fetch(appendEnvParam('/api/prune/images?dangling=false', envId), { method: 'POST' });
+ if (response.ok) {
+ pruneUnusedStatus = 'success';
+ toast.success('Unused images pruned');
+ await fetchImages();
+ } else {
+ pruneUnusedStatus = 'error';
+ toast.error('Failed to prune unused images');
+ }
+ } catch (error) {
+ pruneUnusedStatus = 'error';
+ toast.error('Failed to prune unused images');
+ }
+ pendingTimeouts.push(setTimeout(() => { pruneUnusedStatus = 'idle'; }, 3000));
+ }
+
async function removeImage(id: string, tagName: string) {
deleteError = null;
try {
@@ -618,13 +651,16 @@
open={confirmPrune}
action="Prune"
itemType="dangling images"
- title="Prune images"
+ title="Prune dangling images"
position="left"
onConfirm={pruneImages}
onOpenChange={(open) => confirmPrune = open}
>
{#snippet children({ open })}
-
+
{#if pruneStatus === 'pruning'}
{:else if pruneStatus === 'success'}
@@ -638,6 +674,33 @@
{/snippet}
+ confirmPruneUnused = open}
+ >
+ {#snippet children({ open })}
+
+ {#if pruneUnusedStatus === 'pruning'}
+
+ {:else if pruneUnusedStatus === 'success'}
+
+ {:else if pruneUnusedStatus === 'error'}
+
+ {:else}
+
+ {/if}
+ Prune unused
+
+ {/snippet}
+
{/if}
Refresh
@@ -785,6 +848,16 @@
{group.tags[0].tag}
{/if}
+ {#if group.containers === 0}
+
+ Unused
+
+ {:else if group.tags.length > 1 && group.tags.some(t => t.containers === 0)}
+
+
+ Some unused
+
+ {/if}
{:else if column.id === 'tags'}
@@ -867,6 +940,22 @@
{formatSize(tagInfo.size)}
{:else if column.id === 'created'}
{formatImageDate(tagInfo.created)}
+ {:else if column.id === 'used'}
+ {#if tagInfo.containers > 0}
+
+ {tagInfo.containers} container{tagInfo.containers === 1 ? '' : 's'}
+
+ {:else if tagInfo.containers === 0}
+
+ Unused
+
+ {:else}
+ —
+ {/if}
{:else if column.id === 'actions'}
{#if $canAccess('images', 'inspect')}
@@ -930,7 +1019,7 @@
{/if}
- {#if $canAccess('images', 'remove')}
+ {#if $canAccess('images', 'remove') && tagInfo.containers === 0}
{@render children()}
-
+
diff --git a/src/routes/networks/CreateNetworkModal.svelte b/src/routes/networks/CreateNetworkModal.svelte
index 5a108b5..fa28bb8 100644
--- a/src/routes/networks/CreateNetworkModal.svelte
+++ b/src/routes/networks/CreateNetworkModal.svelte
@@ -277,7 +277,7 @@
-
+
diff --git a/src/routes/networks/NetworkInspectModal.svelte b/src/routes/networks/NetworkInspectModal.svelte
index 5c9eb9c..983599d 100644
--- a/src/routes/networks/NetworkInspectModal.svelte
+++ b/src/routes/networks/NetworkInspectModal.svelte
@@ -61,7 +61,7 @@
-
+
diff --git a/src/routes/registry/+page.svelte b/src/routes/registry/+page.svelte
index 65b7add..a7b5404 100644
--- a/src/routes/registry/+page.svelte
+++ b/src/routes/registry/+page.svelte
@@ -43,8 +43,13 @@
interface ExpandedImageState {
loading: boolean;
+ loadingMore: boolean;
error: string;
tags: TagInfo[];
+ total: number;
+ page: number;
+ pageSize: number;
+ hasNext: boolean;
}
let registries = $state([]);
@@ -226,38 +231,100 @@
const { [imageName]: _, ...rest } = expandedImages;
expandedImages = rest;
} else {
- // Expand and fetch tags
- expandedImages = {
- ...expandedImages,
- [imageName]: { loading: true, error: '', tags: [] }
- };
+ // Expand and fetch first page
+ await fetchTagsPage(imageName, 1, true);
+ }
+ }
- try {
- let url = `/api/registry/tags?image=${encodeURIComponent(imageName)}`;
- if (selectedRegistryId) {
- url += `®istry=${selectedRegistryId}`;
- }
+ async function loadMoreTags(imageName: string) {
+ const state = expandedImages[imageName];
+ if (!state || state.loading || state.loadingMore || !state.hasNext) return;
+ await fetchTagsPage(imageName, state.page + 1, false);
+ }
- const response = await fetch(url);
- if (response.ok) {
- const tags = await response.json();
- expandedImages = {
- ...expandedImages,
- [imageName]: { loading: false, error: '', tags }
- };
- } else {
- const data = await response.json();
- expandedImages = {
- ...expandedImages,
- [imageName]: { loading: false, error: data.error || 'Failed to fetch tags', tags: [] }
- };
- }
- } catch (error: any) {
+ async function fetchTagsPage(imageName: string, page: number, isFirstLoad: boolean) {
+ const currentState = expandedImages[imageName];
+
+ expandedImages = {
+ ...expandedImages,
+ [imageName]: {
+ loading: isFirstLoad,
+ loadingMore: !isFirstLoad,
+ error: '',
+ tags: currentState?.tags || [],
+ total: currentState?.total || 0,
+ page: currentState?.page || 0,
+ pageSize: 20,
+ hasNext: currentState?.hasNext || false
+ }
+ };
+
+ try {
+ let url = `/api/registry/tags?image=${encodeURIComponent(imageName)}&page=${page}&pageSize=20`;
+ if (selectedRegistryId) {
+ url += `®istry=${selectedRegistryId}`;
+ }
+
+ const response = await fetch(url);
+ if (response.ok) {
+ const data = await response.json();
+ const prevState = expandedImages[imageName];
+ const existingTags = isFirstLoad ? [] : (prevState?.tags || []);
expandedImages = {
...expandedImages,
- [imageName]: { loading: false, error: error.message || 'Failed to fetch tags', tags: [] }
+ [imageName]: {
+ loading: false,
+ loadingMore: false,
+ error: '',
+ tags: [...existingTags, ...data.tags],
+ total: data.total,
+ page: data.page,
+ pageSize: data.pageSize,
+ hasNext: data.hasNext
+ }
+ };
+ } else {
+ const data = await response.json();
+ expandedImages = {
+ ...expandedImages,
+ [imageName]: {
+ ...expandedImages[imageName],
+ loading: false,
+ loadingMore: false,
+ error: data.error || 'Failed to fetch tags'
+ }
};
}
+ } catch (error: any) {
+ expandedImages = {
+ ...expandedImages,
+ [imageName]: {
+ ...expandedImages[imageName],
+ loading: false,
+ loadingMore: false,
+ error: error.message || 'Failed to fetch tags'
+ }
+ };
+ }
+ }
+
+ function handleTagsWheel(event: WheelEvent, imageName: string) {
+ const target = event.currentTarget as HTMLElement;
+
+ // Prevent page scroll when at top/bottom of tags list
+ const atTop = target.scrollTop === 0;
+ const atBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 1;
+
+ if ((atTop && event.deltaY < 0) || (atBottom && event.deltaY > 0)) {
+ event.preventDefault();
+ }
+
+ // Load more when near bottom
+ const state = expandedImages[imageName];
+ if (!state || !state.hasNext || state.loading || state.loadingMore) return;
+
+ if (target.scrollHeight - target.scrollTop - target.clientHeight < 50) {
+ loadMoreTags(imageName);
}
}
@@ -328,7 +395,8 @@
...expandedImages,
[imageName]: {
...state,
- tags: state.tags.filter(t => t.name !== tag)
+ tags: state.tags.filter(t => t.name !== tag),
+ total: Math.max(0, state.total - 1)
}
};
}
@@ -514,9 +582,9 @@
{expandState.error}
{:else if expandState?.tags && expandState.tags.length > 0}
-
+
+
+ {#if expandState.total > 0}
+
+ {expandState.tags.length} of {expandState.total} tags loaded
+
+ {/if}
{:else}
No tags found
diff --git a/src/routes/settings/about/AboutTab.svelte b/src/routes/settings/about/AboutTab.svelte
index 802e389..c2547b5 100644
--- a/src/routes/settings/about/AboutTab.svelte
+++ b/src/routes/settings/about/AboutTab.svelte
@@ -3,7 +3,7 @@
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Input } from '$lib/components/ui/input';
- import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee } from 'lucide-svelte';
+ import { Box, Images, HardDrive, Network, Cpu, Server, Crown, Building2, Layers, Clock, Code, Package, ExternalLink, Search, FileText, Tag, Sparkles, Bug, ChevronDown, ChevronRight, Plug, ScrollText, Shield, MessageSquarePlus, GitBranch, Coffee, Monitor, Cog, MemoryStick, Database } from 'lucide-svelte';
import * as Tabs from '$lib/components/ui/tabs';
import { onMount, onDestroy } from 'svelte';
import { licenseStore } from '$lib/stores/license';
@@ -198,6 +198,7 @@
nodeVersion: string;
platform: string;
arch: string;
+ kernel: string;
memory: {
heapUsed: number;
heapTotal: number;
@@ -438,7 +439,7 @@
{/if}
{#if serverUptime !== null}
-
+
Uptime {formatUptime(serverUptime)}
@@ -558,10 +559,13 @@
{/if}
|
-
Platform
+
{systemInfo.runtime.platform}/{systemInfo.runtime.arch}
|
-
Memory
+
+
{systemInfo.runtime.kernel}
+
|
+
{formatBytes(systemInfo.runtime.memory.rss)}
{#if systemInfo.runtime.container.inContainer}
@@ -585,7 +589,7 @@
-
+
Database
diff --git a/src/routes/settings/environments/EnvironmentModal.svelte b/src/routes/settings/environments/EnvironmentModal.svelte
index b04f2f7..c0ccfd7 100644
--- a/src/routes/settings/environments/EnvironmentModal.svelte
+++ b/src/routes/settings/environments/EnvironmentModal.svelte
@@ -415,6 +415,10 @@
newLabelInput = '';
formPublicIp = environment.publicIp || '';
modalTab = 'general';
+ // Reset token state for this environment (important when switching between envs)
+ hawserToken = null;
+ generatedToken = null;
+ pendingToken = null;
// Load scanner settings, notifications, update check settings, and timezone
loadScannerSettings(environment.id);
loadEnvNotifications(environment.id);
@@ -825,6 +829,11 @@
}
}
+ // Reload only availability/versions without overwriting user's unsaved settings changes
+ async function reloadScannerAvailability(envId?: number) {
+ await loadScannerVersionsAsync(envId);
+ }
+
async function saveScannerSettings(envId?: number) {
try {
const response = await fetch('/api/settings/scanner', {
@@ -930,7 +939,8 @@
checkingGrypeUpdate = true;
grypeUpdateStatus = 'idle';
try {
- const response = await fetch('/api/settings/scanner?checkUpdates=true');
+ const envParam = environment?.id ? `&env=${environment.id}` : '';
+ const response = await fetch(`/api/settings/scanner?checkUpdates=true${envParam}`);
const data = await response.json();
if (data.updates) {
grypeUpdateStatus = data.updates.grype?.hasUpdate ? 'update-available' : 'up-to-date';
@@ -947,7 +957,8 @@
checkingTrivyUpdate = true;
trivyUpdateStatus = 'idle';
try {
- const response = await fetch('/api/settings/scanner?checkUpdates=true');
+ const envParam = environment?.id ? `&env=${environment.id}` : '';
+ const response = await fetch(`/api/settings/scanner?checkUpdates=true${envParam}`);
const data = await response.json();
if (data.updates) {
trivyUpdateStatus = data.updates.trivy?.hasUpdate ? 'update-available' : 'up-to-date';
@@ -1198,7 +1209,7 @@
{ if (o) focusFirstInput(); else onClose(); }}>
-
+
{#if !isEditing}
@@ -1362,7 +1373,7 @@
-
+
@@ -2112,7 +2123,7 @@
{/if}
{#if !loadingScannerVersions}
{#if !scannerAvailability.grype}
-
loadScannerSettings(environment?.id)}>
+ reloadScannerAvailability(environment?.id)}>
Pull
@@ -2189,7 +2200,7 @@
{/if}
{#if !loadingScannerVersions}
{#if !scannerAvailability.trivy}
- loadScannerSettings(environment?.id)}>
+ reloadScannerAvailability(environment?.id)}>
Pull
diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte
index 8a794e8..e1d8758 100644
--- a/src/routes/settings/general/GeneralTab.svelte
+++ b/src/routes/settings/general/GeneralTab.svelte
@@ -8,8 +8,8 @@
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
- import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe } from 'lucide-svelte';
- import { appSettings, type DateFormat, type DownloadFormat } from '$lib/stores/settings';
+ import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock } from 'lucide-svelte';
+ import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode } from '$lib/stores/settings';
import { canAccess, authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
@@ -32,6 +32,9 @@
let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled);
let logBufferSizeKb = $derived($appSettings.logBufferSizeKb);
let defaultTimezone = $derived($appSettings.defaultTimezone);
+ let eventCollectionMode = $derived($appSettings.eventCollectionMode);
+ let eventPollInterval = $derived($appSettings.eventPollInterval);
+ let metricsCollectionInterval = $derived($appSettings.metricsCollectionInterval);
const dateFormatOptions: { value: DateFormat; label: string; example: string }[] = [
{ value: 'DD.MM.YYYY', label: 'DD.MM.YYYY', example: '31.12.2024' },
@@ -93,6 +96,27 @@
appSettings.setLogBufferSizeKb(value);
toast.success('Log buffer size updated');
}
+
+ function handleEventCollectionModeChange(value: string | undefined) {
+ if (value === 'stream' || value === 'poll') {
+ appSettings.setEventCollectionMode(value);
+ toast.success(`Event collection mode: ${value}`);
+ }
+ }
+
+ function handleEventPollIntervalChange(selected: { value: number } | undefined) {
+ if (selected?.value) {
+ appSettings.setEventPollInterval(selected.value);
+ toast.success(`Event poll interval: ${selected.value / 1000}s`);
+ }
+ }
+
+ function handleMetricsIntervalChange(selected: { value: number } | undefined) {
+ if (selected?.value) {
+ appSettings.setMetricsCollectionInterval(selected.value);
+ toast.success(`Metrics interval: ${selected.value / 1000}s`);
+ }
+ }
@@ -362,7 +386,107 @@
-
+
+
+
+
Activity event collection mode
+
+
+
+
+
+
+ Stream: Continuous event stream from Docker, instant notifications, higher CPU usage
+ Poll: Periodic checks for new events, slight notification delay, lower CPU usage
+
+
+
+
+
+
+
+
+
+
+
Metrics collection interval
+
+
+
+
+
+
+ How often to collect CPU/memory metrics from running containers. Lower intervals
+ provide more frequent updates but increase CPU usage.
+
+
+
+
+
+ v && handleMetricsIntervalChange({ value: parseInt(v) })}
+ disabled={!$canAccess('settings', 'edit')}
+ >
+
+ {(metricsCollectionInterval || 30000) === 10000 ? '10s' : (metricsCollectionInterval || 30000) === 30000 ? '30s' : (metricsCollectionInterval || 30000) === 60000 ? '60s' : '120s'}
+
+
+ 10s
+ 30s
+ 60s
+ 120s
+
+
+
+
+
+
Schedule execution cleanup
([]);
- let stackSources = $state>({});
+ let stackSources = $state>({});
let stackEnvVarCounts = $state>({});
let gitStacks = $state([]);
let gitRepositories = $state([]);
@@ -43,6 +44,7 @@
let showCreateModal = $state(false);
let showEditModal = $state(false);
let showGitModal = $state(false);
+ let showImportModal = $state(false);
let editingStackName = $state('');
let editingGitStack = $state(null);
let envId = $state(null);
@@ -576,37 +578,24 @@
stackSources = sourcesData && !sourcesData.error ? sourcesData : {};
gitStacks = Array.isArray(gitStacksData) ? gitStacksData : [];
- // Create a set of docker stack names for quick lookup
- const dockerStackNames = new Set(dockerStacks.map((s: ComposeStackInfo) => s.name));
-
- // Add undeployed git stacks as placeholder entries
- const undeployedGitStacks: ComposeStackInfo[] = gitStacks
- .filter((gs: any) => !dockerStackNames.has(gs.stackName))
- .map((gs: any) => ({
- name: gs.stackName,
- status: 'not deployed',
- containers: [],
- containerDetails: [],
- configFile: gs.composePath,
- workingDir: ''
- }));
-
- // Add gitStack to all git-based stacks (both deployed and undeployed)
+ // Add gitStack details to all git-based stacks
+ // Note: The API already includes undeployed stacks from the database,
+ // so we just need to attach the gitStack object for additional metadata
for (const gs of gitStacks) {
if (!stackSources[gs.stackName]) {
- // Undeployed git stack - create new source entry
+ // Git stack not in sources yet - create source entry
stackSources[gs.stackName] = {
sourceType: 'git',
repository: gs.repository,
gitStack: gs
};
} else if (stackSources[gs.stackName].sourceType === 'git') {
- // Deployed git stack - add gitStack to existing source
+ // Git stack already in sources - add gitStack object
stackSources[gs.stackName].gitStack = gs;
}
}
- stacks = [...dockerStacks, ...undeployedGitStacks];
+ stacks = dockerStacks;
// Fetch env var counts for internal and git stacks (in background, don't block UI)
const allStackNames = stacks.map(s => s.name);
@@ -1130,6 +1119,10 @@
Create
+ showImportModal = true}>
+
+ Adopt
+
{/if}
@@ -1281,7 +1274,8 @@
{#snippet cell(column, stack, rowState)}
{@const source = getStackSource(stack.name)}
{#if column.id === 'name'}
- {#if source.sourceType === 'internal'}
+ {#if source.sourceType !== 'git'}
+
{:else}
+
{stack.name}
{/if}
{#if stackEnvVarCounts[stack.name]}
@@ -1307,41 +1302,45 @@
{/if}
{:else if column.id === 'source'}
{#if source.sourceType === 'git'}
+
+
+ Git
+
+ {:else if source.sourceType === 'internal'}
+
+
+ Internal
+
+ {:else}
+
+
+ Untracked
+
+ {/if}
+ {:else if column.id === 'location'}
+ {#if source.composePath}
+ {@const dirPath = source.composePath.replace(/\/[^/]+$/, '')}
-
-
-
- Git
+
+
+ {dirPath}
-
- {#if source.repository}
- {source.repository.url} ({source.repository.branch})
- {:else}
- Deployed from Git repository
- {/if}
+
+ {source.composePath}
- {:else if source.sourceType === 'internal'}
-
-
-
-
- Internal
-
-
- Created in Dockhand
-
{:else}
-
-
-
-
- External
-
-
- Created outside Dockhand
-
+ —
{/if}
{:else if column.id === 'containers'}
@@ -1501,16 +1500,7 @@
{/if}
{#if $canAccess('stacks', 'edit')}
- {#if source.sourceType === 'internal'}
-
editStack(stack.name)}
- title="Edit"
- class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
- >
-
-
- {:else if source.sourceType === 'git' && source.gitStack}
+ {#if source.sourceType === 'git' && source.gitStack}
openGitModal(source.gitStack)}
@@ -1519,6 +1509,16 @@
>
+ {:else}
+
+
editStack(stack.name)}
+ title="Edit"
+ class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
+ >
+
+
{/if}
{/if}
{#if stack.containers && stack.containers.length > 0}
@@ -1947,6 +1947,12 @@
onSaved={fetchStacks}
/>
+
showImportModal = false}
+ onAdopted={fetchStacks}
+/>
+
(null);
- // Stack location (for edit mode)
- let stackLocation = $state(null);
- let pathCopied = $state(false);
- function copyPath() {
- if (stackLocation) {
- navigator.clipboard.writeText(stackLocation);
- pathCopied = true;
- setTimeout(() => pathCopied = false, 2000);
+ // ─── Path State (Simplified) ─────────────────────────────────────────────────
+ // Working paths: what we're currently editing (always strings, never null)
+ let workingComposePath = $state('');
+ let workingEnvPath = $state('');
+
+ // Original paths: loaded from server (for dirty/change detection in edit mode)
+ let originalComposePath = $state(null);
+ let originalEnvPath = $state(null);
+
+ // Auto-computed path from API (for create mode - tracks what the default would be)
+ let autoComputedComposePath = $state('');
+
+ // Path source info (for hint display)
+ let pathSource = $state<'default' | 'custom' | 'browsed' | null>(null);
+
+ // UI state
+ let composePathCopied = $state(false);
+ let envPathCopied = $state(false);
+ let needsFileLocation = $state(false);
+
+ // Container info for untracked stacks
+ let stackContainers = $state<{ name: string; state: string; image: string }[]>([]);
+
+ // Derived: has user customized the compose path from auto-computed default?
+ const isComposePathCustom = $derived(
+ workingComposePath !== '' && workingComposePath !== autoComputedComposePath
+ );
+
+ // Derived: suggested env path when workingEnvPath is empty
+ const suggestedEnvPath = $derived(
+ !workingEnvPath && workingComposePath
+ ? workingComposePath.replace(/\/[^/]+$/, '/.env')
+ : null
+ );
+
+ // Derived: display path for env (actual or suggested)
+ const displayEnvPath = $derived(workingEnvPath || suggestedEnvPath || '');
+
+ // Derived: is env path just a suggestion (not explicitly set)?
+ const isEnvPathSuggested = $derived(!workingEnvPath && !!suggestedEnvPath);
+
+ // Derived: source hint text for the path bar (only in create mode)
+ const pathSourceHint = $derived.by(() => {
+ if (mode !== 'create' || !workingComposePath) return undefined;
+ switch (pathSource) {
+ case 'browsed':
+ return 'Custom location';
+ case 'custom':
+ return 'Using saved location';
+ case 'default':
+ return 'Using default location';
+ default:
+ return undefined;
+ }
+ });
+
+ // Path change confirmation dialog state
+ let showPathChangeConfirm = $state(false);
+ let pathChangeOldDir = $state(null); // Old directory to move files from
+ let pathChangeFileCount = $state(0); // Number of files in old directory
+ let pendingSaveRestart = $state(false); // Whether user clicked "Save & restart" vs "Save"
+
+ // Browse confirmation dialog state (when selecting different file would replace content)
+ let showBrowseConfirm = $state(false);
+ let pendingBrowsePath = $state(null);
+ let pendingBrowseName = $state(null);
+
+ // Single file browser with dynamic config
+ let showFileBrowser = $state(false);
+ let fileBrowserConfig = $state<{
+ title: string;
+ icon?: Component<{ class?: string }>;
+ selectFilter?: RegExp;
+ selectMode: 'file' | 'directory' | 'file_or_directory';
+ onSelect: (path: string, name: string) => void;
+ }>({
+ title: '',
+ icon: undefined,
+ selectFilter: /.*/,
+ selectMode: 'file',
+ onSelect: () => {}
+ });
+
+ function openComposeBrowser() {
+ // For untracked stacks (needsFileLocation), only allow selecting files
+ // For tracked stacks, allow both files and directories
+ const isUntracked = needsFileLocation;
+ fileBrowserConfig = {
+ title: isUntracked ? 'Select compose file' : 'Select compose file or directory',
+ selectFilter: /\.ya?ml$/,
+ selectMode: isUntracked ? 'file' : 'file_or_directory',
+ onSelect: handleComposeSelect
+ };
+ showFileBrowser = true;
+ }
+
+ function openEnvBrowser() {
+ fileBrowserConfig = {
+ title: 'Select environment file or directory',
+ selectFilter: /\.env($|\.)/, // matches .env, .env.local, app.env, etc.
+ selectMode: 'file_or_directory',
+ onSelect: handleEnvSelect
+ };
+ showFileBrowser = true;
+ }
+
+ function openChangeLocationBrowser() {
+ const displayName = mode === 'edit' ? stackName : newStackName;
+ fileBrowserConfig = {
+ title: `Relocate ${displayName}`,
+ icon: FolderSync,
+ selectMode: 'directory',
+ onSelect: handleChangeLocation
+ };
+ showFileBrowser = true;
+ }
+
+ // State for change location confirmation
+ let pendingNewLocation = $state(null);
+ let pendingNewComposePath = $state(null);
+ let pendingNewEnvPath = $state(null);
+ let showChangeLocationConfirm = $state(false);
+ let changeLocationFileCount = $state(0);
+ let changeLocationOldDir = $state(null);
+ let movingLocation = $state(false);
+
+ async function handleChangeLocation(selectedDir: string, _name: string) {
+ showFileBrowser = false;
+
+ // Get the current compose filename
+ const currentComposePath = workingComposePath;
+ const composeFilename = currentComposePath ? currentComposePath.split('/').pop() : 'docker-compose.yml';
+
+ // Build new paths: create a subfolder with the stack name inside selected directory
+ const displayName = mode === 'edit' ? stackName : newStackName;
+ const newDir = `${selectedDir}/${displayName}`;
+ const newComposePath = `${newDir}/${composeFilename}`;
+ const newEnvPath = workingEnvPath ? `${newDir}/.env` : '';
+
+ // Check if old directory has files to move
+ const envId = $currentEnvironment?.id ?? null;
+ try {
+ const response = await fetch(
+ appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/check-path-change`, envId),
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ newComposePath })
+ }
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+ if (data.hasChanges && data.oldDir && data.fileCount > 0) {
+ // Show confirmation dialog
+ pendingNewLocation = newDir;
+ pendingNewComposePath = newComposePath;
+ pendingNewEnvPath = newEnvPath;
+ changeLocationOldDir = data.oldDir;
+ changeLocationFileCount = data.fileCount;
+ showChangeLocationConfirm = true;
+ return;
+ }
+ }
+ } catch (e) {
+ console.warn('Failed to check path changes:', e);
+ }
+
+ // No files to move, just update paths
+ workingComposePath = newComposePath;
+ workingEnvPath = newEnvPath;
+ isDirty = true;
+ }
+
+ function cancelChangeLocation() {
+ showChangeLocationConfirm = false;
+ pendingNewLocation = null;
+ pendingNewComposePath = null;
+ pendingNewEnvPath = null;
+ changeLocationOldDir = null;
+ changeLocationFileCount = 0;
+ }
+
+ async function confirmChangeLocation() {
+ if (!pendingNewComposePath || !changeLocationOldDir) return;
+
+ movingLocation = true;
+ const envId = $currentEnvironment?.id ?? null;
+
+ try {
+ // Call API to move files
+ const response = await fetch(
+ appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/relocate`, envId),
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ oldDir: changeLocationOldDir,
+ newComposePath: pendingNewComposePath,
+ newEnvPath: pendingNewEnvPath || undefined
+ })
+ }
+ );
+
+ if (!response.ok) {
+ const data = await response.json();
+ throw new Error(data.error || 'Failed to move files');
+ }
+
+ const result = await response.json();
+
+ // Update paths
+ workingComposePath = pendingNewComposePath;
+ workingEnvPath = pendingNewEnvPath || '';
+ originalComposePath = pendingNewComposePath;
+ originalEnvPath = pendingNewEnvPath || null;
+
+ // Reload content from new location
+ if (result.composeContent) {
+ composeContent = result.composeContent;
+ }
+ if (result.envVars) {
+ envVars = result.envVars;
+ }
+ if (result.rawEnvContent !== undefined) {
+ rawEnvContent = result.rawEnvContent;
+ }
+
+ // Reset dirty flag since we just reloaded
+ isDirty = false;
+
+ } catch (e: any) {
+ operationError = {
+ title: 'Failed to move files',
+ message: e.message || 'An error occurred while moving files'
+ };
+ } finally {
+ movingLocation = false;
+ showChangeLocationConfirm = false;
+ pendingNewLocation = null;
+ pendingNewComposePath = null;
+ pendingNewEnvPath = null;
+ changeLocationOldDir = null;
+ changeLocationFileCount = 0;
+ }
+ }
+
+ // Generic copy function that returns a reset callback
+ function copyToClipboard(text: string | null, setCopied: (v: boolean) => void) {
+ if (text) {
+ navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ }
+
+ // Parse env vars from raw content
+ function parseEnvVarsFromRaw(content: string) {
+ const vars: EnvVar[] = [];
+ const lines = content.split('\n');
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) continue;
+ const eqIndex = trimmed.indexOf('=');
+ if (eqIndex > 0) {
+ const key = trimmed.substring(0, eqIndex);
+ const value = trimmed.substring(eqIndex + 1);
+ vars.push({ key, value, isSecret: false });
+ }
+ }
+ envVars = vars;
+ }
+
+ // Handle compose file selection from browser
+ async function handleComposeSelect(path: string, name: string) {
+ const isDirectory = !path.match(/\.ya?ml$/i);
+
+ // If selecting a file in edit mode with existing content, show confirmation
+ if (mode === 'edit' && !isDirectory && composeContent.trim()) {
+ // Check if it's the same file (no confirmation needed)
+ const normalizedPath = path.endsWith('/') ? path.slice(0, -1) : path;
+ if (normalizedPath !== workingComposePath) {
+ pendingBrowsePath = path;
+ pendingBrowseName = name;
+ showBrowseConfirm = true;
+ showFileBrowser = false;
+ return;
+ }
+ }
+
+ // Continue with file selection
+ await proceedWithComposeSelect(path, name);
+ }
+
+ // Proceed with compose file selection (after optional confirmation)
+ async function proceedWithComposeSelect(path: string, name: string) {
+ // Check if it's a directory (no extension or doesn't end with .yml/.yaml)
+ const isDirectory = !path.match(/\.ya?ml$/i);
+ const baseDir = path.endsWith('/') ? path.slice(0, -1) : path;
+ let finalPath = path;
+
+ if (isDirectory) {
+ const stackName = newStackName.trim();
+ if (stackName) {
+ // If we have a stack name, include the subfolder
+ finalPath = `${baseDir}/${stackName}/docker-compose.yml`;
+ } else {
+ // No stack name yet - just show the selected directory
+ finalPath = `${baseDir}/`;
+ }
+ }
+
+ workingComposePath = finalPath;
+ pathSource = 'browsed';
+ showFileBrowser = false;
+
+ // Auto-suggest .env in the same directory (only if we have a full path)
+ if (!isDirectory || newStackName.trim()) {
+ const dir = finalPath.replace(/\/[^/]+$/, '');
+ if (!workingEnvPath) {
+ workingEnvPath = `${dir}/.env`;
+ }
+ }
+
+ // Load compose file content when selecting a file (not directory)
+ if (!isDirectory) {
+ await loadFilesFromLocalFilesystem(finalPath, workingEnvPath || suggestedEnvPath || '');
+ }
+ isDirty = true;
+ }
+
+ // Cancel browse confirmation
+ function cancelBrowseConfirm() {
+ showBrowseConfirm = false;
+ pendingBrowsePath = null;
+ pendingBrowseName = null;
+ }
+
+ // Confirm browse and load the new file
+ async function confirmBrowseAndLoad() {
+ showBrowseConfirm = false;
+ if (pendingBrowsePath && pendingBrowseName) {
+ await proceedWithComposeSelect(pendingBrowsePath, pendingBrowseName);
+ }
+ pendingBrowsePath = null;
+ pendingBrowseName = null;
+ }
+
+ // Handle env file selection from browser
+ async function handleEnvSelect(path: string, name: string) {
+ // Check if it's a directory (no extension or doesn't contain .env)
+ const isDirectory = !path.match(/\.env($|\.)/i);
+ let finalPath = path;
+ if (isDirectory) {
+ // Append default env filename
+ finalPath = path.endsWith('/') ? `${path}.env` : `${path}/.env`;
+ }
+
+ workingEnvPath = finalPath;
+ showFileBrowser = false;
+
+ // Load env content when selecting a file (not directory)
+ if (!isDirectory) {
+ try {
+ const envResponse = await fetch(`/api/system/files/content?path=${encodeURIComponent(finalPath)}`);
+ if (envResponse.ok) {
+ const envData = await envResponse.json();
+ rawEnvContent = envData.content || '';
+ parseEnvVarsFromRaw(rawEnvContent);
+ } else {
+ rawEnvContent = '';
+ }
+ } catch (e) {
+ console.error('Failed to load env file:', e);
+ }
+ }
+ isDirty = true;
+ }
+
+ // Load files from local filesystem (when user selects paths)
+ async function loadFilesFromLocalFilesystem(composeFilePath: string, envFilePath: string) {
+ try {
+ // Load compose file
+ const composeResponse = await fetch(`/api/system/files/content?path=${encodeURIComponent(composeFilePath)}`);
+ if (composeResponse.ok) {
+ const composeData = await composeResponse.json();
+ composeContent = composeData.content || '';
+ workingComposePath = composeFilePath;
+ // Clear the needsFileLocation flag since we now have content
+ needsFileLocation = false;
+ stackContainers = [];
+ } else {
+ const err = await composeResponse.json();
+ console.error('Failed to load compose file:', err.error);
+ }
+
+ // Try to load .env file (only set workingEnvPath if it exists)
+ if (envFilePath) {
+ const envResponse = await fetch(`/api/system/files/content?path=${encodeURIComponent(envFilePath)}`);
+ if (envResponse.ok) {
+ const envData = await envResponse.json();
+ rawEnvContent = envData.content || '';
+ workingEnvPath = envFilePath;
+ parseEnvVarsFromRaw(rawEnvContent);
+ } else {
+ // .env file not found - clear env path
+ rawEnvContent = '';
+ workingEnvPath = '';
+ }
+ }
+ } catch (e) {
+ console.error('Failed to load files:', e);
}
}
@@ -255,50 +666,92 @@ services:
loading = true;
loadError = null;
error = null;
+ needsFileLocation = false;
try {
const envId = $currentEnvironment?.id ?? null;
// Load compose file
- const response = await fetch(`/api/stacks/${encodeURIComponent(stackName)}/compose`);
+ const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId));
const data = await response.json();
if (!response.ok) {
+ // Check if this stack needs file location selection
+ if (data.needsFileLocation) {
+ needsFileLocation = true;
+ // Initialize paths from response (may have suggested paths)
+ workingComposePath = data.composePath || '';
+ workingEnvPath = data.envPath || '';
+ // Show empty editors - user can browse for files
+ composeContent = '';
+ rawEnvContent = '';
+ loadError = null;
+ loading = false; // Important: stop loading spinner
+
+ // Fetch containers for this stack to show what's running
+ try {
+ const stacksRes = await fetch(appendEnvParam('/api/stacks', envId));
+ if (stacksRes.ok) {
+ const stacks = await stacksRes.json();
+ const thisStack = stacks.find((s: any) => s.name === stackName);
+ if (thisStack?.containerDetails) {
+ stackContainers = thisStack.containerDetails.map((c: any) => ({
+ name: c.name || 'unknown',
+ state: c.state || 'unknown',
+ image: c.image || 'unknown'
+ }));
+ }
+ }
+ } catch (e) {
+ console.error('Failed to fetch stack containers:', e);
+ }
+ return;
+ }
throw new Error(data.error || 'Failed to load compose file');
}
composeContent = data.content;
- stackLocation = data.stackDir || null;
+ // Set working paths
+ workingComposePath = data.composePath || '';
+ workingEnvPath = data.envPath || '';
+ // Track original paths for detecting changes
+ originalComposePath = data.composePath || null;
+ originalEnvPath = data.envPath || null;
- // Load environment variables (parsed)
- const envResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId));
+ // Load both env endpoints in parallel, then process results together
+ const [envResponse, rawEnvResponse] = await Promise.all([
+ fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env`, envId)),
+ fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId))
+ ]);
+
+ // Process env vars from DB
+ let loadedVars: EnvVar[] = [];
if (envResponse.ok) {
const envData = await envResponse.json();
- envVars = envData.variables || [];
- // Track if DB had any vars (for proper cleanup on clear-all)
- hadExistingDbVars = envVars.length > 0;
- // Track existing secret keys (secrets loaded from DB cannot have visibility toggled)
+ loadedVars = envData.variables || [];
+ hadExistingDbVars = loadedVars.length > 0;
existingSecretKeys = new Set(
- envVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
+ loadedVars.filter(v => v.isSecret && v.key.trim()).map(v => v.key.trim())
);
}
- // Load raw .env file content (for preserving comments)
- const rawEnvResponse = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/env/raw`, envId));
+ // Process raw .env file content
+ let loadedRawContent = '';
if (rawEnvResponse.ok) {
const rawEnvData = await rawEnvResponse.json();
- rawEnvContent = rawEnvData.content || '';
- console.log('[loadComposeFile] rawEnvContent loaded:', rawEnvContent);
+ loadedRawContent = rawEnvData.content || '';
}
+
+ // Pass data directly to syncAfterLoad - no tick() needed
+ // This sets both envVars and rawEnvContent synchronously via the panel
+ loading = false;
+ await tick(); // Wait for panel ref to be available
+ envVarsPanelRef?.syncAfterLoad(loadedVars, loadedRawContent);
+ isDirty = false;
+
} catch (e: any) {
loadError = e.message;
- } finally {
loading = false;
- // Merge variables and rawContent after both are loaded
- await tick();
- envVarsPanelRef?.mergeOnLoad();
- // Reset dirty flag after loading completes
- isDirty = false;
}
}
@@ -367,23 +820,36 @@ services:
try {
const envId = $currentEnvironment?.id ?? null;
- // Create the stack (include env vars and raw content for .env file)
+ // Build request body
+ const requestBody: Record = {
+ name: newStackName.trim(),
+ compose: content,
+ start,
+ // Send raw env content (non-secrets only, preserves comments/formatting)
+ rawEnvContent: prepared.rawContent.trim() ? prepared.rawContent : undefined,
+ // Also send parsed vars for DB secret tracking (includes secrets)
+ envVars: prepared.variables.length > 0 ? prepared.variables.map(v => ({
+ key: v.key.trim(),
+ value: v.value,
+ isSecret: v.isSecret
+ })) : undefined
+ };
+
+ // Include custom paths if specified
+ if (workingComposePath.trim()) {
+ requestBody.composePath = workingComposePath.trim();
+ }
+ // Use working env path or suggested path
+ const envPathToSave = workingEnvPath.trim() || suggestedEnvPath || '';
+ if (envPathToSave) {
+ requestBody.envPath = envPathToSave;
+ }
+
+ // Create the stack
const response = await fetch(appendEnvParam('/api/stacks', envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- name: newStackName.trim(),
- compose: content,
- start,
- // Send raw env content (non-secrets only, preserves comments/formatting)
- rawEnvContent: prepared.rawContent.trim() ? prepared.rawContent : undefined,
- // Also send parsed vars for DB secret tracking (includes secrets)
- envVars: prepared.variables.length > 0 ? prepared.variables.map(v => ({
- key: v.key.trim(),
- value: v.value,
- isSecret: v.isSecret
- })) : undefined
- })
+ body: JSON.stringify(requestBody)
});
if (!response.ok) {
@@ -404,14 +870,56 @@ services:
}
}
- async function handleSave(restart = false) {
+ async function handleSave(restart = false, moveFromDir: string | null = null) {
errors = {};
- if (!composeContent.trim()) {
- errors.compose = 'Compose file content cannot be empty';
+ // Validate compose content (unless file location is needed and we have a path)
+ if (!composeContent.trim() && !workingComposePath.trim()) {
+ errors.compose = 'Compose file content or path is required';
return;
}
+ // If file location is needed, require a compose path
+ if (needsFileLocation && !workingComposePath.trim()) {
+ errors.compose = 'Please select a compose file location';
+ return;
+ }
+
+ const envId = $currentEnvironment?.id ?? null;
+
+ // Check if directory has changed (edit mode only, and not already confirmed)
+ if (mode === 'edit' && !moveFromDir) {
+ const newComposePath = workingComposePath.trim() || null;
+
+ // Only check if compose path changed (which means directory changed)
+ if (newComposePath && originalComposePath && newComposePath !== originalComposePath) {
+ try {
+ const checkResponse = await fetch(
+ appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/check-path-change`, envId),
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ newComposePath })
+ }
+ );
+ if (checkResponse.ok) {
+ const checkData = await checkResponse.json();
+ if (checkData.hasChanges && checkData.oldDir && checkData.fileCount > 0) {
+ // Show confirmation dialog
+ pathChangeOldDir = checkData.oldDir;
+ pathChangeFileCount = checkData.fileCount;
+ pendingSaveRestart = restart;
+ showPathChangeConfirm = true;
+ return;
+ }
+ }
+ } catch (e) {
+ console.warn('Failed to check path changes:', e);
+ // Continue with save even if check fails
+ }
+ }
+ }
+
saving = true;
savingWithRestart = restart;
error = null;
@@ -419,19 +927,46 @@ services:
// Prepare env vars for saving - syncs variables and rawContent
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
- try {
- const envId = $currentEnvironment?.id ?? null;
+ // Resolve env path (use working or suggested)
+ const envPathToSave = workingEnvPath.trim() || suggestedEnvPath || '';
- // Save compose file
+ try {
+ // Build request body - include paths if they've been set/changed
+ const requestBody: Record = {
+ content: composeContent,
+ restart
+ };
+
+ // Include compose path if set (either custom path or user selected)
+ if (workingComposePath.trim()) {
+ requestBody.composePath = workingComposePath.trim();
+ }
+
+ // Include env path - empty string means "no env file", null/undefined means "use default"
+ if (envPathToSave) {
+ requestBody.envPath = envPathToSave;
+ }
+
+ // Include old paths for file move/rename operations
+ if (originalComposePath && workingComposePath.trim() && originalComposePath !== workingComposePath.trim()) {
+ requestBody.oldComposePath = originalComposePath;
+ }
+ if (originalEnvPath && envPathToSave && originalEnvPath !== envPathToSave) {
+ requestBody.oldEnvPath = originalEnvPath;
+ }
+
+ // Include old directory to move files from if user confirmed
+ if (moveFromDir) {
+ requestBody.moveFromDir = moveFromDir;
+ }
+
+ // Save compose file (with optional paths)
const response = await fetch(
appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/compose`, envId),
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- content: composeContent,
- restart
- })
+ body: JSON.stringify(requestBody)
}
);
@@ -505,6 +1040,19 @@ services:
}
}
+ // Handle path change confirmation - move files to new location and proceed
+ function confirmPathChangeAndMove() {
+ showPathChangeConfirm = false;
+ handleSave(pendingSaveRestart, pathChangeOldDir);
+ }
+
+ // Handle path change - keep old files and proceed (just save without moving)
+ function confirmPathChangeKeepFiles() {
+ showPathChangeConfirm = false;
+ // Pass empty string to skip move check this time (not null, which means "not checked yet")
+ handleSave(pendingSaveRestart, '');
+ }
+
function tryClose() {
if (isDirty) {
showConfirmClose = true;
@@ -535,7 +1083,25 @@ services:
showConfirmClose = false;
codeEditorRef = null;
operationError = null;
- stackLocation = null;
+ // Reset path state
+ workingComposePath = '';
+ workingEnvPath = '';
+ originalComposePath = null;
+ originalEnvPath = null;
+ autoComputedComposePath = '';
+ pathSource = null;
+ needsFileLocation = false;
+ stackContainers = [];
+ showFileBrowser = false;
+ // Reset path change confirmation state
+ showPathChangeConfirm = false;
+ pathChangeOldDir = null;
+ pathChangeFileCount = 0;
+ pendingSaveRestart = false;
+ // Reset browse confirmation state
+ showBrowseConfirm = false;
+ pendingBrowsePath = null;
+ pendingBrowseName = null;
onClose();
}
@@ -580,6 +1146,56 @@ services:
return () => clearTimeout(timeout);
});
+
+ // Auto-update default paths when stack name changes in create mode
+ $effect(() => {
+ if (mode !== 'create' || !open) return;
+ // Don't overwrite if user has browsed and selected a path
+ if (pathSource === 'browsed') return;
+
+ const name = newStackName.trim();
+ const location = $appSettings.primaryStackLocation;
+
+ if (!name) {
+ // Clear paths when no name
+ workingComposePath = '';
+ workingEnvPath = '';
+ autoComputedComposePath = '';
+ pathSource = null;
+ return;
+ }
+
+ // Fetch the actual absolute path from the backend
+ const envId = $currentEnvironment?.id ?? null;
+ const fetchDefaultPath = async () => {
+ try {
+ const params = new URLSearchParams({ name });
+ if (envId) params.set('env', String(envId));
+ if (location) {
+ params.set('location', location);
+ }
+ const response = await fetch(`/api/stacks/default-path?${params}`);
+ if (response.ok) {
+ const data = await response.json();
+ // Check if user has customized before updating auto-computed
+ // Compare current working path against OLD auto path (before we update it)
+ const userHasCustomized = workingComposePath !== '' &&
+ workingComposePath !== autoComputedComposePath;
+ // Track the auto-computed path
+ autoComputedComposePath = data.composePath;
+ // Only update working paths if user hasn't customized
+ if (!userHasCustomized) {
+ workingComposePath = data.composePath;
+ workingEnvPath = data.envPath;
+ pathSource = data.source || 'default';
+ }
+ }
+ } catch (e) {
+ console.error('Failed to fetch default path:', e);
+ }
+ };
+ fetchDefaultPath();
+ });
@@ -620,24 +1238,8 @@ services:
{#if mode === 'create'}
Create a new Docker Compose stack
- {:else if stackLocation}
-
-
- {stackLocation}
-
- {#if pathCopied}
-
- {:else}
-
- {/if}
-
-
{:else}
- Edit compose file and view stack structure
+ Edit compose file and environment variables
{/if}
@@ -704,82 +1306,160 @@ services:
Loading compose file...
- {:else if mode === 'edit' && loadError}
-
-
-
-
Could not load compose file
-
{loadError}
-
- This stack may have been created outside of Dockhand or the compose file may have been moved.
-
-
-
{:else}
-
+
{#if mode === 'create'}
-
-
Stack name
-
errors.stackName = undefined}
- />
- {#if errors.stackName}
-
{errors.stackName}
- {/if}
+
+
+
Stack name
+
errors.stackName = undefined}
+ />
+ {#if errors.stackName}
+
{errors.stackName}
+ {/if}
+
+
+
+ {/if}
+
+
+ {#if mode === 'edit' && needsFileLocation}
+
+
+
+
+
+ Untracked stack. Select the compose file location to start managing this stack with Dockhand.
+
+ {#if stackContainers.length > 0}
+
+
Running containers:
+
+ {#each stackContainers as container}
+
+
+ {container.name}
+
+ {/each}
+
+
+ {/if}
+
{/if}
-
+
{#if activeTab === 'editor'}
-
-
- {#if open}
-
-
-
- {/if}
-
-
-
-
-
+
+
+
+
+
copyToClipboard(workingComposePath, (v) => composePathCopied = v)}
+ onBrowse={openComposeBrowser}
+ onChangeLocation={mode === 'edit' && !needsFileLocation ? openChangeLocationBrowser : undefined}
+ defaultText={mode === 'create' ? 'Enter stack name above' : 'Not specified'}
+ sourceHint={pathSourceHint}
+ />
+
+
+
+
+
+
copyToClipboard(displayEnvPath, (v) => envPathCopied = v)}
+ onBrowse={openEnvBrowser}
+ isEditable={true}
+ isCustom={!!workingEnvPath}
+ defaultText={mode === 'create' ? 'Enter stack name above' : 'Not specified'}
+ isSuggested={isEnvPathSuggested}
+ onPathChange={(value) => {
+ workingEnvPath = value;
+ isDirty = true;
+ }}
+ />
-
-
-
{ markDirty(); debouncedValidate(); }}
- theme={editorTheme}
- infoText="These variables will be written to a .env file in the stack directory."
- />
+
+
+
+
+ {#if open}
+
+ {#if needsFileLocation && !composeContent}
+
+
+
+
No compose file selected
+
+ Browse to locate the compose file for this stack. The editor will load the file contents once selected.
+
+
+
+ Browse for compose file
+
+
+
+
+ What happens when you select a file: Dockhand will track this compose file, letting you edit, start, and stop the stack from the UI. Your files stay in their current location.
+
+
+ {:else}
+
+ {/if}
+
+ {/if}
+
+
+
+
+
+ { markDirty(); debouncedValidate(); }}
+ theme={editorTheme}
+ infoText="These variables will be written to a .env file in the stack directory."
+ />
+
{:else if activeTab === 'graph'}
@@ -831,7 +1511,7 @@ services:
{:else}
- handleSave(false)} disabled={saving || loading || !!loadError}>
+ handleSave(false)} disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}>
{#if saving && !savingWithRestart}
Saving...
@@ -840,7 +1520,7 @@ services:
Save
{/if}
- handleSave(true)} disabled={saving || loading || !!loadError}>
+ handleSave(true)} disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}>
{#if saving && savingWithRestart}
Restarting...
@@ -875,6 +1555,122 @@ services:
+
+
+
+
+ Move stack files?
+
+ You've changed the stack location. There {pathChangeFileCount === 1 ? 'is' : 'are'} {pathChangeFileCount} file{pathChangeFileCount === 1 ? '' : 's'} in the old location that can be moved to the new location.
+
+
+ {#if pathChangeOldDir}
+
+
+
+ {pathChangeOldDir}
+
+
+ {/if}
+
+ Would you like to move all files to the new location, or leave them in place?
+
+
+
showPathChangeConfirm = false}>
+ Cancel
+
+
+ Leave files
+
+
+
+ Move files
+
+
+
+
+
+
+
+
+
+ Replace editor content?
+
+ Loading a different compose file will replace the current editor content.
+
+
+
+
+ Current:
+
+ {workingComposePath || '(unsaved)'}
+
+
+
+
+
New:
+
+ {pendingBrowsePath}
+
+
+
+
+
+ Cancel
+
+
+ Replace content
+
+
+
+
+
+
+
+
+
+
+
+ Relocate stack?
+
+
+ All {changeLocationFileCount} file{changeLocationFileCount === 1 ? '' : 's'} in the stack folder will be moved.
+
+
+
+
+ From
+
+ {changeLocationOldDir}
+
+
+
+
+ To
+
+ {pendingNewLocation}
+
+
+
+
+
+ Cancel
+
+
+ {#if movingLocation}
+
+ Moving...
+ {:else}
+
+ Move files
+ {/if}
+
+
+
+
+
{#if operationError}
{@const errorDialogOpen = true}
@@ -886,3 +1682,14 @@ services:
onClose={() => operationError = null}
/>
{/if}
+
+
+ showFileBrowser = false}
+/>
diff --git a/src/routes/volumes/VolumeBrowserModal.svelte b/src/routes/volumes/VolumeBrowserModal.svelte
index 8ecfebb..66a13f3 100644
--- a/src/routes/volumes/VolumeBrowserModal.svelte
+++ b/src/routes/volumes/VolumeBrowserModal.svelte
@@ -50,7 +50,7 @@
-
+
diff --git a/src/routes/volumes/VolumeInspectModal.svelte b/src/routes/volumes/VolumeInspectModal.svelte
index 9875d52..ae66005 100644
--- a/src/routes/volumes/VolumeInspectModal.svelte
+++ b/src/routes/volumes/VolumeInspectModal.svelte
@@ -48,7 +48,7 @@
-
+