mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
v1.0.20
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dockhand",
|
||||
"private": true,
|
||||
"version": "1.0.19",
|
||||
"version": "1.0.18",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx vite dev",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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}: <missing> → ${JSON.stringify(b[key])}`);
|
||||
} else if (!(key in b)) {
|
||||
diffs.push(`${childPath}: ${JSON.stringify(a[key])} → <missing>`);
|
||||
} else {
|
||||
diffs.push(...deepDiff(a[key], b[key], childPath));
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreate a container using full Config/HostConfig passthrough from inspect data.
|
||||
|
||||
@@ -138,6 +138,10 @@ const stackLocks = new Map<string, Promise<void>>();
|
||||
// Track active TLS temp directories for cleanup on unexpected process exit
|
||||
const activeTlsDirs = new Set<string>();
|
||||
|
||||
// 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<string, string>();
|
||||
|
||||
// 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<string | undefined> {
|
||||
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));
|
||||
|
||||
@@ -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}: <missing> → ${JSON.stringify(b[key])}`);
|
||||
} else if (!(key in b)) {
|
||||
diffs.push(`${childPath}: ${JSON.stringify(a[key])} → <missing>`);
|
||||
} else {
|
||||
diffs.push(...deepDiff(a[key], b[key], childPath));
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format field name for display (camelCase to Title Case)
|
||||
*/
|
||||
|
||||
@@ -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<void>((resolve) => {
|
||||
await new Promise<void>((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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user