From 55f3101a197cd2392cbb4ed95a192ca39fb4edc2 Mon Sep 17 00:00:00 2001 From: jarek Date: Fri, 13 Mar 2026 08:22:10 +0100 Subject: [PATCH] v1.0.21 --- Dockerfile | 6 +- VERSION | 1 + collector/main.go | 11 +- docker-entrypoint-node.sh | 7 +- package.json | 4 +- server.js | 9 ++ src/app.css | 38 +++++ src/hooks.server.ts | 1 + src/lib/components/cron-editor.svelte | 13 +- src/lib/components/host-info.svelte | 18 ++- src/lib/data/changelog.json | 20 +++ src/lib/server/dns-dispatcher.ts | 91 ++++++++++++ src/lib/server/docker.ts | 80 ++++++----- src/lib/server/hawser.ts | 99 ++++++++++--- src/lib/server/scheduler/cron-utils.ts | 32 +++++ src/lib/server/scheduler/index.ts | 51 ++----- src/lib/server/scheduler/tasks/image-prune.ts | 17 ++- src/lib/server/stacks.ts | 101 +------------- src/lib/stores/environment.ts | 1 + src/lib/stores/settings.ts | 11 ++ src/routes/api/containers/[id]/+server.ts | 14 +- .../api/containers/[id]/files/+server.ts | 5 +- .../containers/[id]/files/content/+server.ts | 7 +- .../containers/[id]/files/download/+server.ts | 2 +- .../containers/[id]/files/upload/+server.ts | 2 +- .../api/containers/[id]/rename/+server.ts | 7 +- .../api/containers/[id]/restart/+server.ts | 7 +- .../api/containers/[id]/start/+server.ts | 7 +- .../api/containers/[id]/stop/+server.ts | 7 +- .../api/containers/[id]/update/+server.ts | 9 +- .../api/containers/check-updates/+server.ts | 37 +++-- .../api/dashboard/stats/stream/+server.ts | 17 +++ src/routes/api/environments/+server.ts | 8 +- src/routes/api/host/+server.ts | 2 +- src/routes/api/registry/tags/+server.ts | 7 +- src/routes/api/self-update/+server.ts | 70 +++++++--- src/routes/api/self-update/check/+server.ts | 18 ++- .../api/self-update/progress/+server.ts | 12 +- src/routes/api/settings/general/+server.ts | 14 +- src/routes/api/volumes/[name]/+server.ts | 16 ++- src/routes/containers/+page.svelte | 58 +++++++- src/routes/containers/BatchUpdateModal.svelte | 7 + .../containers/EditContainerModal.svelte | 6 +- src/routes/logs/+page.svelte | 125 +---------------- src/routes/logs/LogViewer.svelte | 31 ++--- src/routes/logs/LogsPanel.svelte | 130 +----------------- .../environments/EnvironmentModal.svelte | 16 ++- .../environments/EnvironmentsTab.svelte | 40 +++--- src/routes/settings/general/GeneralTab.svelte | 15 ++ src/routes/stacks/+page.svelte | 2 +- vite.config.ts | 53 ++++--- 51 files changed, 763 insertions(+), 599 deletions(-) create mode 100644 VERSION create mode 100644 src/lib/server/dns-dispatcher.ts create mode 100644 src/lib/server/scheduler/cron-utils.ts diff --git a/Dockerfile b/Dockerfile index abb4f82..3cec145 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,7 +37,7 @@ RUN APKO_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") " - busybox" \ " - tzdata" \ " - docker-cli" \ - " - docker-compose" \ + " - docker-compose=5.0.2-r1" \ " - docker-cli-buildx" \ " - sqlite" \ " - postgresql-client" \ @@ -162,7 +162,7 @@ RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:3000/ || exit 1 + CMD curl -f http://localhost:${PORT:-3000}/ || exit 1 ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] -CMD ["node", "/app/server.js"] +CMD [] diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..4b296b2 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +v1.0.21 diff --git a/collector/main.go b/collector/main.go index 643ab38..4482cc2 100644 --- a/collector/main.go +++ b/collector/main.go @@ -274,7 +274,11 @@ func buildTLSConfig(cfg *EnvConfig) (*tls.Config, error) { } if cfg.CA != "" { - pool := x509.NewCertPool() + // Start from system cert pool so intermediate CAs can chain to system roots + pool, err := x509.SystemCertPool() + if err != nil { + pool = x509.NewCertPool() + } if !pool.AppendCertsFromPEM([]byte(cfg.CA)) { return nil, fmt.Errorf("failed to parse CA certificate") } @@ -928,6 +932,11 @@ func main() { } } + // stdin closed — parent process exited or pipe broke. Shut down cleanly + // so Node.js can restart us if needed. + if err := scanner.Err(); err != nil { + fmt.Fprintf(os.Stderr, "[collector] stdin read error: %v\n", err) + } fmt.Fprintf(os.Stderr, "[collector] stdin closed, exiting\n") mgr.shutdown() } diff --git a/docker-entrypoint-node.sh b/docker-entrypoint-node.sh index 1fac2c6..16e65ca 100644 --- a/docker-entrypoint-node.sh +++ b/docker-entrypoint-node.sh @@ -6,11 +6,14 @@ set -e PUID=${PUID:-1001} PGID=${PGID:-1001} +# Increase body size limit for container file uploads (default 512KB is too small) +export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G} + # Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true) if [ "$MEMORY_MONITOR" = "true" ]; then - DEFAULT_CMD="node --expose-gc /app/server.js" + DEFAULT_CMD="node --use-openssl-ca --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js" else - DEFAULT_CMD="node /app/server.js" + DEFAULT_CMD="node --use-openssl-ca --dns-result-order=ipv4first --no-network-family-autoselection /app/server.js" fi # === Detect if running as root === diff --git a/package.json b/package.json index c004ea8..bc2a7b7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.20", + "version": "1.0.21", "type": "module", "scripts": { "dev": "npx vite dev", @@ -71,6 +71,7 @@ "@codemirror/view": "6.39.11", "@lezer/highlight": "1.2.3", "@lucide/lab": "^0.1.2", + "ansi_up": "6.0.6", "argon2": "^0.41.1", "better-sqlite3": "^11.7.0", "codemirror": "6.0.2", @@ -86,6 +87,7 @@ "qrcode": "^1.5.4", "svelte-dnd-action": "0.9.69", "svelte-sonner": "1.0.7", + "undici": "7.22.0", "ws": "^8.18.0" }, "devDependencies": { diff --git a/server.js b/server.js index 8df9fd4..c76cd67 100644 --- a/server.js +++ b/server.js @@ -16,6 +16,15 @@ import { randomUUID } from 'node:crypto'; import { WebSocketServer } from 'ws'; import { handler } from './build/handler.js'; +// Patch console to prepend ISO timestamps +const _log = console.log; +const _error = console.error; +const _warn = console.warn; +const ts = () => new Date().toISOString(); +console.log = (...args) => _log(ts(), ...args); +console.error = (...args) => _error(ts(), ...args); +console.warn = (...args) => _warn(ts(), ...args); + const PORT = parseInt(process.env.PORT || '3000', 10); const HOST = process.env.HOST || '0.0.0.0'; diff --git a/src/app.css b/src/app.css index bb9cf72..b4ca927 100644 --- a/src/app.css +++ b/src/app.css @@ -1715,3 +1715,41 @@ html { } + +/* ansi_up color classes (use_classes = true) — shared by all log viewers */ +.ansi-black-fg { color: #3f3f46; } +.ansi-red-fg { color: #ef4444; } +.ansi-green-fg { color: #22c55e; } +.ansi-yellow-fg { color: #eab308; } +.ansi-blue-fg { color: #3b82f6; } +.ansi-magenta-fg { color: #d946ef; } +.ansi-cyan-fg { color: #06b6d4; } +.ansi-white-fg { color: #e4e4e7; } +.ansi-bright-black-fg { color: #71717a; } +.ansi-bright-red-fg { color: #f87171; } +.ansi-bright-green-fg { color: #4ade80; } +.ansi-bright-yellow-fg { color: #facc15; } +.ansi-bright-blue-fg { color: #60a5fa; } +.ansi-bright-magenta-fg { color: #e879f9; } +.ansi-bright-cyan-fg { color: #22d3ee; } +.ansi-bright-white-fg { color: #fafafa; } +.ansi-black-bg { background-color: #18181b; } +.ansi-red-bg { background-color: #dc2626; } +.ansi-green-bg { background-color: #16a34a; } +.ansi-yellow-bg { background-color: #ca8a04; } +.ansi-blue-bg { background-color: #2563eb; } +.ansi-magenta-bg { background-color: #c026d3; } +.ansi-cyan-bg { background-color: #0891b2; } +.ansi-white-bg { background-color: #d4d4d8; } +.ansi-bright-black-bg { background-color: #52525b; } +.ansi-bright-red-bg { background-color: #ef4444; } +.ansi-bright-green-bg { background-color: #22c55e; } +.ansi-bright-yellow-bg { background-color: #eab308; } +.ansi-bright-blue-bg { background-color: #3b82f6; } +.ansi-bright-magenta-bg { background-color: #d946ef; } +.ansi-bright-cyan-bg { background-color: #06b6d4; } +.ansi-bright-white-bg { background-color: #fafafa; } +.ansi-bold { font-weight: bold; } +.ansi-dim { opacity: 0.7; } +.ansi-italic { font-style: italic; } +.ansi-underline { text-decoration: underline; } diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 1d0ff2b..bb697b4 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,4 +1,5 @@ // v1.0.12 +import '$lib/server/dns-dispatcher.js'; import { initDatabase, hasAdminUser } from '$lib/server/db'; import { startSubprocesses, stopSubprocesses } from '$lib/server/subprocess-manager'; import { startScheduler } from '$lib/server/scheduler'; diff --git a/src/lib/components/cron-editor.svelte b/src/lib/components/cron-editor.svelte index c4e768a..d1af258 100644 --- a/src/lib/components/cron-editor.svelte +++ b/src/lib/components/cron-editor.svelte @@ -21,15 +21,18 @@ const parts = cron.split(' '); if (parts.length < 5) return 'custom'; - const [, , day, month, dow] = parts; + const [min, hr, day, month, dow] = parts; - // Weekly: specific day of week (0-6), day and month are wildcards - if (dow !== '*' && day === '*' && month === '*') { + // Simple minute and hour: plain numbers only (not */n, ranges, or lists) + const isSimpleNumber = (s: string) => /^\d+$/.test(s); + + // Weekly: specific single day of week (0-6), day and month are wildcards, simple min/hour + if (dow !== '*' && /^\d$/.test(dow) && day === '*' && month === '*' && isSimpleNumber(min) && isSimpleNumber(hr)) { return 'weekly'; } - // Daily: all wildcards except minute and hour - if (day === '*' && month === '*' && dow === '*') { + // Daily: all wildcards except simple minute and hour + if (day === '*' && month === '*' && dow === '*' && isSimpleNumber(min) && isSimpleNumber(hr)) { return 'daily'; } diff --git a/src/lib/components/host-info.svelte b/src/lib/components/host-info.svelte index a966b32..ef3a4bf 100644 --- a/src/lib/components/host-info.svelte +++ b/src/lib/components/host-info.svelte @@ -8,7 +8,7 @@ import { getIconComponent } from '$lib/utils/icons'; import { toast } from 'svelte-sonner'; import { themeStore, type FontSize } from '$lib/stores/theme'; - import { formatTime } from '$lib/stores/settings'; + import { getTimeFormat } from '$lib/stores/settings'; // Font size scaling for header let fontSize = $state('normal'); @@ -305,6 +305,20 @@ hostInfo ? ((hostInfo.totalMemory - hostInfo.freeMemory) / hostInfo.totalMemory) * 100 : 0 ); + let currentTimezone = $derived( + $environments.find((e: Environment) => Number(e.id) === Number(currentEnvId))?.timezone ?? 'UTC' + ); + + function formatLastUpdated(date: Date, timezone: string): string { + return new Intl.DateTimeFormat('en-GB', { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: getTimeFormat() === '12h' + }).format(date); + } + function handleClickOutside(event: MouseEvent) { const target = event.target as HTMLElement; if (!target.closest('.env-dropdown')) { @@ -452,7 +466,7 @@ class="flex items-center gap-2 {isConnected ? 'text-emerald-500' : 'text-muted-foreground'}" title={isConnected ? 'Live updates connected' : 'Live updates disconnected'} > - {formatTime(lastUpdated, { includeSeconds: true })} + {formatLastUpdated(lastUpdated, currentTimezone)} {#if isConnected} Live diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index dcab5dc..d2cf22f 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,4 +1,24 @@ [ + { + "version": "1.0.21", + "date": "2026-03-13", + "changes": [ + { "type": "feature", "text": "option to truncate port list (#702)" }, + { "type": "feature", "text": "log viewer supports ANSII 256 colors (#743)" }, + { "type": "fix", "text": "IPv6 Problems (#714, #731)" }, + { "type": "fix", "text": "polling storm & mass disconnect (#733, #741)" }, + { "type": "fix", "text": "custom cron schedule displayed incorrectly (#727)" }, + { "type": "fix", "text": "wrong cron schedule (#706)" }, + { "type": "fix", "text": "file browser does not allow upload over 512 KB (#687)" }, + { "type": "fix", "text": "can't set memory swappiness when using Podman (#691)" }, + { "type": "fix", "text": "compose API negotiation fix (#692, #696)" }, + { "type": "fix", "text": "not deployed git stacks continue to show the Down action (#694)" }, + { "type": "fix", "text": "display time doesn't reflect time zone (#735)" }, + { "type": "fix", "text": "prune dangling images counter not working (#718)" }, + { "type": "fix", "text": "own PORT env not used in HEALTHCHECK (#745)" } + ], + "imageTag": "fnsys/dockhand:v1.0.21" + }, { "version": "1.0.20", "date": "2026-03-02", diff --git a/src/lib/server/dns-dispatcher.ts b/src/lib/server/dns-dispatcher.ts new file mode 100644 index 0000000..aa09969 --- /dev/null +++ b/src/lib/server/dns-dispatcher.ts @@ -0,0 +1,91 @@ +import { setGlobalDispatcher, Agent } from 'undici'; +import dns from 'node:dns'; +import net from 'node:net'; + +const origLookup = dns.lookup.bind(dns); + +// DNS cache: hostname → { address, family, expiresAt } (positive) +// DNS negative cache: hostname → { error, expiresAt } (failed lookups) +const dnsCache = new Map(); +const dnsNegCache = new Map(); +const DNS_TTL_MS = 30_000; +const DNS_NEG_TTL_MS = 10_000; // Cache failures for 10s to prevent DNS server storms + +// In-flight deduplication: hostname → pending Promise<{address, family}> +const inFlight = new Map>(); + +function lookupWithCache(hostname: string): Promise<{ address: string; family: number }> { + // Positive cache hit + const cached = dnsCache.get(hostname); + if (cached) { + if (cached.expiresAt > Date.now()) { + return Promise.resolve({ address: cached.address, family: cached.family }); + } + dnsCache.delete(hostname); // evict stale entry + } + + // Negative cache hit — don't hammer DNS for recently-failed hostnames + const negCached = dnsNegCache.get(hostname); + if (negCached) { + if (negCached.expiresAt > Date.now()) { + return Promise.reject(negCached.error); + } + dnsNegCache.delete(hostname); + } + + // In-flight deduplication + const pending = inFlight.get(hostname); + if (pending) return pending; + + // Use getaddrinfo (libc) as primary — works through Docker's embedded DNS (127.0.0.11) + // and respects --dns-result-order=ipv4first from entrypoint. This matches Bun's native + // behavior which worked reliably on NAS environments where c-ares failed (#676). + const promise = new Promise<{ address: string; family: number }>((resolve, reject) => { + origLookup(hostname, { all: false }, (err, address, family) => { + if (err) { + // Cache the failure so parallel/subsequent requests don't all hammer DNS + dnsNegCache.set(hostname, { error: err, expiresAt: Date.now() + DNS_NEG_TTL_MS }); + reject(err); + } else { + const result = { address: address as string, family: family as number }; + dnsCache.set(hostname, { ...result, expiresAt: Date.now() + DNS_TTL_MS }); + resolve(result); + } + }); + }).finally(() => { + inFlight.delete(hostname); + }); + + inFlight.set(hostname, promise); + return promise; +} + +setGlobalDispatcher( + new Agent({ + connect: { + // Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676). + timeout: 30_000, + lookup(hostname: string, opts: any, cb: any) { + if (typeof opts === 'function') { + cb = opts; + opts = {}; + } + + // IP addresses / localhost → no DNS needed + if (net.isIP(hostname) || hostname === 'localhost') { + return origLookup(hostname, opts, cb); + } + + lookupWithCache(hostname) + .then(({ address, family }) => { + if (opts.all) { + cb(null, [{ address, family }]); + } else { + cb(null, address, family); + } + }) + .catch((err) => cb(err)); + } + } + }) +); diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index d9b179e..6232f06 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -10,6 +10,7 @@ import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import * as http from 'node:http'; import * as https from 'node:https'; +import * as tls from 'node:tls'; import { createHash } from 'node:crypto'; import type { Environment } from './db'; import { getStackEnvVarsAsRecord } from './db'; @@ -345,7 +346,12 @@ function getHttpsAgent(config: DockerClientConfig): https.Agent { timeout: 30000, }; - if (config.ca) agentOptions.ca = config.ca; + if (config.ca) { + // Include both the custom CA and Node.js built-in root certificates. + // Node.js replaces the entire CA store when `ca` is set, unlike Bun which appends. + // Without this, certs signed by intermediate CAs fail with "unable to get local issuer certificate". + agentOptions.ca = [config.ca, ...tls.rootCertificates]; + } if (config.cert) agentOptions.cert = config.cert; if (config.key) agentOptions.key = config.key; if (config.skipVerify) agentOptions.rejectUnauthorized = false; @@ -874,7 +880,8 @@ export async function dockerFetch( headers, streaming || false, (streaming || path === '/_hawser/compose') ? 300000 : 30000, // 5 min for streaming/compose, 30s for normal - isBinary + isBinary, + fetchOptions.signal ?? undefined ); const elapsed = Date.now() - startTime; // Only warn for slow requests, but skip /stats which is expected to be slow (5-10s) @@ -1233,7 +1240,7 @@ export interface CreateContainerOptions { networkIpv6Address?: string; /** Gateway priority for the primary network (Docker Engine 28+) */ networkGwPriority?: number; - user?: string; + user?: string | null; privileged?: boolean; healthcheck?: HealthcheckConfig; memory?: number; @@ -1286,7 +1293,7 @@ export interface CreateContainerOptions { // Device requests (GPU access, etc.) deviceRequests?: DeviceRequest[]; // Container runtime (e.g., 'runc', 'nvidia' for GPU containers) - runtime?: string; + runtime?: string | null; // Read-only root filesystem readonlyRootfs?: boolean; // CPU pinning (e.g., "0-3", "0,1") @@ -1322,8 +1329,8 @@ export async function createContainer(options: CreateContainerOptions, envId?: n containerConfig.Cmd = options.cmd; } - if (options.user) { - containerConfig.User = options.user; + if (options.user !== undefined) { + containerConfig.User = options.user ?? ''; } if (options.healthcheck) { @@ -1436,7 +1443,7 @@ export async function createContainer(options: CreateContainerOptions, envId?: n }; } - if (options.privileged) { + if (options.privileged !== undefined) { containerConfig.HostConfig.Privileged = options.privileged; } @@ -1614,8 +1621,8 @@ export async function createContainer(options: CreateContainerOptions, envId?: n } // Container runtime (e.g., 'nvidia' for GPU containers) - if (options.runtime) { - containerConfig.HostConfig.Runtime = options.runtime; + if (options.runtime !== undefined) { + containerConfig.HostConfig.Runtime = options.runtime ?? ''; } // Read-only root filesystem @@ -1782,6 +1789,13 @@ export async function recreateContainerFromInspect( HostConfig: hostConfig }; + // Strip default MemorySwappiness — Podman + cgroupv2 rejects it. + // Docker returns -1, Podman returns 0 when unset. + const swappiness = createConfig.HostConfig?.MemorySwappiness; + if (swappiness == null || swappiness === -1 || swappiness === 0) { + delete createConfig.HostConfig.MemorySwappiness; + } + // container: mode shares the network namespace — Docker rejects // networking-related fields on the dependent container since they're // owned by the network provider container @@ -1883,6 +1897,11 @@ export async function recreateContainerFromInspect( [initialNetworkName]: endpointConfig } }; + // Container-level MacAddress conflicts with endpoint-level MacAddress. + // Docker requires them to match or the top-level one to be empty. + // The MAC is preserved in the endpoint config (correct location per API v1.44+), + // so clear the top-level one to avoid the conflict error. + delete createConfig.MacAddress; } // 5. Create new container @@ -2236,7 +2255,7 @@ export function extractContainerOptions(inspectData: any): CreateContainerOption groupAdd: hostConfig.GroupAdd?.length > 0 ? hostConfig.GroupAdd : undefined, // Memory swappiness - memorySwappiness: hostConfig.MemorySwappiness !== null ? hostConfig.MemorySwappiness : undefined, + memorySwappiness: hostConfig.MemorySwappiness != null && hostConfig.MemorySwappiness !== -1 && hostConfig.MemorySwappiness !== 0 ? hostConfig.MemorySwappiness : undefined, // User namespace mode usernsMode: hostConfig.UsernsMode || undefined @@ -2665,6 +2684,10 @@ async function getRegistryBearerToken(registry: string, repo: string): Promise { try { + // Edge connections go WebSocket → agent → Docker daemon, which adds latency on slow hosts. + // Use a longer timeout for edge to avoid false negatives on overloaded NAS/VPS devices. + const config = await getDockerConfig(envId).catch(() => null); + const timeoutMs = config?.connectionType === 'hawser-edge' ? 20000 : 5000; const response = await dockerFetch('/_ping', { - signal: AbortSignal.timeout(5000) + signal: AbortSignal.timeout(timeoutMs) }, envId); await drainResponse(response); return response.ok; } catch (error: any) { const msg = error?.message || String(error); if (msg.includes('unreachable')) { - const config = await getDockerConfig(envId).catch(() => null); console.warn(`[Docker] ${config?.connectionType || 'direct'} ${config?.host || envId}: /_ping failed - host unreachable`); } return false; @@ -3286,24 +3319,7 @@ export interface NetworkInfo { export async function listNetworks(envId?: number | null): Promise { const networks = await dockerJsonRequest('/networks', {}, envId); - // Docker's /networks endpoint returns empty Containers - we need to inspect each network - // to get the actual connected containers. Run inspections in parallel for performance. - const networkDetails = await Promise.all( - networks.map(async (network: any) => { - try { - const details = await dockerJsonRequest(`/networks/${network.Id}`, {}, envId); - return { - ...network, - Containers: details.Containers || {} - }; - } catch { - // If inspection fails, return network with empty containers - return network; - } - }) - ); - - return networkDetails.map((network: any) => ({ + return networks.map((network: any) => ({ id: network.Id, name: network.Name, driver: network.Driver, diff --git a/src/lib/server/hawser.ts b/src/lib/server/hawser.ts index 4be3758..c0768bd 100644 --- a/src/lib/server/hawser.ts +++ b/src/lib/server/hawser.ts @@ -44,6 +44,7 @@ export interface EdgeConnection { lastHeartbeat: number; pendingRequests: Map; pendingStreamRequests: Map; + pingInterval?: ReturnType; lastMetrics?: { uptime?: number; cpuUsage?: number; @@ -77,7 +78,7 @@ declare global { var __hawserSendMessage: ((envId: number, message: string) => boolean) | undefined; var __hawserHandleContainerEvent: ((envId: number, event: ContainerEventMessage['event']) => Promise) | undefined; var __hawserHandleMetrics: ((envId: number, metrics: MetricsMessage['metrics']) => Promise) | undefined; - var __hawserHandleMessage: ((ws: any, msg: any, connId: string) => Promise) | undefined; + var __hawserHandleMessage: ((ws: any, msg: any, connId: string, remoteIp?: string) => Promise) | undefined; var __hawserHandleDisconnect: ((ws: any, connId: string) => void) | undefined; var __terminalHandleExecMessage: ((msg: any) => void) | undefined; } @@ -119,6 +120,11 @@ export function initializeEdgeManager(): void { conn.pendingRequests.clear(); conn.pendingStreamRequests.clear(); + if (conn.pingInterval) { + clearInterval(conn.pingInterval); + conn.pingInterval = undefined; + } + conn.ws.close(1001, 'Connection timeout'); edgeConnections.delete(envId); updateEnvironmentStatus(envId, null); @@ -255,11 +261,15 @@ globalThis.__hawserHandleMetrics = handleEdgeMetrics; export async function validateHawserToken( token: string ): Promise<{ valid: boolean; environmentId?: number; tokenId?: number }> { - // Get all active tokens - const tokens = await db.select().from(hawserTokens).where(eq(hawserTokens.isActive, true)); + // Fast path: lookup by token prefix (first 8 chars) instead of iterating all tokens. + // This reduces O(N) Argon2id verifications to O(1) DB lookup + 1 verify. + const prefix = token.substring(0, 8); + const candidates = await db + .select() + .from(hawserTokens) + .where(and(eq(hawserTokens.tokenPrefix, prefix), eq(hawserTokens.isActive, true))); - // Check each token (tokens are hashed) - for (const t of tokens) { + for (const t of candidates) { try { const isValid = await verifyPassword(token, t.token); if (isValid) { @@ -276,7 +286,7 @@ export async function validateHawserToken( }; } } catch { - // Invalid hash, continue checking + // Invalid hash format, skip } } @@ -371,6 +381,12 @@ export function closeEdgeConnection(environmentId: number): void { `Rejecting ${pendingCount} pending requests and ${streamCount} stream requests.` ); + // Clear ping interval + if (connection.pingInterval) { + clearInterval(connection.pingInterval); + connection.pingInterval = undefined; + } + // Reject all pending requests for (const [requestId, pending] of connection.pendingRequests) { console.log(`[Hawser] Rejecting pending request ${requestId} due to environment deletion`); @@ -427,6 +443,12 @@ export function handleEdgeConnection( existing.pendingRequests.clear(); existing.pendingStreamRequests.clear(); + // Clear ping interval before closing + if (existing.pingInterval) { + clearInterval(existing.pingInterval); + existing.pingInterval = undefined; + } + // Immediately destroy TCP socket — no graceful close needed for replaced connections if (typeof existing.ws.terminate === 'function') { existing.ws.terminate(); @@ -452,6 +474,17 @@ export function handleEdgeConnection( edgeConnections.set(environmentId, connection); + // Start server-side ping interval to keep connection alive. + // 5s is conservative against reverse proxies with aggressive idle timeouts. + connection.pingInterval = setInterval(() => { + try { + connection.ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() })); + } catch { + clearInterval(connection.pingInterval!); + connection.pingInterval = undefined; + } + }, 5000); + // Update environment record updateEnvironmentStatus(environmentId, connection); @@ -499,7 +532,8 @@ export async function sendEdgeRequest( headers?: Record, streaming = false, timeout = 30000, - isBinary = false + isBinary = false, + signal?: AbortSignal ): Promise { const connection = edgeConnections.get(environmentId); if (!connection) { @@ -517,6 +551,27 @@ export async function sendEdgeRequest( reject(new Error('Request timeout')); }, timeout); + // Honor AbortSignal from caller (e.g., AbortSignal.timeout(5000) for dockerPing) + if (signal) { + if (signal.aborted) { + clearTimeout(timeoutHandle); + reject(new Error('Request aborted')); + return; + } + signal.addEventListener( + 'abort', + () => { + connection.pendingRequests.delete(requestId); + if (streaming) { + connection.pendingStreamRequests.delete(requestId); + } + clearTimeout(timeoutHandle); + reject(new Error('Request aborted')); + }, + { once: true } + ); + } + // For streaming requests, the Go agent sends 'stream' messages instead of a single 'response'. // We need to register a stream handler that collects all data and resolves when complete. if (streaming) { @@ -792,6 +847,12 @@ export function handleHeartbeat(environmentId: number): void { export function handleDisconnect(environmentId: number): void { const connection = edgeConnections.get(environmentId); if (connection) { + // Clear ping interval + if (connection.pingInterval) { + clearInterval(connection.pingInterval); + connection.pingInterval = undefined; + } + // Reject all pending requests for (const [, pending] of connection.pendingRequests) { clearTimeout(pending.timeout); @@ -983,11 +1044,12 @@ export type HawserMessage = const wsToEnvId = new Map(); // Auth fail cache to prevent brute-force token validation. -// Entries are periodically cleaned up to prevent unbounded growth. +// 5 min cooldown — hawser agents use exponential backoff (30-60s), +// so a short cooldown lets every retry through. const hawserAuthFailCache = new Map(); -const HAWSER_AUTH_FAIL_COOLDOWN_MS = 30_000; +const HAWSER_AUTH_FAIL_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes -// Periodic cleanup of expired auth fail entries (every 60s) +// Periodic cleanup of expired auth fail entries setInterval(() => { const now = Date.now(); for (const [key, timestamp] of hawserAuthFailCache) { @@ -995,7 +1057,7 @@ setInterval(() => { hawserAuthFailCache.delete(key); } } -}, 60_000); +}, HAWSER_AUTH_FAIL_COOLDOWN_MS); // ─── Reconnection storm throttle ─── // Tracks per-environment reconnection frequency to detect storms @@ -1007,7 +1069,7 @@ interface ReconnectTrackerEntry { } const reconnectTracker = new Map(); const RECONNECT_WINDOW_MS = 2 * 60 * 1000; // 2-minute sliding window -const RECONNECT_BURST = 3; // allow 3 reconnections per window +const RECONNECT_BURST = 10; // allow 10 reconnections per window const COOLDOWN_LEVELS_SECS = [30, 60, 120, 300]; // escalating cooldown in seconds const STABLE_THRESHOLD_MS = 5 * 60 * 1000; // stable connection resets tracker const STALE_TRACKER_MS = 10 * 60 * 1000; // clean up stale tracker entries @@ -1062,13 +1124,14 @@ function recordReconnection(envId: number): { allowed: true } | { allowed: false * * Registered as globalThis.__hawserHandleMessage for server.js to call. */ -async function handleHawserWsMessage(ws: any, msg: any, connId: string): Promise { +async function handleHawserWsMessage(ws: any, msg: any, connId: string, remoteIp?: string): Promise { if (msg.type === 'hello') { - const remoteAddr = connId; + const rateLimitKey = remoteIp || connId; - // Rate limit auth failures - const lastFail = hawserAuthFailCache.get(remoteAddr); + // Rate limit auth failures by remote IP (not connId which is unique per connection) + const lastFail = hawserAuthFailCache.get(rateLimitKey); if (lastFail && Date.now() - lastFail < HAWSER_AUTH_FAIL_COOLDOWN_MS) { + console.log(`[Hawser WS] Rate limited ${connId} (IP: ${rateLimitKey}) — ${Math.round((Date.now() - lastFail) / 1000)}s since last fail`); ws.send(JSON.stringify({ type: 'error', message: 'Too many failed attempts' })); ws.close(1008, 'Rate limited'); return; @@ -1083,8 +1146,8 @@ async function handleHawserWsMessage(ws: any, msg: any, connId: string): Promise try { const result = await validateHawserToken(msg.token); if (!result.valid || !result.environmentId) { - console.log(`[Hawser WS] Authentication failed for connection ${connId}`); - hawserAuthFailCache.set(remoteAddr, Date.now()); + console.log(`[Hawser WS] Authentication failed for connection ${connId} (IP: ${rateLimitKey})`); + hawserAuthFailCache.set(rateLimitKey, Date.now()); ws.send(JSON.stringify({ type: 'error', message: 'Invalid token' })); ws.close(1008, 'Invalid token'); return; diff --git a/src/lib/server/scheduler/cron-utils.ts b/src/lib/server/scheduler/cron-utils.ts new file mode 100644 index 0000000..21866b5 --- /dev/null +++ b/src/lib/server/scheduler/cron-utils.ts @@ -0,0 +1,32 @@ +import { Cron } from 'croner'; + +/** + * Get the next run time for a cron expression. + * Uses legacyMode: false so day-of-month + day-of-week use AND logic. + * @param cronExpression - The cron expression + * @param timezone - Optional IANA timezone (e.g., 'Europe/Warsaw'). Defaults to local timezone. + */ +export function getNextRun(cronExpression: string, timezone?: string): Date | null { + try { + const options = timezone ? { timezone, legacyMode: false } : { legacyMode: false }; + const job = new Cron(cronExpression, options); + const next = job.nextRun(); + job.stop(); + return next; + } catch { + return null; + } +} + +/** + * Check if a cron expression is valid. + */ +export function isValidCron(cronExpression: string): boolean { + try { + const job = new Cron(cronExpression, { legacyMode: false }); + job.stop(); + return true; + } catch { + return false; + } +} diff --git a/src/lib/server/scheduler/index.ts b/src/lib/server/scheduler/index.ts index c6d66a0..8a2f210 100644 --- a/src/lib/server/scheduler/index.ts +++ b/src/lib/server/scheduler/index.ts @@ -107,11 +107,11 @@ export async function startScheduler(): Promise { const defaultTimezone = await getDefaultTimezone(); // Start system cleanup jobs (static schedules with default timezone) - cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone }, async () => { + cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => { await runScheduleCleanupJob(); }); - eventCleanupJob = new Cron(eventCleanupCron, { timezone: defaultTimezone }, async () => { + eventCleanupJob = new Cron(eventCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => { await runEventCleanupJob(); }); @@ -127,15 +127,10 @@ export async function startScheduler(): Promise { }; // Volume helper cleanup runs every 30 minutes to clean up expired browse containers - volumeHelperCleanupJob = new Cron('*/30 * * * *', { timezone: defaultTimezone }, async () => { + volumeHelperCleanupJob = new Cron('*/30 * * * *', { timezone: defaultTimezone, legacyMode: false }, async () => { await runVolumeHelperCleanupJob('cron', volumeCleanupFns); }); - // Run volume helper cleanup immediately on startup to clean up stale containers - runVolumeHelperCleanupJob('startup', volumeCleanupFns).catch(err => { - const errorMsg = err instanceof Error ? err.message : String(err); - console.error('[Scheduler] Error during startup volume helper cleanup:', errorMsg); - }); console.log(`[Scheduler] System schedule cleanup: ${scheduleCleanupCron} [${defaultTimezone}]`); console.log(`[Scheduler] System event cleanup: ${eventCleanupCron} [${defaultTimezone}]`); @@ -331,7 +326,7 @@ export async function registerSchedule( const timezone = environmentId ? await getEnvironmentTimezone(environmentId) : 'UTC'; // Create new Cron instance with timezone - const job = new Cron(cronExpression, { timezone }, async () => { + const job = new Cron(cronExpression, { timezone, legacyMode: false }, async () => { // Defensive check: verify schedule still exists and is enabled if (type === 'container_update') { const setting = await getAutoUpdateSettingById(scheduleId); @@ -494,15 +489,15 @@ export async function refreshSystemJobs(): Promise { } // Re-create with new timezone - cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone }, async () => { + cleanupJob = new Cron(scheduleCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => { await runScheduleCleanupJob(); }); - eventCleanupJob = new Cron(eventCleanupCron, { timezone: defaultTimezone }, async () => { + eventCleanupJob = new Cron(eventCleanupCron, { timezone: defaultTimezone, legacyMode: false }, async () => { await runEventCleanupJob(); }); - volumeHelperCleanupJob = new Cron('*/30 * * * *', { timezone: defaultTimezone }, async () => { + volumeHelperCleanupJob = new Cron('*/30 * * * *', { timezone: defaultTimezone, legacyMode: false }, async () => { await runVolumeHelperCleanupJob('cron', volumeCleanupFns); }); @@ -654,35 +649,9 @@ export async function triggerSystemJob(jobId: string): Promise<{ success: boolea // UTILITY FUNCTIONS // ============================================================================= -/** - * Get the next run time for a cron expression. - * @param cronExpression - The cron expression - * @param timezone - Optional IANA timezone (e.g., 'Europe/Warsaw'). Defaults to local timezone. - */ -export function getNextRun(cronExpression: string, timezone?: string): Date | null { - try { - const options = timezone ? { timezone } : undefined; - const job = new Cron(cronExpression, options); - const next = job.nextRun(); - job.stop(); - return next; - } catch { - return null; - } -} - -/** - * Check if a cron expression is valid. - */ -export function isValidCron(cronExpression: string): boolean { - try { - const job = new Cron(cronExpression); - job.stop(); - return true; - } catch { - return false; - } -} +// Imported from cron-utils.ts (isolated from DB deps for unit test compatibility) +import { getNextRun, isValidCron } from './cron-utils'; +export { getNextRun, isValidCron }; /** * Get system schedules info for the API. diff --git a/src/lib/server/scheduler/tasks/image-prune.ts b/src/lib/server/scheduler/tasks/image-prune.ts index abba8b4..5d2083f 100644 --- a/src/lib/server/scheduler/tasks/image-prune.ts +++ b/src/lib/server/scheduler/tasks/image-prune.ts @@ -74,12 +74,17 @@ export async function runImagePrune( // Extract space reclaimed and images removed from result const spaceReclaimed = result?.SpaceReclaimed || 0; - // Count unique images by filtering Untagged entries that are not digest references - // Docker returns multiple entries per image: Untagged (tag), Untagged (digest @sha256:), Deleted (layers) - // We only count tag-based Untagged entries to get actual image count - const imagesRemoved = result?.ImagesDeleted - ?.filter((img: any) => img.Untagged && !img.Untagged.includes('@sha256:')) - .length || 0; + // Count unique images removed. + // Docker returns: Untagged (tag), Untagged (digest @sha256:), Deleted (layer sha256:) + // For tagged images: count Untagged entries that are NOT digest references (tag-based) + // For dangling images: there are no tag-based entries, only digest-based Untagged entries + // So count tag-based Untagged first, fall back to digest-based Untagged for dangling prune + const deleted = result?.ImagesDeleted || []; + const tagEntries = deleted.filter((img: any) => img.Untagged && !img.Untagged.includes('@sha256:')); + const digestEntries = deleted.filter((img: any) => img.Untagged && img.Untagged.includes('@sha256:')); + const imagesRemoved = tagEntries.length > 0 + ? tagEntries.length + : digestEntries.length; // Format space for human-readable output const formatBytes = (bytes: number): string => { diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index c2a3ce8..ee032be 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -3,7 +3,6 @@ * * Provides compose-first stack operations for internal, git, and external stacks. * All lifecycle operations use docker compose commands. - * v1.0.20 */ import { existsSync, mkdirSync, rmSync, readdirSync, cpSync, statSync, unlinkSync, renameSync, readFileSync, writeFileSync } from 'node:fs'; @@ -139,10 +138,6 @@ const stackLocks = new Map>(); // Track active TLS temp directories for cleanup on unexpected process exit const activeTlsDirs = new Set(); -// Cache of envId → daemon max API version (e.g. "1.43") -// Populated lazily to avoid CLI/daemon version mismatch on older Docker hosts (e.g. Synology) -const dockerApiVersionCache = new Map(); - // Register cleanup handlers once at module load if (typeof process !== 'undefined') { const cleanupTlsDirs = () => { @@ -158,85 +153,6 @@ if (typeof process !== 'undefined') { process.on('SIGTERM', () => { cleanupTlsDirs(); process.exit(143); }); } -/** - * Fetch and cache the Docker daemon's maximum supported API version for a given environment. - * Used to set DOCKER_API_VERSION when spawning docker compose, preventing version mismatch - * errors on older Docker hosts (e.g. Synology DSM). - * - * Strategy: - * 1. Try Dockhand's HTTP API call to the daemon (works for all environment types) - * 2. Fall back to `docker version` CLI command (works for local socket connections) - */ -async function getDockerApiVersionForCli(envId: number | null | undefined): Promise { - const key = String(envId ?? 'local'); - if (dockerApiVersionCache.has(key)) return dockerApiVersionCache.get(key); - - // Strategy 1: Use Dockhand's HTTP API to query the daemon - if (envId) { - try { - const { getDockerVersion } = await import('./docker.js'); - const version = await getDockerVersion(envId) as { ApiVersion?: string }; - const apiVersion: string | undefined = version?.ApiVersion; - if (apiVersion) { - console.log(`[Docker API Version] Detected daemon API version ${apiVersion} for env ${key} (via HTTP API)`); - dockerApiVersionCache.set(key, apiVersion); - return apiVersion; - } - } catch (err: any) { - console.warn(`[Docker API Version] HTTP API query failed for env ${key}: ${err?.message || err}`); - } - } - - // Strategy 2: Fall back to `docker version` CLI command - // This handles local socket connections where envId is null and also - // cases where the HTTP API query fails (e.g. daemon quirks on Synology) - try { - const apiVersion = await getDockerApiVersionViaCli(); - if (apiVersion) { - console.log(`[Docker API Version] Detected daemon API version ${apiVersion} for env ${key} (via CLI)`); - dockerApiVersionCache.set(key, apiVersion); - return apiVersion; - } - } catch (err: any) { - console.warn(`[Docker API Version] CLI query failed for env ${key}: ${err?.message || err}`); - } - - console.warn(`[Docker API Version] Could not detect daemon API version for env ${key}`); - return undefined; -} - -/** - * Get the Docker daemon's API version using the `docker version` CLI command. - * This is a fallback for when the HTTP API query fails or envId is null. - */ -function getDockerApiVersionViaCli(): Promise { - return new Promise((resolve) => { - const proc = nodeSpawn('docker', ['version', '--format', '{{.Server.APIVersion}}'], { - stdio: ['ignore', 'pipe', 'pipe'], - timeout: 5000, - // Use the minimum Docker API version (1.25) for this probe command. - // This ensures the probe itself doesn't fail due to the version mismatch - // we're trying to detect. - env: { - PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin', - DOCKER_API_VERSION: '1.25' - } - }); - let stdout = ''; - proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); }); - proc.stderr?.on('data', () => {}); // drain stderr to prevent pipe buffer blocking - proc.on('close', (code) => { - const version = stdout.trim(); - if (code === 0 && /^\d+\.\d+$/.test(version)) { - resolve(version); - } else { - resolve(undefined); - } - }); - proc.on('error', () => resolve(undefined)); - }); -} - /** * Execute a function with exclusive lock on a stack. * Prevents race conditions when multiple operations target the same stack. @@ -821,7 +737,7 @@ async function loginToRegistries(dockerHost?: string, logPrefix = '[Stack]', api if (dockerHost) { spawnEnv.DOCKER_HOST = dockerHost; } - // Cap Docker CLI API version to prevent version mismatch errors + // Pass through explicit DOCKER_API_VERSION if provided by caller if (apiVersion) { spawnEnv.DOCKER_API_VERSION = apiVersion; } @@ -997,13 +913,10 @@ async function executeLocalCompose( spawnEnv.DOCKER_HOST = process.env.DOCKER_HOST; } - // Auto-cap Docker CLI API version to the daemon's max supported version. - // This fixes compatibility with older Docker daemons (e.g. Synology DSM) that - // reject newer client versions. DOCKER_API_VERSION env var overrides this if set. - const daemonApiVersion = process.env.DOCKER_API_VERSION - ?? await getDockerApiVersionForCli(envId); - if (daemonApiVersion) { - spawnEnv.DOCKER_API_VERSION = daemonApiVersion; + // Honor explicit DOCKER_API_VERSION override from environment (user-controlled). + // Otherwise let compose negotiate natively — 5.0.2 handles old daemons correctly. + if (process.env.DOCKER_API_VERSION) { + spawnEnv.DOCKER_API_VERSION = process.env.DOCKER_API_VERSION; } // Check if .env file exists on disk (for legacy support decision) @@ -1162,7 +1075,7 @@ async function executeLocalCompose( console.log(`${logPrefix} Working directory:`, stackDir); console.log(`${logPrefix} Compose file:`, composeFile); console.log(`${logPrefix} DOCKER_HOST:`, dockerHost || '(local socket)'); - console.log(`${logPrefix} DOCKER_API_VERSION:`, daemonApiVersion || '(not set - using CLI default)'); + console.log(`${logPrefix} DOCKER_API_VERSION:`, spawnEnv.DOCKER_API_VERSION || '(not set - native negotiation)'); console.log(`${logPrefix} Force recreate:`, forceRecreate ?? false); console.log(`${logPrefix} Remove volumes:`, removeVolumes ?? false); console.log(`${logPrefix} Service name:`, serviceName ?? '(all services)'); @@ -1173,7 +1086,7 @@ async function executeLocalCompose( // Login to registries before pulling images if (operation === 'up' || operation === 'pull') { - await loginToRegistries(dockerHost, logPrefix, daemonApiVersion); + await loginToRegistries(dockerHost, logPrefix, spawnEnv.DOCKER_API_VERSION); } try { diff --git a/src/lib/stores/environment.ts b/src/lib/stores/environment.ts index b3594a2..9512f49 100644 --- a/src/lib/stores/environment.ts +++ b/src/lib/stores/environment.ts @@ -17,6 +17,7 @@ export interface Environment { socketPath?: string; connectionType?: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge'; publicIp?: string | null; + timezone?: string; } const STORAGE_KEY = 'dockhand:environment'; diff --git a/src/lib/stores/settings.ts b/src/lib/stores/settings.ts index d88e1ce..b3e0843 100644 --- a/src/lib/stores/settings.ts +++ b/src/lib/stores/settings.ts @@ -26,6 +26,7 @@ export interface AppSettings { eventCollectionMode: EventCollectionMode; eventPollInterval: number; metricsCollectionInterval: number; + compactPorts: boolean; externalStackPaths: string[]; primaryStackLocation: string | null; } @@ -50,6 +51,7 @@ const DEFAULT_SETTINGS: AppSettings = { eventCollectionMode: 'stream', eventPollInterval: 60000, metricsCollectionInterval: 30000, + compactPorts: false, externalStackPaths: [], primaryStackLocation: null }; @@ -88,6 +90,7 @@ function createSettingsStore() { eventCollectionMode: settings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode, eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval, metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval, + compactPorts: settings.compactPorts ?? DEFAULT_SETTINGS.compactPorts, externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths, primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation }); @@ -129,6 +132,7 @@ function createSettingsStore() { eventCollectionMode: updatedSettings.eventCollectionMode ?? DEFAULT_SETTINGS.eventCollectionMode, eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval, metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval, + compactPorts: updatedSettings.compactPorts ?? DEFAULT_SETTINGS.compactPorts, externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths, primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation }); @@ -290,6 +294,13 @@ function createSettingsStore() { return newSettings; }); }, + setCompactPorts: (value: boolean) => { + update((current) => { + const newSettings = { ...current, compactPorts: value }; + saveSettings({ compactPorts: value }); + return newSettings; + }); + }, setExternalStackPaths: (value: string[]) => { update((current) => { const newSettings = { ...current, externalStackPaths: value }; diff --git a/src/routes/api/containers/[id]/+server.ts b/src/routes/api/containers/[id]/+server.ts index 289dc00..d1448ea 100644 --- a/src/routes/api/containers/[id]/+server.ts +++ b/src/routes/api/containers/[id]/+server.ts @@ -30,8 +30,11 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const details = await inspectContainer(params.id, envIdNum); return json(details); - } catch (error) { - console.error('Error inspecting container:', error); + } catch (error: any) { + if (error?.statusCode === 404) { + return json({ error: error.json?.message || 'Container not found' }, { status: 404 }); + } + console.error('Error inspecting container:', error?.message || error); return json({ error: 'Failed to inspect container' }, { status: 500 }); } }; @@ -96,8 +99,11 @@ export const DELETE: RequestHandler = async (event) => { } return json({ success: true }); - } catch (error) { - console.error('Error removing container:', error); + } catch (error: any) { + if (error?.statusCode === 404) { + return json({ error: error.json?.message || 'Container not found' }, { status: 404 }); + } + console.error('Error removing container:', error?.message || error); return json({ error: 'Failed to remove container' }, { status: 500 }); } }; diff --git a/src/routes/api/containers/[id]/files/+server.ts b/src/routes/api/containers/[id]/files/+server.ts index cb3e91d..4bf7353 100644 --- a/src/routes/api/containers/[id]/files/+server.ts +++ b/src/routes/api/containers/[id]/files/+server.ts @@ -26,7 +26,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { return json(result); } catch (error: any) { - console.error('Error listing container directory:', error); + if (error?.statusCode === 404) { + return json({ error: error.json?.message || 'Container not found' }, { status: 404 }); + } + console.error('Error listing container directory:', error?.message || error); return json({ error: error.message || 'Failed to list directory' }, { status: 500 }); } }; diff --git a/src/routes/api/containers/[id]/files/content/+server.ts b/src/routes/api/containers/[id]/files/content/+server.ts index 1904c3e..c02dfc7 100644 --- a/src/routes/api/containers/[id]/files/content/+server.ts +++ b/src/routes/api/containers/[id]/files/content/+server.ts @@ -36,7 +36,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { return json({ content, path }); } catch (error: any) { - console.error('Error reading container file:', error); + if (error?.statusCode === 404 && !error.message?.includes('No such file')) { + return json({ error: error.json?.message || 'Container not found' }, { status: 404 }); + } + console.error('Error reading container file:', error?.message || error); const msg = error.message || String(error); if (msg.includes('No such file or directory')) { @@ -92,7 +95,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) => return json({ success: true, path }); } catch (error: any) { - console.error('Error writing container file:', error); + console.error('Error writing container file:', error?.message || error); const msg = error.message || String(error); if (msg.includes('Permission denied')) { diff --git a/src/routes/api/containers/[id]/files/download/+server.ts b/src/routes/api/containers/[id]/files/download/+server.ts index 4bcf2b3..c47c571 100644 --- a/src/routes/api/containers/[id]/files/download/+server.ts +++ b/src/routes/api/containers/[id]/files/download/+server.ts @@ -76,7 +76,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { return new Response(body, { headers }); } catch (error: any) { - console.error('Error downloading container file:', error); + console.error('Error downloading container file:', error?.message || error); if (error.message?.includes('No such file or directory')) { return new Response(JSON.stringify({ error: 'File not found' }), { diff --git a/src/routes/api/containers/[id]/files/upload/+server.ts b/src/routes/api/containers/[id]/files/upload/+server.ts index c76b3c4..abc8411 100644 --- a/src/routes/api/containers/[id]/files/upload/+server.ts +++ b/src/routes/api/containers/[id]/files/upload/+server.ts @@ -140,7 +140,7 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) => errors: errors.length > 0 ? errors : undefined }); } catch (error: any) { - console.error('Error uploading to container:', error); + console.error('Error uploading to container:', error?.message || error); if (error.message?.includes('Permission denied')) { return json({ error: 'Permission denied to write to this path' }, { status: 403 }); diff --git a/src/routes/api/containers/[id]/rename/+server.ts b/src/routes/api/containers/[id]/rename/+server.ts index 78860d7..43f229d 100644 --- a/src/routes/api/containers/[id]/rename/+server.ts +++ b/src/routes/api/containers/[id]/rename/+server.ts @@ -46,8 +46,11 @@ export const POST: RequestHandler = async (event) => { } return json({ success: true }); - } catch (error) { - console.error('Error renaming container:', error); + } catch (error: any) { + if (error?.statusCode === 404) { + return json({ error: error.json?.message || 'Container not found' }, { status: 404 }); + } + console.error('Error renaming container:', error?.message || error); return json({ error: 'Failed to rename container' }, { status: 500 }); } }; diff --git a/src/routes/api/containers/[id]/restart/+server.ts b/src/routes/api/containers/[id]/restart/+server.ts index 20698ca..a0025fd 100644 --- a/src/routes/api/containers/[id]/restart/+server.ts +++ b/src/routes/api/containers/[id]/restart/+server.ts @@ -38,8 +38,11 @@ export const POST: RequestHandler = async (event) => { await auditContainer(event, 'restart', params.id, containerName, envIdNum); return json({ success: true }); - } catch (error) { - console.error('Error restarting container:', error); + } catch (error: any) { + if (error?.statusCode === 404) { + return json({ error: error.json?.message || 'Container not found' }, { status: 404 }); + } + console.error('Error restarting container:', error?.message || error); return json({ error: 'Failed to restart container' }, { status: 500 }); } }; diff --git a/src/routes/api/containers/[id]/start/+server.ts b/src/routes/api/containers/[id]/start/+server.ts index 3bfbd88..6bbeae5 100644 --- a/src/routes/api/containers/[id]/start/+server.ts +++ b/src/routes/api/containers/[id]/start/+server.ts @@ -31,8 +31,11 @@ export const POST: RequestHandler = async (event) => { await auditContainer(event, 'start', params.id, containerName, envIdNum); return json({ success: true }); - } catch (error) { - console.error('Error starting container:', error); + } catch (error: any) { + if (error?.statusCode === 404) { + return json({ error: error.json?.message || 'Container not found' }, { status: 404 }); + } + console.error('Error starting container:', error?.message || error); return json({ error: 'Failed to start container' }, { status: 500 }); } }; diff --git a/src/routes/api/containers/[id]/stop/+server.ts b/src/routes/api/containers/[id]/stop/+server.ts index 8befb03..3ed814b 100644 --- a/src/routes/api/containers/[id]/stop/+server.ts +++ b/src/routes/api/containers/[id]/stop/+server.ts @@ -31,8 +31,11 @@ export const POST: RequestHandler = async (event) => { await auditContainer(event, 'stop', params.id, containerName, envIdNum); return json({ success: true }); - } catch (error) { - console.error('Error stopping container:', error); + } catch (error: any) { + if (error?.statusCode === 404) { + return json({ error: error.json?.message || 'Container not found' }, { status: 404 }); + } + console.error('Error stopping container:', error?.message || error); return json({ error: 'Failed to stop container' }, { status: 500 }); } }; diff --git a/src/routes/api/containers/[id]/update/+server.ts b/src/routes/api/containers/[id]/update/+server.ts index 775c114..2ec5117 100644 --- a/src/routes/api/containers/[id]/update/+server.ts +++ b/src/routes/api/containers/[id]/update/+server.ts @@ -47,8 +47,11 @@ export const POST: RequestHandler = async (event) => { await auditContainer(event, 'update', container.id, options.name, envIdNum, { ...options, startAfterUpdate }); return json({ success: true, id: container.id }); - } catch (error) { - console.error('Error updating container:', error); - return json({ error: 'Failed to update container', details: String(error) }, { status: 500 }); + } catch (error: any) { + if (error?.statusCode === 404) { + return json({ error: error.json?.message || 'Container not found' }, { status: 404 }); + } + console.error('Error updating container:', error?.message || error); + return json({ error: 'Failed to update container', details: error?.message || String(error) }, { status: 500 }); } }; diff --git a/src/routes/api/containers/check-updates/+server.ts b/src/routes/api/containers/check-updates/+server.ts index 507a552..94ba655 100644 --- a/src/routes/api/containers/check-updates/+server.ts +++ b/src/routes/api/containers/check-updates/+server.ts @@ -4,6 +4,7 @@ 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'; +import { createJobResponse } from '$lib/server/sse'; export interface UpdateCheckResult { containerId: string; @@ -19,9 +20,9 @@ export interface UpdateCheckResult { /** * Check all containers for available image updates. - * Returns all results at once after checking in parallel. + * Returns progress events during checking, final result when done. */ -export const POST: RequestHandler = async ({ url, cookies }) => { +export const POST: RequestHandler = async ({ url, cookies, request }) => { const auth = await authorize(cookies); const envId = url.searchParams.get('env'); @@ -32,21 +33,21 @@ export const POST: RequestHandler = async ({ url, cookies }) => { return json({ error: 'Permission denied' }, { status: 403 }); } - try { + return createJobResponse(async (send) => { // Clear existing pending updates for this environment before checking if (envIdNum) { await clearPendingContainerUpdates(envIdNum); } const allContainers = await listContainers(true, envIdNum); - - // Include all containers (system containers get flagged, not filtered) const containers = allContainers; + send('progress', { checked: 0, total: containers.length }); + // Check container for updates + let checked = 0; const checkContainer = async (container: typeof containers[0]): Promise => { try { - // Get container's image name from config const inspectData = await inspectContainer(container.id, envIdNum) as any; const imageName = inspectData.Config?.Image; const currentImageId = inspectData.Image; @@ -62,7 +63,6 @@ export const POST: RequestHandler = async ({ url, cookies }) => { }; } - // Use shared update detection function const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum); return { @@ -88,13 +88,23 @@ export const POST: RequestHandler = async ({ url, cookies }) => { } }; - // Check all containers in parallel - const results = await Promise.all(containers.map(checkContainer)); + // Sliding window concurrency limit to avoid DNS threadpool saturation (#676). + const CONCURRENCY = 20; + const results: UpdateCheckResult[] = new Array(containers.length); + let next = 0; + async function runNext(): Promise { + while (next < containers.length) { + const idx = next++; + results[idx] = await checkContainer(containers[idx]); + checked++; + send('progress', { checked, total: containers.length }); + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext())); const updatesFound = results.filter(r => r.hasUpdate).length; // Save containers with updates to the database for persistence - // Skip system containers (Dockhand/Hawser) - they use their own update paths if (envIdNum) { for (const result of results) { if (result.hasUpdate && !result.systemContainer) { @@ -108,13 +118,10 @@ export const POST: RequestHandler = async ({ url, cookies }) => { } } - return json({ + send('result', { total: containers.length, updatesFound, results }); - } catch (error: any) { - console.error('Error checking for updates:', error); - return json({ error: 'Failed to check for updates', details: error.message }, { status: 500 }); - } + }, request); }; diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts index c31e047..48c73c7 100644 --- a/src/routes/api/dashboard/stats/stream/+server.ts +++ b/src/routes/api/dashboard/stats/stream/+server.ts @@ -24,6 +24,7 @@ import { authorize } from '$lib/server/authorize'; import { prefersJSON, sseToJSON } from '$lib/server/sse'; import type { EnvironmentStats } from '../+server'; import { parseLabels } from '$lib/utils/label-colors'; +import { isEdgeConnected } from '$lib/server/hawser'; // Skip disk usage collection (Synology NAS performance fix) @@ -249,6 +250,22 @@ async function getEnvironmentStatsProgressive( loading: { ...envStats.loading } }); + // For edge envs with no connected agent, skip the 5s ping and fail immediately. + // On restart, agents take 30-70s to reconnect — without this check, every open + // dashboard tab fires a 5s ping per edge env simultaneously, creating a flood. + if (env.connectionType === 'hawser-edge' && !isEdgeConnected(env.id)) { + envStats.online = false; + envStats.error = 'Agent not connected'; + envStats.loading = undefined; + onPartialUpdate({ + id: env.id, + online: false, + error: 'Agent not connected', + loading: undefined + }); + return envStats; + } + // Quick reachability check — if ping fails, skip all expensive Docker API calls if (!await dockerPing(env.id)) { envStats.online = false; diff --git a/src/routes/api/environments/+server.ts b/src/routes/api/environments/+server.ts index b4f6941..bc6ec1a 100644 --- a/src/routes/api/environments/+server.ts +++ b/src/routes/api/environments/+server.ts @@ -80,8 +80,14 @@ export const POST: RequestHandler = async (event) => { return json({ error: 'An environment with this name already exists' }, { status: 409 }); } - // Host is required for direct and hawser-standard connections + // Validate connection type + const validConnectionTypes = ['socket', 'direct', 'hawser-standard', 'hawser-edge']; const connectionType = data.connectionType || 'socket'; + if (!validConnectionTypes.includes(connectionType)) { + return json({ error: `Invalid connection type: ${connectionType}` }, { status: 400 }); + } + + // Host is required for direct and hawser-standard connections if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) { return json({ error: 'Host is required for this connection type' }, { status: 400 }); } diff --git a/src/routes/api/host/+server.ts b/src/routes/api/host/+server.ts index a7417d1..efd1d4a 100644 --- a/src/routes/api/host/+server.ts +++ b/src/routes/api/host/+server.ts @@ -152,7 +152,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => { return json(hostInfo); } catch (error) { - console.error('Failed to get host info:', error); + console.error('Failed to get host info:', (error as Error)?.message ?? error); return json({ error: 'Failed to get host info' }, { status: 500 }); } }; diff --git a/src/routes/api/registry/tags/+server.ts b/src/routes/api/registry/tags/+server.ts index 7b4f440..6e76915 100644 --- a/src/routes/api/registry/tags/+server.ts +++ b/src/routes/api/registry/tags/+server.ts @@ -49,7 +49,9 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize: if (response.status === 404) { throw new Error('Image not found on Docker Hub'); } - throw new Error(`Docker Hub returned error: ${response.status}`); + const err = new Error(`Docker Hub returned error: ${response.status}`) as any; + err.statusCode = response.status; + throw err; } const data = await response.json(); @@ -162,6 +164,9 @@ export const GET: RequestHandler = async ({ url }) => { if (error.code === 'ENOTFOUND') { return json({ error: 'Registry host not found' }, { status: 503 }); } + if (error.statusCode) { + return json({ error: error.message || 'Failed to fetch tags' }, { status: error.statusCode }); + } return json({ error: error.message || 'Failed to fetch tags' }, { status: 500 }); } diff --git a/src/routes/api/self-update/+server.ts b/src/routes/api/self-update/+server.ts index f90d120..71adf1c 100644 --- a/src/routes/api/self-update/+server.ts +++ b/src/routes/api/self-update/+server.ts @@ -1,26 +1,41 @@ import { json } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; -import { getOwnContainerId, getHostDockerSocket } from '$lib/server/host-path'; +import { getOwnContainerId, getHostDockerSocket, getOwnDockerHost, getOwnNetworkMode } from '$lib/server/host-path'; import { buildRegistryAuthHeader, unixSocketRequest, unixSocketStreamRequest } from '$lib/server/docker'; import type { RequestHandler } from './$types'; import { prefersJSON, sseToJSON } from '$lib/server/sse'; const UPDATER_IMAGE = 'fnsys/dockhand-updater:latest'; const UPDATER_LABEL = 'dockhand.updater'; -const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock'; -/** Fetch from the local Docker socket (buffered). */ -function localDockerFetch(path: string, options: RequestInit = {}): Promise { - return unixSocketRequest(DOCKER_SOCKET, path, options); +/** Get TCP Docker host if configured, null otherwise. */ +function getDockerTcpHost(): string | null { + const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost(); + return dockerHost?.startsWith('tcp://') ? dockerHost : null; } -/** Fetch from the local Docker socket (streaming body for pull progress). */ +/** Fetch from the local Docker (buffered). Supports TCP and Unix socket. */ +function localDockerFetch(path: string, options: RequestInit = {}): Promise { + const tcpHost = getDockerTcpHost(); + if (tcpHost) { + return fetch(tcpHost.replace('tcp://', 'http://') + path, options); + } + const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock'; + return unixSocketRequest(socketPath, path, options); +} + +/** Fetch from the local Docker (streaming body for pull progress). */ function localDockerStreamFetch(path: string, options: RequestInit = {}): Promise { - return unixSocketStreamRequest(DOCKER_SOCKET, path, options); + const tcpHost = getDockerTcpHost(); + if (tcpHost) { + return fetch(tcpHost.replace('tcp://', 'http://') + path, options); + } + const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock'; + return unixSocketStreamRequest(socketPath, path, options); } /** - * Pull an image via local Docker socket, streaming progress via callback. + * Pull an image via local Docker, streaming progress via callback. */ async function pullImageLocal(imageName: string, onProgress?: (line: string) => void): Promise { let fromImage = imageName; @@ -79,9 +94,14 @@ async function pullImageLocal(imageName: string, onProgress?: (line: string) => } /** - * Check if Docker socket is mounted read-write + * Check if Docker access allows write operations. + * TCP connections always allow writes (no RO mount concept). + * Socket connections check if the mount is read-write. */ -async function isDockerSocketWritable(containerId: string): Promise { +async function isDockerWritable(containerId: string): Promise { + // TCP connections don't have mount-level RO/RW — access implies full control + if (getDockerTcpHost()) return true; + const response = await localDockerFetch(`/containers/${containerId}/json`); if (!response.ok) return false; @@ -235,7 +255,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { return json({ error: 'Not running in Docker' }, { status: 400 }); } - const writable = await isDockerSocketWritable(containerId); + const writable = await isDockerWritable(containerId); if (!writable) { return json({ error: 'Docker socket is mounted read-only. Self-update requires read-write Docker socket access.' @@ -252,8 +272,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => { return json({ error: 'Failed to determine container name' }, { status: 500 }); } - const socketHostPath = getHostDockerSocket(); - // Start SSE stream for preparation progress const encoder = new TextEncoder(); let controllerClosed = false; @@ -353,6 +371,25 @@ export const POST: RequestHandler = async ({ request, cookies }) => { ...networkEnvVars ]; + // Configure updater's Docker access based on connection type + const tcpHost = getDockerTcpHost(); + const updaterHostConfig: Record = { AutoRemove: true }; + + if (tcpHost) { + // TCP: pass DOCKER_HOST so docker CLI in sidecar uses TCP + updaterEnv.push(`DOCKER_HOST=${tcpHost}`); + // Put sidecar on same network so it can reach the Docker TCP endpoint + const network = getOwnNetworkMode(); + if (network) { + updaterHostConfig.NetworkMode = network; + } + send('log', { message: `Updater using TCP: ${tcpHost}` }); + } else { + // Socket: bind-mount the host Docker socket + const socketHostPath = getHostDockerSocket(); + updaterHostConfig.Binds = [`${socketHostPath}:/var/run/docker.sock`]; + } + console.log('[SelfUpdate] Creating updater container...'); const updaterResponse = await localDockerFetch('/containers/create?name=dockhand-updater', { method: 'POST', @@ -363,12 +400,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { Labels: { [UPDATER_LABEL]: 'true' }, - HostConfig: { - AutoRemove: true, - Binds: [ - `${socketHostPath}:/var/run/docker.sock` - ] - } + HostConfig: updaterHostConfig }) }); diff --git a/src/routes/api/self-update/check/+server.ts b/src/routes/api/self-update/check/+server.ts index c81cee8..ad0b894 100644 --- a/src/routes/api/self-update/check/+server.ts +++ b/src/routes/api/self-update/check/+server.ts @@ -1,15 +1,23 @@ import { json } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; -import { getOwnContainerId } from '$lib/server/host-path'; +import { getOwnContainerId, getOwnDockerHost } from '$lib/server/host-path'; import { getRegistryManifestDigest, unixSocketRequest } from '$lib/server/docker'; import { compareVersions } from '$lib/utils/version'; import type { RequestHandler } from './$types'; -const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock'; - -/** Fetch from the local Docker socket directly (not through environment routing) */ +/** Fetch from the local Docker directly (not through environment routing) */ function localDockerFetch(path: string, options: RequestInit = {}): Promise { - return unixSocketRequest(DOCKER_SOCKET, path, options); + const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost(); + + if (dockerHost?.startsWith('tcp://')) { + // TCP connection (socat proxy, socket-proxy, remote Docker) + const url = dockerHost.replace('tcp://', 'http://') + path; + return fetch(url, options); + } + + // Unix socket (default) + const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock'; + return unixSocketRequest(socketPath, path, options); } /** diff --git a/src/routes/api/self-update/progress/+server.ts b/src/routes/api/self-update/progress/+server.ts index d92bd9b..dd87211 100644 --- a/src/routes/api/self-update/progress/+server.ts +++ b/src/routes/api/self-update/progress/+server.ts @@ -1,13 +1,17 @@ import { json } from '@sveltejs/kit'; import { authorize } from '$lib/server/authorize'; +import { getOwnDockerHost } from '$lib/server/host-path'; import { unixSocketRequest } from '$lib/server/docker'; import type { RequestHandler } from './$types'; -const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock'; - -/** Fetch from the local Docker socket directly */ +/** Fetch from the local Docker directly. Supports TCP and Unix socket. */ function localDockerFetch(path: string): Promise { - return unixSocketRequest(DOCKER_SOCKET, path); + const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost(); + if (dockerHost?.startsWith('tcp://')) { + return fetch(dockerHost.replace('tcp://', 'http://') + path); + } + const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock'; + return unixSocketRequest(socketPath, path); } /** diff --git a/src/routes/api/settings/general/+server.ts b/src/routes/api/settings/general/+server.ts index aaf3ecc..7118af0 100644 --- a/src/routes/api/settings/general/+server.ts +++ b/src/routes/api/settings/general/+server.ts @@ -65,6 +65,8 @@ export interface GeneralSettings { gridFontSize: string; terminalFont: string; editorFont: string; + // Compact ports + compactPorts: boolean; // External stack paths externalStackPaths: string[]; // Primary stack location @@ -85,6 +87,7 @@ const DEFAULT_SETTINGS: Omit { gridFontSize, terminalFont, editorFont, + compactPorts, externalStackPaths, primaryStackLocation ] = await Promise.all([ @@ -169,6 +173,7 @@ export const GET: RequestHandler = async ({ cookies }) => { getSetting('theme_grid_font_size'), getSetting('theme_terminal_font'), getSetting('theme_editor_font'), + getSetting('compact_ports'), getExternalStackPaths(), getPrimaryStackLocation() ]); @@ -200,6 +205,7 @@ export const GET: RequestHandler = async ({ cookies }) => { gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize, terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont, editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont, + compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts, externalStackPaths, primaryStackLocation }; @@ -219,7 +225,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { try { const body = await request.json(); - const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, externalStackPaths, primaryStackLocation } = body; + const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, externalStackPaths, primaryStackLocation } = body; if (confirmDestructive !== undefined) { await setSetting('confirm_destructive', confirmDestructive); @@ -312,6 +318,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => { if (editorFont !== undefined && VALID_EDITOR_FONTS.includes(editorFont)) { await setSetting('theme_editor_font', editorFont); } + if (compactPorts !== undefined) { + await setSetting('compact_ports', compactPorts); + } if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) { // Filter to valid non-empty strings const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim()); @@ -355,6 +364,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { gridFontSizeVal, terminalFontVal, editorFontVal, + compactPortsVal, externalStackPathsVal, primaryStackLocationVal ] = await Promise.all([ @@ -384,6 +394,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { getSetting('theme_grid_font_size'), getSetting('theme_terminal_font'), getSetting('theme_editor_font'), + getSetting('compact_ports'), getExternalStackPaths(), getPrimaryStackLocation() ]); @@ -415,6 +426,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => { gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize, terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont, editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont, + compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts, externalStackPaths: externalStackPathsVal, primaryStackLocation: primaryStackLocationVal }; diff --git a/src/routes/api/volumes/[name]/+server.ts b/src/routes/api/volumes/[name]/+server.ts index 2f0c643..ad90296 100644 --- a/src/routes/api/volumes/[name]/+server.ts +++ b/src/routes/api/volumes/[name]/+server.ts @@ -24,9 +24,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => { const volume = await inspectVolume(params.name, envIdNum); return json(volume); - } catch (error) { - console.error('Failed to inspect volume:', error); - return json({ error: 'Failed to inspect volume' }, { status: 500 }); + } catch (error: any) { + const status = error.statusCode ?? 500; + console.error(`Failed to inspect volume ${params.name}: ${error.message}`); + return json({ error: 'Failed to inspect volume' }, { status }); } }; @@ -57,7 +58,12 @@ export const DELETE: RequestHandler = async (event) => { return json({ success: true }); } catch (error: any) { - console.error('Failed to remove volume:', error); - return json({ error: 'Failed to remove volume', details: error.message }, { status: 500 }); + const status = error.statusCode ?? 500; + if (status === 404) { + console.warn(`Failed to remove volume ${params.name}: ${error.message}`); + } else { + console.error(`Failed to remove volume ${params.name}: ${error.message}`); + } + return json({ error: 'Failed to remove volume', details: error.message }, { status }); } }; diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index 24ed5e0..03a1810 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -81,6 +81,7 @@ import { appSettings } from '$lib/stores/settings'; import { canAccess } from '$lib/stores/auth'; import { vulnerabilityCriteriaIcons } from '$lib/utils/update-steps'; + import { watchJob } from '$lib/utils/sse-fetch'; import { ipToNumber } from '$lib/utils/ip'; import { formatHostPortUrl } from '$lib/utils/url'; import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, type ShellDetectionResult } from '$lib/utils/shell-detection'; @@ -262,6 +263,8 @@ // Update check state let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle'); + let updateCheckProgress = $state({ checked: 0, total: 0 }); + let updateCheckBtnEl = $state(null); let showBatchUpdateModal = $state(false); const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds); const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames); @@ -423,6 +426,13 @@ async function checkForUpdates() { updateCheckStatus = 'checking'; + updateCheckProgress = { checked: 0, total: 0 }; + + // Lock button width to prevent layout shift + if (updateCheckBtnEl) { + updateCheckBtnEl.style.minWidth = `${updateCheckBtnEl.offsetWidth}px`; + } + try { const response = await fetch(appendEnvParam('/api/containers/check-updates', envId), { method: 'POST' @@ -430,9 +440,20 @@ if (!response.ok) { updateCheckStatus = 'error'; pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000)); + if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = ''; return; } - const data = await response.json(); + const { jobId } = await response.json(); + + const data: any = await watchJob(jobId, (line) => { + if (line.event === 'progress') { + updateCheckProgress = line.data as { checked: number; total: number }; + } + }); + + // Unlock button width + if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = ''; + const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate); const failedChecks = data.results.filter((r: any) => r.error && !r.hasUpdate).length; const failedSuffix = failedChecks > 0 ? ` (${failedChecks} failed to check)` : ''; @@ -459,6 +480,7 @@ } catch (error) { updateCheckStatus = 'error'; pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000)); + if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = ''; } } @@ -1369,22 +1391,35 @@ {/if} {#if updatableContainersCount > 0}