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