diff --git a/package.json b/package.json index 9533afc..7a4247e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dockhand", "private": true, - "version": "1.0.19", + "version": "1.0.18", "type": "module", "scripts": { "dev": "npx vite dev", diff --git a/src/lib/data/changelog.json b/src/lib/data/changelog.json index 8bdde78..57ff30c 100644 --- a/src/lib/data/changelog.json +++ b/src/lib/data/changelog.json @@ -1,7 +1,15 @@ [ + { + "version": "1.0.20", + "date": "2026-03-02", + "changes": [ + { "type": "fix", "text": "regression on Synology DSM" } + ], + "imageTag": "fnsys/dockhand:v1.0.20" + }, { "version": "1.0.19", - "comingSoon": true, + "date": "2026-03-01", "changes": [ { "type": "feature", "text": "Inline logs panel on stacks page — view container logs without leaving the page" }, { "type": "feature", "text": "Make ports column sortable in containers grid" }, diff --git a/src/lib/server/docker.ts b/src/lib/server/docker.ts index c3fceab..1fd7f5a 100644 --- a/src/lib/server/docker.ts +++ b/src/lib/server/docker.ts @@ -14,6 +14,7 @@ import { createHash } from 'node:crypto'; import type { Environment } from './db'; import { getStackEnvVarsAsRecord } from './db'; import { isSystemContainer } from './scheduler/tasks/update-utils'; +import { deepDiff } from '../utils/diff.js'; /** * Custom error for when an environment is not found. @@ -1664,42 +1665,6 @@ export async function createContainer(options: CreateContainerOptions, envId?: n return { id: result.Id, start: () => startContainer(result.Id, envId) }; } -/** - * Deep-diff two objects recursively, returning all paths that differ. - */ -export function deepDiff(a: any, b: any, path = ''): string[] { - const diffs: string[] = []; - - if (a === b) return diffs; - if (a === null || b === null || typeof a !== typeof b) { - diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`); - return diffs; - } - if (typeof a !== 'object') { - if (a !== b) diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`); - return diffs; - } - if (Array.isArray(a) || Array.isArray(b)) { - const aStr = JSON.stringify(a); - const bStr = JSON.stringify(b); - if (aStr !== bStr) diffs.push(`${path}: ${aStr} → ${bStr}`); - return diffs; - } - - const allKeys = Array.from(new Set([...Object.keys(a), ...Object.keys(b)])); - for (const key of allKeys) { - const childPath = path ? `${path}.${key}` : key; - if (!(key in a)) { - diffs.push(`${childPath}: → ${JSON.stringify(b[key])}`); - } else if (!(key in b)) { - diffs.push(`${childPath}: ${JSON.stringify(a[key])} → `); - } else { - diffs.push(...deepDiff(a[key], b[key], childPath)); - } - } - - return diffs; -} /** * Recreate a container using full Config/HostConfig passthrough from inspect data. diff --git a/src/lib/server/stacks.ts b/src/lib/server/stacks.ts index e169084..348ddc1 100644 --- a/src/lib/server/stacks.ts +++ b/src/lib/server/stacks.ts @@ -138,6 +138,10 @@ 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 = () => { @@ -153,6 +157,25 @@ 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). + */ +async function getDockerApiVersionForCli(envId: number | null | undefined): Promise { + const key = String(envId ?? 'local'); + if (dockerApiVersionCache.has(key)) return dockerApiVersionCache.get(key); + try { + const { getDockerVersion } = await import('./docker.js'); + const version = await getDockerVersion(envId); + const apiVersion: string | undefined = version?.ApiVersion; + if (apiVersion) dockerApiVersionCache.set(key, apiVersion); + return apiVersion; + } catch { + return undefined; + } +} + /** * Execute a function with exclusive lock on a stack. * Prevents race conditions when multiple operations target the same stack. @@ -909,6 +932,15 @@ 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; + } + // Check if .env file exists on disk (for legacy support decision) const defaultEnvPath = join(stackDir, '.env'); const hasEnvFile = existsSync(defaultEnvPath) || (customEnvPath && existsSync(customEnvPath)); diff --git a/src/lib/utils/diff.ts b/src/lib/utils/diff.ts index db37017..95b23cf 100644 --- a/src/lib/utils/diff.ts +++ b/src/lib/utils/diff.ts @@ -185,6 +185,44 @@ function formatValue(val: any): any { return val; } +/** + * Deep-diff two objects recursively, returning all paths that differ. + * Used for comparing container inspect snapshots before and after recreation. + */ +export function deepDiff(a: any, b: any, path = ''): string[] { + const diffs: string[] = []; + + if (a === b) return diffs; + if (a === null || b === null || typeof a !== typeof b) { + diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`); + return diffs; + } + if (typeof a !== 'object') { + if (a !== b) diffs.push(`${path}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`); + return diffs; + } + if (Array.isArray(a) || Array.isArray(b)) { + const aStr = JSON.stringify(a); + const bStr = JSON.stringify(b); + if (aStr !== bStr) diffs.push(`${path}: ${aStr} → ${bStr}`); + return diffs; + } + + const allKeys = Array.from(new Set([...Object.keys(a), ...Object.keys(b)])); + for (const key of allKeys) { + const childPath = path ? `${path}.${key}` : key; + if (!(key in a)) { + diffs.push(`${childPath}: → ${JSON.stringify(b[key])}`); + } else if (!(key in b)) { + diffs.push(`${childPath}: ${JSON.stringify(a[key])} → `); + } else { + diffs.push(...deepDiff(a[key], b[key], childPath)); + } + } + + return diffs; +} + /** * Format field name for display (camelCase to Title Case) */ diff --git a/src/routes/api/images/pull/+server.ts b/src/routes/api/images/pull/+server.ts index 909b2db..e4855cf 100644 --- a/src/routes/api/images/pull/+server.ts +++ b/src/routes/api/images/pull/+server.ts @@ -6,7 +6,7 @@ import { saveVulnerabilityScan, getEnvironment } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; import { auditImage } from '$lib/server/audit'; import { sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser'; -import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs'; +import { createJobResponse } from '$lib/server/sse'; /** * Check if environment is edge mode @@ -74,78 +74,74 @@ export const POST: RequestHandler = async (event) => { // Check if this is an edge environment const edgeCheck = await isEdgeMode(envId); - // Job pattern: create job, run in background, return jobId immediately - const job = createJob(); + return createJobResponse(async (send) => { + const sendData = (data: unknown) => { + send('progress', data); + }; - const sendData = (data: unknown) => { - appendLine(job, { data }); - }; + /** + * Handle scan-on-pull after image is pulled + */ + const handleScanOnPull = async () => { + if (skipScanOnPull) return; - /** - * Handle scan-on-pull after image is pulled - */ - const handleScanOnPull = async () => { - if (skipScanOnPull) return; + const { scanner } = await getScannerSettings(envId); + if (scanner !== 'none') { + sendData({ status: 'scanning', message: 'Starting vulnerability scan...' }); - const { scanner } = await getScannerSettings(envId); - if (scanner !== 'none') { - sendData({ status: 'scanning', message: 'Starting vulnerability scan...' }); + try { + const results = await scanImage(image, envId, (progress) => { + sendData({ status: 'scan-progress', ...progress }); + }); - try { - const results = await scanImage(image, envId, (progress) => { - sendData({ status: 'scan-progress', ...progress }); - }); + for (const result of results) { + await saveVulnerabilityScan({ + environmentId: envId ?? null, + imageId: result.imageId, + imageName: result.imageName, + scanner: result.scanner, + scannedAt: result.scannedAt, + scanDuration: result.scanDuration, + criticalCount: result.summary.critical, + highCount: result.summary.high, + mediumCount: result.summary.medium, + lowCount: result.summary.low, + negligibleCount: result.summary.negligible, + unknownCount: result.summary.unknown, + vulnerabilities: result.vulnerabilities, + error: result.error ?? null + }); + } - for (const result of results) { - await saveVulnerabilityScan({ - environmentId: envId ?? null, - imageId: result.imageId, - imageName: result.imageName, - scanner: result.scanner, - scannedAt: result.scannedAt, - scanDuration: result.scanDuration, - criticalCount: result.summary.critical, - highCount: result.summary.high, - mediumCount: result.summary.medium, - lowCount: result.summary.low, - negligibleCount: result.summary.negligible, - unknownCount: result.summary.unknown, - vulnerabilities: result.vulnerabilities, - error: result.error ?? null + const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0); + sendData({ + status: 'scan-complete', + message: `Scan complete - found ${totalVulns} vulnerabilities`, + results + }); + } catch (scanError) { + console.error('Scan-on-pull failed:', scanError); + sendData({ + status: 'scan-error', + error: scanError instanceof Error ? scanError.message : String(scanError) }); } - - const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0); - sendData({ - status: 'scan-complete', - message: `Scan complete - found ${totalVulns} vulnerabilities`, - results - }); - } catch (scanError) { - console.error('Scan-on-pull failed:', scanError); - sendData({ - status: 'scan-error', - error: scanError instanceof Error ? scanError.message : String(scanError) - }); } - } - }; + }; - // Run operation in background - (async () => { console.log(`Starting pull for image: ${image}${edgeCheck.isEdge ? ' (edge mode)' : ''}`); if (edgeCheck.isEdge && edgeCheck.environmentId) { if (!isEdgeConnected(edgeCheck.environmentId)) { sendData({ status: 'error', error: 'Edge agent not connected' }); - failJob(job, 'Edge agent not connected'); - return; + send('result', { status: 'error', error: 'Edge agent not connected' }); + throw new Error('Edge agent not connected'); } const pullUrl = buildPullUrl(image); const authHeaders = await buildRegistryAuthHeader(image); - await new Promise((resolve) => { + await new Promise((resolve, reject) => { const { cancel } = sendEdgeStreamRequest( edgeCheck.environmentId!, 'POST', @@ -173,14 +169,14 @@ export const POST: RequestHandler = async (event) => { onEnd: async () => { sendData({ status: 'complete' }); await handleScanOnPull(); - completeJob(job, { status: 'complete' }); + send('result', { status: 'complete' }); resolve(); }, onError: (error: string) => { console.error('Edge pull error:', error); sendData({ status: 'error', error }); - failJob(job, error); - resolve(); + send('result', { status: 'error', error }); + reject(new Error(error)); } }, undefined, @@ -198,17 +194,14 @@ export const POST: RequestHandler = async (event) => { sendData({ status: 'complete' }); await handleScanOnPull(); - completeJob(job, { status: 'complete' }); + send('result', { status: 'complete' }); } catch (error) { console.error('Error pulling image:', error); const errMsg = String(error); sendData({ status: 'error', error: errMsg }); - failJob(job, errMsg); + send('result', { status: 'error', error: errMsg }); + throw error; } } - })().catch((err) => { - failJob(job, err instanceof Error ? err.message : String(err)); - }); - - return json({ jobId: job.id }); + }, request); }; diff --git a/src/routes/api/images/scan/+server.ts b/src/routes/api/images/scan/+server.ts index 7d6ba12..0cb9164 100644 --- a/src/routes/api/images/scan/+server.ts +++ b/src/routes/api/images/scan/+server.ts @@ -2,7 +2,7 @@ import { json, type RequestHandler } from '@sveltejs/kit'; import { scanImage, type ScanProgress, type ScanResult } from '$lib/server/scanner'; import { saveVulnerabilityScan, getLatestScanForImage } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; -import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs'; +import { createJobResponse } from '$lib/server/sse'; // Helper to convert ScanResult to database format function scanResultToDbFormat(result: ScanResult, envId?: number) { @@ -24,7 +24,7 @@ function scanResultToDbFormat(result: ScanResult, envId?: number) { }; } -// POST - Start a scan (returns { jobId } for progress polling) +// POST - Start a scan (returns { jobId } for progress polling, or synchronous JSON for Accept: application/json) export const POST: RequestHandler = async ({ request, url, cookies }) => { const auth = await authorize(cookies); @@ -43,14 +43,11 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { return json({ error: 'Image name is required' }, { status: 400 }); } - // Job pattern: create job, run in background, return jobId immediately - const job = createJob(); + return createJobResponse(async (send) => { + const sendProgress = (progress: ScanProgress) => { + send('progress', progress); + }; - const sendProgress = (progress: ScanProgress) => { - appendLine(job, { data: progress }); - }; - - (async () => { try { const results = await scanImage(imageName, envId, sendProgress, forceScannerType); @@ -67,8 +64,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { result: results[0], results: results // Include all scanner results }; - sendProgress(completeProgress); - completeJob(job, completeProgress); + send('result', completeProgress); } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); const errorProgress: ScanProgress = { @@ -76,14 +72,10 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => { message: `Scan failed: ${errorMsg}`, error: errorMsg }; - sendProgress(errorProgress); - failJob(job, errorMsg); + send('result', errorProgress); + throw error; } - })().catch((err) => { - failJob(job, err instanceof Error ? err.message : String(err)); - }); - - return json({ jobId: job.id }); + }, request); }; // GET - Get cached scan results for an image diff --git a/src/routes/logs/+page.svelte b/src/routes/logs/+page.svelte index b4e72b3..ee6dd4f 100644 --- a/src/routes/logs/+page.svelte +++ b/src/routes/logs/+page.svelte @@ -432,6 +432,16 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; const loggableContainers = allContainers.filter((c: ContainerInfo) => c.state === 'running' || c.state === 'exited' ); + + // Before updating containers, capture current running set for grouped mode change detection + let prevRunningIds: string[] = []; + if (layoutMode === 'grouped' && selectedContainerIds.size > 0) { + prevRunningIds = Array.from(selectedContainerIds).filter(id => { + const container = containers.find(c => c.id === id); + return container?.state === 'running'; + }); + } + containers = loggableContainers; // If selected container is no longer available, clear selection @@ -439,6 +449,23 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server'; selectedContainer = null; logs = ''; } + + // Grouped mode: restart stream if the running/stopped split changed + if (layoutMode === 'grouped' && selectedContainerIds.size > 0 && streamingEnabled) { + const newRunningIds = Array.from(selectedContainerIds).filter(id => { + const container = loggableContainers.find((c: ContainerInfo) => c.id === id); + return container?.state === 'running'; + }); + + const runningSetChanged = + prevRunningIds.length !== newRunningIds.length || + !prevRunningIds.every(id => newRunningIds.includes(id)); + + if (runningSetChanged) { + startGroupedStreaming(); + } + } + return loggableContainers; } catch (error) { console.error('Failed to fetch containers:', error);