Compare commits

..

1 Commits

Author SHA1 Message Date
jarek e8ab07ec3f 1.0.9 2026-01-17 15:06:14 +01:00
60 changed files with 2831 additions and 919 deletions
+2
View File
@@ -49,6 +49,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64")
" - tzdata" \
" - docker-cli" \
" - docker-compose" \
" - docker-cli-buildx" \
" - sqlite" \
" - git" \
" - openssh-client" \
@@ -142,6 +143,7 @@ ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
PGID=1001
# Create docker compose plugin symlink (we use `docker compose` syntax, Wolfi has standalone binary)
# Note: docker-cli-buildx package already creates the buildx symlink
RUN mkdir -p /usr/libexec/docker/cli-plugins \
&& ln -s /usr/bin/docker-compose /usr/libexec/docker/cli-plugins/docker-compose
+13 -11
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.8",
"version": "1.0.3",
"type": "module",
"scripts": {
"dev": "bunx --bun vite dev",
@@ -50,10 +50,10 @@
"@codemirror/lang-xml": "6.1.0",
"@codemirror/lang-yaml": "6.1.2",
"@codemirror/language": "6.12.1",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.3",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.5.4",
"@codemirror/theme-one-dark": "6.1.3",
"@codemirror/view": "6.39.9",
"@codemirror/view": "6.39.11",
"@lezer/highlight": "1.2.3",
"@lucide/lab": "^0.1.2",
"codemirror": "6.0.2",
@@ -75,12 +75,12 @@
"@layerstack/tailwind": "^1.0.1",
"@lucide/svelte": "^0.562.0",
"@playwright/test": "1.57.0",
"@sveltejs/kit": "^2.49.3",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@sveltejs/kit": "2.49.5",
"@sveltejs/vite-plugin-svelte": "6.2.4",
"@tailwindcss/vite": "^4.1.18",
"@types/bun": "^1.3.5",
"@types/bun": "1.3.6",
"@types/js-yaml": "^4.0.9",
"@types/nodemailer": "^7.0.4",
"@types/nodemailer": "7.0.5",
"@types/qrcode": "^1.5.6",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
@@ -96,7 +96,7 @@
"lucide-svelte": "^0.562.0",
"mode-watcher": "^1.1.0",
"postcss": "^8.5.6",
"svelte": "^5.46.1",
"svelte": "5.46.4",
"svelte-adapter-bun": "1.0.1",
"svelte-check": "^4.3.5",
"svelte-easy-crop": "^5.0.0",
@@ -109,9 +109,11 @@
"vite": "^7.3.1"
},
"overrides": {
"@codemirror/state": "6.5.3",
"@codemirror/view": "6.39.9",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.11",
"@codemirror/language": "6.12.1",
"@codemirror/commands": "6.10.1",
"@codemirror/search": "6.6.0",
"@lezer/common": "1.5.0",
"@lezer/highlight": "1.2.3"
}
+61 -1
View File
@@ -5,9 +5,34 @@ import { isAuthEnabled, validateSession } from '$lib/server/auth';
import { setServerStartTime } from '$lib/server/uptime';
import { checkLicenseExpiry, getHostname } from '$lib/server/license';
import { initCryptoFallback } from '$lib/server/crypto-fallback';
import { detectHostDataDir } from '$lib/server/host-path';
import { listContainers, removeContainer } from '$lib/server/docker';
import { rmSync, readdirSync, existsSync } from 'fs';
import { join } from 'path';
import type { HandleServerError, Handle } from '@sveltejs/kit';
import { redirect } from '@sveltejs/kit';
// Cleanup orphaned scanner version containers from previous runs
async function cleanupOrphanedScannerContainers() {
try {
const containers = await listContainers(true);
const orphaned = containers.filter(c =>
c.name?.startsWith('dockhand-grype-version-') ||
c.name?.startsWith('dockhand-trivy-version-')
);
for (const c of orphaned) {
try {
await removeContainer(c.id, true);
} catch { /* ignore */ }
}
if (orphaned.length > 0) {
console.log(`[Startup] Cleaned up ${orphaned.length} orphaned scanner containers`);
}
} catch (error) {
// Silently ignore - Docker may not be available yet or no containers to clean
}
}
// License expiry check interval (24 hours)
const LICENSE_CHECK_INTERVAL = 86400000;
@@ -24,10 +49,46 @@ if (!initialized) {
// Initialize crypto fallback first (detects old kernels and logs status)
initCryptoFallback();
// Cleanup orphaned TLS temp directories from previous crashes
const dataDir = process.env.DATA_DIR || './data';
const tmpDir = join(dataDir, 'tmp');
if (existsSync(tmpDir)) {
try {
const entries = readdirSync(tmpDir);
for (const entry of entries) {
if (entry.startsWith('tls-')) {
const path = join(tmpDir, entry);
try {
rmSync(path, { recursive: true, force: true });
console.log(`[Startup] Cleaned orphaned TLS temp dir: ${entry}`);
} catch { /* ignore */ }
}
}
} catch { /* ignore */ }
}
setServerStartTime(); // Track when server started
initDatabase();
// Log hostname for license validation (set by entrypoint in Docker, or os.hostname() outside)
console.log('Hostname for license validation:', getHostname());
// Detect host data directory for path translation
// This allows Dockhand to translate container paths to host paths for compose volume mounts
detectHostDataDir().then(hostPath => {
if (hostPath) {
console.log(`[Startup] Host data directory detected: ${hostPath}`);
} else {
console.warn('[Startup] Could not detect host data path.');
console.warn('[Startup] Git stacks with relative volume paths may not work correctly.');
console.warn('[Startup] Consider setting HOST_DATA_DIR or using matching volume paths (-v /app/data:/app/data)');
}
}).catch(err => {
console.error('[Startup] Failed to detect host data directory:', err);
});
// Cleanup orphaned scanner containers from previous runs (non-blocking)
cleanupOrphanedScannerContainers().catch(err => {
console.error('Failed to cleanup orphaned scanner containers:', err);
});
// Start background subprocesses for metrics and event collection (isolated processes)
startSubprocesses().catch(err => {
console.error('Failed to start background subprocesses:', err);
@@ -174,4 +235,3 @@ export const handleError: HandleServerError = ({ error, event }) => {
code: 'INTERNAL_ERROR'
};
};
// CI trigger 1766327149
+1 -4
View File
@@ -316,13 +316,10 @@
envAbortController = new AbortController();
fetchHostInfo();
fetchDiskUsage();
const hostInterval = setInterval(fetchHostInfo, 30000);
const diskInterval = setInterval(fetchDiskUsage, 30000);
// No polling - only fetch on mount and environment switch
document.addEventListener('click', handleClickOutside);
return () => {
abortPendingRequests(); // Abort on destroy
clearInterval(hostInterval);
clearInterval(diskInterval);
document.removeEventListener('click', handleClickOutside);
};
});
+18
View File
@@ -1,4 +1,22 @@
[
{
"version": "1.0.9",
"date": "2026-01-17",
"changes": [
{ "type": "feature", "text": "Shell: detect available shells in container before connecting" },
{ "type": "fix", "text": "Fix GHCR registry authentication with OAuth2 token flow" },
{ "type": "fix", "text": "Add page titles for browser tab updates on navigation" },
{ "type": "fix", "text": "Add stack name conflict warning" },
{ "type": "feature", "text": "Add docker-buildx plugin to container image" },
{ "type": "fix", "text": "Fix relative paths not working for adopted/imported stacks" },
{ "type": "fix", "text": "Fix TLS certificates not passed to docker-compose for direct connections" },
{ "type": "fix", "text": "Fix registry queries for images with docker.io prefix" },
{ "type": "fix", "text": "Fix compose editor issues when editing near env var references" },
{ "type": "fix", "text": "Fix branch switching causing unknown revision error in git stacks" },
{ "type": "fix", "text": "Fix SSE connection leak" }
],
"imageTag": "fnsys/dockhand:v1.0.9"
},
{
"version": "1.0.8",
"date": "2026-01-13",
+252
View File
@@ -71,6 +71,12 @@
"license": "MIT",
"repository": "https://github.com/codemirror/lang-yaml"
},
{
"name": "@codemirror/language",
"version": "6.11.3",
"license": "MIT",
"repository": "https://github.com/codemirror/language"
},
{
"name": "@codemirror/language",
"version": "6.12.1",
@@ -89,6 +95,12 @@
"license": "MIT",
"repository": "https://github.com/codemirror/search"
},
{
"name": "@codemirror/state",
"version": "6.5.2",
"license": "MIT",
"repository": "https://github.com/codemirror/state"
},
{
"name": "@codemirror/state",
"version": "6.5.3",
@@ -101,6 +113,12 @@
"license": "MIT",
"repository": "https://github.com/codemirror/theme-one-dark"
},
{
"name": "@codemirror/view",
"version": "6.38.8",
"license": "MIT",
"repository": "https://github.com/codemirror/view"
},
{
"name": "@codemirror/view",
"version": "6.39.9",
@@ -227,6 +245,12 @@
"license": "MIT",
"repository": "https://github.com/sveltejs/acorn-typescript"
},
{
"name": "@types/better-sqlite3",
"version": "7.6.13",
"license": "MIT",
"repository": "https://github.com/DefinitelyTyped/DefinitelyTyped"
},
{
"name": "@types/estree",
"version": "1.0.8",
@@ -275,6 +299,36 @@
"license": "Apache-2.0",
"repository": "https://github.com/A11yance/axobject-query"
},
{
"name": "base64-js",
"version": "1.5.1",
"license": "MIT",
"repository": "https://github.com/beatgammit/base64-js"
},
{
"name": "better-sqlite3",
"version": "12.5.0",
"license": "MIT",
"repository": "https://github.com/WiseLibs/better-sqlite3"
},
{
"name": "bindings",
"version": "1.5.0",
"license": "MIT",
"repository": "https://github.com/TooTallNate/node-bindings"
},
{
"name": "bl",
"version": "4.1.0",
"license": "MIT",
"repository": "https://github.com/rvagg/bl"
},
{
"name": "buffer",
"version": "5.7.1",
"license": "MIT",
"repository": "https://github.com/feross/buffer"
},
{
"name": "bun-types",
"version": "1.3.5",
@@ -287,6 +341,12 @@
"license": "MIT",
"repository": "https://github.com/sindresorhus/camelcase"
},
{
"name": "chownr",
"version": "1.1.4",
"license": "ISC",
"repository": "https://github.com/isaacs/chownr"
},
{
"name": "cliui",
"version": "6.0.0",
@@ -341,6 +401,24 @@
"license": "MIT",
"repository": "https://github.com/sindresorhus/decamelize"
},
{
"name": "decompress-response",
"version": "6.0.0",
"license": "MIT",
"repository": "https://github.com/sindresorhus/decompress-response"
},
{
"name": "deep-extend",
"version": "0.6.0",
"license": "MIT",
"repository": "https://github.com/unclechu/node-deep-extend"
},
{
"name": "detect-libc",
"version": "2.1.2",
"license": "Apache-2.0",
"repository": "https://github.com/lovell/detect-libc"
},
{
"name": "devalue",
"version": "5.5.0",
@@ -371,6 +449,12 @@
"license": "MIT",
"repository": "https://github.com/mathiasbynens/emoji-regex"
},
{
"name": "end-of-stream",
"version": "1.4.5",
"license": "MIT",
"repository": "https://github.com/mafintosh/end-of-stream"
},
{
"name": "esm-env",
"version": "1.2.2",
@@ -383,24 +467,66 @@
"license": "MIT",
"repository": "https://github.com/sveltejs/esrap"
},
{
"name": "expand-template",
"version": "2.0.3",
"license": "(MIT OR WTFPL)",
"repository": "https://github.com/ralphtheninja/expand-template"
},
{
"name": "file-uri-to-path",
"version": "1.0.0",
"license": "MIT",
"repository": "https://github.com/TooTallNate/file-uri-to-path"
},
{
"name": "find-up",
"version": "4.1.0",
"license": "MIT",
"repository": "https://github.com/sindresorhus/find-up"
},
{
"name": "fs-constants",
"version": "1.0.0",
"license": "MIT",
"repository": "https://github.com/mafintosh/fs-constants"
},
{
"name": "get-caller-file",
"version": "2.0.5",
"license": "ISC",
"repository": "https://github.com/stefanpenner/get-caller-file"
},
{
"name": "github-from-package",
"version": "0.0.0",
"license": "MIT",
"repository": "https://github.com/substack/github-from-package"
},
{
"name": "hash-wasm",
"version": "4.12.0",
"license": "MIT",
"repository": "https://github.com/Daninet/hash-wasm"
},
{
"name": "ieee754",
"version": "1.2.1",
"license": "BSD-3-Clause",
"repository": "https://github.com/feross/ieee754"
},
{
"name": "inherits",
"version": "2.0.4",
"license": "ISC",
"repository": "https://github.com/isaacs/inherits"
},
{
"name": "ini",
"version": "1.3.8",
"license": "ISC",
"repository": "https://github.com/isaacs/ini"
},
{
"name": "is-fullwidth-code-point",
"version": "3.0.0",
@@ -443,12 +569,48 @@
"license": "MIT",
"repository": "https://github.com/Rich-Harris/magic-string"
},
{
"name": "mimic-response",
"version": "3.1.0",
"license": "MIT",
"repository": "https://github.com/sindresorhus/mimic-response"
},
{
"name": "minimist",
"version": "1.2.8",
"license": "MIT",
"repository": "https://github.com/minimistjs/minimist"
},
{
"name": "mkdirp-classic",
"version": "0.5.3",
"license": "MIT",
"repository": "https://github.com/mafintosh/mkdirp-classic"
},
{
"name": "napi-build-utils",
"version": "2.0.0",
"license": "MIT",
"repository": "https://github.com/inspiredware/napi-build-utils"
},
{
"name": "node-abi",
"version": "3.85.0",
"license": "MIT",
"repository": "https://github.com/electron/node-abi"
},
{
"name": "nodemailer",
"version": "7.0.12",
"license": "MIT-0",
"repository": "https://github.com/nodemailer/nodemailer"
},
{
"name": "once",
"version": "1.4.0",
"license": "ISC",
"repository": "https://github.com/isaacs/once"
},
{
"name": "otpauth",
"version": "9.4.1",
@@ -491,6 +653,18 @@
"license": "Unlicense",
"repository": "https://github.com/porsager/postgres"
},
{
"name": "prebuild-install",
"version": "7.1.3",
"license": "MIT",
"repository": "https://github.com/prebuild/prebuild-install"
},
{
"name": "pump",
"version": "3.0.3",
"license": "MIT",
"repository": "https://github.com/mafintosh/pump"
},
{
"name": "punycode",
"version": "2.3.1",
@@ -503,6 +677,18 @@
"license": "MIT",
"repository": "https://github.com/soldair/node-qrcode"
},
{
"name": "rc",
"version": "1.2.8",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"repository": "https://github.com/dominictarr/rc"
},
{
"name": "readable-stream",
"version": "3.6.2",
"license": "MIT",
"repository": "https://github.com/nodejs/readable-stream"
},
{
"name": "require-directory",
"version": "2.1.1",
@@ -521,12 +707,36 @@
"license": "MIT",
"repository": "https://github.com/svecosystem/runed"
},
{
"name": "safe-buffer",
"version": "5.2.1",
"license": "MIT",
"repository": "https://github.com/feross/safe-buffer"
},
{
"name": "semver",
"version": "7.7.3",
"license": "ISC",
"repository": "https://github.com/npm/node-semver"
},
{
"name": "set-blocking",
"version": "2.0.0",
"license": "ISC",
"repository": "https://github.com/yargs/set-blocking"
},
{
"name": "simple-concat",
"version": "1.0.1",
"license": "MIT",
"repository": "https://github.com/feross/simple-concat"
},
{
"name": "simple-get",
"version": "4.0.1",
"license": "MIT",
"repository": "https://github.com/feross/simple-get"
},
{
"name": "strict-event-emitter-types",
"version": "2.0.0",
@@ -539,12 +749,24 @@
"license": "MIT",
"repository": "https://github.com/sindresorhus/string-width"
},
{
"name": "string_decoder",
"version": "1.3.0",
"license": "MIT",
"repository": "https://github.com/nodejs/string_decoder"
},
{
"name": "strip-ansi",
"version": "6.0.1",
"license": "MIT",
"repository": "https://github.com/chalk/strip-ansi"
},
{
"name": "strip-json-comments",
"version": "2.0.1",
"license": "MIT",
"repository": "https://github.com/sindresorhus/strip-json-comments"
},
{
"name": "style-mod",
"version": "4.1.3",
@@ -569,18 +791,42 @@
"license": "MIT",
"repository": "https://github.com/wobsoriano/svelte-sonner"
},
{
"name": "tar-fs",
"version": "2.1.4",
"license": "MIT",
"repository": "https://github.com/mafintosh/tar-fs"
},
{
"name": "tar-stream",
"version": "2.2.0",
"license": "MIT",
"repository": "https://github.com/mafintosh/tar-stream"
},
{
"name": "tr46",
"version": "6.0.0",
"license": "MIT",
"repository": "https://github.com/jsdom/tr46"
},
{
"name": "tunnel-agent",
"version": "0.6.0",
"license": "Apache-2.0",
"repository": "https://github.com/mikeal/tunnel-agent"
},
{
"name": "undici-types",
"version": "7.16.0",
"license": "MIT",
"repository": "https://github.com/nodejs/undici"
},
{
"name": "util-deprecate",
"version": "1.0.2",
"license": "MIT",
"repository": "https://github.com/TooTallNate/util-deprecate"
},
{
"name": "w3c-keyname",
"version": "2.2.8",
@@ -611,6 +857,12 @@
"license": "MIT",
"repository": "https://github.com/chalk/wrap-ansi"
},
{
"name": "wrappy",
"version": "1.0.2",
"license": "ISC",
"repository": "https://github.com/npm/wrappy"
},
{
"name": "y18n",
"version": "4.0.3",
+2 -2
View File
@@ -1911,7 +1911,7 @@ export async function createGitRepository(data: {
name: data.name,
url: data.url,
branch: data.branch || 'main',
composePath: data.composePath || 'docker-compose.yml',
composePath: data.composePath || 'compose.yaml',
credentialId: data.credentialId || null,
environmentId: data.environmentId || null,
autoUpdate: data.autoUpdate || false,
@@ -2325,7 +2325,7 @@ export async function createGitStack(data: {
stackName: data.stackName,
environmentId: data.environmentId ?? null,
repositoryId: data.repositoryId,
composePath: data.composePath || 'docker-compose.yml',
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
+2 -2
View File
@@ -288,7 +288,7 @@ export const gitRepositories = sqliteTable('git_repositories', {
url: text('url').notNull(),
branch: text('branch').default('main'),
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
composePath: text('compose_path').default('docker-compose.yml'),
composePath: text('compose_path').default('compose.yaml'),
environmentId: integer('environment_id'),
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
@@ -308,7 +308,7 @@ export const gitStacks = sqliteTable('git_stacks', {
stackName: text('stack_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
composePath: text('compose_path').default('docker-compose.yml'),
composePath: text('compose_path').default('compose.yaml'),
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
autoUpdate: integer('auto_update', { mode: 'boolean' }).default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
+2 -2
View File
@@ -291,7 +291,7 @@ export const gitRepositories = pgTable('git_repositories', {
url: text('url').notNull(),
branch: text('branch').default('main'),
credentialId: integer('credential_id').references(() => gitCredentials.id, { onDelete: 'set null' }),
composePath: text('compose_path').default('docker-compose.yml'),
composePath: text('compose_path').default('compose.yaml'),
environmentId: integer('environment_id'),
autoUpdate: boolean('auto_update').default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
@@ -311,7 +311,7 @@ export const gitStacks = pgTable('git_stacks', {
stackName: text('stack_name').notNull(),
environmentId: integer('environment_id').references(() => environments.id, { onDelete: 'cascade' }),
repositoryId: integer('repository_id').notNull().references(() => gitRepositories.id, { onDelete: 'cascade' }),
composePath: text('compose_path').default('docker-compose.yml'),
composePath: text('compose_path').default('compose.yaml'),
envFilePath: text('env_file_path'), // Path to .env file in repository (e.g., ".env", "config/.env.prod")
autoUpdate: boolean('auto_update').default(false),
autoUpdateSchedule: text('auto_update_schedule').default('daily'),
+288 -122
View File
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord } from './db';
import { isSystemContainer } from './scheduler/tasks/update-utils';
/**
* Custom error for when an environment is not found.
@@ -736,7 +737,8 @@ export async function listContainers(all = true, envId?: number | null): Promise
restartCount: restartCounts.get(container.Id) || 0,
mounts,
labels: container.Labels || {},
command: container.Command || ''
command: container.Command || '',
systemContainer: isSystemContainer(container.Image || '')
};
});
}
@@ -1204,6 +1206,12 @@ function parseImageReference(imageName: string): { registry: string; repo: strin
}
}
// Normalize docker.io to index.docker.io (Docker Hub's actual registry host)
// docker.io redirects to www.docker.com, while index.docker.io is the real API
if (registry === 'docker.io') {
registry = 'index.docker.io';
}
// Docker Hub requires library/ prefix for official images
if (registry === 'index.docker.io' && !repo.includes('/')) {
repo = `library/${repo}`;
@@ -1349,6 +1357,141 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise<s
}
}
/**
* Get authentication header for registry API requests.
* Handles the Docker Registry V2 OAuth2 token flow:
* 1. Challenge request to /v2/ to get WWW-Authenticate header
* 2. Parse realm, service from challenge
* 3. Request token from realm with credentials (if available)
*
* @param registryUrl - Full registry URL (e.g., 'https://ghcr.io')
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
* @param credentials - Optional credentials { username, password }
* @returns Authorization header value (e.g., 'Bearer xxx' or 'Basic xxx') or null
*/
export async function getRegistryAuthHeader(
registryUrl: string,
scope: string,
credentials?: { username: string; password: string } | null
): Promise<string | null> {
try {
// Normalize URL
let baseUrl = registryUrl;
if (!baseUrl.startsWith('http')) {
baseUrl = `https://${baseUrl}`;
}
baseUrl = baseUrl.replace(/\/$/, '');
// Step 1: Challenge request to /v2/
const challengeResponse = await fetch(`${baseUrl}/v2/`, {
method: 'GET',
headers: { 'User-Agent': 'Dockhand/1.0' }
});
// If 200, no auth needed
if (challengeResponse.ok) {
return null;
}
// If not 401, something else is wrong
if (challengeResponse.status !== 401) {
console.error(`Registry challenge failed: ${challengeResponse.status}`);
return null;
}
// Step 2: Parse WWW-Authenticate header
const wwwAuth = challengeResponse.headers.get('WWW-Authenticate') || '';
const challenge = wwwAuth.toLowerCase();
if (challenge.startsWith('basic')) {
// Basic auth - use credentials if we have them
if (credentials) {
const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
return `Basic ${basicAuth}`;
}
return null;
}
if (!challenge.startsWith('bearer')) {
console.error(`Unsupported auth type: ${wwwAuth}`);
return null;
}
// Parse bearer challenge: Bearer realm="...",service="...",scope="..."
const realmMatch = wwwAuth.match(/realm="([^"]+)"/i);
const serviceMatch = wwwAuth.match(/service="([^"]+)"/i);
if (!realmMatch) {
console.error('No realm in WWW-Authenticate header');
return null;
}
const realm = realmMatch[1];
const service = serviceMatch ? serviceMatch[1] : '';
// Step 3: Request token from realm (with credentials if available)
const tokenUrl = new URL(realm);
if (service) tokenUrl.searchParams.set('service', service);
tokenUrl.searchParams.set('scope', scope);
const tokenHeaders: Record<string, string> = { 'User-Agent': 'Dockhand/1.0' };
// Add Basic auth header if we have credentials
if (credentials) {
const basicAuth = Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64');
tokenHeaders['Authorization'] = `Basic ${basicAuth}`;
}
const tokenResponse = await fetch(tokenUrl.toString(), {
headers: tokenHeaders
});
if (!tokenResponse.ok) {
const errorBody = await tokenResponse.text().catch(() => '');
console.error(`Token request failed: ${tokenResponse.status} - ${errorBody}`);
return null;
}
const tokenData = await tokenResponse.json() as { token?: string; access_token?: string };
const token = tokenData.token || tokenData.access_token || null;
return token ? `Bearer ${token}` : null;
} catch (e) {
console.error('Failed to get registry auth header:', e);
return null;
}
}
/**
* Helper to get normalized registry URL and auth header for registry API requests.
* Combines URL normalization, credential extraction, and token flow in one call.
*
* @param registry - Registry object from database
* @param scope - Token scope (e.g., 'registry:catalog:*' or 'repository:user/repo:pull')
* @returns { baseUrl, authHeader } - Normalized URL and auth header (or null)
*/
export async function getRegistryAuth(
registry: { url: string; username?: string | null; password?: string | null },
scope: string
): Promise<{ baseUrl: string; authHeader: string | null }> {
// Normalize URL
let baseUrl = registry.url;
if (!baseUrl.startsWith('http')) {
baseUrl = `https://${baseUrl}`;
}
baseUrl = baseUrl.replace(/\/$/, '');
// Get auth header using proper token flow
const credentials = registry.username && registry.password
? { username: registry.username, password: registry.password }
: null;
const authHeader = await getRegistryAuthHeader(baseUrl, scope, credentials);
return { baseUrl, authHeader };
}
/**
* Check the registry for the current manifest digest of an image.
* Simple HEAD request to get Docker-Content-Digest header.
@@ -2161,14 +2304,15 @@ export async function runContainer(options: {
binds?: string[];
env?: string[];
name?: string;
autoRemove?: boolean;
envId?: number | null;
}): Promise<{ stdout: string; stderr: string }> {
// Add random suffix to avoid naming conflicts
const baseName = options.name || `dockhand-temp-${Date.now()}`;
const containerName = `${baseName}-${randomSuffix()}`;
// Create container
// Create container - disable AutoRemove since we fetch logs after exit
// and clean up manually. AutoRemove causes race condition where container
// is removed before we can fetch logs.
const containerConfig: any = {
Image: options.image,
Cmd: options.cmd,
@@ -2176,7 +2320,7 @@ export async function runContainer(options: {
Tty: false,
HostConfig: {
Binds: options.binds || [],
AutoRemove: options.autoRemove !== false
AutoRemove: false // Never use AutoRemove - we clean up manually after fetching logs
}
};
@@ -2190,15 +2334,21 @@ export async function runContainer(options: {
);
const containerId = createResult.Id;
console.log(`[runContainer] Created container ${containerId} for image ${options.image}`);
try {
// Start container
console.log(`[runContainer] Starting container ${containerId}...`);
await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId);
// Wait for container to finish
await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId);
console.log(`[runContainer] Waiting for container ${containerId} to finish...`);
const waitResponse = await dockerFetch(`/containers/${containerId}/wait`, { method: 'POST' }, options.envId);
const waitResult = await waitResponse.json().catch(() => ({}));
console.log(`[runContainer] Container ${containerId} finished with exit code:`, waitResult?.StatusCode);
// Get logs
// Get logs - container is stopped but NOT removed yet since AutoRemove is false
console.log(`[runContainer] Fetching logs for container ${containerId}...`);
const logsResponse = await dockerFetch(
`/containers/${containerId}/logs?stdout=true&stderr=true`,
{},
@@ -2206,15 +2356,20 @@ export async function runContainer(options: {
);
const buffer = Buffer.from(await logsResponse.arrayBuffer());
return demuxDockerStream(buffer, { separateStreams: true }) as { stdout: string; stderr: string };
console.log(`[runContainer] Got logs buffer, size: ${buffer.length} bytes`);
const result = demuxDockerStream(buffer, { separateStreams: true }) as { stdout: string; stderr: string };
console.log(`[runContainer] Demuxed: stdout=${result.stdout.length} chars, stderr=${result.stderr.length} chars`);
if (result.stdout.length === 0 && result.stderr.length === 0 && buffer.length > 0) {
console.log(`[runContainer] WARNING: Buffer has data but demux returned empty. First 100 bytes:`, buffer.slice(0, 100));
}
return result;
} finally {
// Cleanup container if not auto-removed
if (options.autoRemove === false) {
try {
await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId);
} catch {
// Ignore cleanup errors
}
// Always cleanup container manually
try {
await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId);
} catch {
// Ignore cleanup errors
}
}
}
@@ -2230,11 +2385,10 @@ export async function runContainerWithStreaming(options: {
onStdout?: (data: string) => void;
onStderr?: (data: string) => void;
}): Promise<string> {
// Add random suffix to avoid naming conflicts
const baseName = options.name || `dockhand-stream-${Date.now()}`;
const containerName = `${baseName}-${randomSuffix()}`;
// Create container
// Create container WITHOUT AutoRemove - we need to fetch logs after it exits
const containerConfig: any = {
Image: options.image,
Cmd: options.cmd,
@@ -2242,123 +2396,135 @@ export async function runContainerWithStreaming(options: {
Tty: false,
HostConfig: {
Binds: options.binds || [],
AutoRemove: true
AutoRemove: false
}
};
// Try to create container, handle 409 conflict by removing stale container
let createResult: { Id: string };
try {
createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{
method: 'POST',
body: JSON.stringify(containerConfig)
},
options.envId
);
} catch (error: any) {
// Check for 409 conflict (container name already in use)
if (error?.message?.includes('409') || error?.status === 409) {
console.log(`[Docker] Container name conflict for ${containerName}, attempting cleanup...`);
// Try to force remove the conflicting container
try {
await dockerFetch(`/containers/${containerName}?force=true`, { method: 'DELETE' }, options.envId);
console.log(`[Docker] Removed stale container ${containerName}`);
} catch (removeError) {
console.error(`[Docker] Failed to remove stale container:`, removeError);
}
// Retry with a new random suffix
const retryName = `${baseName}-${randomSuffix()}`;
createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(retryName)}`,
{
method: 'POST',
body: JSON.stringify(containerConfig)
},
options.envId
);
} else {
throw error;
}
}
const containerId = createResult.Id;
// Start container
await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId);
// Check if this is an edge environment for streaming approach
const config = await getDockerConfig(options.envId ?? undefined);
// Stream logs while container is running
if (config.connectionType === 'hawser-edge' && config.environmentId) {
// Edge mode: use sendEdgeStreamRequest for real-time streaming
return new Promise<string>((resolve, reject) => {
let stdout = '';
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
const { cancel } = sendEdgeStreamRequest(
config.environmentId!,
'GET',
`/containers/${containerId}/logs?stdout=true&stderr=true&follow=true`,
{
onData: (data: string) => {
try {
// Data is base64 encoded from edge agent
const decoded = Buffer.from(data, 'base64');
buffer = Buffer.concat([buffer, decoded]);
// Process Docker stream frames
const result = processStreamFrames(buffer, options.onStdout, options.onStderr);
stdout += result.stdout;
buffer = result.remaining;
} catch {
// If not base64, try as raw data
const result = processStreamFrames(Buffer.from(data), options.onStdout, options.onStderr);
stdout += result.stdout;
}
},
onEnd: () => {
resolve(stdout);
},
onError: (error: string) => {
// If container finished, treat as success
if (error.includes('container') && (error.includes('exited') || error.includes('not running'))) {
resolve(stdout);
} else {
reject(new Error(error));
}
}
}
);
});
}
// Non-edge mode: use regular streaming
const logsResponse = await dockerFetch(
`/containers/${containerId}/logs?stdout=true&stderr=true&follow=true`,
{ streaming: true },
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{ method: 'POST', body: JSON.stringify(containerConfig) },
options.envId
);
let stdout = '';
const reader = logsResponse.body?.getReader();
if (reader) {
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
const containerId = createResult.Id;
const config = await getDockerConfig(options.envId ?? undefined);
while (true) {
const { done, value } = await reader.read();
if (done) break;
try {
// Start container
await dockerFetch(`/containers/${containerId}/start`, { method: 'POST' }, options.envId);
buffer = Buffer.concat([buffer, Buffer.from(value)]);
const result = processStreamFrames(buffer, options.onStdout, options.onStderr);
stdout += result.stdout;
buffer = result.remaining;
// Stream stderr for real-time progress while container runs
if (config.connectionType === 'hawser-edge' && config.environmentId) {
await streamEdgeStderr(config.environmentId, containerId, options.onStderr);
} else {
await streamLocalStderr(containerId, options.envId, options.onStderr);
}
// Container has exited. Now fetch stdout reliably (no race condition).
const stdout = await fetchContainerStdout(containerId, config, options.envId);
return stdout;
} finally {
// Always cleanup container
try {
await dockerFetch(`/containers/${containerId}?force=true`, { method: 'DELETE' }, options.envId);
} catch {
// Ignore cleanup errors
}
}
}
return stdout;
// Stream only stderr for real-time progress (local/standard mode)
async function streamLocalStderr(
containerId: string,
envId: number | null | undefined,
onStderr?: (data: string) => void
): Promise<void> {
const response = await dockerFetch(
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=true`,
{ streaming: true },
envId
);
const reader = response.body?.getReader();
if (!reader) return;
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer = Buffer.concat([buffer, Buffer.from(value)]);
const result = processStreamFrames(buffer, undefined, onStderr);
buffer = result.remaining;
}
}
// Stream only stderr for real-time progress (edge mode)
async function streamEdgeStderr(
environmentId: number,
containerId: string,
onStderr?: (data: string) => void
): Promise<void> {
return new Promise((resolve, reject) => {
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
sendEdgeStreamRequest(
environmentId,
'GET',
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=true`,
{
onData: (data: string) => {
try {
const decoded = Buffer.from(data, 'base64');
buffer = Buffer.concat([buffer, decoded]);
const result = processStreamFrames(buffer, undefined, onStderr);
buffer = result.remaining;
} catch {
// Ignore decode errors
}
},
onEnd: () => resolve(),
onError: (error: string) => {
// Container exited = success
if (error.includes('container') && (error.includes('exited') || error.includes('not running'))) {
resolve();
} else {
reject(new Error(error));
}
}
}
);
});
}
// Fetch stdout after container has exited (reliable, no race)
async function fetchContainerStdout(
containerId: string,
config: Awaited<ReturnType<typeof getDockerConfig>>,
envId: number | null | undefined
): Promise<string> {
if (config.connectionType === 'hawser-edge' && config.environmentId) {
const response = await sendEdgeRequest(
config.environmentId,
'GET',
`/containers/${containerId}/logs?stdout=true&stderr=false&follow=false`
);
if (!response.body) return '';
const bodyData = typeof response.body === 'string'
? Buffer.from(response.body, 'base64')
: Buffer.from(response.body);
const result = processStreamFrames(bodyData, undefined, undefined);
return result.stdout;
}
// Local/standard mode
const response = await dockerFetch(
`/containers/${containerId}/logs?stdout=true&stderr=false&follow=false`,
{},
envId
);
const buffer = Buffer.from(await response.arrayBuffer());
const result = processStreamFrames(buffer, undefined, undefined);
return result.stdout;
}
// Push image to registry
+107 -96
View File
@@ -1,5 +1,5 @@
import { existsSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { join, resolve, dirname, basename, relative } from 'node:path';
import {
getGitRepository,
getGitCredential,
@@ -11,7 +11,7 @@ import {
type GitCredential,
type GitStackWithRepo
} from './db';
import { deployStack } from './stacks';
import { deployStack, getStackDir } from './stacks';
// Directory for storing cloned repositories
const GIT_REPOS_DIR = process.env.GIT_REPOS_DIR || './data/git-repos';
@@ -142,8 +142,10 @@ export interface SyncResult {
commit?: string;
composeContent?: string;
composeDir?: string; // Directory containing the compose file (for copying all files)
composeFileName?: string; // Filename of the compose file (e.g., "docker-compose.yaml")
envFileVars?: Record<string, string>; // Variables from .env file in repo
envFileContent?: string; // Raw .env file content (for Hawser deployments)
envFileName?: string; // Filename of env file relative to composeDir (e.g., ".env" or "../.env")
error?: string;
updated?: boolean;
}
@@ -505,6 +507,22 @@ function getStackRepoPath(stackId: number): string {
return join(GIT_REPOS_DIR, `stack-${stackId}`);
}
/**
* Get the current commit hash from a repo path (if it exists).
* Used to detect if repo was updated after re-clone.
*/
async function getPreviousCommit(repoPath: string, env: GitEnv): Promise<string | null> {
if (!existsSync(repoPath)) {
return null;
}
try {
const result = await execGit(['rev-parse', 'HEAD'], repoPath, env);
return result.code === 0 ? result.stdout.trim() : null;
} catch {
return null;
}
}
export async function syncGitStack(stackId: number): Promise<SyncResult> {
const gitStack = await getGitStack(stackId);
if (!gitStack) {
@@ -551,55 +569,40 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
let updated = false;
let currentCommit = '';
if (!existsSync(repoPath)) {
console.log(`${logPrefix} Repo doesn't exist locally, cloning...`);
// Clone the repository (shallow clone)
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
console.log(`${logPrefix} Clone exit code:`, result.code);
if (result.stdout) console.log(`${logPrefix} Clone stdout:`, result.stdout);
if (result.stderr) console.log(`${logPrefix} Clone stderr:`, result.stderr);
if (result.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${result.stderr}`);
}
updated = true;
} else {
console.log(`${logPrefix} Repo exists, pulling latest...`);
// Get current commit before pull
const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const beforeCommit = beforeResult.stdout;
console.log(`${logPrefix} Commit before pull:`, beforeCommit.substring(0, 7));
// Pull latest changes
const result = await execGit(['pull', 'origin', repo.branch], repoPath, env);
console.log(`${logPrefix} Pull exit code:`, result.code);
if (result.stdout) console.log(`${logPrefix} Pull stdout:`, result.stdout);
if (result.stderr) console.log(`${logPrefix} Pull stderr:`, result.stderr);
if (result.code !== 0) {
throw new Error(`Git pull failed: ${result.stderr}`);
}
// Get commit after pull
const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const afterCommit = afterResult.stdout;
console.log(`${logPrefix} Commit after pull:`, afterCommit.substring(0, 7));
updated = beforeCommit !== afterCommit;
console.log(`${logPrefix} Repo updated:`, updated);
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
// Shallow clones are fast so this is acceptable
const previousCommit = await getPreviousCommit(repoPath, env);
if (existsSync(repoPath)) {
console.log(`${logPrefix} Removing existing clone for fresh sync...`);
rmSync(repoPath, { recursive: true, force: true });
}
console.log(`${logPrefix} Cloning repository...`);
const repoUrl = buildRepoUrl(repo.url, credential);
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
console.log(`${logPrefix} Clone exit code:`, result.code);
if (result.stdout) console.log(`${logPrefix} Clone stdout:`, result.stdout);
if (result.stderr) console.log(`${logPrefix} Clone stderr:`, result.stderr);
if (result.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${result.stderr}`);
}
// Check if commit changed
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const newCommit = newCommitResult.stdout.trim();
updated = previousCommit !== newCommit;
console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, updated: ${updated}`);
// Get current commit hash
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
currentCommit = commitResult.stdout.substring(0, 7);
@@ -618,13 +621,16 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
console.log(`${logPrefix} Compose content:`);
console.log(composeContent);
// Determine the compose directory (for copying all files)
// Determine the compose directory and filename (for copying all files)
const composeDir = dirname(composePath);
const composeFileName = basename(gitStack.composePath); // e.g., "docker-compose.yaml"
console.log(`${logPrefix} Compose directory:`, composeDir);
console.log(`${logPrefix} Compose filename:`, composeFileName);
// Read env file if configured (optional - don't fail if missing)
let envFileVars: Record<string, string> | undefined;
let envFileContent: string | undefined;
let envFileName: string | undefined;
if (gitStack.envFilePath) {
const envFilePath = join(repoPath, gitStack.envFilePath);
console.log(`${logPrefix} Looking for env file at:`, envFilePath);
@@ -634,6 +640,11 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
envFileContent = await Bun.file(envFilePath).text();
envFileVars = parseEnvFileContent(envFileContent, gitStack.stackName);
console.log(`${logPrefix} Env file parsed, vars count:`, Object.keys(envFileVars).length);
// Compute env file path relative to compose directory
// This is needed for --env-file flag after files are copied to stack directory
envFileName = relative(composeDir, envFilePath);
console.log(`${logPrefix} Env filename relative to compose dir:`, envFileName);
} catch (err) {
// Log but don't fail - env file is optional
console.warn(`${logPrefix} Failed to read env file ${gitStack.envFilePath}:`, err);
@@ -668,7 +679,9 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
commit: currentCommit,
composeContent,
composeDir,
composeFileName,
envFileVars,
envFileName,
updated
};
} catch (error: any) {
@@ -735,12 +748,17 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
// Note: Without this, docker compose only detects compose file changes, not env var changes
console.log(`${logPrefix} Calling deployStack...`);
console.log(`${logPrefix} Source directory (composeDir):`, syncResult.composeDir);
console.log(`${logPrefix} Compose filename:`, syncResult.composeFileName);
console.log(`${logPrefix} Env filename:`, syncResult.envFileName ?? '(none)');
const result = await deployStack({
name: gitStack.stackName,
compose: syncResult.composeContent!,
envId: gitStack.environmentId,
envFileVars: syncResult.envFileVars,
sourceDir: syncResult.composeDir, // Copy entire directory from git repo
composeFileName: syncResult.composeFileName, // Use original compose filename from repo
envFileName: syncResult.envFileName, // Env file relative to compose dir (for --env-file flag, optional)
forceRecreate
});
@@ -752,13 +770,21 @@ export async function deployGitStack(stackId: number, options?: { force?: boolea
if (result.error) console.log(`${logPrefix} Error:`, result.error);
if (result.success) {
// Record the stack source
// Record the stack source with resolved compose path for consistency
const stackDir = await getStackDir(gitStack.stackName, gitStack.environmentId);
const resolvedComposePath = syncResult.composeFileName
? join(stackDir, syncResult.composeFileName)
: undefined;
console.log(`${logPrefix} Resolved compose path for stack_sources:`, resolvedComposePath);
await upsertStackSource({
stackName: gitStack.stackName,
environmentId: gitStack.environmentId,
sourceType: 'git',
gitRepositoryId: gitStack.repositoryId,
gitStackId: stackId
gitStackId: stackId,
composePath: resolvedComposePath
});
}
@@ -873,54 +899,39 @@ export async function deployGitStackWithProgress(
let updated = false;
let currentCommit = '';
if (!existsSync(repoPath)) {
// Step 2: Cloning
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
// Shallow clones are fast so this is acceptable
const previousCommit = await getPreviousCommit(repoPath, env);
const repoUrl = buildRepoUrl(repo.url, credential);
// Step 2: Cloning
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
// Step 3: Fetching
onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps });
const result = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
if (result.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${result.stderr}`);
}
updated = true;
} else {
// Step 2-3: Fetching and resetting to latest (works with shallow clones)
onProgress({ status: 'fetching', message: 'Fetching latest changes...', step: 2, totalSteps });
const beforeResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const beforeCommit = beforeResult.stdout;
// Fetch the latest from origin (shallow fetch)
const fetchResult = await execGit(['fetch', '--depth=1', 'origin', repo.branch], repoPath, env);
if (fetchResult.code !== 0) {
throw new Error(`Git fetch failed: ${fetchResult.stderr}`);
}
// Reset to the fetched commit (this works reliably with shallow clones)
onProgress({ status: 'fetching', message: 'Updating to latest...', step: 3, totalSteps });
const resetResult = await execGit(['reset', '--hard', `origin/${repo.branch}`], repoPath, env);
if (resetResult.code !== 0) {
throw new Error(`Git reset failed: ${resetResult.stderr}`);
}
const afterResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const afterCommit = afterResult.stdout;
updated = beforeCommit !== afterCommit;
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
const repoUrl = buildRepoUrl(repo.url, credential);
// Step 3: Fetching
onProgress({ status: 'fetching', message: `Fetching branch ${repo.branch}...`, step: 3, totalSteps });
const cloneResult = await execGit(
['clone', '--depth=1', '--branch', repo.branch, repoUrl, repoPath],
process.cwd(),
env
);
if (cloneResult.code !== 0) {
// Clean up partial clone directory on failure
if (existsSync(repoPath)) {
rmSync(repoPath, { recursive: true, force: true });
}
throw new Error(`Git clone failed: ${cloneResult.stderr}`);
}
// Check if commit changed
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
const newCommit = newCommitResult.stdout.trim();
updated = previousCommit !== newCommit;
// Get current commit hash
const commitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
currentCommit = commitResult.stdout.substring(0, 7);
+232
View File
@@ -0,0 +1,232 @@
/**
* Host Path Resolution Module
*
* Dockhand runs inside a Docker container where paths differ from the host.
* This module detects the host path for the DATA_DIR mount, enabling proper
* volume path resolution for compose stacks.
*
* Problem:
* - Dockhand container has /app/data mounted from host (e.g., -v dockhand_data:/app/data)
* - Compose file says: ./ca.pem:/ca.pem (relative path)
* - docker-compose resolves this to /app/data/stacks/.../ca.pem
* - Docker daemon on HOST receives this path, but /app/data doesn't exist on host!
* - Docker creates a directory instead of mounting the file
*
* Solution:
* - Query Docker API to find the host source path for our /app/data mount
* - Rewrite relative paths in compose files to use the host path
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// Cache the host data dir to avoid repeated API calls
let cachedHostDataDir: string | null = null;
let detectionAttempted = false;
/**
* Get our own container ID
*/
function getOwnContainerId(): string | null {
// Method 1: From cgroup (works in most cases)
try {
const cgroup = readFileSync('/proc/self/cgroup', 'utf-8');
// Look for docker container ID (64 hex chars)
const match = cgroup.match(/[a-f0-9]{64}/);
if (match) {
return match[0];
}
} catch {
// Can't read cgroup
}
// Method 2: From mountinfo
try {
const mountinfo = readFileSync('/proc/self/mountinfo', 'utf-8');
const match = mountinfo.match(/\/docker\/containers\/([a-f0-9]{64})/);
if (match) {
return match[1];
}
} catch {
// Can't read mountinfo
}
// Method 3: HOSTNAME might be container ID (short form)
const hostname = process.env.HOSTNAME;
if (hostname && /^[a-f0-9]{12}$/.test(hostname)) {
return hostname;
}
return null;
}
/**
* Get the host path for our DATA_DIR mount by inspecting our own container
*/
export async function detectHostDataDir(): Promise<string | null> {
// Return cached value if already detected
if (detectionAttempted) {
return cachedHostDataDir;
}
detectionAttempted = true;
// Check if user explicitly set HOST_DATA_DIR
if (process.env.HOST_DATA_DIR) {
cachedHostDataDir = process.env.HOST_DATA_DIR;
console.log(`[HostPath] Using HOST_DATA_DIR from environment: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
const containerId = getOwnContainerId();
if (!containerId) {
console.warn('[HostPath] Running in Docker but could not detect container ID');
return null;
}
console.log(`[HostPath] Detected container ID: ${containerId.substring(0, 12)}`);
// Get DATA_DIR (inside container)
const dataDir = resolve(process.env.DATA_DIR || '/app/data');
try {
// Query Docker API to inspect our own container
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
// Use fetch with unix socket
const response = await fetch(`http://localhost/containers/${containerId}/json`, {
// @ts-ignore - Bun supports unix sockets
unix: socketPath
});
if (!response.ok) {
console.warn(`[HostPath] Failed to inspect container: ${response.status}`);
return null;
}
const containerInfo = await response.json() as {
Mounts?: Array<{
Type: string;
Source: string;
Destination: string;
}>;
};
// Find the mount for our DATA_DIR
const dataMount = containerInfo.Mounts?.find(m => m.Destination === dataDir);
if (dataMount) {
cachedHostDataDir = dataMount.Source;
console.log(`[HostPath] Detected host path for ${dataDir}: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
// Check if DATA_DIR is a subdirectory of a mount
for (const mount of containerInfo.Mounts || []) {
if (dataDir.startsWith(mount.Destination + '/') || dataDir === mount.Destination) {
const relativePath = dataDir.substring(mount.Destination.length);
cachedHostDataDir = mount.Source + relativePath;
console.log(`[HostPath] Detected host path for ${dataDir} via parent mount: ${cachedHostDataDir}`);
return cachedHostDataDir;
}
}
console.warn(`[HostPath] Could not find mount for ${dataDir} in container mounts`);
return null;
} catch (err) {
console.warn(`[HostPath] Failed to query Docker API: ${err}`);
return null;
}
}
/**
* Get the cached host data dir (call detectHostDataDir first during startup)
*/
export function getHostDataDir(): string | null {
return cachedHostDataDir;
}
/**
* Translate a container path to host path
*
* @param containerPath - Path inside the container (e.g., /app/data/stacks/mystack/file.txt)
* @returns Host path if translation is needed, or original path if not
*/
export function translateToHostPath(containerPath: string): string {
const hostDataDir = getHostDataDir();
if (!hostDataDir) {
return containerPath;
}
const dataDir = resolve(process.env.DATA_DIR || '/app/data');
// Check if the path is under DATA_DIR
if (containerPath.startsWith(dataDir + '/') || containerPath === dataDir) {
const relativePath = containerPath.substring(dataDir.length);
return hostDataDir + relativePath;
}
return containerPath;
}
/**
* Rewrite relative volume paths in a compose file to use absolute host paths.
* This is necessary when Dockhand runs inside Docker with a mounted data volume.
*
* Transforms:
* ./config.toml:/config.toml -> /host/path/to/stack/config.toml:/config.toml
*
* @param composeContent - The compose file content
* @param workingDir - The working directory (container path) where the compose file is located
* @returns Modified compose content with absolute host paths, or original if no translation needed
*/
export function rewriteComposeVolumePaths(composeContent: string, workingDir: string): { content: string; modified: boolean; changes: string[] } {
const hostDataDir = getHostDataDir();
const changes: string[] = [];
if (!hostDataDir) {
return { content: composeContent, modified: false, changes };
}
const dataDir = resolve(process.env.DATA_DIR || '/app/data');
// Check if workingDir is under DATA_DIR
if (!workingDir.startsWith(dataDir + '/') && workingDir !== dataDir) {
return { content: composeContent, modified: false, changes };
}
// Calculate the host working directory
const relativePath = workingDir.substring(dataDir.length);
const hostWorkingDir = hostDataDir + relativePath;
// Parse compose content line by line to find and rewrite volume mounts
// We look for patterns like:
// - ./something:/container/path
// - "./something:/container/path"
// - './something:/container/path'
const lines = composeContent.split('\n');
const modifiedLines: string[] = [];
for (const line of lines) {
// Match volume mount patterns with relative paths
// Handles: - ./path:/dest, - "./path:/dest", - './path:/dest'
const volumeMatch = line.match(/^(\s*-\s*)(['"]?)(\.\/[^'":\s]+)(\2)(:.+)$/);
if (volumeMatch) {
const [, prefix, quote, relativeSrc, , destPart] = volumeMatch;
// Convert relative path to absolute host path
const absoluteHostPath = hostWorkingDir + '/' + relativeSrc.substring(2); // Remove ./
const newLine = `${prefix}${absoluteHostPath}${destPart}`;
modifiedLines.push(newLine);
changes.push(` ${relativeSrc} -> ${absoluteHostPath}`);
} else {
modifiedLines.push(line);
}
}
return {
content: modifiedLines.join('\n'),
modified: changes.length > 0,
changes
};
}
+14
View File
@@ -497,6 +497,12 @@ export async function scanWithGrype(
}
);
// Defensive logging for empty output
console.log(`[Grype] Scanner container output received, length: ${output.length}`);
if (output.length === 0) {
console.error('[Grype] WARNING: Empty output from scanner container - possible race condition');
}
onProgress?.({
stage: 'parsing',
message: 'Parsing scan results...',
@@ -589,6 +595,12 @@ export async function scanWithTrivy(
}
);
// Defensive logging for empty output
console.log(`[Trivy] Scanner container output received, length: ${output.length}`);
if (output.length === 0) {
console.error('[Trivy] WARNING: Empty output from scanner container - possible race condition');
}
onProgress?.({
stage: 'parsing',
message: 'Parsing scan results...',
@@ -731,6 +743,7 @@ async function getScannerVersion(
// Create temporary container to get version
const versionCmd = scannerType === 'grype' ? ['version'] : ['--version'];
console.log(`[Scanner] Getting ${scannerType} version with cmd:`, versionCmd);
const { stdout, stderr } = await runContainer({
image: scannerImage,
cmd: versionCmd,
@@ -738,6 +751,7 @@ async function getScannerVersion(
envId
});
console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`);
const output = stdout || stderr;
// Parse version from output
@@ -31,7 +31,7 @@ import {
} from '../../docker';
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from './update-utils';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
/**
* Execute a container auto-update.
@@ -98,14 +98,18 @@ export async function runContainerUpdate(
return;
}
// Prevent Dockhand from updating itself
if (isDockhandContainer(imageNameFromConfig)) {
log(`Skipping Dockhand container - cannot auto-update self`);
// Prevent system containers (Dockhand/Hawser) from being updated
const systemContainerType = isSystemContainer(imageNameFromConfig);
if (systemContainerType) {
const reason = systemContainerType === 'dockhand'
? 'Cannot auto-update Dockhand itself'
: 'Cannot auto-update Hawser agent';
log(`Skipping ${systemContainerType} container - ${reason}`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Cannot auto-update Dockhand itself' }
details: { reason }
});
return;
}
@@ -33,7 +33,7 @@ import {
} from '../../docker';
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from './update-utils';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
interface UpdateInfo {
containerId: string;
@@ -224,9 +224,13 @@ export async function runEnvUpdateCheckJob(
const blockedContainers: { name: string; reason: string; scannerResults?: { scanner: string; critical: number; high: number; medium: number; low: number }[] }[] = [];
for (const update of updatesAvailable) {
// Skip Dockhand container - cannot update itself
if (isDockhandContainer(update.imageName)) {
await log(`\n[${update.containerName}] Skipping - cannot auto-update Dockhand itself`);
// Skip system containers (Dockhand/Hawser) - cannot update themselves
const systemContainerType = isSystemContainer(update.imageName);
if (systemContainerType) {
const reason = systemContainerType === 'dockhand'
? 'cannot auto-update Dockhand itself'
: 'cannot auto-update Hawser agent';
await log(`\n[${update.containerName}] Skipping - ${reason}`);
continue;
}
@@ -99,6 +99,32 @@ export function isDockhandContainer(imageName: string): boolean {
return imageName.toLowerCase().includes('fnsys/dockhand');
}
/**
* Check if a container is a Hawser agent.
* Official image: ghcr.io/finsys/hawser
*/
export function isHawserContainer(imageName: string): boolean {
const lower = imageName.toLowerCase();
return lower.includes('finsys/hawser') || lower.includes('ghcr.io/finsys/hawser');
}
/**
* System container type - containers that cannot be updated from within Dockhand.
*/
export type SystemContainerType = 'dockhand' | 'hawser';
/**
* Check if a container is a system container (Dockhand or Hawser).
* System containers cannot be updated from within Dockhand because:
* - Dockhand: Would need to stop itself to update
* - Hawser: Would disconnect from the environment it's managing
*/
export function isSystemContainer(imageName: string): SystemContainerType | null {
if (isDockhandContainer(imageName)) return 'dockhand';
if (isHawserContainer(imageName)) return 'hawser';
return null;
}
/**
* Combine multiple scan summaries by taking the maximum of each severity level.
*/
+2 -2
View File
@@ -9,8 +9,8 @@ import { readdirSync, existsSync, statSync } from 'node:fs';
import { join, basename, dirname, resolve } from 'node:path';
import { getExternalStackPaths, getStackSources, upsertStackSource, type StackSourceType } from './db';
// Compose file patterns to detect (in order of priority)
const COMPOSE_PATTERNS = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
// Compose file patterns to detect (in order of priority - prefer new style first)
const COMPOSE_PATTERNS = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
// Directories to skip during scanning
const SKIP_DIRECTORIES = ['.git', 'node_modules', '.docker', '__pycache__', '.venv', 'venv'];
+234 -44
View File
@@ -23,11 +23,23 @@ import {
deleteStackEnvVars
} from './db';
import { deleteGitStackFiles } from './git';
import { cleanPem } from '$lib/utils/pem';
import { rewriteComposeVolumePaths, getHostDataDir } from './host-path';
// =============================================================================
// TYPES
// =============================================================================
/**
* TLS configuration for remote Docker connections
*/
interface TlsConfig {
ca?: string;
cert?: string;
key?: string;
skipVerify?: boolean;
}
/**
* Stack source types
*/
@@ -82,6 +94,10 @@ export interface DeployStackOptions {
envFileVars?: Record<string, string>;
sourceDir?: string; // Directory to copy all files from (for git stacks)
forceRecreate?: boolean;
composePath?: string; // Custom compose file path (for adopted/imported stacks)
envPath?: string; // Custom env file path (for adopted/imported stacks)
composeFileName?: string; // Compose filename to use (e.g., "docker-compose.yaml") for git stacks
envFileName?: string; // Env filename relative to compose dir (e.g., ".env") for git stacks
}
// =============================================================================
@@ -114,6 +130,24 @@ let _stacksDir: string | null = null;
// Per-stack locking mechanism to prevent race conditions during concurrent operations
const stackLocks = new Map<string, Promise<void>>();
// Track active TLS temp directories for cleanup on unexpected process exit
const activeTlsDirs = new Set<string>();
// Register cleanup handlers once at module load
if (typeof process !== 'undefined') {
const cleanupTlsDirs = () => {
for (const dir of activeTlsDirs) {
try {
rmSync(dir, { recursive: true, force: true });
} catch { /* ignore */ }
}
activeTlsDirs.clear();
};
process.on('exit', cleanupTlsDirs);
process.on('SIGINT', () => { cleanupTlsDirs(); process.exit(130); });
process.on('SIGTERM', () => { cleanupTlsDirs(); process.exit(143); });
}
/**
* Execute a function with exclusive lock on a stack.
* Prevents race conditions when multiple operations target the same stack.
@@ -287,9 +321,9 @@ export function listManagedStacks(): string[] {
return readdirSync(stacksDir, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.filter((dirent) => {
const composeYml = join(stacksDir, dirent.name, 'docker-compose.yml');
const composeYaml = join(stacksDir, dirent.name, 'docker-compose.yaml');
return existsSync(composeYml) || existsSync(composeYaml);
// Check all valid compose filenames
const composeNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
return composeNames.some(name => existsSync(join(stacksDir, dirent.name, name)));
})
.map((dirent) => dirent.name);
}
@@ -381,8 +415,8 @@ export async function getStackComposeFile(
const stackDir = await findStackDir(stackName, envId);
if (stackDir) {
// Check all common compose file names
const composeFileNames = ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'];
// Check all common compose file names (prefer new style first)
const composeFileNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
for (const fileName of composeFileNames) {
const actualComposePath = join(stackDir, fileName);
@@ -617,7 +651,7 @@ export async function saveStackComposeFile(
stackDir = existingDir;
}
const composeFile = join(stackDir, 'docker-compose.yml');
const composeFile = join(stackDir, 'compose.yaml');
const exists = existsSync(stackDir);
if (create) {
@@ -720,11 +754,14 @@ interface ComposeCommandOptions {
workingDir?: string;
/** Full path to the compose file (for imported stacks, to avoid writing to internal dir) */
composePath?: string;
/** Full path to the env file (for --env-file flag, supports custom names) */
envPath?: string;
}
/**
* Execute a docker compose command locally via Bun.spawn.
*
* @param tlsConfig - TLS configuration for remote Docker connections (certs written to temp files)
* @param envVars - Non-secret environment variables (from .env file, passed for backward compat)
* @param secretVars - Secret environment variables (injected via shell env, NEVER written to disk)
* @param workingDir - Optional working directory for compose execution (for imported stacks)
@@ -735,13 +772,15 @@ async function executeLocalCompose(
stackName: string,
composeContent: string,
dockerHost?: string,
tlsConfig?: TlsConfig,
envVars?: Record<string, string>,
secretVars?: Record<string, string>,
forceRecreate?: boolean,
removeVolumes?: boolean,
envId?: number | null,
workingDir?: string,
customComposePath?: string
customComposePath?: string,
customEnvPath?: string
): Promise<StackOperationResult> {
const logPrefix = `[Stack:${stackName}]`;
@@ -752,21 +791,45 @@ async function executeLocalCompose(
let composeFile: string;
if (customComposePath && workingDir) {
// Imported stack: use original location, don't copy files
// Custom compose path provided - use the provided working directory and compose file
// This applies to:
// - Imported/adopted stacks: files exist at original location, no copying needed
// - Git stacks: files were already copied to workingDir by deployStack(), use them in-place
// In both cases, we don't write the compose file - it already exists
stackDir = workingDir;
composeFile = customComposePath;
// Don't write to the compose file - it already exists at the custom location
// The user manages this file externally
} else {
// Internal stack: use default data directory
stackDir = operation === 'up'
? await getStackDir(stackName, envId)
: (await findStackDir(stackName, envId) || await getStackDir(stackName, envId));
mkdirSync(stackDir, { recursive: true });
composeFile = join(stackDir, 'docker-compose.yml');
composeFile = join(stackDir, 'compose.yaml');
await Bun.write(composeFile, composeContent);
}
// Rewrite relative volume paths for host path translation (in memory only, not saved to disk)
// This is needed when Dockhand runs inside Docker - the Docker daemon on the host
// can't see container paths like /app/data/..., so we translate them to host paths
// Only do this for local Docker (no dockerHost) - for remote Docker the paths wouldn't make sense
let finalComposeContent = composeContent;
if (!dockerHost && getHostDataDir()) {
const rewriteResult = rewriteComposeVolumePaths(composeContent, stackDir);
if (rewriteResult.modified) {
finalComposeContent = rewriteResult.content;
console.log(`${logPrefix} [HostPath] Translating relative volume paths for Docker host:`);
for (const change of rewriteResult.changes) {
console.log(`${logPrefix} [HostPath]${change}`);
}
console.log(`${logPrefix} [HostPath] Translated compose content:`);
console.log(`${logPrefix} [HostPath] ----------------------------------------`);
for (const line of finalComposeContent.split('\n')) {
console.log(`${logPrefix} [HostPath] ${line}`);
}
console.log(`${logPrefix} [HostPath] ----------------------------------------`);
}
}
// Build spawn environment:
// 1. Start with process.env
// 2. Add DOCKER_HOST if specified
@@ -785,8 +848,58 @@ async function executeLocalCompose(
Object.assign(spawnEnv, secretVars);
}
// Handle TLS certificates for remote Docker connections
// Docker CLI requires file paths, so we write certs to a temp directory
let tlsCertDir: string | undefined;
if (tlsConfig && (tlsConfig.ca || tlsConfig.cert)) {
// Create temp directory for TLS certs in DATA_DIR (guaranteed writable in Docker)
// Use resolve() to get absolute path - docker compose runs from a different working dir
const dataDir = resolve(process.env.DATA_DIR || './data');
tlsCertDir = join(dataDir, 'tmp', `tls-${stackName}-${Date.now()}`);
mkdirSync(tlsCertDir, { recursive: true });
// Track for cleanup on unexpected process exit
activeTlsDirs.add(tlsCertDir);
// Write certs to files (docker-compose expects specific filenames)
if (tlsConfig.ca) {
const cleanedCa = cleanPem(tlsConfig.ca);
if (cleanedCa) await Bun.write(join(tlsCertDir, 'ca.pem'), cleanedCa);
}
if (tlsConfig.cert) {
const cleanedCert = cleanPem(tlsConfig.cert);
if (cleanedCert) await Bun.write(join(tlsCertDir, 'cert.pem'), cleanedCert);
}
if (tlsConfig.key) {
const cleanedKey = cleanPem(tlsConfig.key);
if (cleanedKey) await Bun.write(join(tlsCertDir, 'key.pem'), cleanedKey);
}
// Set Docker TLS environment variables
spawnEnv.DOCKER_TLS = '1';
spawnEnv.DOCKER_CERT_PATH = tlsCertDir;
spawnEnv.DOCKER_TLS_VERIFY = tlsConfig.skipVerify ? '0' : '1';
console.log(`${logPrefix} TLS enabled: DOCKER_CERT_PATH=${tlsCertDir}, DOCKER_TLS_VERIFY=${spawnEnv.DOCKER_TLS_VERIFY}`);
}
// Build command based on operation
const args = ['docker', 'compose', '-p', stackName, '-f', composeFile];
// If we have modified compose content (host path translation), use stdin instead of file
const useStdin = finalComposeContent !== composeContent;
const args = ['docker', 'compose', '-p', stackName, '-f', useStdin ? '-' : composeFile];
// Add --env-file flag if env file exists
// This makes Docker Compose load the .env file automatically (like Portainer)
// Uses custom path if provided, otherwise defaults to .env in stack directory
const envFilePath = customEnvPath || join(stackDir, '.env');
if (existsSync(envFilePath)) {
args.push('--env-file', envFilePath);
}
if (useStdin) {
console.log(`${logPrefix} [HostPath] Using stdin for compose content (paths translated)`);
}
switch (operation) {
case 'up':
@@ -836,10 +949,17 @@ async function executeLocalCompose(
const proc = Bun.spawn(args, {
cwd: stackDir,
env: spawnEnv,
stdin: useStdin ? 'pipe' : 'inherit',
stdout: 'pipe',
stderr: 'pipe'
});
// If using stdin (host path translation), write the modified compose content
if (useStdin && proc.stdin) {
proc.stdin.write(finalComposeContent);
proc.stdin.end();
}
// Set up timeout with SIGTERM -> SIGKILL escalation
let timedOut = false;
const timeoutId = setTimeout(() => {
@@ -909,6 +1029,17 @@ async function executeLocalCompose(
output: '',
error: `Failed to run docker compose ${operation}: ${err.message}`
};
} finally {
// Cleanup TLS temp directory (always runs, even on exception)
if (tlsCertDir) {
activeTlsDirs.delete(tlsCertDir);
try {
rmSync(tlsCertDir, { recursive: true, force: true });
console.log(`${logPrefix} Cleaned up TLS temp directory: ${tlsCertDir}`);
} catch {
// Ignore cleanup errors
}
}
}
}
@@ -1063,7 +1194,7 @@ async function executeComposeCommand(
envVars?: Record<string, string>,
secretVars?: Record<string, string>
): Promise<StackOperationResult> {
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath } = options;
const { stackName, envId, forceRecreate, removeVolumes, stackFiles, workingDir, composePath, envPath } = options;
// Get environment configuration
const env = envId ? await getEnvironment(envId) : null;
@@ -1074,14 +1205,16 @@ async function executeComposeCommand(
operation,
stackName,
composeContent,
undefined,
undefined, // dockerHost
undefined, // tlsConfig
envVars,
secretVars,
forceRecreate,
removeVolumes,
envId,
workingDir,
composePath
composePath,
envPath
);
}
@@ -1103,18 +1236,29 @@ async function executeComposeCommand(
case 'direct': {
const port = env.port || 2375;
const dockerHost = `tcp://${env.host}:${port}`;
// Build TLS config if using HTTPS
const tlsConfig: TlsConfig | undefined = env.protocol === 'https' ? {
ca: env.tlsCa || undefined,
cert: env.tlsCert || undefined,
key: env.tlsKey || undefined,
skipVerify: env.tlsSkipVerify ?? false
} : undefined;
return executeLocalCompose(
operation,
stackName,
composeContent,
dockerHost,
tlsConfig,
envVars,
secretVars,
forceRecreate,
removeVolumes,
envId,
workingDir,
composePath
composePath,
envPath
);
}
@@ -1124,14 +1268,16 @@ async function executeComposeCommand(
operation,
stackName,
composeContent,
undefined,
undefined, // dockerHost
undefined, // tlsConfig
envVars,
secretVars,
forceRecreate,
removeVolumes,
envId,
workingDir,
composePath
composePath,
envPath
);
}
}
@@ -1437,7 +1583,8 @@ async function requireComposeFile(
envVars,
secretVars,
stackDir: composeResult.stackDir,
composePath: composeResult.composePath
composePath: composeResult.composePath ?? undefined,
envPath: envFilePath ?? undefined
};
}
@@ -1458,7 +1605,7 @@ export async function startStack(
return executeComposeCommand(
'up',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
result.secretVars
@@ -1482,7 +1629,7 @@ export async function stopStack(
return executeComposeCommand(
'stop',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
result.secretVars
@@ -1506,7 +1653,7 @@ export async function restartStack(
return executeComposeCommand(
'restart',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
result.secretVars
@@ -1531,7 +1678,7 @@ export async function downStack(
return executeComposeCommand(
'down',
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath },
{ stackName, envId, removeVolumes, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
result.secretVars
@@ -1561,7 +1708,8 @@ export async function removeStack(
stackName,
envId,
workingDir: composeResult.stackDir,
composePath: composeResult.composePath
composePath: composeResult.composePath ?? undefined,
envPath: composeResult.envPath ?? undefined
},
composeResult.content!,
envVars,
@@ -1671,7 +1819,7 @@ export async function removeStack(
* Uses stack locking to prevent concurrent deployments.
*/
export async function deployStack(options: DeployStackOptions): Promise<StackOperationResult> {
const { name, compose, envId, envFileVars, sourceDir, forceRecreate } = options;
const { name, compose, envId, envFileVars, sourceDir, forceRecreate, composePath, envPath, composeFileName, envFileName } = options;
const logPrefix = `[Stack:${name}]`;
console.log(`${logPrefix} ========================================`);
@@ -1680,6 +1828,10 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
console.log(`${logPrefix} Environment ID:`, envId ?? '(none - local)');
console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false);
console.log(`${logPrefix} Source directory:`, sourceDir ?? '(none)');
console.log(`${logPrefix} Custom compose path:`, composePath ?? '(none)');
console.log(`${logPrefix} Custom env path:`, envPath ?? '(none)');
console.log(`${logPrefix} Compose filename:`, composeFileName ?? '(none)');
console.log(`${logPrefix} Env filename:`, envFileName ?? '(none)');
console.log(`${logPrefix} Env file vars provided:`, envFileVars ? Object.keys(envFileVars).length : 0);
if (envFileVars && Object.keys(envFileVars).length > 0) {
console.log(`${logPrefix} Env file var keys:`, Object.keys(envFileVars).join(', '));
@@ -1698,31 +1850,56 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
}
return withStackLock(name, async () => {
const stackDir = await getStackDir(name, envId);
// Read all files from source directory if provided (for Hawser deployments)
// Determine working directory: use custom composePath directory if provided,
// otherwise fall back to internal stack directory
let workingDir: string;
let actualComposePath: string | undefined;
let actualEnvPath: string | undefined = envPath; // Start with provided envPath (for adopted stacks)
let stackFiles: Record<string, string> | undefined;
if (sourceDir && existsSync(sourceDir)) {
if (composePath) {
// Adopted/imported stack: use the original compose file location
// This ensures relative paths in the compose file resolve correctly
// Files are NOT copied - we use them in-place at their original location
workingDir = dirname(composePath);
actualComposePath = composePath;
console.log(`${logPrefix} Using custom compose path, workingDir:`, workingDir);
} else if (sourceDir && existsSync(sourceDir)) {
// Git stack: copy entire source directory to internal stack directory
workingDir = await getStackDir(name, envId);
// Set actualComposePath using the provided compose filename from git stack config
if (composeFileName) {
actualComposePath = join(workingDir, composeFileName);
console.log(`${logPrefix} Using compose filename from git config:`, composeFileName);
console.log(`${logPrefix} Actual compose path will be:`, actualComposePath);
}
// Set actualEnvPath using the provided env filename from git stack config
// Only if envFileName is provided (env file is optional for git stacks)
if (envFileName) {
actualEnvPath = join(workingDir, envFileName);
console.log(`${logPrefix} Using env filename from git config:`, envFileName);
console.log(`${logPrefix} Actual env path will be:`, actualEnvPath);
}
// Read all files for Hawser deployments
stackFiles = await readDirFilesAsMap(sourceDir);
console.log(`${logPrefix} Read ${Object.keys(stackFiles).length} files from source directory`);
console.log(`${logPrefix} Files:`, Object.keys(stackFiles).join(', '));
}
// Handle stack directory setup
if (sourceDir && existsSync(sourceDir)) {
// Copy entire source directory to stack directory (for git stacks)
// Copy source to stack directory
console.log(`${logPrefix} Copying source directory to stack directory...`);
if (existsSync(stackDir)) {
rmSync(stackDir, { recursive: true, force: true });
if (existsSync(workingDir)) {
rmSync(workingDir, { recursive: true, force: true });
}
cpSync(sourceDir, stackDir, { recursive: true });
console.log(`${logPrefix} Copied ${sourceDir} -> ${stackDir}`);
cpSync(sourceDir, workingDir, { recursive: true });
console.log(`${logPrefix} Copied ${sourceDir} -> ${workingDir}`);
} else {
// Traditional behavior: create directory and write compose file only
mkdirSync(stackDir, { recursive: true });
const composeFile = join(stackDir, 'docker-compose.yml');
await Bun.write(composeFile, compose);
console.log(`${logPrefix} Compose file written to:`, composeFile);
// Internal stack: compose file should already exist (written by saveStackComposeFile)
// Just determine the working directory
workingDir = await getStackDir(name, envId);
console.log(`${logPrefix} Using internal stack directory:`, workingDir);
}
console.log(`${logPrefix} Compose content length:`, compose.length, 'chars');
@@ -1746,7 +1923,20 @@ export async function deployStack(options: DeployStackOptions): Promise<StackOpe
}
console.log(`${logPrefix} Calling executeComposeCommand...`);
const result = await executeComposeCommand('up', { stackName: name, envId, forceRecreate, stackFiles }, compose, envVars);
const result = await executeComposeCommand(
'up',
{
stackName: name,
envId,
forceRecreate,
stackFiles,
workingDir,
composePath: actualComposePath,
envPath: actualEnvPath
},
compose,
envVars
);
console.log(`${logPrefix} ========================================`);
console.log(`${logPrefix} DEPLOY STACK RESULT`);
console.log(`${logPrefix} ========================================`);
@@ -1779,7 +1969,7 @@ export async function pullStackImages(
return executeComposeCommand(
'pull',
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath },
{ stackName, envId, workingDir: result.stackDir, composePath: result.composePath, envPath: result.envPath },
result.content!,
result.envVars,
result.secretVars
@@ -544,8 +544,9 @@ async function refreshEventCollectors() {
}
}
} catch (error) {
console.error('[EventSubprocess] Failed to refresh collectors:', error);
send({ type: 'error', message: `Failed to refresh collectors: ${error}` });
const message = error instanceof Error ? error.message : String(error);
console.error(`[EventSubprocess] Failed to refresh collectors: ${message}`);
send({ type: 'error', message: `Failed to refresh collectors: ${message}` });
}
}
@@ -614,7 +615,8 @@ async function start(): Promise<void> {
currentPollInterval = await getEventPollInterval();
console.log(`[EventSubprocess] Initial mode: ${currentMode}, poll interval: ${currentPollInterval}ms`);
} catch (error) {
console.error('[EventSubprocess] Failed to load settings, using defaults:', error);
const message = error instanceof Error ? error.message : String(error);
console.error(`[EventSubprocess] Failed to load settings, using defaults: ${message}`);
}
// Start collectors for all environments
@@ -132,7 +132,8 @@ async function collectEnvMetrics(env: { id: number; name: string; host?: string;
}
} catch (error) {
// Skip this environment if it fails (might be offline)
console.error(`[MetricsSubprocess] Failed to collect metrics for ${env.name}:`, error);
const message = error instanceof Error ? error.message : String(error);
console.warn(`[MetricsSubprocess] Failed to collect metrics for ${env.name}: ${message}`);
}
}
@@ -165,12 +166,14 @@ async function collectMetrics() {
if (result.status === 'fulfilled' && result.value === null) {
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics timed out after ${ENV_METRICS_TIMEOUT}ms`);
} else if (result.status === 'rejected') {
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed:`, result.reason);
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" metrics failed: ${reason}`);
}
});
} catch (error) {
console.error('[MetricsSubprocess] Metrics collection error:', error);
send({ type: 'error', message: `Metrics collection error: ${error}` });
const message = error instanceof Error ? error.message : String(error);
console.error(`[MetricsSubprocess] Metrics collection error: ${message}`);
send({ type: 'error', message: `Metrics collection error: ${message}` });
}
}
@@ -308,7 +311,8 @@ async function checkEnvDiskSpace(env: { id: number; name: string; collectMetrics
}
} catch (error) {
// Skip this environment if it fails
console.error(`[MetricsSubprocess] Failed to check disk space for ${env.name}:`, error);
const message = error instanceof Error ? error.message : String(error);
console.warn(`[MetricsSubprocess] Failed to check disk space for ${env.name}: ${message}`);
}
}
@@ -341,12 +345,14 @@ async function checkDiskSpace() {
if (result.status === 'fulfilled' && result.value === null) {
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check timed out after ${ENV_DISK_TIMEOUT}ms`);
} else if (result.status === 'rejected') {
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check failed:`, result.reason);
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
console.warn(`[MetricsSubprocess] Environment "${enabledEnvs[index].name}" disk check failed: ${reason}`);
}
});
} catch (error) {
console.error('[MetricsSubprocess] Disk space check error:', error);
send({ type: 'error', message: `Disk space check error: ${error}` });
const message = error instanceof Error ? error.message : String(error);
console.error(`[MetricsSubprocess] Disk space check error: ${message}`);
send({ type: 'error', message: `Disk space check error: ${message}` });
}
}
+14 -7
View File
@@ -74,19 +74,26 @@ function createDashboardDataStore() {
lastFetchTime: Date.now()
}));
},
// Partial update for progressive loading - merges into existing stats
// Partial update for progressive loading - deep merges into existing stats
// This preserves nested object properties (like containers.pendingUpdates)
updateTilePartial: (id: number, partialStats: Partial<EnvironmentStats>) => {
update(data => ({
...data,
tiles: data.tiles.map(t => {
if (t.id === id && t.stats) {
return {
...t,
stats: {
...t.stats,
...partialStats
// Deep merge: for nested objects, merge properties instead of replacing
const mergedStats = { ...t.stats };
for (const [key, value] of Object.entries(partialStats)) {
const existing = (mergedStats as any)[key];
// Deep merge for plain objects (not arrays or null)
if (existing && typeof existing === 'object' && !Array.isArray(existing) &&
value && typeof value === 'object' && !Array.isArray(value)) {
(mergedStats as any)[key] = { ...existing, ...value };
} else if (value !== undefined) {
(mergedStats as any)[key] = value;
}
};
}
return { ...t, stats: mergedStats };
}
return t;
}),
+6 -1
View File
@@ -88,6 +88,7 @@ export function appendEnvParam(url: string, envId: number | null | undefined): s
// Store for environments list with auto-refresh capability
function createEnvironmentsStore() {
const { subscribe, set, update } = writable<Environment[]>([]);
const loaded = writable<boolean>(false); // Tracks if environments have been fetched at least once
let loading = false;
async function fetchEnvironments() {
@@ -98,6 +99,7 @@ function createEnvironmentsStore() {
if (response.ok) {
const data: Environment[] = await response.json();
set(data);
loaded.set(true);
// Auto-select environment if none selected or current one no longer exists
const current = get(currentEnvironment);
@@ -133,6 +135,7 @@ function createEnvironmentsStore() {
} else {
// Clear environments on permission denied or other errors
set([]);
loaded.set(true); // Mark as loaded even on error - we've completed the fetch
// Also clear the current environment from localStorage
localStorage.removeItem(STORAGE_KEY);
currentEnvironment.set(null);
@@ -140,6 +143,7 @@ function createEnvironmentsStore() {
} catch (error) {
console.error('Failed to fetch environments:', error);
set([]);
loaded.set(true); // Mark as loaded even on error - we've completed the fetch
localStorage.removeItem(STORAGE_KEY);
currentEnvironment.set(null);
} finally {
@@ -156,7 +160,8 @@ function createEnvironmentsStore() {
subscribe,
refresh: fetchEnvironments,
set,
update
update,
loaded // Expose the loaded store for consumers to know when first fetch is complete
};
}
-134
View File
@@ -1,134 +0,0 @@
import { writable, get } from 'svelte/store';
import { currentEnvironment, appendEnvParam } from './environment';
export interface ContainerStats {
id: string;
name: string;
cpuPercent: number;
memoryUsage: number;
memoryLimit: number;
memoryPercent: number;
}
export interface HostInfo {
hostname: string;
ipAddress: string;
platform: string;
arch: string;
cpus: number;
totalMemory: number;
freeMemory: number;
uptime: number;
dockerVersion: string;
dockerContainers: number;
dockerContainersRunning: number;
dockerImages: number;
}
export interface HostMetric {
cpu_percent: number;
memory_percent: number;
memory_used: number;
memory_total: number;
timestamp: string;
}
// Historical data settings
const MAX_HISTORY = 60; // 10 minutes at 10s intervals (server collects every 10s)
const POLL_INTERVAL = 5000; // 5 seconds
// Stores
export const cpuHistory = writable<number[]>([]);
export const memoryHistory = writable<number[]>([]);
export const containerStats = writable<ContainerStats[]>([]);
export const hostInfo = writable<HostInfo | null>(null);
export const lastUpdated = writable<Date>(new Date());
export const isCollecting = writable<boolean>(false);
let pollInterval: ReturnType<typeof setInterval> | null = null;
let envId: number | null = null;
let initialFetchDone = false;
// Subscribe to environment changes
currentEnvironment.subscribe((env) => {
envId = env?.id ?? null;
// Reset history when environment changes
if (initialFetchDone) {
cpuHistory.set([]);
memoryHistory.set([]);
initialFetchDone = false;
}
});
// Helper for fetch with timeout
async function fetchWithTimeout(url: string, timeout = 5000): Promise<any> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
clearTimeout(timeoutId);
return response.json();
} catch {
clearTimeout(timeoutId);
return null;
}
}
async function fetchStats() {
// Don't fetch if no environment is selected
if (!envId) return;
// Fire all fetches independently - don't block on slow ones
fetchWithTimeout(appendEnvParam('/api/containers/stats?limit=5', envId), 5000).then(data => {
if (Array.isArray(data)) {
containerStats.set(data);
}
});
fetchWithTimeout(appendEnvParam('/api/host', envId), 5000).then(data => {
if (data && !data.error) {
hostInfo.set(data);
}
});
fetchWithTimeout(appendEnvParam('/api/metrics?limit=60', envId), 5000).then(data => {
if (data?.metrics && data.metrics.length > 0) {
const metrics: HostMetric[] = data.metrics;
const cpuValues = metrics.map(m => m.cpu_percent);
const memValues = metrics.map(m => m.memory_percent);
cpuHistory.set(cpuValues.slice(-MAX_HISTORY));
memoryHistory.set(memValues.slice(-MAX_HISTORY));
initialFetchDone = true;
}
});
lastUpdated.set(new Date());
}
export function startStatsCollection() {
if (pollInterval) return; // Already running
isCollecting.set(true);
fetchStats(); // Initial fetch
pollInterval = setInterval(fetchStats, POLL_INTERVAL);
}
export function stopStatsCollection() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
isCollecting.set(false);
}
// Get current values
export function getCurrentCpu(): number {
const history = get(cpuHistory);
return history.length > 0 ? history[history.length - 1] : 0;
}
export function getCurrentMemory(): number {
const history = get(memoryHistory);
return history.length > 0 ? history[history.length - 1] : 0;
}
+12
View File
@@ -1,5 +1,10 @@
// Shared types that can be used in both client and server code
/**
* System container type - containers that cannot be updated from within Dockhand.
*/
export type SystemContainerType = 'dockhand' | 'hawser';
export interface ContainerInfo {
id: string;
name: string;
@@ -24,6 +29,13 @@ export interface ContainerInfo {
}>;
networkMode: string;
networks: string[];
/**
* Identifies system containers (Dockhand, Hawser) that cannot be updated from within Dockhand.
* - 'dockhand': The Dockhand container itself
* - 'hawser': A Hawser remote agent container
* - null/undefined: Regular container
*/
systemContainer?: SystemContainerType | null;
}
export interface ImageInfo {
+79
View File
@@ -0,0 +1,79 @@
import { appendEnvParam } from '$lib/stores/environment';
export interface ShellInfo {
path: string;
label: string;
available: boolean;
}
export const SHELL_OPTIONS: Omit<ShellInfo, 'available'>[] = [
{ path: '/bin/bash', label: 'Bash' },
{ path: '/bin/sh', label: 'Shell (sh)' },
{ path: '/bin/zsh', label: 'Zsh' },
{ path: '/bin/ash', label: 'Ash (Alpine)' }
];
export const USER_OPTIONS = [
{ value: 'root', label: 'root' },
{ value: 'nobody', label: 'nobody' },
{ value: '', label: 'Container default' }
];
export interface ShellDetectionResult {
shells: string[];
defaultShell: string | null;
allShells: ShellInfo[];
error?: string;
}
/**
* Detect available shells in a container
*/
export async function detectShells(
containerId: string,
envId: number | null
): Promise<ShellDetectionResult> {
try {
const response = await fetch(
appendEnvParam(`/api/containers/${containerId}/shells`, envId)
);
const data = await response.json();
return {
shells: data.shells || [],
defaultShell: data.defaultShell || null,
allShells: data.allShells || SHELL_OPTIONS.map(s => ({ ...s, available: false })),
error: data.error
};
} catch (error) {
console.error('Failed to detect shells:', error);
return {
shells: [],
defaultShell: null,
allShells: SHELL_OPTIONS.map(s => ({ ...s, available: false })),
error: 'Failed to detect available shells'
};
}
}
/**
* Get the best available shell from the detection result
* Returns the user's preferred shell if available, otherwise the default
*/
export function getBestShell(
result: ShellDetectionResult,
preferredShell: string
): string | null {
// If preferred shell is available, use it
if (result.shells.includes(preferredShell)) {
return preferredShell;
}
// Otherwise use the default shell
return result.defaultShell;
}
/**
* Check if any shell is available
*/
export function hasAvailableShell(result: ShellDetectionResult): boolean {
return result.shells.length > 0;
}
-5
View File
@@ -10,7 +10,6 @@
import CommandPalette from '$lib/components/CommandPalette.svelte';
import WhatsNewModal from '$lib/components/WhatsNewModal.svelte';
import { SidebarProvider, SidebarTrigger } from '$lib/components/ui/sidebar';
import { startStatsCollection, stopStatsCollection } from '$lib/stores/stats';
import { connectSSE, disconnectSSE } from '$lib/stores/events';
import { currentEnvironment, environments } from '$lib/stores/environment';
import { licenseStore, daysUntilExpiry } from '$lib/stores/license';
@@ -70,9 +69,6 @@
// Initialize grid preferences
gridPreferencesStore.init();
// Start global stats collection for CPU/Memory graphs
startStatsCollection();
// Connect to SSE for real-time Docker events (global)
connectSSE(envId);
@@ -86,7 +82,6 @@
checkWhatsNew();
return () => {
stopStatsCollection();
disconnectSSE();
};
});
+83 -10
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Dashboard - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
@@ -11,7 +15,7 @@
import EnvironmentTileSkeleton from './dashboard/EnvironmentTileSkeleton.svelte';
import DraggableGrid, { type GridItemLayout } from './dashboard/DraggableGrid.svelte';
import { dashboardPreferences, dashboardData, GRID_COLS, GRID_ROW_HEIGHT, type TileItem } from '$lib/stores/dashboard';
import { currentEnvironment } from '$lib/stores/environment';
import { currentEnvironment, environments } from '$lib/stores/environment';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import type { EnvironmentStats } from './api/dashboard/stats/+server';
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
@@ -42,11 +46,69 @@
let tiles = $state<TileItem[]>([]);
let gridItems = $state<GridItemLayout[]>([]);
let initialLoading = $state(true);
let environmentsLoaded = $state(false); // Tracks if environments were ever received (prevents false "no environments" message)
let refreshing = $state(false);
let prefsLoaded = $state(false);
const mobileWatcher = new IsMobile();
const isMobile = $derived.by(() => mobileWatcher.current);
// Subscribe to environments store's loaded flag for quick "loaded" detection
// When loaded, immediately create skeleton tiles so the UI shows something useful
// The SSE stream will then update these tiles with real stats
$effect(() => {
const unsubscribe = environments.loaded.subscribe(loaded => {
if (loaded) {
environmentsLoaded = true;
// Create skeleton tiles immediately from the fast environments store
// This avoids waiting for the slower SSE stream to show initial UI
const envList = $environments;
if (tiles.length === 0 && envList.length > 0) {
const skeletonTiles = envList.map(env => ({
id: env.id,
stats: {
id: env.id,
name: env.name,
host: env.host,
port: env.port,
icon: env.icon || 'globe',
socketPath: env.socketPath,
collectActivity: false,
collectMetrics: true,
connectionType: env.connectionType || 'socket',
labels: [],
scannerEnabled: false,
online: undefined,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0, pendingUpdates: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
buildCacheSize: 0,
networks: { total: 0 },
stacks: { total: 0, running: 0, partial: 0, stopped: 0 },
metrics: null,
events: { total: 0, today: 0 },
topContainers: [],
loading: { containers: true, images: true, volumes: true, networks: true, stacks: true, diskUsage: true, topContainers: true }
} as EnvironmentStats,
info: { id: env.id, name: env.name, host: env.host, port: env.port, icon: env.icon || 'globe', socketPath: env.socketPath, collectActivity: false, collectMetrics: true, connectionType: env.connectionType || 'socket' },
loading: true
}));
tiles = skeletonTiles;
// Generate grid layout for these tiles
const savedLayout = $dashboardPreferences.gridLayout;
const tileIds = envList.map(env => env.id);
const newGridItems = savedLayout.length > 0
? mergeLayout(tileIds, savedLayout)
: generateDefaultLayout(tileIds);
gridItems = newGridItems;
}
}
});
return unsubscribe;
});
// Label filtering - load from localStorage
let filterLabels = $state<string[]>([]);
let labelFilterLoaded = $state(false);
@@ -321,6 +383,8 @@
const data = JSON.parse(eventData);
if (eventType === 'environments') {
// Mark that we've received environment data (prevents false "no environments" message)
environmentsLoaded = true;
// Create tiles for each environment with initial loading state
const envList = data as (EnvironmentInfo & { loading?: EnvironmentStats['loading'] })[];
const cachedData = dashboardData.getData();
@@ -342,7 +406,7 @@
labels: env.labels || [],
scannerEnabled: false,
online: undefined, // undefined = connecting, false = offline, true = online
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0, pendingUpdates: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
@@ -394,18 +458,26 @@
}
} else if (eventType === 'partial') {
// Progressive update - merge partial data into existing stats
// Only apply defined values to avoid overwriting with undefined
// Use deep merge for nested objects to preserve existing values
const partialStats = data as Partial<EnvironmentStats> & { id: number };
const tile = tiles.find(t => t.id === partialStats.id);
if (tile?.stats) {
// Use direct mutation for Svelte 5 reactivity
// Deep merge for nested objects like containers, images, etc.
for (const [key, value] of Object.entries(partialStats)) {
if (value !== undefined && key !== 'id') {
(tile.stats as any)[key] = value;
const existing = (tile.stats as any)[key];
// Deep merge for plain objects (not arrays or null)
if (existing && typeof existing === 'object' && !Array.isArray(existing) &&
value && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(existing, value);
} else {
(tile.stats as any)[key] = value;
}
}
}
}
// Also update the store
// Also update the store with deep merge
const definedStats: Partial<EnvironmentStats> = {};
for (const [key, value] of Object.entries(partialStats)) {
if (value !== undefined) {
@@ -799,6 +871,7 @@
tiles = cachedData.tiles;
gridItems = cachedData.gridItems;
initialLoading = false;
environmentsLoaded = true;
// Then refresh in background
fetchStatsStreaming(true);
@@ -852,7 +925,7 @@
<div class="flex flex-col gap-4 h-full overflow-auto pb-4">
<!-- Header -->
<div class="flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<div class="flex items-center gap-4">
<PageHeader icon={LayoutGrid} title="Environments" />
@@ -940,14 +1013,14 @@
</div>
</div>
<!-- Initial loading state before any tiles -->
{#if initialLoading && tiles.length === 0}
<!-- Initial loading state before any tiles - show until we know whether environments exist -->
{#if !environmentsLoaded && tiles.length === 0}
<div class="flex items-center justify-center gap-2 text-muted-foreground py-8">
<Loader2 class="w-5 h-5 animate-spin text-primary" />
<span class="text-sm">Loading environments...</span>
</div>
{:else if tiles.length === 0}
<!-- No environments -->
{:else if tiles.length === 0 && environmentsLoaded && $environments.length === 0}
<!-- No environments - only shown after we've confirmed there are none -->
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div class="w-16 h-16 mb-4 rounded-2xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center">
<Server class="w-8 h-8 opacity-40" />
+3 -5
View File
@@ -625,10 +625,7 @@
connectSSE();
initialLoadDone = true;
});
return () => {
disconnectSSE();
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
onDestroy(() => {
@@ -644,7 +641,7 @@
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Header with inline filters -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<div class="flex items-center gap-3">
<PageHeader icon={Activity} title="Activity" count={visibleEnd > 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" />
<Badge variant="outline" class="gap-1.5 {($appSettings.eventCollectionMode || 'stream') === 'stream' ? 'text-green-500 border-green-500/50' : 'text-amber-500 border-amber-500/50'}">
@@ -782,6 +779,7 @@
variant="destructive"
disabled={clearingActivity}
onOpenChange={(open) => showClearConfirm = open}
unstyled
>
{#snippet children({ open })}
<Button variant="outline" size="sm" disabled={clearingActivity || total === 0}>
@@ -0,0 +1,99 @@
import { json } from '@sveltejs/kit';
import { execInContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
// Shell paths to check
const SHELLS_TO_CHECK = [
{ path: '/bin/bash', label: 'Bash' },
{ path: '/bin/sh', label: 'Shell (sh)' },
{ path: '/bin/zsh', label: 'Zsh' },
{ path: '/bin/ash', label: 'Ash (Alpine)' }
];
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check - need exec permission to detect shells
if (auth.authEnabled && !await auth.can('containers', 'exec', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Environment access check (enterprise only)
if (envIdNum && auth.isEnterprise && !await auth.canAccessEnvironment(envIdNum)) {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const containerId = params.id;
const availableShells: string[] = [];
// Check each shell by testing if the file exists and is executable
// Use a single command to check all shells at once for efficiency
const checkCommand = SHELLS_TO_CHECK.map(s =>
`test -x ${s.path} && echo "${s.path}"`
).join('; ');
try {
const output = await execInContainer(
containerId,
['sh', '-c', checkCommand],
envIdNum
);
// Parse output - each line is an available shell path
const lines = output.trim().split('\n').filter(Boolean);
availableShells.push(...lines);
} catch {
// If even sh fails, try checking with test commands individually
// This handles edge cases where sh might not be available
for (const shell of SHELLS_TO_CHECK) {
try {
await execInContainer(
containerId,
['test', '-x', shell.path],
envIdNum
);
availableShells.push(shell.path);
} catch {
// Shell not available, continue to next
}
}
}
// Determine default shell - prefer bash, then sh, then first available
let defaultShell: string | null = null;
if (availableShells.includes('/bin/bash')) {
defaultShell = '/bin/bash';
} else if (availableShells.includes('/bin/sh')) {
defaultShell = '/bin/sh';
} else if (availableShells.length > 0) {
defaultShell = availableShells[0];
}
return json({
shells: availableShells,
defaultShell,
allShells: SHELLS_TO_CHECK.map(s => ({
path: s.path,
label: s.label,
available: availableShells.includes(s.path)
}))
});
} catch (error) {
console.error('Error detecting shells:', error);
return json({
error: 'Failed to detect shells',
shells: [],
defaultShell: null,
allShells: SHELLS_TO_CHECK.map(s => ({
path: s.path,
label: s.label,
available: false
}))
}, { status: 200 }); // Return 200 with empty results rather than 500
}
};
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
export interface UpdateCheckResult {
containerId: string;
@@ -36,7 +37,10 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
await clearPendingContainerUpdates(envIdNum);
}
const containers = await listContainers(true, envIdNum);
const allContainers = await listContainers(true, envIdNum);
// Filter out system containers (Dockhand, Hawser) - they cannot be updated from within Dockhand
const containers = allContainers.filter(c => !isSystemContainer(c.image));
// Check container for updates
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
+10 -5
View File
@@ -5,7 +5,8 @@ import {
getContainerEventStats,
getEnvSetting,
hasEnvironments,
getEnvUpdateCheckSettings
getEnvUpdateCheckSettings,
getPendingContainerUpdates
} from '$lib/server/db';
import {
listContainers,
@@ -61,6 +62,7 @@ export interface EnvironmentStats {
paused: number;
restarting: number;
unhealthy: number;
pendingUpdates: number;
};
images: {
total: number;
@@ -165,7 +167,7 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0, pendingUpdates: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
@@ -252,10 +254,11 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
// Get latest metrics and event stats in parallel
const [latestMetrics, eventStats] = await Promise.all([
// Get latest metrics, event stats, and pending updates in parallel
const [latestMetrics, eventStats, pendingUpdates] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id)
getContainerEventStats(env.id),
getPendingContainerUpdates(env.id)
]);
if (latestMetrics) {
@@ -272,6 +275,8 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
today: eventStats.today
};
envStats.containers.pendingUpdates = pendingUpdates.length;
} catch (error) {
// Convert technical error messages to user-friendly ones
const errorStr = String(error);
@@ -6,7 +6,8 @@ import {
getContainerEventStats,
getContainerEvents,
getEnvSetting,
getEnvUpdateCheckSettings
getEnvUpdateCheckSettings,
getPendingContainerUpdates
} from '$lib/server/db';
import {
listContainers,
@@ -136,7 +137,7 @@ async function getEnvironmentStatsProgressive(
labels: parseLabels(env.labels),
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
online: false,
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0, pendingUpdates: 0 },
images: { total: 0, totalSize: 0 },
volumes: { total: 0, totalSize: 0 },
containersSize: 0,
@@ -174,11 +175,12 @@ async function getEnvironmentStatsProgressive(
// 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([
const [latestMetrics, eventStats, recentEventsResult, metricsHistory, pendingUpdates] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
getContainerEvents({ environmentId: env.id, limit: 10 }),
getHostMetrics(30, env.id)
getHostMetrics(30, env.id),
getPendingContainerUpdates(env.id)
]);
if (latestMetrics) {
@@ -195,6 +197,8 @@ async function getEnvironmentStatsProgressive(
today: eventStats.today
};
envStats.containers.pendingUpdates = pendingUpdates.length;
if (recentEventsResult.events.length > 0) {
envStats.recentEvents = recentEventsResult.events.map(e => ({
container_name: e.containerName || 'unknown',
@@ -221,6 +225,7 @@ async function getEnvironmentStatsProgressive(
scannerEnabled: envStats.scannerEnabled,
updateCheckEnabled: envStats.updateCheckEnabled,
updateCheckAutoUpdate: envStats.updateCheckAutoUpdate,
containers: { ...envStats.containers },
loading: { ...envStats.loading }
});
@@ -251,6 +256,7 @@ async function getEnvironmentStatsProgressive(
envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length;
envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length;
envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length;
// Note: pendingUpdates is already set from DB query, preserve it
envStats.loading!.containers = false;
onPartialUpdate({
+1 -1
View File
@@ -109,7 +109,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
stackName: trimmedStackName,
environmentId: data.environmentId || null,
repositoryId: repositoryId,
composePath: data.composePath || 'docker-compose.yml',
composePath: data.composePath || 'compose.yaml',
envFilePath: data.envFilePath || null,
autoUpdate: data.autoUpdate || false,
autoUpdateSchedule: data.autoUpdateSchedule || 'daily',
+33 -13
View File
@@ -1,10 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
const PAGE_SIZE = 100;
export const GET: RequestHandler = async ({ url }) => {
try {
const registryId = url.searchParams.get('registry');
const lastParam = url.searchParams.get('last'); // For pagination
if (!registryId) {
return json({ error: 'Registry ID is required' }, { status: 400 });
@@ -20,22 +24,20 @@ export const GET: RequestHandler = async ({ url }) => {
return json({ error: 'Docker Hub does not support catalog listing. Please use search instead.' }, { status: 400 });
}
// Build the catalog URL
let catalogUrl = registry.url;
if (!catalogUrl.endsWith('/')) {
catalogUrl += '/';
}
catalogUrl += 'v2/_catalog';
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
// Build catalog URL with pagination
let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`;
if (lastParam) {
catalogUrl += `&last=${encodeURIComponent(lastParam)}`;
}
// Prepare headers
const headers: HeadersInit = {
'Accept': 'application/json'
};
// Add auth if credentials are present
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(catalogUrl, {
@@ -56,7 +58,18 @@ export const GET: RequestHandler = async ({ url }) => {
const data = await response.json();
// The V2 API returns { repositories: [...] }
const repositories = data.repositories || [];
const repositories: string[] = data.repositories || [];
// Parse Link header for pagination
// Format: </v2/_catalog?last=xxx&n=100>; rel="next"
let nextLast: string | null = null;
const linkHeader = response.headers.get('Link');
if (linkHeader) {
const nextMatch = linkHeader.match(/<[^>]*[?&]last=([^&>]+)[^>]*>;\s*rel="next"/);
if (nextMatch) {
nextLast = decodeURIComponent(nextMatch[1]);
}
}
// For each repository, we could fetch tags, but that's expensive
// Just return the repository names for now
@@ -68,7 +81,14 @@ export const GET: RequestHandler = async ({ url }) => {
is_automated: false
}));
return json(results);
return json({
repositories: results,
pagination: {
pageSize: PAGE_SIZE,
hasMore: !!nextLast,
nextLast: nextLast
}
});
} catch (error: any) {
console.error('Error fetching registry catalog:', error);
+6 -9
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
function isDockerHub(url: string): boolean {
const lower = url.toLowerCase();
@@ -37,22 +38,18 @@ export const DELETE: RequestHandler = async ({ url }) => {
return json({ error: 'Docker Hub does not support image deletion via API. Please use the Docker Hub web interface.' }, { status: 400 });
}
let baseUrl = registry.url;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull,push,delete`);
const headers: HeadersInit = {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
};
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
if (authHeader) {
headers['Authorization'] = authHeader;
}
// Step 1: Get the manifest digest
const manifestUrl = `${baseUrl}v2/${imageName}/manifests/${tag}`;
const manifestUrl = `${baseUrl}/v2/${imageName}/manifests/${tag}`;
const headResponse = await fetch(manifestUrl, {
method: 'HEAD',
headers
@@ -74,7 +71,7 @@ export const DELETE: RequestHandler = async ({ url }) => {
}
// Step 2: Delete the manifest by digest
const deleteUrl = `${baseUrl}v2/${imageName}/manifests/${digest}`;
const deleteUrl = `${baseUrl}/v2/${imageName}/manifests/${digest}`;
const deleteResponse = await fetch(deleteUrl, {
method: 'DELETE',
headers
+116 -34
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
interface SearchResult {
name: string;
@@ -44,46 +45,30 @@ async function searchDockerHub(term: string, limit: number): Promise<SearchResul
}
async function searchPrivateRegistry(registry: any, term: string, limit: number): Promise<SearchResult[]> {
// Private registries use the V2 catalog API
let baseUrl = registry.url;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
const results: string[] = [];
const catalogUrl = `${baseUrl}v2/_catalog?n=1000`;
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
}
const response = await fetch(catalogUrl, {
method: 'GET',
headers
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
// Strategy 1: If term looks like an image name (contains /), try direct lookup first
// This is much faster than iterating through catalog for large registries like ghcr.io
if (term.includes('/')) {
const directResult = await tryDirectImageLookup(registry, term);
if (directResult) {
results.push(term);
}
throw new Error(`Registry returned error: ${response.status}`);
}
const data = await response.json();
const repositories = data.repositories || [];
// Filter repositories by search term (case-insensitive)
const termLower = term.toLowerCase();
const filtered = repositories
.filter((name: string) => name.toLowerCase().includes(termLower))
.slice(0, limit);
// Strategy 2: Fall back to catalog search for partial matches or if direct lookup failed
if (results.length < limit) {
const catalogResults = await searchCatalog(registry, term, limit - results.length);
// Add catalog results, avoiding duplicates
for (const name of catalogResults) {
if (!results.includes(name)) {
results.push(name);
}
}
}
// Return results in the same format as Docker Hub
return filtered.map((name: string) => ({
return results.map((name: string) => ({
name,
description: '',
star_count: 0,
@@ -92,6 +77,103 @@ async function searchPrivateRegistry(registry: any, term: string, limit: number)
}));
}
// Try to directly check if an image exists by querying its tags endpoint
async function tryDirectImageLookup(registry: any, imageName: string): Promise<boolean> {
try {
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(`${baseUrl}/v2/${imageName}/tags/list`, {
method: 'GET',
headers
});
// 200 = image exists, 404 = doesn't exist
return response.ok;
} catch {
return false;
}
}
// Search through catalog (slow for large registries, limited to first few pages)
async function searchCatalog(registry: any, term: string, limit: number): Promise<string[]> {
const { baseUrl, authHeader } = await getRegistryAuth(registry, 'registry:catalog:*');
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (authHeader) {
headers['Authorization'] = authHeader;
}
const termLower = term.toLowerCase();
const results: string[] = [];
const PAGE_SIZE = 200;
const MAX_PAGES = 3; // Limit pages to avoid long waits on huge registries
let lastRepo: string | null = null;
let pagesSearched = 0;
while (results.length < limit && pagesSearched < MAX_PAGES) {
let catalogUrl = `${baseUrl}/v2/_catalog?n=${PAGE_SIZE}`;
if (lastRepo) {
catalogUrl += `&last=${encodeURIComponent(lastRepo)}`;
}
const response = await fetch(catalogUrl, {
method: 'GET',
headers
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Authentication failed');
}
throw new Error(`Registry returned error: ${response.status}`);
}
const data = await response.json();
const repositories: string[] = data.repositories || [];
if (repositories.length === 0) {
break;
}
// Filter and add matching repos
for (const name of repositories) {
if (name.toLowerCase().includes(termLower)) {
results.push(name);
if (results.length >= limit) {
break;
}
}
}
// Get last repo for next page
lastRepo = repositories[repositories.length - 1];
// Check if there are more pages
const linkHeader = response.headers.get('Link');
if (!linkHeader || !linkHeader.includes('rel="next"')) {
if (repositories.length < PAGE_SIZE) {
break;
}
}
pagesSearched++;
}
return results;
}
export const GET: RequestHandler = async ({ url }) => {
const term = url.searchParams.get('term');
const limit = parseInt(url.searchParams.get('limit') || '25', 10);
+5 -10
View File
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
interface TagInfo {
name: string;
@@ -72,21 +73,15 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize:
}
async function fetchRegistryTags(registry: any, imageName: string): Promise<TagInfo[]> {
// Standard V2 registry API
let baseUrl = registry.url;
if (!baseUrl.endsWith('/')) {
baseUrl += '/';
}
const tagsUrl = `${baseUrl}v2/${imageName}/tags/list`;
const { baseUrl, authHeader } = await getRegistryAuth(registry, `repository:${imageName}:pull`);
const tagsUrl = `${baseUrl}/v2/${imageName}/tags/list`;
const headers: HeadersInit = {
'Accept': 'application/json'
};
if (registry.username && registry.password) {
const credentials = Buffer.from(`${registry.username}:${registry.password}`).toString('base64');
headers['Authorization'] = `Basic ${credentials}`;
if (authHeader) {
headers['Authorization'] = authHeader;
}
const response = await fetch(tagsUrl, {
@@ -37,13 +37,17 @@ export const POST: RequestHandler = async ({ params, request, url, cookies }) =>
currentComposePath = source.composePath;
currentDir = dirname(source.composePath);
} else {
// Stack uses default directory structure
// Stack uses default directory structure - check all valid compose filenames
const stackDir = await findStackDir(name, envIdNum);
if (stackDir) {
const defaultComposePath = join(stackDir, 'docker-compose.yml');
if (existsSync(defaultComposePath)) {
currentComposePath = defaultComposePath;
currentDir = stackDir;
const composeNames = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
for (const fileName of composeNames) {
const composePath = join(stackDir, fileName);
if (existsSync(composePath)) {
currentComposePath = composePath;
currentDir = stackDir;
break;
}
}
}
}
@@ -47,7 +47,7 @@ export const GET: RequestHandler = async ({ url }) => {
return json({
stackDir,
composePath: `${stackDir}/docker-compose.yml`,
composePath: `${stackDir}/compose.yaml`,
envPath: `${stackDir}/.env`,
source: location ? 'custom' : 'default'
});
+19 -11
View File
@@ -581,6 +581,9 @@
}
}
// Resize handler - stored at module scope for cleanup in onDestroy
let resizeHandler: (() => void) | null = null;
onMount(async () => {
// Load saved filters from localStorage first
loadFiltersFromStorage();
@@ -608,23 +611,28 @@
initialLoadDone = true;
// Update container height on resize
const updateHeight = () => {
resizeHandler = () => {
if (scrollContainer) {
containerHeight = scrollContainer.clientHeight;
}
};
updateHeight();
window.addEventListener('resize', updateHeight);
resizeHandler();
window.addEventListener('resize', resizeHandler);
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
return () => {
window.removeEventListener('resize', updateHeight);
// Disconnect SSE when component unmounts
disconnectAuditSSE();
if (unsubscribeSSE) {
unsubscribeSSE();
}
};
onDestroy(() => {
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler);
resizeHandler = null;
}
// Disconnect SSE when component unmounts
disconnectAuditSSE();
if (unsubscribeSSE) {
unsubscribeSSE();
unsubscribeSSE = null;
}
});
// Refetch when license changes (only after initial mount)
+285 -96
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Containers - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
@@ -6,6 +10,7 @@
import * as Dialog from '$lib/components/ui/dialog';
import * as Popover from '$lib/components/ui/popover';
import * as Select from '$lib/components/ui/select';
import * as Tooltip from '$lib/components/ui/tooltip';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -51,7 +56,12 @@
ShieldX,
Shield,
ShieldCheck,
Box
Box,
Ship,
Cable,
Copy,
Loader2,
AlertCircle
} from 'lucide-svelte';
import { broom } from '@lucide/lab';
import CreateContainerModal from './CreateContainerModal.svelte';
@@ -70,6 +80,7 @@
import { canAccess } from '$lib/stores/auth';
import { vulnerabilityCriteriaIcons } from '$lib/utils/update-steps';
import { ipToNumber } from '$lib/utils/ip';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellDetectionResult } from '$lib/utils/shell-detection';
import { DataGrid } from '$lib/components/data-grid';
import type { ColumnConfig } from '$lib/types';
import type { DataGridRowState } from '$lib/components/data-grid/types';
@@ -170,6 +181,8 @@
batchUpdateContainerIds = [];
batchUpdateContainerNames = new Map();
updateCheckStatus = 'idle';
// Clear shell detection cache for new environment
shellDetectionCache = {};
fetchContainers();
fetchStats();
loadPendingUpdates();
@@ -181,6 +194,7 @@
batchUpdateContainerIds = [];
batchUpdateContainerNames = new Map();
updateCheckStatus = 'idle';
shellDetectionCache = {};
}
});
let loading = $state(true);
@@ -267,14 +281,28 @@
// Set of container IDs with updates available (for O(1) lookup)
const containersWithUpdatesSet = $derived(new Set(batchUpdateContainerIds));
// Check if any selected container has an update available
const selectedHaveUpdates = $derived(
Array.from(selectedContainers).some(id => containersWithUpdatesSet.has(id))
// Count of updatable containers (excluding system containers like Dockhand/Hawser)
const updatableContainersCount = $derived(
batchUpdateContainerIds.filter(id => {
const container = containers.find(c => c.id === id);
return container && !container.systemContainer;
}).length
);
// Count selected containers with updates
// Check if any selected container has an update available (excluding system containers)
const selectedHaveUpdates = $derived(
Array.from(selectedContainers).some(id => {
const container = containers.find(c => c.id === id);
return container && containersWithUpdatesSet.has(id) && !container.systemContainer;
})
);
// Count selected containers with updates (excluding system containers)
const selectedWithUpdatesCount = $derived(
Array.from(selectedContainers).filter(id => containersWithUpdatesSet.has(id)).length
Array.from(selectedContainers).filter(id => {
const container = containers.find(c => c.id === id);
return container && containersWithUpdatesSet.has(id) && !container.systemContainer;
}).length
);
// Selection helpers
@@ -431,8 +459,11 @@
}
function updateSelectedContainers() {
// Only include selected containers that have updates available
const selectedWithUpdates = Array.from(selectedContainers).filter(id => containersWithUpdatesSet.has(id));
// Only include selected containers that have updates available (excluding system containers)
const selectedWithUpdates = Array.from(selectedContainers).filter(id => {
const container = containers.find(c => c.id === id);
return container && containersWithUpdatesSet.has(id) && !container.systemContainer;
});
if (selectedWithUpdates.length === 0) return;
const selectedNames = new Map<string, string>();
@@ -450,14 +481,15 @@
function updateAllContainers() {
if (batchUpdateContainerIds.length === 0) return;
// Build names map from all containers with updates
// Build names map from all containers with updates (excluding system containers)
const allNames = new Map<string, string>();
for (const id of batchUpdateContainerIds) {
const container = containers.find(c => c.id === id);
if (container) {
if (container && !container.systemContainer) {
allNames.set(id, container.name);
}
}
if (allNames.size === 0) return;
batchUpdateContainerNames = allNames;
showBatchUpdateModal = true;
}
@@ -512,18 +544,44 @@
return activeTerminals.find(t => t.containerId === containerId);
}
// Shell and user options
const shellOptions = [
{ value: '/bin/bash', label: 'Bash' },
{ value: '/bin/sh', label: 'Shell (sh)' },
{ value: '/bin/zsh', label: 'Zsh' },
{ value: '/bin/ash', label: 'Ash (Alpine)' }
];
const userOptions = [
{ value: 'root', label: 'root' },
{ value: 'nobody', label: 'nobody' },
{ value: '', label: 'Container default' }
];
// Shell detection state per container
let shellDetectionCache = $state<Record<string, ShellDetectionResult>>({});
let detectingShellsFor = $state<string | null>(null);
// Check if any shell is available for a container
function anyShellAvailableFor(containerId: string): boolean {
const detection = shellDetectionCache[containerId];
return !detection || hasAvailableShell(detection);
}
// Detect shells when popover opens
async function detectContainerShells(containerId: string) {
if (shellDetectionCache[containerId] || detectingShellsFor === containerId) return;
detectingShellsFor = containerId;
try {
const result = await detectShells(containerId, $currentEnvironment?.id ?? null);
shellDetectionCache[containerId] = result;
// Auto-select best available shell if current is not available
const bestShell = getBestShell(result, terminalShell);
if (bestShell && bestShell !== terminalShell) {
terminalShell = bestShell;
}
} catch (error) {
console.error('Failed to detect shells:', error);
} finally {
detectingShellsFor = null;
}
}
// User options from shared utilities
const userOptions = USER_OPTIONS;
// Stats polling interval - module scope for cleanup in onDestroy
let statsInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeDockerEvent: (() => void) | null = null;
// Logs state - track active logs per container (like terminals)
interface ActiveLogs {
containerId: string;
@@ -1214,6 +1272,18 @@
return '-';
}
let copiedCommand = $state<string | null>(null);
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(() => {
copiedCommand = text;
toast.success('Copied to clipboard');
setTimeout(() => { copiedCommand = null; }, 2000);
}).catch(() => {
toast.error('Failed to copy to clipboard');
});
}
function parseUptimeToSeconds(status: string): number {
// Parse uptime from status to seconds for sorting
// Running containers have positive values (higher = longer uptime)
@@ -1322,27 +1392,37 @@
// Initial fetch is handled by $effect - no need to duplicate here
// Set up interval to refresh stats every 5 seconds
const statsInterval = setInterval(() => {
// Set up interval to refresh stats every 5 seconds (use module-scope var for cleanup)
statsInterval = setInterval(() => {
if (envId) fetchStats();
}, 5000);
// Subscribe to container events (SSE connection is global in layout)
const unsubscribe = onDockerEvent((event) => {
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isContainerListChange(event)) {
fetchContainers();
fetchStats();
}
});
return () => {
unsubscribe();
clearInterval(statsInterval);
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
// Cleanup resize event listeners and pending timeouts on component destroy
// Cleanup on component destroy
onDestroy(() => {
// Clear stats polling interval
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
// Unsubscribe from Docker events
if (unsubscribeDockerEvent) {
unsubscribeDockerEvent();
unsubscribeDockerEvent = null;
}
// Cleanup resize event listeners and pending timeouts
document.removeEventListener('mousemove', handleWidthResize);
document.removeEventListener('mouseup', stopWidthResize);
document.removeEventListener('visibilitychange', handleVisibilityChange);
@@ -1353,7 +1433,7 @@
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Box} title="Containers" count={containers.length} />
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
@@ -1400,7 +1480,7 @@
{/if}
Check for updates
</Button>
{#if batchUpdateContainerIds.length > 0}
{#if updatableContainersCount > 0}
<Button
size="sm"
variant="outline"
@@ -1409,7 +1489,7 @@
title="Update all containers with available updates"
>
<CircleArrowUp class="w-3.5 h-3.5 mr-1" />
Update all ({batchUpdateContainerIds.length})
Update all ({updatableContainersCount})
</Button>
{/if}
{#if $canAccess('containers', 'remove')}
@@ -1457,13 +1537,14 @@
</div>
</div>
<!-- Selection bar -->
{#if selectedContainers.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
{#if selectedContainers.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
disabled={bulkActionInProgress}
>
@@ -1481,7 +1562,7 @@
onOpenChange={(open) => confirmBulkStart = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-green-600 hover:border-green-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-green-600 hover:border-green-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<Play class="w-3 h-3" />
Start
</span>
@@ -1499,7 +1580,7 @@
onOpenChange={(open) => confirmBulkStop = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-red-600 hover:border-red-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-red-600 hover:border-red-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<Square class="w-3 h-3" />
Stop
</span>
@@ -1516,7 +1597,7 @@
onOpenChange={(open) => confirmBulkPause = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-yellow-600 hover:border-yellow-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-yellow-600 hover:border-yellow-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<Pause class="w-3 h-3" />
Pause
</span>
@@ -1535,7 +1616,7 @@
onOpenChange={(open) => confirmBulkUnpause = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-blue-600 hover:border-blue-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-blue-600 hover:border-blue-500/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<Play class="w-3 h-3" />
Unpause
</span>
@@ -1554,7 +1635,7 @@
onOpenChange={(open) => confirmBulkRestart = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<RotateCw class="w-3 h-3" />
Restart
</span>
@@ -1572,7 +1653,7 @@
onOpenChange={(open) => confirmBulkRemove = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}">
<Trash2 class="w-3 h-3" />
Remove
</span>
@@ -1582,7 +1663,7 @@
{#if selectedHaveUpdates}
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-amber-500/40 shadow-sm text-amber-600 hover:border-amber-500 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-amber-500/40 text-amber-600 hover:border-amber-500 hover:shadow transition-all cursor-pointer {bulkActionInProgress ? 'opacity-50' : ''}"
onclick={updateSelectedContainers}
disabled={bulkActionInProgress}
title="Update selected containers to latest image"
@@ -1594,10 +1675,11 @@
{#if bulkActionInProgress}
<CircleArrowUp class="w-3 h-3 animate-spin ml-1" />
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#if $environments.length === 0 || !$currentEnvironment}
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
{:else if !loading && containers.length === 0}
<EmptyState
@@ -1626,7 +1708,7 @@
let classes = '';
if (currentLogsContainerId === container.id) classes += 'bg-blue-500/10 hover:bg-blue-500/15 ';
if (currentTerminalContainerId === container.id) classes += 'bg-green-500/10 hover:bg-green-500/15 ';
if ($appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id)) classes += 'has-update ';
if ($appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) && !container.systemContainer) classes += 'has-update ';
return classes;
}}
onRowClick={(container, e) => {
@@ -1640,15 +1722,98 @@
{@const ports = formatPorts(container.ports)}
{@const stack = getComposeProject(container.labels)}
{#if column.id === 'name'}
<button
type="button"
class="text-xs font-medium truncate block text-left hover:text-primary hover:underline cursor-pointer"
title={container.name}
onclick={(e) => { e.stopPropagation(); inspectContainer(container); }}
>{container.name}</button>
<div class="flex items-center gap-1.5 min-w-0">
<button
type="button"
class="text-xs font-medium truncate text-left hover:text-primary hover:underline cursor-pointer"
title={container.name}
onclick={(e) => { e.stopPropagation(); inspectContainer(container); }}
>{container.name}</button>
{#if container.systemContainer}
{@const hasUpdate = containersWithUpdatesSet.has(container.id)}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="secondary" class="text-2xs py-0 px-1 shrink-0 {hasUpdate ? 'bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20' : 'bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-500/20'} cursor-help flex items-center gap-0.5">
{#if container.systemContainer === 'dockhand'}
<Ship class="w-2.5 h-2.5" />
{:else}
<Cable class="w-2.5 h-2.5" />
{/if}
{container.systemContainer === 'dockhand' ? 'Dockhand' : 'Hawser'}
{#if hasUpdate}
<CircleArrowUp class="w-2.5 h-2.5" />
{/if}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content side="right" class="w-auto p-3">
{#if container.systemContainer === 'dockhand'}
{#if hasUpdate}
{@const composeCmd = 'docker compose pull && docker compose up -d'}
{@const dockerCmd = `docker stop ${container.name} && docker pull fnsys/dockhand:latest && docker start ${container.name}`}
<div class="space-y-2">
<p class="font-medium text-sm flex items-center gap-1.5">
<CircleArrowUp class="w-4 h-4 text-amber-500" />
Update available
</p>
<p class="text-muted-foreground text-xs">Cannot be updated from within Dockhand. Update manually:</p>
<div class="space-y-1.5">
<p class="text-muted-foreground text-2xs">Using Compose:</p>
<div class="flex items-center gap-2 bg-muted rounded p-2">
<code class="text-2xs font-mono whitespace-nowrap">{composeCmd}</code>
<Button size="icon" variant="ghost" class="h-5 w-5 shrink-0" onclick={(e) => { e.stopPropagation(); copyToClipboard(composeCmd); }}>
{#if copiedCommand === composeCmd}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</Button>
</div>
<p class="text-muted-foreground text-2xs">Using Docker CLI:</p>
<div class="flex items-center gap-2 bg-muted rounded p-2">
<code class="text-2xs font-mono whitespace-nowrap">{dockerCmd}</code>
<Button size="icon" variant="ghost" class="h-5 w-5 shrink-0" onclick={(e) => { e.stopPropagation(); copyToClipboard(dockerCmd); }}>
{#if copiedCommand === dockerCmd}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3" />
{/if}
</Button>
</div>
</div>
</div>
{:else}
<p class="text-sm whitespace-nowrap">Dockhand management container</p>
{/if}
{:else}
{#if hasUpdate}
<div class="space-y-2">
<p class="font-medium text-sm flex items-center gap-1.5 whitespace-nowrap">
<CircleArrowUp class="w-4 h-4 text-amber-500" />
Update available
</p>
<p class="text-muted-foreground text-xs whitespace-nowrap">Update on the remote host where Hawser runs.</p>
<a
href="https://github.com/Finsys/hawser"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline text-xs flex items-center gap-1 whitespace-nowrap"
onclick={(e) => e.stopPropagation()}
>
<ExternalLink class="w-3 h-3" />
Update instructions on GitHub
</a>
</div>
{:else}
<p class="text-sm whitespace-nowrap">Hawser remote agent</p>
{/if}
{/if}
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{:else if column.id === 'image'}
<div class="flex items-center gap-1.5 {$appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) ? 'update-border' : ''}">
{#if containersWithUpdatesSet.has(container.id)}
<div class="flex items-center gap-1.5 {$appSettings.highlightUpdates && containersWithUpdatesSet.has(container.id) && !container.systemContainer ? 'update-border' : ''}">
{#if containersWithUpdatesSet.has(container.id) && !container.systemContainer}
<span title="Update available">
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
</span>
@@ -1799,7 +1964,7 @@
{/if}
{:else if column.id === 'actions'}
<div class="relative flex gap-0.5 justify-end">
{#if containersWithUpdatesSet.has(container.id)}
{#if containersWithUpdatesSet.has(container.id) && !container.systemContainer}
<button
type="button"
onclick={() => updateSingleContainer(container.id, container.name)}
@@ -1934,7 +2099,10 @@
<Terminal class="w-4 h-4 text-green-400" style="filter: drop-shadow(0 0 4px rgba(74,222,128,0.9)) drop-shadow(0 0 8px rgba(74,222,128,0.6));" strokeWidth={2.5} />
</button>
{:else}
<Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => { terminalPopoverStates[container.id] = open; }}>
<Popover.Root open={terminalPopoverStates[container.id] ?? false} onOpenChange={(open) => {
terminalPopoverStates[container.id] = open;
if (open) detectContainerShells(container.id);
}}>
<Popover.Trigger
onclick={(e: MouseEvent) => e.stopPropagation()}
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
@@ -1948,46 +2116,66 @@
<span class="text-xs font-medium truncate" title={container.name}>{container.name}</span>
</div>
</div>
<div class="p-3 space-y-3">
<div class="space-y-1.5">
<Label class="text-xs">Shell</Label>
<Select.Root type="single" bind:value={terminalShell}>
<Select.Trigger class="w-full h-8 text-xs">
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{shellOptions.find(o => o.value === terminalShell)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each shellOptions as option}
<Select.Item value={option.value} label={option.label}>
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if detectingShellsFor === container.id}
<div class="p-4 text-center">
<Loader2 class="w-5 h-5 mx-auto mb-2 text-muted-foreground animate-spin" />
<p class="text-xs text-muted-foreground">Detecting shells...</p>
</div>
<div class="space-y-1.5">
<Label class="text-xs">User</Label>
<Select.Root type="single" bind:value={terminalUser}>
<Select.Trigger class="w-full h-8 text-xs">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{userOptions.find(o => o.value === terminalUser)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{:else if !anyShellAvailableFor(container.id)}
<div class="p-4 text-center">
<AlertCircle class="w-5 h-5 mx-auto mb-2 text-amber-500" />
<p class="text-xs font-medium text-amber-500">No shell available</p>
<p class="text-xs text-muted-foreground mt-1">This container has no shell installed.</p>
</div>
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
<Terminal class="w-3 h-3 mr-1" />
Connect
</Button>
</div>
{:else}
<div class="p-3 space-y-3">
<div class="space-y-1.5">
<Label class="text-xs">Shell</Label>
<Select.Root type="single" bind:value={terminalShell}>
<Select.Trigger class="w-full h-8 text-xs">
<Shell class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{shellDetectionCache[container.id]?.allShells.find(o => o.path === terminalShell)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#if shellDetectionCache[container.id]}
{#each shellDetectionCache[container.id].allShells as option}
<Select.Item value={option.path} label={option.label} disabled={!option.available}>
<Shell class="w-3 h-3 mr-1.5 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
{option.label}
{#if !option.available}
<span class="text-xs ml-1">(unavailable)</span>
{/if}
</span>
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1.5">
<Label class="text-xs">User</Label>
<Select.Root type="single" bind:value={terminalUser}>
<Select.Trigger class="w-full h-8 text-xs">
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
<span>{userOptions.find(o => o.value === terminalUser)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-3 h-3 mr-1.5 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Button size="sm" class="w-full h-7 text-xs" onclick={() => startTerminal(container)}>
<Terminal class="w-3 h-3 mr-1" />
Connect
</Button>
</div>
{/if}
</Popover.Content>
</Popover.Root>
{/if}
@@ -2197,5 +2385,6 @@
border-radius: 4px;
box-shadow: 0 0 8px rgb(245 158 11 / 0.4);
}
</style>
+68 -30
View File
@@ -4,11 +4,14 @@
import CronEditor from '$lib/components/cron-editor.svelte';
import VulnerabilityCriteriaSelector, { type VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
import { currentEnvironment } from '$lib/stores/environment';
import { Ship, Cable, ExternalLink, AlertTriangle } from 'lucide-svelte';
import type { SystemContainerType } from '$lib/types';
interface Props {
enabled: boolean;
cronExpression: string;
vulnerabilityCriteria: VulnerabilityCriteria;
systemContainer?: SystemContainerType | null;
onenablechange?: (enabled: boolean) => void;
oncronchange?: (cron: string) => void;
oncriteriachange?: (criteria: VulnerabilityCriteria) => void;
@@ -18,6 +21,7 @@
enabled = $bindable(),
cronExpression = $bindable(),
vulnerabilityCriteria = $bindable(),
systemContainer = null,
onenablechange,
oncronchange,
oncriteriachange
@@ -47,35 +51,69 @@
}
</script>
<div class="space-y-3">
<div class="flex items-center gap-3">
<Label class="text-xs font-normal">Enable automatic image updates</Label>
<TogglePill
bind:checked={enabled}
onchange={(value) => onenablechange?.(value)}
/>
</div>
{#if enabled}
<CronEditor
value={cronExpression}
onchange={(cron) => {
cronExpression = cron;
oncronchange?.(cron);
}}
/>
{#if envHasScanning}
<div class="space-y-1.5">
<Label class="text-xs font-medium">Vulnerability criteria</Label>
<VulnerabilityCriteriaSelector
bind:value={vulnerabilityCriteria}
onchange={(v) => oncriteriachange?.(v)}
/>
<p class="text-xs text-muted-foreground">
Block auto-updates if new image has vulnerabilities matching this criteria
</p>
{#if systemContainer}
<!-- System container - show informational message instead of settings -->
<div class="rounded-lg border border-blue-500/30 bg-blue-500/5 p-3">
<div class="flex items-start gap-2">
<AlertTriangle class="w-4 h-4 text-blue-500 mt-0.5 shrink-0" />
<div class="space-y-2 text-xs">
{#if systemContainer === 'dockhand'}
<p class="font-medium text-blue-600 dark:text-blue-400">Auto-updates not available</p>
<p class="text-muted-foreground">
Dockhand cannot update itself. To update, run on the host:
</p>
<code class="block bg-muted rounded px-2 py-1 font-mono text-2xs">
docker compose pull && docker compose up -d
</code>
{:else}
<p class="font-medium text-blue-600 dark:text-blue-400">Auto-updates not available</p>
<p class="text-muted-foreground">
Hawser agents must be updated on their remote host.
</p>
<a
href="https://github.com/Finsys/hawser"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline flex items-center gap-1"
>
<ExternalLink class="w-3 h-3" />
View update instructions on GitHub
</a>
{/if}
</div>
</div>
</div>
{:else}
<div class="space-y-3">
<div class="flex items-center gap-3">
<Label class="text-xs font-normal">Enable automatic image updates</Label>
<TogglePill
bind:checked={enabled}
onchange={(value) => onenablechange?.(value)}
/>
</div>
{#if enabled}
<CronEditor
value={cronExpression}
onchange={(cron) => {
cronExpression = cron;
oncronchange?.(cron);
}}
/>
{#if envHasScanning}
<div class="space-y-1.5">
<Label class="text-xs font-medium">Vulnerability criteria</Label>
<VulnerabilityCriteriaSelector
bind:value={vulnerabilityCriteria}
onchange={(v) => oncriteriachange?.(v)}
/>
<p class="text-xs text-muted-foreground">
Block auto-updates if new image has vulnerabilities matching this criteria
</p>
</div>
{/if}
{/if}
{/if}
</div>
</div>
{/if}
@@ -978,7 +978,7 @@
<Tabs.Content value="env" class="space-y-4 overflow-auto">
{#if containerData.Config?.Env && containerData.Config.Env.length > 0}
<div class="space-y-1">
{#each containerData.Config.Env as envVar}
{#each [...containerData.Config.Env].sort((a, b) => a.split('=')[0].localeCompare(b.split('=')[0])) as envVar}
{@const [key, ...valueParts] = envVar.split('=')}
{@const value = valueParts.join('=')}
<div class="text-xs p-2 bg-muted rounded">
@@ -1214,10 +1214,10 @@
</Tabs.Content>
<!-- Health Tab -->
<Tabs.Content value="health" class="space-y-4 overflow-auto">
<Tabs.Content value="health" class="flex flex-col overflow-hidden">
{#if containerData.State?.Health}
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="flex flex-col flex-1 min-h-0 gap-3">
<div class="grid grid-cols-2 gap-3 text-sm shrink-0">
<div>
<p class="text-muted-foreground">Status</p>
<Badge variant={containerData.State.Health.Status === 'healthy' ? 'default' : 'destructive'}>
@@ -1231,9 +1231,9 @@
</div>
{#if containerData.State.Health.Log && containerData.State.Health.Log.length > 0}
<div class="space-y-2">
<h3 class="text-sm font-semibold">Health check log</h3>
<div class="space-y-1 max-h-64 overflow-y-auto">
<div class="flex flex-col flex-1 min-h-0">
<h3 class="text-sm font-semibold mb-2 shrink-0">Health check log</h3>
<div class="space-y-1 overflow-y-auto flex-1">
{#each containerData.State.Health.Log.slice(-5) as log}
<div class="p-2 border border-border rounded text-xs space-y-1">
<div class="flex justify-between items-center">
@@ -9,6 +9,15 @@
import { Badge } from '$lib/components/ui/badge';
import AutoUpdateSettings from './AutoUpdateSettings.svelte';
import type { VulnerabilityCriteria } from '$lib/components/VulnerabilityCriteriaSelector.svelte';
import type { SystemContainerType } from '$lib/types';
// Detect system containers (must match server-side logic in update-utils.ts)
function detectSystemContainer(imageName: string): SystemContainerType | null {
const lower = imageName.toLowerCase();
if (lower.includes('fnsys/dockhand')) return 'dockhand';
if (lower.includes('finsys/hawser') || lower.includes('ghcr.io/finsys/hawser')) return 'hawser';
return null;
}
// Protocol options for ports
const protocolOptions = [
@@ -1263,6 +1272,7 @@
bind:enabled={autoUpdateEnabled}
bind:cronExpression={autoUpdateCronExpression}
bind:vulnerabilityCriteria={vulnerabilityCriteria}
systemContainer={detectSystemContainer(image)}
/>
</div>
</div>
+128 -72
View File
@@ -4,7 +4,8 @@
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Label } from '$lib/components/ui/label';
import { Terminal as TerminalIcon, X, ExternalLink, Shell, User } from 'lucide-svelte';
import { Terminal as TerminalIcon, X, ExternalLink, Shell, User, Loader2, AlertCircle } from 'lucide-svelte';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellDetectionResult } from '$lib/utils/shell-detection';
// Dynamic imports for browser-only xterm
let Terminal: any;
@@ -16,10 +17,11 @@
open: boolean;
containerId: string;
containerName: string;
envId?: number | null;
onClose: () => void;
}
let { open = $bindable(), containerId, containerName, onClose }: Props = $props();
let { open = $bindable(), containerId, containerName, envId = null, onClose }: Props = $props();
let terminalRef: HTMLDivElement;
let terminal: Terminal | null = null;
@@ -28,19 +30,14 @@
let connected = $state(false);
let error = $state<string | null>(null);
// Shell options
const shellOptions = [
{ value: '/bin/bash', label: 'Bash' },
{ value: '/bin/sh', label: 'Shell (sh)' },
{ value: '/bin/zsh', label: 'Zsh' },
{ value: '/bin/ash', label: 'Ash (Alpine)' }
];
// Shell detection state
let shellDetection = $state<ShellDetectionResult | null>(null);
let detectingShells = $state(false);
const userOptions = [
{ value: 'root', label: 'root' },
{ value: 'nobody', label: 'nobody' },
{ value: '', label: 'Container default' }
];
// Derived: check if any shell is available
const anyShellAvailable = $derived(
!shellDetection || hasAvailableShell(shellDetection)
);
let selectedShell = $state('/bin/bash');
let selectedUser = $state('root');
@@ -108,7 +105,10 @@
error = null;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/containers/${containerId}/exec?shell=${encodeURIComponent(selectedShell)}&user=${encodeURIComponent(selectedUser)}`;
let wsUrl = `${protocol}//${window.location.host}/api/containers/${containerId}/exec?shell=${encodeURIComponent(selectedShell)}&user=${encodeURIComponent(selectedUser)}`;
if (envId) {
wsUrl += `&envId=${envId}`;
}
terminal.writeln(`\x1b[90mConnecting to ${containerName}...\x1b[0m`);
terminal.writeln(`\x1b[90mShell: ${selectedShell}, User: ${selectedUser || 'default'}\x1b[0m`);
@@ -162,7 +162,7 @@
}
function startSession() {
if (!xtermLoaded) return;
if (!xtermLoaded || !anyShellAvailable) return;
showConfig = false;
// Wait for DOM update then init terminal
setTimeout(() => {
@@ -176,7 +176,7 @@
user: selectedUser,
name: containerName
});
const url = `/terminal/${containerId}?${params.toString()}`;
const url = `/terminal?container=${containerId}`;
window.open(url, `terminal_${containerId}`, 'width=900,height=600,resizable=yes,scrollbars=no');
handleClose();
}
@@ -212,6 +212,27 @@
}
}
// Detect shells when dialog opens
async function detectContainerShells() {
if (!containerId) return;
detectingShells = true;
shellDetection = null;
try {
shellDetection = await detectShells(containerId, envId);
// Auto-select best available shell if current is not available
const bestShell = getBestShell(shellDetection, selectedShell);
if (bestShell && bestShell !== selectedShell) {
selectedShell = bestShell;
}
} catch (error) {
console.error('Failed to detect shells:', error);
} finally {
detectingShells = false;
}
}
onMount(async () => {
window.addEventListener('resize', handleResize);
@@ -235,10 +256,13 @@
cleanup();
});
// Reset when dialog closes
// Detect shells when dialog opens, reset when it closes
$effect(() => {
if (!open) {
if (open) {
detectContainerShells();
} else {
cleanup();
shellDetection = null;
}
});
</script>
@@ -268,63 +292,95 @@
{#if showConfig}
<div class="flex-1 flex items-center justify-center p-6">
<div class="w-full max-w-md space-y-6">
{#if detectingShells}
<div class="text-center">
<TerminalIcon class="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 class="text-lg font-medium">Open terminal session</h3>
<p class="text-sm text-muted-foreground mt-1">
Configure the shell and user for this session
<Loader2 class="w-12 h-12 mx-auto mb-4 text-muted-foreground animate-spin" />
<h3 class="text-lg font-medium">Detecting available shells...</h3>
</div>
{:else if !anyShellAvailable}
<div class="text-center">
<AlertCircle class="w-12 h-12 mx-auto mb-4 text-amber-500" />
<h3 class="text-lg font-medium text-amber-500">No shell available</h3>
<p class="text-sm text-muted-foreground mt-2">
This container does not have any shell installed.
</p>
</div>
<div class="space-y-4">
<div class="space-y-2">
<Label>Shell</Label>
<Select.Root type="single" bind:value={selectedShell}>
<Select.Trigger class="w-full h-10">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{shellOptions.find(o => o.value === selectedShell)?.label || 'Select shell'}</span>
</Select.Trigger>
<Select.Content>
{#each shellOptions as option}
<Select.Item value={option.value} label={option.label}>
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>User</Label>
<Select.Root type="single" bind:value={selectedUser}>
<Select.Trigger class="w-full h-10">
<User class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{userOptions.find(o => o.value === selectedUser)?.label || 'Select user'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="flex gap-2">
<Button onclick={startSession} class="flex-1" disabled={!xtermLoaded}>
<TerminalIcon class="w-4 h-4 mr-2" />
{xtermLoaded ? 'Connect' : 'Loading...'}
</Button>
<Button onclick={openInNewWindow} variant="outline" disabled={!xtermLoaded} title="Open in new window">
<ExternalLink class="w-4 h-4" />
<p class="text-xs text-muted-foreground/70 mt-1">
Containers built from scratch or distroless images often don't include shells.
</p>
<Button onclick={handleClose} variant="outline" class="mt-6">
Close
</Button>
</div>
</div>
{:else}
<div class="w-full max-w-md space-y-6">
<div class="text-center">
<TerminalIcon class="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 class="text-lg font-medium">Open terminal session</h3>
<p class="text-sm text-muted-foreground mt-1">
Configure the shell and user for this session
</p>
</div>
<div class="space-y-4">
<div class="space-y-2">
<Label>Shell</Label>
<Select.Root type="single" bind:value={selectedShell}>
<Select.Trigger class="w-full h-10">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{shellDetection?.allShells.find(o => o.path === selectedShell)?.label || 'Select shell'}</span>
</Select.Trigger>
<Select.Content>
{#if shellDetection}
{#each shellDetection.allShells as option}
<Select.Item
value={option.path}
label={option.label}
disabled={!option.available}
>
<Shell class="w-4 h-4 mr-2 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
{option.label}
{#if !option.available}
<span class="text-xs ml-1">(unavailable)</span>
{/if}
</span>
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-2">
<Label>User</Label>
<Select.Root type="single" bind:value={selectedUser}>
<Select.Trigger class="w-full h-10">
<User class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{USER_OPTIONS.find(o => o.value === selectedUser)?.label || 'Select user'}</span>
</Select.Trigger>
<Select.Content>
{#each USER_OPTIONS as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div class="flex gap-2">
<Button onclick={startSession} class="flex-1" disabled={!xtermLoaded || !anyShellAvailable}>
<TerminalIcon class="w-4 h-4 mr-2" />
{xtermLoaded ? 'Connect' : 'Loading...'}
</Button>
<Button onclick={openInNewWindow} variant="outline" disabled={!xtermLoaded} title="Open in new window">
<ExternalLink class="w-4 h-4" />
</Button>
</div>
</div>
{/if}
</div>
{:else}
<div class="flex-1 bg-[#0c0c0c] p-2 overflow-hidden">
@@ -5,6 +5,7 @@
Pause,
RefreshCw,
AlertTriangle,
ArrowUpCircle,
Loader2
} from 'lucide-svelte';
@@ -14,6 +15,7 @@
paused: number;
restarting: number;
unhealthy: number;
pendingUpdates: number;
total: number;
}
@@ -54,10 +56,14 @@
<AlertTriangle class="w-3 h-3 text-muted-foreground/50" />
<div class="skeleton w-3 h-3 rounded"></div>
</div>
<div class="flex items-center gap-0.5">
<ArrowUpCircle class="w-3 h-3 text-muted-foreground/50" />
<div class="skeleton w-3 h-3 rounded"></div>
</div>
</div>
{:else if showSkeleton}
<!-- Full skeleton grid view -->
<div class="grid grid-cols-6 gap-1 min-h-5">
<div class="grid grid-cols-7 gap-1 min-h-5">
<div class="flex items-center gap-1">
<Play class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
@@ -78,6 +84,10 @@
<AlertTriangle class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
</div>
<div class="flex items-center gap-1">
<ArrowUpCircle class="w-3.5 h-3.5 text-muted-foreground/50" />
<div class="skeleton w-4 h-4 rounded"></div>
</div>
<div class="flex items-center gap-1">
<span class="text-xs text-muted-foreground/50">Total</span>
<div class="skeleton w-4 h-4 rounded"></div>
@@ -106,10 +116,14 @@
<AlertTriangle class="w-3 h-3 {containers.unhealthy > 0 ? 'text-red-500' : 'text-emerald-500'}" />
<span class="text-2xs font-medium">{containers.unhealthy}</span>
</div>
<div class="flex items-center gap-0.5 {containers.pendingUpdates > 0 ? 'pending-glow' : ''}" title="Pending updates">
<ArrowUpCircle class="w-3 h-3 {containers.pendingUpdates > 0 ? 'text-amber-400' : 'text-muted-foreground'}" />
<span class="text-2xs font-medium {containers.pendingUpdates > 0 ? 'text-amber-400' : ''}">{containers.pendingUpdates}</span>
</div>
</div>
{:else}
<!-- Full grid view -->
<div class="grid grid-cols-6 gap-1 min-h-5">
<div class="grid grid-cols-7 gap-1 min-h-5">
<div class="flex items-center gap-1" title="Running containers">
<Play class="w-3.5 h-3.5 text-emerald-500" />
<span class="text-sm font-medium">{containers.running}</span>
@@ -130,6 +144,10 @@
<AlertTriangle class="w-3.5 h-3.5 {containers.unhealthy > 0 ? 'text-red-500' : 'text-emerald-500'}" />
<span class="text-sm font-medium">{containers.unhealthy}</span>
</div>
<div class="flex items-center gap-1 {containers.pendingUpdates > 0 ? 'pending-glow' : ''}" title="Pending updates">
<ArrowUpCircle class="w-3.5 h-3.5 {containers.pendingUpdates > 0 ? 'text-amber-400' : 'text-muted-foreground'}" />
<span class="text-sm font-medium {containers.pendingUpdates > 0 ? 'text-amber-400' : ''}">{containers.pendingUpdates}</span>
</div>
<div class="flex items-center gap-1" title="Total containers">
<span class="text-xs text-muted-foreground">Total</span>
<span class="text-sm font-medium">{containers.total}</span>
@@ -147,4 +165,15 @@
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@keyframes pending-pulse {
0%, 100% {
filter: drop-shadow(0 0 2px rgba(251, 191, 36, 0.4));
}
50% {
filter: drop-shadow(0 0 3px rgba(251, 191, 36, 0.6)) drop-shadow(0 0 5px rgba(251, 191, 36, 0.3));
}
}
:global(.pending-glow) {
animation: pending-pulse 2s ease-in-out infinite;
}
</style>
+1 -1
View File
@@ -245,7 +245,7 @@
</script>
<div class="space-y-4">
<div class="flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Globe} title="Environments">
<Badge variant="secondary" class="text-xs">{environments.length} total</Badge>
</PageHeader>
+38 -15
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Images - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -66,6 +70,10 @@
let loading = $state(true);
let envId = $state<number | null>(null);
// Polling interval - module scope for cleanup in onDestroy
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeDockerEvent: (() => void) | null = null;
// Registry state
let registries = $state<Registry[]>([]);
@@ -604,22 +612,33 @@
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('resume', handleVisibilityChange);
const unsubscribe = onDockerEvent((event) => {
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isImageListChange(event)) {
fetchImages();
}
});
const interval = setInterval(() => {
refreshInterval = setInterval(() => {
if (envId) fetchImages();
}, 30000);
return () => {
clearInterval(interval);
unsubscribe();
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
// Cleanup on component destroy
onDestroy(() => {
// Clear polling interval
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
// Unsubscribe from Docker events
if (unsubscribeDockerEvent) {
unsubscribeDockerEvent();
unsubscribeDockerEvent = null;
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
pendingTimeouts.forEach(id => clearTimeout(id));
@@ -628,7 +647,7 @@
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader
icon={Images}
title="Images"
@@ -655,6 +674,7 @@
position="left"
onConfirm={pruneImages}
onOpenChange={(open) => confirmPrune = open}
unstyled
>
{#snippet children({ open })}
<span
@@ -682,6 +702,7 @@
position="left"
onConfirm={pruneUnusedImages}
onOpenChange={(open) => confirmPruneUnused = open}
unstyled
>
{#snippet children({ open })}
<span
@@ -706,13 +727,14 @@
</div>
</div>
<!-- Selection bar -->
{#if selectedImages.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
{#if selectedImages.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
>
Clear
@@ -720,7 +742,7 @@
{#if $canAccess('images', 'remove')}
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all disabled:opacity-50 cursor-pointer"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-destructive hover:border-destructive/40 hover:shadow transition-all disabled:opacity-50 cursor-pointer"
onclick={bulkRemove}
disabled={selectedInFilter.length === 0}
>
@@ -728,8 +750,9 @@
Delete
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
@@ -1027,7 +1050,7 @@
itemType="image"
itemName={tagInfo.fullRef}
title="Remove"
onConfirm={() => removeImage(tagInfo.fullRef, tagInfo.fullRef)}
onConfirm={() => removeImage(tagInfo.imageId, tagInfo.fullRef)}
onOpenChange={(open) => confirmDeleteId = open ? tagInfo.fullRef : null}
>
{#snippet children({ open })}
+13 -2
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Logs - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
@@ -227,6 +231,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
let envId = $state<number | null>(null);
let isInitialLoad = $state(true);
// Polling interval - module scope for cleanup in onDestroy
let containerInterval: ReturnType<typeof setInterval> | null = null;
// Searchable dropdown state
let searchQuery = $state('');
let dropdownOpen = $state(false);
@@ -1399,11 +1406,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
onMount(() => {
// All initialization is handled in currentEnvironment.subscribe
// This just sets up the refresh interval
const containerInterval = setInterval(fetchContainers, 10000);
return () => clearInterval(containerInterval);
containerInterval = setInterval(fetchContainers, 10000);
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
onDestroy(() => {
if (containerInterval) {
clearInterval(containerInterval);
containerInterval = null;
}
stopStreaming();
});
</script>
+36 -14
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Networks - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -28,6 +32,10 @@
let loading = $state(true);
let envId = $state<number | null>(null);
// Polling interval - module scope for cleanup in onDestroy
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeDockerEvent: (() => void) | null = null;
// Search and sort state - with debounce
let searchInput = $state('');
let searchQuery = $state('');
@@ -452,22 +460,33 @@
document.addEventListener('resume', handleVisibilityChange);
// Subscribe to network events (SSE connection is global in layout)
const unsubscribe = onDockerEvent((event) => {
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isNetworkListChange(event)) {
fetchNetworks();
}
});
const interval = setInterval(() => {
refreshInterval = setInterval(() => {
if (envId) fetchNetworks();
}, 30000);
return () => {
clearInterval(interval);
unsubscribe();
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
// Cleanup on component destroy
onDestroy(() => {
// Clear polling interval
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
// Unsubscribe from Docker events
if (unsubscribeDockerEvent) {
unsubscribeDockerEvent();
unsubscribeDockerEvent = null;
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
pendingTimeouts.forEach(id => clearTimeout(id));
@@ -476,7 +495,7 @@
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Network} title="Networks" count={networks.length} />
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
@@ -512,6 +531,7 @@
position="left"
onConfirm={pruneNetworks}
onOpenChange={(open) => confirmPrune = open}
unstyled
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm bg-background shadow-xs border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 {pruneStatus === 'pruning' ? 'opacity-50 pointer-events-none' : ''}">
@@ -542,13 +562,14 @@
</div>
</div>
<!-- Selection bar -->
{#if selectedNetworks.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
{#if selectedNetworks.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
>
Clear
@@ -564,15 +585,16 @@
onOpenChange={(open) => confirmBulkRemove = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<Trash2 class="w-3 h-3" />
Delete
</span>
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
+113 -18
View File
@@ -57,13 +57,26 @@
let selectedRegistryId = $state<number | null>(null);
let searchTerm = $state('');
let browseFilter = $state('');
let results = $state<SearchResult[]>([]);
let loading = $state(false);
let browsing = $state(false);
let loadingMore = $state(false);
let searched = $state(false);
let browseMode = $state(false);
let errorMessage = $state('');
// Pagination state for browse mode
let hasMoreResults = $state(false);
let nextPageCursor = $state<string | null>(null);
// Filtered results for browse mode
let filteredResults = $derived(
browseMode && browseFilter.trim()
? results.filter(r => r.name.toLowerCase().includes(browseFilter.toLowerCase()))
: results
);
// Copy to registry modal state
let showCopyModal = $state(false);
let copyImageName = $state('');
@@ -174,28 +187,60 @@
}
}
async function browse() {
async function browse(loadMore = false) {
if (!selectedRegistryId) return;
browsing = true;
searched = true;
browseMode = true;
if (loadMore) {
loadingMore = true;
} else {
browsing = true;
searched = true;
browseMode = true;
results = [];
hasMoreResults = false;
nextPageCursor = null;
}
errorMessage = '';
try {
const response = await fetch(`/api/registry/catalog?registry=${selectedRegistryId}`);
let url = `/api/registry/catalog?registry=${selectedRegistryId}`;
if (loadMore && nextPageCursor) {
url += `&last=${encodeURIComponent(nextPageCursor)}`;
}
const response = await fetch(url);
if (response.ok) {
results = await response.json();
const data = await response.json();
// Handle both old array format and new paginated format
if (Array.isArray(data)) {
// Old format (backwards compat)
results = loadMore ? [...results, ...data] : data;
hasMoreResults = false;
nextPageCursor = null;
} else {
// New paginated format
const newResults = data.repositories || [];
results = loadMore ? [...results, ...newResults] : newResults;
hasMoreResults = data.pagination?.hasMore || false;
nextPageCursor = data.pagination?.nextLast || null;
}
} else {
const data = await response.json();
errorMessage = data.error || 'Failed to browse registry';
results = [];
if (!loadMore) {
results = [];
}
}
} catch (error) {
console.error('Failed to browse registry:', error);
errorMessage = 'Failed to browse registry';
results = [];
if (!loadMore) {
results = [];
}
} finally {
browsing = false;
loadingMore = false;
}
}
@@ -221,8 +266,11 @@
results = [];
searched = false;
browseMode = false;
browseFilter = '';
errorMessage = '';
expandedImages = {};
hasMoreResults = false;
nextPageCursor = null;
}
async function toggleImageExpansion(imageName: string) {
@@ -433,7 +481,7 @@
</script>
<div class="h-full flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Download} title="Registry" showConnection={false} />
{#if $canAccess('registries', 'edit')}
<a href="/settings?tab=registries" class="inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground h-8 rounded-md px-3 text-xs">
@@ -446,14 +494,17 @@
<!-- Registry Selector + Search Bar -->
<div class="shrink-0 flex gap-2">
<Select.Root type="single" value={selectedRegistryId ? String(selectedRegistryId) : undefined} onValueChange={(v) => { selectedRegistryId = Number(v); handleRegistryChange(); }}>
<Select.Trigger class="h-9 w-48">
<Select.Trigger class="h-9 min-w-48 max-w-64 shrink-0">
{@const selected = registries.find(r => r.id === selectedRegistryId)}
{#if selected && isDockerHub(selected)}
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground" />
<Icon iconNode={whale} class="w-4 h-4 mr-2 text-muted-foreground shrink-0" />
{:else}
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
<Server class="w-4 h-4 mr-2 text-muted-foreground shrink-0" />
{/if}
<span class="truncate">{selected ? selected.name : 'Select registry'}</span>
{#if selected?.hasCredentials}
<Badge variant="outline" class="ml-1.5 text-xs shrink-0">auth</Badge>
{/if}
<span>{selected ? `${selected.name}${selected.hasCredentials ? ' (auth)' : ''}` : 'Select registry'}</span>
</Select.Trigger>
<Select.Content>
{#each registries as registry}
@@ -490,7 +541,7 @@
Search
</Button>
{#if supportsBrowsing()}
<Button variant="outline" onclick={browse} disabled={loading || browsing}>
<Button variant="outline" onclick={() => browse()} disabled={loading || browsing}>
{#if browsing}
<RefreshCw class="w-4 h-4 mr-1 animate-spin" />
{:else}
@@ -507,10 +558,36 @@
{:else if errorMessage}
<p class="text-red-600 dark:text-red-400 text-sm">{errorMessage}</p>
{:else if searched && results.length === 0}
<p class="text-muted-foreground text-sm">
{browseMode ? 'No images found in this registry' : `No images found for "${searchTerm}"`}
</p>
<div class="text-sm">
<p class="text-muted-foreground">
{browseMode ? 'No images found in this registry' : `No images found for "${searchTerm}"`}
</p>
{#if !browseMode && supportsBrowsing()}
<p class="text-muted-foreground mt-2">
Tip: Large registries don't support search. Try <button class="text-primary underline" onclick={() => browse()}>Browse</button> and use the filter to find images.
</p>
{/if}
</div>
{:else if results.length > 0}
<!-- Browse mode filter -->
{#if browseMode}
<div class="shrink-0 flex items-center gap-2 text-sm">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Filter results..."
bind:value={browseFilter}
class="h-8 pl-8 text-xs"
/>
</div>
<span class="text-muted-foreground text-xs">
{filteredResults.length === results.length
? `${results.length} images`
: `${filteredResults.length} of ${results.length} images`}
</span>
</div>
{/if}
<div
bind:this={scrollContainer}
class="flex-1 min-h-0 rounded-lg overflow-auto"
@@ -527,7 +604,7 @@
</tr>
</thead>
<tbody>
{#each results as result (result.name)}
{#each filteredResults as result (result.name)}
{@const isExpanded = !!expandedImages[result.name]}
{@const expandState = expandedImages[result.name]}
<!-- Main row -->
@@ -684,6 +761,24 @@
</tbody>
</table>
</div>
<!-- Load More button for pagination (outside scroll container so always visible) -->
{#if browseMode && hasMoreResults}
<div class="shrink-0 flex justify-center py-3 border-t border-muted">
<Button
variant="outline"
size="sm"
onclick={() => browse(true)}
disabled={loadingMore}
>
{#if loadingMore}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Loading...
{:else}
Load more images
{/if}
</Button>
</div>
{/if}
{:else}
<div class="text-center py-12 text-muted-foreground">
<Download class="w-12 h-12 mx-auto mb-4 opacity-50" />
+1 -1
View File
@@ -894,7 +894,7 @@
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Header with filters -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Timer} title="Schedules" count={filteredSchedules.length} />
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
+5 -1
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Settings - Dockhand</title>
</svelte:head>
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -37,7 +41,7 @@
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Settings} title="Settings" showConnection={false} />
</div>
@@ -1002,7 +1002,7 @@
}
// Refresh scanner status after pull
await checkScannerImages();
await loadScannerVersionsAsync(environment?.id);
grypeUpdateStatus = 'up-to-date';
setTimeout(() => { grypeUpdateStatus = 'idle'; }, 3000);
} catch (error) {
@@ -1043,7 +1043,7 @@
}
// Refresh scanner status after pull
await checkScannerImages();
await loadScannerVersionsAsync(environment?.id);
trivyUpdateStatus = 'up-to-date';
setTimeout(() => { trivyUpdateStatus = 'idle'; }, 3000);
} catch (error) {
@@ -555,7 +555,7 @@
<div class="text-center py-8 text-muted-foreground">
<FolderOpen class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">No Docker Compose files found in the configured paths.</p>
<p class="text-xs mt-1">Make sure your paths contain docker-compose.yml, compose.yml, or similar files.</p>
<p class="text-xs mt-1">Make sure your paths contain compose.yaml, compose.yml, or similar files.</p>
</div>
{/if}
+45 -21
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Stacks - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
@@ -52,6 +56,11 @@
// Derived: current environment details for reactive port URL generation
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
// Polling intervals - module scope for cleanup in onDestroy
let stacksInterval: ReturnType<typeof setInterval> | null = null;
let statsInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeDockerEvent: (() => void) | null = null;
// Helper: extract host from URL (e.g., tcp://192.168.1.4:2376 -> 192.168.1.4)
function extractHostFromUrl(urlString: string): string | null {
if (!urlString) return null;
@@ -1037,38 +1046,51 @@
document.addEventListener('resume', handleVisibilityChange);
// Subscribe to container events (stacks are identified by container labels)
const unsubscribe = onDockerEvent((event) => {
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isContainerListChange(event)) {
fetchStacks();
fetchStats();
}
});
// Refresh stacks every 30 seconds
const stacksInterval = setInterval(() => {
// Refresh stacks every 30 seconds (use module-scope vars for cleanup)
stacksInterval = setInterval(() => {
if (envId) fetchStacks();
}, 30000);
// Refresh stats every 5 seconds (faster for resource monitoring)
const statsInterval = setInterval(() => {
statsInterval = setInterval(() => {
if (envId) fetchStats();
}, 5000);
return () => {
clearInterval(stacksInterval);
clearInterval(statsInterval);
unsubscribe();
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
// Cleanup on component destroy
onDestroy(() => {
// Clear polling intervals
if (stacksInterval) {
clearInterval(stacksInterval);
stacksInterval = null;
}
if (statsInterval) {
clearInterval(statsInterval);
statsInterval = null;
}
// Unsubscribe from Docker events
if (unsubscribeDockerEvent) {
unsubscribeDockerEvent();
unsubscribeDockerEvent = null;
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
});
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={Layers} title="Compose stacks" count={stacks.length}>
{#if stacks.length > 0}
<button
@@ -1127,13 +1149,14 @@
</div>
</div>
<!-- Selection bar -->
{#if selectedStacks.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
{#if selectedStacks.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
>
Clear
@@ -1151,7 +1174,7 @@
onOpenChange={(open) => confirmBulkStart = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-green-600 hover:border-green-500/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-green-600 hover:border-green-500/40 hover:shadow transition-all cursor-pointer">
<Play class="w-3 h-3" />
Start
</span>
@@ -1171,7 +1194,7 @@
onOpenChange={(open) => confirmBulkRestart = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-amber-600 hover:border-amber-500/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-amber-600 hover:border-amber-500/40 hover:shadow transition-all cursor-pointer">
<RotateCcw class="w-3 h-3" />
Restart
</span>
@@ -1190,7 +1213,7 @@
onOpenChange={(open) => confirmBulkStop = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-red-600 hover:border-red-500/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-red-600 hover:border-red-500/40 hover:shadow transition-all cursor-pointer">
<Square class="w-3 h-3" />
Stop
</span>
@@ -1209,7 +1232,7 @@
onOpenChange={(open) => confirmBulkDown = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-orange-600 hover:border-orange-500/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-orange-600 hover:border-orange-500/40 hover:shadow transition-all cursor-pointer">
<ArrowBigDown class="w-3 h-3" />
Down
</span>
@@ -1228,15 +1251,16 @@
onOpenChange={(open) => confirmBulkRemove = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<Trash2 class="w-3 h-3" />
Remove
</span>
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />
+4 -4
View File
@@ -79,7 +79,7 @@
// Form state - stack deployment config
let formStackName = $state('');
let formStackNameUserModified = $state(false);
let formComposePath = $state('docker-compose.yml');
let formComposePath = $state('compose.yaml');
let formAutoUpdate = $state(false);
let formAutoUpdateCron = $state('0 3 * * *');
let formWebhookEnabled = $state(false);
@@ -290,7 +290,7 @@
formNewRepoCredentialId = null;
formStackName = '';
formStackNameUserModified = false;
formComposePath = 'docker-compose.yml';
formComposePath = 'compose.yaml';
formEnvFilePath = null;
formAutoUpdate = false;
formAutoUpdateCron = '0 3 * * *';
@@ -344,7 +344,7 @@
try {
let body: any = {
stackName: formStackName,
composePath: formComposePath || 'docker-compose.yml',
composePath: formComposePath || 'compose.yaml',
envFilePath: formEnvFilePath,
environmentId: environmentId,
autoUpdate: formAutoUpdate,
@@ -661,7 +661,7 @@
<div class="space-y-2">
<Label for="compose-path">Compose file path</Label>
<Input id="compose-path" bind:value={formComposePath} placeholder="docker-compose.yml" />
<Input id="compose-path" bind:value={formComposePath} placeholder="compose.yaml" />
<p class="text-xs text-muted-foreground">Path to the compose file within the repository</p>
</div>
+47 -6
View File
@@ -65,6 +65,9 @@
// Error dialog state
let operationError = $state<{ title: string; message: string; details?: string } | null>(null);
// Stack exists warning dialog state
let showExistsWarning = $state(false);
// ─── Path State (Simplified) ─────────────────────────────────────────────────
// Working paths: what we're currently editing (always strings, never null)
@@ -197,7 +200,7 @@
// Get the current compose filename
const currentComposePath = workingComposePath;
const composeFilename = currentComposePath ? currentComposePath.split('/').pop() : 'docker-compose.yml';
const composeFilename = currentComposePath ? currentComposePath.split('/').pop() : 'compose.yaml';
// Build new paths: create a subfolder with the stack name inside selected directory
const displayName = mode === 'edit' ? stackName : newStackName;
@@ -371,7 +374,7 @@
const stackName = newStackName.trim();
if (stackName) {
// If we have a stack name, include the subfolder
finalPath = `${baseDir}/${stackName}/docker-compose.yml`;
finalPath = `${baseDir}/${stackName}/compose.yaml`;
} else {
// No stack name yet - just show the selected directory
finalPath = `${baseDir}/`;
@@ -811,6 +814,26 @@ services:
if (hasErrors) return;
const envId = $currentEnvironment?.id ?? null;
// Check if stack already exists
try {
const stacksResponse = await fetch(appendEnvParam('/api/stacks', envId));
if (stacksResponse.ok) {
const stacks = await stacksResponse.json();
const existingStack = stacks.find((s: { name: string }) =>
s.name.toLowerCase() === newStackName.trim().toLowerCase()
);
if (existingStack) {
showExistsWarning = true;
return;
}
}
} catch (e) {
console.warn('Failed to check for existing stacks:', e);
// Continue with creation if check fails
}
saving = true;
error = null;
@@ -818,8 +841,6 @@ services:
const prepared = envVarsPanelRef?.prepareForSave() || { rawContent: '', variables: [] };
try {
const envId = $currentEnvironment?.id ?? null;
// Build request body
const requestBody: Record<string, unknown> = {
name: newStackName.trim(),
@@ -1365,7 +1386,7 @@ services:
<PathBarItem
label="Compose file"
path={workingComposePath || null}
placeholder="/path/to/docker-compose.yml"
placeholder="/path/to/compose.yaml"
copied={composePathCopied}
onCopy={() => copyToClipboard(workingComposePath, (v) => composePathCopied = v)}
onBrowse={openComposeBrowser}
@@ -1457,7 +1478,7 @@ services:
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
onchange={() => { markDirty(); debouncedValidate(); }}
theme={editorTheme}
infoText="These variables will be written to a .env file in the stack directory."
infoText="These variables will be written to a .env file in the stack directory and passed to the compose command."
/>
</div>
</div>
@@ -1671,6 +1692,26 @@ services:
</Dialog.Content>
</Dialog.Root>
<!-- Stack already exists warning dialog -->
<Dialog.Root bind:open={showExistsWarning}>
<Dialog.Content class="max-w-sm">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<TriangleAlert class="w-5 h-5 text-amber-500" />
Stack already exists
</Dialog.Title>
<Dialog.Description>
A stack named "{newStackName}" already exists. Please choose a different name.
</Dialog.Description>
</Dialog.Header>
<div class="flex justify-end mt-4">
<Button size="sm" onclick={() => showExistsWarning = false}>
OK
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
<!-- Error dialog for failed operations -->
{#if operationError}
{@const errorDialogOpen = true}
+157 -50
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Terminal - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
@@ -5,12 +9,13 @@
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User } from 'lucide-svelte';
import { Search, ChevronDown, Terminal as TerminalIcon, Unplug, RefreshCw, Trash2, Copy, Shell, User, Loader2, AlertCircle } from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import type { ContainerInfo } from '$lib/types';
import { currentEnvironment, environments, appendEnvParam } from '$lib/stores/environment';
import Terminal from './Terminal.svelte';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellInfo, type ShellDetectionResult } from '$lib/utils/shell-detection';
// Track if we've handled the initial container from URL
let initialContainerHandled = $state(false);
@@ -23,23 +28,18 @@
let terminalComponent: ReturnType<typeof Terminal> | undefined;
let connected = $state(false);
// Shell detection state
let shellDetection = $state<ShellDetectionResult | null>(null);
let detectingShells = $state(false);
// Shell/user options
let selectedShell = $state('/bin/bash');
let selectedUser = $state('root');
let terminalFontSize = $state(14);
const shellOptions = [
{ value: '/bin/bash', label: 'Bash' },
{ value: '/bin/sh', label: 'Shell (sh)' },
{ value: '/bin/zsh', label: 'Zsh' },
{ value: '/bin/ash', label: 'Ash (Alpine)' }
];
const userOptions = [
{ value: 'root', label: 'root' },
{ value: 'nobody', label: 'nobody' },
{ value: '', label: 'Container default' }
];
// Track previous shell/user for reconnection
let prevShell = $state('/bin/bash');
let prevUser = $state('root');
const fontSizeOptions = [10, 12, 14, 16, 18];
@@ -47,6 +47,20 @@
let searchQuery = $state('');
let dropdownOpen = $state(false);
// Derived: check if selected shell is available
const selectedShellAvailable = $derived(
!shellDetection || shellDetection.shells.includes(selectedShell)
);
// Derived: check if any shell is available
const anyShellAvailable = $derived(
!shellDetection || hasAvailableShell(shellDetection)
);
// Polling intervals - module scope for cleanup in onDestroy
let containerInterval: ReturnType<typeof setInterval> | null = null;
let connectedPollInterval: ReturnType<typeof setInterval> | null = null;
// Subscribe to environment changes
currentEnvironment.subscribe((env) => {
envId = env?.id ?? null;
@@ -81,7 +95,7 @@
}
}
function selectContainer(container: ContainerInfo) {
async function selectContainer(container: ContainerInfo) {
// Disconnect from previous container
if (selectedContainer && terminalComponent) {
terminalComponent.dispose();
@@ -89,6 +103,23 @@
selectedContainer = container;
searchQuery = '';
dropdownOpen = false;
// Detect available shells
detectingShells = true;
shellDetection = null;
try {
shellDetection = await detectShells(container.id, envId);
// Auto-select best available shell if current is not available
const bestShell = getBestShell(shellDetection, selectedShell);
if (bestShell && bestShell !== selectedShell) {
selectedShell = bestShell;
}
} catch (error) {
console.error('Failed to detect shells:', error);
} finally {
detectingShells = false;
}
}
function clearSelection() {
@@ -98,6 +129,7 @@
selectedContainer = null;
searchQuery = '';
connected = false;
shellDetection = null;
}
function handleInputFocus() {
@@ -119,6 +151,18 @@
}
}
// Watch for shell/user changes while connected and trigger reconnect
$effect(() => {
if (selectedContainer && connected && terminalComponent) {
if (selectedShell !== prevShell || selectedUser !== prevUser) {
// Reconnect with new shell/user
terminalComponent.reconnect();
}
}
prevShell = selectedShell;
prevUser = selectedUser;
});
// Change font size
function changeFontSize(newSize: number) {
terminalFontSize = newSize;
@@ -143,24 +187,28 @@
}
}
const containerInterval = setInterval(fetchContainers, 10000);
containerInterval = setInterval(fetchContainers, 10000);
// Poll connected state from terminal component
const connectedPollInterval = setInterval(() => {
connectedPollInterval = setInterval(() => {
if (terminalComponent) {
connected = terminalComponent.getConnected();
}
}, 500);
window.addEventListener('resize', handleResize);
return () => {
clearInterval(containerInterval);
clearInterval(connectedPollInterval);
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
onDestroy(() => {
if (containerInterval) {
clearInterval(containerInterval);
containerInterval = null;
}
if (connectedPollInterval) {
clearInterval(connectedPollInterval);
connectedPollInterval = null;
}
window.removeEventListener('resize', handleResize);
terminalComponent?.dispose();
});
@@ -225,42 +273,83 @@
</Button>
{/if}
{#if !selectedContainer}
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">Shell:</Label>
<!-- Shell selector - always visible -->
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">Shell:</Label>
{#if detectingShells}
<div class="h-9 w-36 flex items-center justify-center border rounded-md bg-muted/50">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
{:else}
<Select.Root type="single" bind:value={selectedShell}>
<Select.Trigger class="h-9 w-36">
<Select.Trigger class="h-9 w-44" disabled={!anyShellAvailable}>
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{shellOptions.find(o => o.value === selectedShell)?.label || 'Select'}</span>
<span class={!selectedShellAvailable ? 'text-muted-foreground line-through' : ''}>
{shellDetection?.allShells.find(o => o.path === selectedShell)?.label ||
(selectedShell === '/bin/bash' ? 'Bash' :
selectedShell === '/bin/sh' ? 'Shell (sh)' :
selectedShell === '/bin/zsh' ? 'Zsh' :
selectedShell === '/bin/ash' ? 'Ash (Alpine)' : 'Select')}
</span>
</Select.Trigger>
<Select.Content>
{#each shellOptions as option}
<Select.Item value={option.value} label={option.label}>
{#if shellDetection}
{#each shellDetection.allShells as option}
<Select.Item
value={option.path}
label={option.label}
disabled={!option.available}
>
<Shell class="w-4 h-4 mr-2 {option.available ? 'text-green-500' : 'text-muted-foreground/40'}" />
<span class={option.available ? 'text-foreground' : 'text-muted-foreground/60'}>
{option.label}
{#if !option.available}
<span class="text-xs ml-1">(unavailable)</span>
{/if}
</span>
</Select.Item>
{/each}
{:else}
<Select.Item value="/bin/bash" label="Bash">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
Bash
</Select.Item>
{/each}
<Select.Item value="/bin/sh" label="Shell (sh)">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
Shell (sh)
</Select.Item>
<Select.Item value="/bin/zsh" label="Zsh">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
Zsh
</Select.Item>
<Select.Item value="/bin/ash" label="Ash (Alpine)">
<Shell class="w-4 h-4 mr-2 text-muted-foreground" />
Ash (Alpine)
</Select.Item>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">User:</Label>
<Select.Root type="single" bind:value={selectedUser}>
<Select.Trigger class="h-9 w-40">
<User class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{userOptions.find(o => o.value === selectedUser)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each userOptions as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
{/if}
</div>
<!-- User selector - always visible -->
<div class="flex items-center gap-2">
<Label class="text-sm text-muted-foreground">User:</Label>
<Select.Root type="single" bind:value={selectedUser}>
<Select.Trigger class="h-9 w-48">
<User class="w-4 h-4 mr-2 text-muted-foreground" />
<span>{USER_OPTIONS.find(o => o.value === selectedUser)?.label || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each USER_OPTIONS as option}
<Select.Item value={option.value} label={option.label}>
<User class="w-4 h-4 mr-2 text-muted-foreground" />
{option.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<!-- Shell output - full height -->
@@ -272,6 +361,24 @@
<p>Select a container to open shell</p>
</div>
</div>
{:else if detectingShells}
<div class="flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<Loader2 class="w-12 h-12 mx-auto mb-3 opacity-50 animate-spin" />
<p>Detecting available shells...</p>
</div>
</div>
{:else if !anyShellAvailable}
<div class="flex items-center justify-center h-full text-muted-foreground">
<div class="text-center">
<AlertCircle class="w-12 h-12 mx-auto mb-3 opacity-50 text-amber-500" />
<p class="font-medium text-amber-500">No shell available in this container</p>
<p class="text-sm mt-2">This container may not have a shell installed.</p>
<p class="text-xs mt-1 text-muted-foreground/70">
Containers built from scratch or distroless images often don't include shells.
</p>
</div>
</div>
{:else}
<!-- Header bar inside black area -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-zinc-800 bg-zinc-900/50 shrink-0">
@@ -320,7 +427,7 @@
</div>
</div>
<div class="flex-1 min-h-0 w-full">
{#key selectedContainer.id}
{#key `${selectedContainer.id}-${selectedShell}-${selectedUser}`}
<Terminal
bind:this={terminalComponent}
containerId={selectedContainer.id}
+36 -14
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Volumes - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -31,6 +35,10 @@
let loading = $state(true);
let envId = $state<number | null>(null);
// Polling interval - module scope for cleanup in onDestroy
let refreshInterval: ReturnType<typeof setInterval> | null = null;
let unsubscribeDockerEvent: (() => void) | null = null;
// Search and sort state - with debounce
let searchInput = $state('');
let searchQuery = $state('');
@@ -369,22 +377,33 @@
document.addEventListener('resume', handleVisibilityChange);
// Subscribe to volume events (SSE connection is global in layout)
const unsubscribe = onDockerEvent((event) => {
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isVolumeListChange(event)) {
fetchVolumes();
}
});
const interval = setInterval(() => {
refreshInterval = setInterval(() => {
if (envId) fetchVolumes();
}, 30000);
return () => {
clearInterval(interval);
unsubscribe();
};
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
// Cleanup on component destroy
onDestroy(() => {
// Clear polling interval
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
// Unsubscribe from Docker events
if (unsubscribeDockerEvent) {
unsubscribeDockerEvent();
unsubscribeDockerEvent = null;
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
pendingTimeouts.forEach(id => clearTimeout(id));
@@ -393,7 +412,7 @@
</script>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<PageHeader icon={HardDrive} title="Volumes" count={volumes.length} />
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
@@ -431,6 +450,7 @@
position="left"
onConfirm={pruneVolumes}
onOpenChange={(open) => confirmPrune = open}
unstyled
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1.5 h-8 px-3 rounded-md text-sm bg-background shadow-xs border hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 {pruneStatus === 'pruning' ? 'opacity-50 pointer-events-none' : ''}">
@@ -458,13 +478,14 @@
</div>
</div>
<!-- Selection bar -->
{#if selectedVolumes.size > 0}
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<!-- Selection bar - always reserve space to prevent layout shift -->
<div class="h-4 shrink-0">
{#if selectedVolumes.size > 0}
<div class="flex items-center gap-1 text-xs text-muted-foreground h-full">
<span>{selectedInFilter.length} selected</span>
<button
type="button"
class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:border-foreground/30 hover:shadow transition-all"
class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:border-foreground/30 hover:shadow transition-all"
onclick={selectNone}
>
Clear
@@ -480,15 +501,16 @@
onOpenChange={(open) => confirmBulkRemove = open}
>
{#snippet children({ open })}
<span class="inline-flex items-center gap-1 px-2.5 py-1 rounded-full border border-border shadow-sm hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<span class="inline-flex items-center gap-1 px-1.5 py-0 rounded border border-border hover:text-destructive hover:border-destructive/40 hover:shadow transition-all cursor-pointer">
<Trash2 class="w-3 h-3" />
Delete
</span>
{/snippet}
</ConfirmPopover>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{#if !loading && ($environments.length === 0 || !$currentEnvironment)}
<NoEnvironment />