This commit is contained in:
jarek committed 2026-03-02 07:59:58 +01:00
1 parent 4b430340db
commit 9c451aedf9
120 files changed
+7777 -6046

No files matched your search

@@ -3,6 +3,7 @@ import { containerEventEmitter } from '$lib/server/event-collector';
import { authorize } from '$lib/server/authorize';
import { json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
@@ -52,6 +53,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
};
// Send initial connection event
sendEvent('connected', { timestamp: new Date().toISOString() });
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
@@ -84,6 +86,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
},
cancel() {
// Cleanup when client disconnects
clearInterval(heartbeatInterval);
if (handleEvent) {
containerEventEmitter.off('event', handleEvent);
+10 -3
View File
@@ -30,11 +30,14 @@ export const POST: RequestHandler = async (event) => {
}
// Rate limiting by IP and username
const clientIp = getClientAddress();
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| request.headers.get('x-real-ip')
|| getClientAddress();
const rateLimitKey = `${clientIp}:${username}`;
const { limited, retryAfter } = isRateLimited(rateLimitKey);
if (limited) {
console.warn(`[Auth] Login rate-limited: user=${username} ip=${clientIp} retryAfter=${retryAfter}s`);
return json(
{ error: `Too many login attempts. Please try again in ${retryAfter} seconds.` },
{ status: 429 }
@@ -66,6 +69,7 @@ export const POST: RequestHandler = async (event) => {
if (!result.success) {
recordFailedAttempt(rateLimitKey);
console.warn(`[Auth] Login failed: user=${username} provider=${authProviderType} ip=${clientIp} reason=${result.error || 'Authentication failed'}`);
return json({ error: result.error || 'Authentication failed' }, { status: 401 });
}
@@ -80,12 +84,14 @@ export const POST: RequestHandler = async (event) => {
const user = await getUserByUsername(username);
if (!user || !(await verifyMfaToken(user.id, mfaToken))) {
recordFailedAttempt(rateLimitKey);
console.warn(`[Auth] MFA failed: user=${username} ip=${clientIp}`);
return json({ error: 'Invalid MFA code' }, { status: 401 });
}
// MFA verified, create session
const session = await createUserSession(user.id, authProviderType, cookies);
const session = await createUserSession(user.id, authProviderType, cookies, request);
clearRateLimit(rateLimitKey);
console.log(`[Auth] Login successful: user=${username} provider=${authProviderType} ip=${clientIp} mfa=yes`);
// Audit log
await auditAuth(event, 'login', user.username, {
@@ -107,8 +113,9 @@ export const POST: RequestHandler = async (event) => {
// No MFA, create session directly
if (result.user) {
const session = await createUserSession(result.user.id, authProviderType, cookies);
const session = await createUserSession(result.user.id, authProviderType, cookies, request);
clearRateLimit(rateLimitKey);
console.log(`[Auth] Login successful: user=${result.user.username} provider=${authProviderType} ip=${clientIp} mfa=no`);
// Audit log
await auditAuth(event, 'login', result.user.username, {
+4
View File
@@ -11,8 +11,12 @@ export const POST: RequestHandler = async (event) => {
// Get current user before destroying session for audit log
const auth = await authorize(cookies);
const username = auth.user?.username || 'unknown';
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| event.request.headers.get('x-real-ip')
|| event.getClientAddress();
await destroySession(cookies);
console.log(`[Auth] Logout: user=${username} ip=${clientIp}`);
// Audit log
await auditAuth(event, 'logout', username);
+9 -1
View File
@@ -17,9 +17,15 @@ export const GET: RequestHandler = async (event) => {
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// Extract client IP for logging
const clientIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| event.request.headers.get('x-real-ip')
|| event.getClientAddress();
// Handle error from IdP
if (error) {
console.error('OIDC error from IdP:', error, errorDescription);
console.warn(`[Auth] OIDC login failed: ip=${clientIp} error=${errorDescription || error}`);
const errorMsg = encodeURIComponent(errorDescription || error);
throw redirect(302, `/login?error=${errorMsg}`);
}
@@ -33,12 +39,14 @@ export const GET: RequestHandler = async (event) => {
const result = await handleOidcCallback(code, state);
if (!result.success || !result.user) {
console.warn(`[Auth] OIDC login failed: ip=${clientIp} error=${result.error || 'Authentication failed'}`);
const errorMsg = encodeURIComponent(result.error || 'Authentication failed');
throw redirect(302, `/login?error=${errorMsg}`);
}
// Create session
await createUserSession(result.user.id, 'oidc', cookies);
await createUserSession(result.user.id, 'oidc', cookies, event.request);
console.log(`[Auth] OIDC login successful: user=${result.user.username} provider=${result.providerName || 'oidc'} ip=${clientIp}`);
// Audit log
await auditAuth(event, 'login', result.user.username, {
+57 -109
View File
@@ -8,8 +8,6 @@ import {
pauseContainer,
unpauseContainer,
removeContainer,
inspectContainer,
listContainers,
removeImage,
removeVolume,
removeNetwork
@@ -23,6 +21,8 @@ import {
} from '$lib/server/stacks';
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
import { unregisterSchedule } from '$lib/server/scheduler';
import { prefersJSON } from '$lib/server/sse';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
// SSE Event types
export type BatchEventType = 'start' | 'progress' | 'complete' | 'error';
@@ -105,15 +105,13 @@ interface BatchRequest {
async function processWithConcurrency<T>(
items: T[],
concurrency: number,
processor: (item: T, index: number) => Promise<void>,
signal: AbortSignal
processor: (item: T, index: number) => Promise<void>
): Promise<void> {
let currentIndex = 0;
const total = items.length;
async function processNext(): Promise<void> {
while (currentIndex < total) {
if (signal.aborted) return;
const index = currentIndex++;
await processor(items[index], index);
}
@@ -128,7 +126,7 @@ async function processWithConcurrency<T>(
}
/**
* Unified batch operations endpoint with SSE streaming.
* Unified batch operations endpoint (job pattern).
* Handles bulk operations for containers, images, volumes, networks, and stacks.
*/
export const POST: RequestHandler = async ({ url, cookies, request }) => {
@@ -182,124 +180,74 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
// Check if audit is needed (enterprise only)
const needsAudit = auth.isEnterprise;
// Create abort controller for cancellation
const abortController = new AbortController();
// Sync path for API clients that prefer plain JSON (Accept: application/json only)
if (prefersJSON(request)) {
let successCount = 0;
let failCount = 0;
const encoder = new TextEncoder();
let controllerClosed = false;
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
await processWithConcurrency(items, 3, async (item, index) => {
const { id, name } = item;
try {
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
successCount++;
} catch {
failCount++;
}
});
const stream = new ReadableStream({
async start(controller) {
const safeEnqueue = (data: BatchEvent) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
controllerClosed = true;
abortController.abort();
}
}
};
return json({
type: 'complete',
summary: { total: items.length, success: successCount, failed: failCount }
});
}
// Send SSE keepalive comments every 5s
keepaliveInterval = setInterval(() => {
if (controllerClosed) return;
try {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
} catch {
controllerClosed = true;
abortController.abort();
}
}, 5000);
// Job pattern: create job, process in background, return jobId immediately
const job = createJob();
let successCount = 0;
let failCount = 0;
(async () => {
let successCount = 0;
let failCount = 0;
// Send start event
safeEnqueue({
type: 'start',
total: items.length
appendLine(job, { data: { type: 'start', total: items.length } });
await processWithConcurrency(items, 3, async (item, index) => {
const { id, name } = item;
appendLine(job, {
data: { type: 'progress', id, name, status: 'processing', current: index + 1, total: items.length }
});
// Process items with concurrency of 3
await processWithConcurrency(
items,
3,
async (item, index) => {
if (abortController.signal.aborted) return;
const { id, name } = item;
// Send processing status
safeEnqueue({
try {
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
appendLine(job, {
data: { type: 'progress', id, name, status: 'success', current: index + 1, total: items.length }
});
successCount++;
} catch (error: any) {
appendLine(job, {
data: {
type: 'progress',
id,
name,
status: 'processing',
status: 'error',
error: error.message || 'Unknown error',
current: index + 1,
total: items.length
});
try {
await executeOperation(entityType, operation, id, name, envIdNum, options, needsAudit);
safeEnqueue({
type: 'progress',
id,
name,
status: 'success',
current: index + 1,
total: items.length
});
successCount++;
} catch (error: any) {
safeEnqueue({
type: 'progress',
id,
name,
status: 'error',
error: error.message || 'Unknown error',
current: index + 1,
total: items.length
});
failCount++;
}
},
abortController.signal
);
// Send complete event
safeEnqueue({
type: 'complete',
summary: {
total: items.length,
success: successCount,
failed: failCount
}
});
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
});
failCount++;
}
controller.close();
},
cancel() {
controllerClosed = true;
abortController.abort();
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
}
});
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
const completeEvent = {
type: 'complete',
summary: { total: items.length, success: successCount, failed: failCount }
};
appendLine(job, { data: completeEvent });
completeJob(job, completeEvent);
})().catch((err) => failJob(job, err.message));
return json({ jobId: job.id });
};
/**
+4 -2
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, type CreateContainerOptions } from '$lib/server/docker';
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, DockerConnectionError, type CreateContainerOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { hasEnvironments } from '$lib/server/db';
@@ -40,7 +40,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
console.error('Error listing containers:', error);
if (!(error instanceof DockerConnectionError)) {
console.error('Error listing containers:', error);
}
// Return empty array instead of error to allow UI to load
return json([]);
}
@@ -1,3 +1,4 @@
import { gzipSync } from 'node:zlib';
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import type { RequestHandler } from './$types';
@@ -50,9 +51,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let extension = '.tar';
if (format === 'tar.gz') {
// Compress with gzip using Bun's native implementation
// Compress with gzip
const tarData = new Uint8Array(await response.arrayBuffer());
body = Bun.gzipSync(tarData);
body = gzipSync(tarData);
contentType = 'application/gzip';
extension = '.tar.gz';
}
@@ -1,6 +1,8 @@
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getEnvironment } from '$lib/server/db';
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
@@ -56,6 +58,7 @@ async function getDockerConfig(envId?: number | null): Promise<DockerClientConfi
return { type: 'hawser-edge', environmentId: envId };
}
const protocol = (env.protocol as 'http' | 'https') || 'http';
return {
type: protocol,
host: env.host || 'localhost',
@@ -119,6 +122,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const safeEnqueue = (data: string) => {
@@ -226,6 +230,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
cancelStream = cancel;
},
cancel() {
controllerClosed = true;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
@@ -279,28 +284,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let inspectResponse: Response;
if (config.type === 'socket') {
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
// @ts-ignore - Bun supports unix socket
unix: config.socketPath
});
inspectResponse = await unixSocketRequest(config.socketPath, inspectPath);
} else if (config.type === 'https') {
const extraHeaders: Record<string, string> = {};
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
inspectResponse = await httpsAgentRequest(config as BaseDockerClientConfig, inspectPath, {}, false, extraHeaders);
} else {
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
const inspectUrl = `http://${config.host}:${config.port}${inspectPath}`;
const inspectHeaders: Record<string, string> = {};
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
const fetchOpts: any = { headers: inspectHeaders };
if (config.type === 'https') {
fetchOpts.tls = {
sessionTimeout: 0,
servername: config.host,
rejectUnauthorized: !config.skipVerify
};
if (config.ca) fetchOpts.tls.ca = [config.ca];
if (config.cert) fetchOpts.tls.cert = [config.cert];
if (config.key) fetchOpts.tls.key = config.key;
fetchOpts.keepalive = false;
if (process.env.DEBUG_TLS) fetchOpts.verbose = true;
}
inspectResponse = await fetch(inspectUrl, fetchOpts);
inspectResponse = await fetch(inspectUrl, { headers: inspectHeaders });
}
if (inspectResponse.ok) {
@@ -322,6 +315,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const safeEnqueue = (data: string) => {
@@ -343,32 +337,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let response: Response;
if (config.type === 'socket') {
response = await fetch(`http://localhost${logsPath}`, {
// @ts-ignore - Bun supports unix socket
unix: config.socketPath,
signal: abortController?.signal
});
response = await unixSocketStreamRequest(config.socketPath, logsPath);
} else if (config.type === 'https') {
const extraHeaders: Record<string, string> = {};
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
response = await httpsAgentRequest(config as BaseDockerClientConfig, logsPath, {}, true, extraHeaders);
} else {
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
const logsUrl = `http://${config.host}:${config.port}${logsPath}`;
const logsHeaders: Record<string, string> = {};
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
const fetchOpts: any = {
headers: logsHeaders,
signal: abortController?.signal
};
if (config.type === 'https') {
fetchOpts.tls = {
sessionTimeout: 0,
servername: config.host,
rejectUnauthorized: !config.skipVerify
};
if (config.ca) fetchOpts.tls.ca = [config.ca];
if (config.cert) fetchOpts.tls.cert = [config.cert];
if (config.key) fetchOpts.tls.key = config.key;
fetchOpts.keepalive = false;
if (process.env.DEBUG_TLS) fetchOpts.verbose = true;
}
response = await fetch(logsUrl, fetchOpts);
response = await fetch(logsUrl, { headers: logsHeaders });
}
if (!response.ok) {
@@ -434,7 +412,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
}
}
reader.releaseLock();
await reader.cancel().catch(() => {});
reader.releaseLock();
} catch (error) {
if (!controllerClosed) {
const errorMsg = error instanceof Error ? error.message : String(error);
@@ -444,7 +423,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
}
}
// Clean up on normal stream end (not just cancel)
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (!controllerClosed) {
controllerClosed = true;
try {
controller.close();
} catch {
@@ -453,7 +439,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
}
},
cancel() {
controllerClosed = true;
if (!controllerClosed) {
controllerClosed = true;
}
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
@@ -16,6 +16,7 @@ import { getScannerSettings, scanImage } from '$lib/server/scanner';
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
export interface ScanResult {
critical: number;
@@ -96,423 +97,123 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'containerIds array is required' }, { status: 400 });
}
const encoder = new TextEncoder();
let controllerClosed = false;
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
// Job pattern: create job, run in background, return jobId immediately
const job = createJob();
const stream = new ReadableStream({
async start(controller) {
const safeEnqueue = (data: UpdateProgress) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
controllerClosed = true;
}
}
};
const sendData = (data: UpdateProgress) => {
appendLine(job, { data });
};
// Send SSE keepalive comments every 5s to prevent Traefik (10s idle timeout) from closing connection
keepaliveInterval = setInterval(() => {
if (controllerClosed) return;
try {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
} catch {
controllerClosed = true;
}
}, 5000);
(async () => {
let successCount = 0;
let failCount = 0;
let blockedCount = 0;
let skippedCount = 0;
let successCount = 0;
let failCount = 0;
let blockedCount = 0;
let skippedCount = 0;
// Get scanner settings for this environment
const scannerSettings = await getScannerSettings(envIdNum);
// Scan if scanning is enabled (scanner !== 'none')
// The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
const shouldScan = scannerSettings.scanner !== 'none';
// Get scanner settings for this environment
const scannerSettings = await getScannerSettings(envIdNum);
// Scan if scanning is enabled (scanner !== 'none')
// The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
const shouldScan = scannerSettings.scanner !== 'none';
// Send start event
sendData({
type: 'start',
total: containerIds.length,
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
});
// Send start event
safeEnqueue({
type: 'start',
total: containerIds.length,
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
});
// Process containers sequentially
for (let i = 0; i < containerIds.length; i++) {
const containerId = containerIds[i];
let containerName = 'unknown';
// Process containers sequentially
for (let i = 0; i < containerIds.length; i++) {
const containerId = containerIds[i];
let containerName = 'unknown';
try {
// Find container
const containers = await listContainers(true, envIdNum);
const container = containers.find(c => c.id === containerId);
try {
// Find container
const containers = await listContainers(true, envIdNum);
const container = containers.find(c => c.id === containerId);
if (!container) {
safeEnqueue({
type: 'progress',
containerId,
containerName: 'unknown',
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: 'Container not found'
});
failCount++;
continue;
}
containerName = container.name;
// Get full container config
const inspectData = await inspectContainer(containerId, envIdNum) as any;
const wasRunning = inspectData.State.Running;
const config = inspectData.Config;
const hostConfig = inspectData.HostConfig;
const imageName = config.Image;
const currentImageId = inspectData.Image;
// Skip Dockhand container - cannot update itself
if (isDockhandContainer(imageName)) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - cannot update Dockhand itself`
});
skippedCount++;
continue;
}
// Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageName)) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - image pinned to specific digest`
});
skippedCount++;
continue;
}
// Step 1: Pull latest image
safeEnqueue({
if (!container) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'pulling',
containerName: 'unknown',
step: 'failed',
current: i + 1,
total: containerIds.length,
message: `Pulling ${imageName}...`
success: false,
error: 'Container not found'
});
failCount++;
continue;
}
try {
await pullImage(imageName, (data: any) => {
if (data.status) {
safeEnqueue({
type: 'pull_log',
containerId,
containerName,
pullStatus: data.status,
pullId: data.id,
pullProgress: data.progress
});
}
}, envIdNum);
} catch (pullError: any) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: `Pull failed: ${pullError.message}`
});
failCount++;
continue;
}
containerName = container.name;
// SAFE-PULL FLOW with vulnerability scanning
if (shouldScan && !isDigestBasedImage(imageName)) {
const tempTag = getTempImageTag(imageName);
// Get full container config
const inspectData = await inspectContainer(containerId, envIdNum) as any;
const config = inspectData.Config;
const imageName = config.Image;
const currentImageId = inspectData.Image;
// Get new image ID
const newImageId = await getImageIdByTag(imageName, envIdNum);
if (!newImageId) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: 'Failed to get new image ID after pull'
});
failCount++;
continue;
}
// Restore original tag to old image (safety)
const [oldRepo, oldTag] = parseImageNameAndTag(imageName);
try {
await tagImage(currentImageId, oldRepo, oldTag, envIdNum);
} catch {
// Ignore - old image might have been removed
}
// Tag new image with temp suffix
const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
await tagImage(newImageId, tempRepo, tempTagName, envIdNum);
// Step 2: Scan temp image
safeEnqueue({
type: 'scan_start',
containerId,
containerName,
step: 'scanning',
current: i + 1,
total: containerIds.length,
message: `Scanning ${imageName} for vulnerabilities...`
});
let scanBlocked = false;
let blockReason = '';
let finalScanResult: ScanResult | undefined;
let individualScannerResults: ScannerResult[] = [];
try {
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
if (progress.output || progress.message) {
safeEnqueue({
type: 'scan_log',
containerId,
containerName,
scanner: progress.scanner,
message: progress.output || progress.message
});
}
});
if (scanResults.length > 0) {
const scanSummary = combineScanSummaries(scanResults);
finalScanResult = {
critical: scanSummary.critical,
high: scanSummary.high,
medium: scanSummary.medium,
low: scanSummary.low,
negligible: scanSummary.negligible,
unknown: scanSummary.unknown
};
// Build individual scanner results
individualScannerResults = scanResults.map(result => ({
scanner: result.scanner as 'grype' | 'trivy',
critical: result.summary.critical,
high: result.summary.high,
medium: result.summary.medium,
low: result.summary.low,
negligible: result.summary.negligible,
unknown: result.summary.unknown
}));
// Save scan results
for (const result of scanResults) {
try {
await saveVulnerabilityScan({
environmentId: envIdNum,
imageId: newImageId,
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
});
} catch { /* ignore save errors */ }
}
// Check if blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
if (blocked) {
scanBlocked = true;
blockReason = reason;
}
}
// Collect vulnerabilities from all scanners (cap at 100)
const vulnerabilities = scanResults
.flatMap(r => r.vulnerabilities || [])
.slice(0, 100)
.map(v => ({
id: v.id,
severity: v.severity,
package: v.package,
version: v.version,
fixedVersion: v.fixedVersion,
link: v.link,
scanner: v.scanner
}));
safeEnqueue({
type: 'scan_complete',
containerId,
containerName,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
message: finalScanResult
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
: 'Scan complete: no vulnerabilities found'
});
} catch (scanErr: any) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: `Scan failed: ${scanErr.message}`
});
// Clean up temp image on scan failure
try {
await removeTempImage(newImageId, envIdNum);
} catch { /* ignore cleanup errors */ }
failCount++;
continue;
}
if (scanBlocked) {
// BLOCKED - Remove temp image and skip this container
safeEnqueue({
type: 'blocked',
containerId,
containerName,
step: 'blocked',
current: i + 1,
total: containerIds.length,
success: false,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
blockReason,
message: `Update blocked: ${blockReason}`
});
try {
await removeTempImage(newImageId, envIdNum);
} catch { /* ignore cleanup errors */ }
blockedCount++;
continue;
}
// APPROVED - Re-tag to original
await tagImage(newImageId, oldRepo, oldTag, envIdNum);
try {
await removeTempImage(tempTag, envIdNum);
} catch { /* ignore cleanup errors */ }
}
// Progress logging function for shared functions
const logProgress = (message: string) => {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'creating',
current: i + 1,
total: containerIds.length,
message
});
};
let updateSuccess = false;
let newContainerId = containerId;
safeEnqueue({
// Skip Dockhand container - cannot update itself
if (isDockhandContainer(imageName)) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'creating',
current: i + 1,
total: containerIds.length,
message: `Recreating ${containerName}...`
});
updateSuccess = await recreateContainer(containerName, envIdNum, logProgress, imageName);
if (updateSuccess) {
const updatedContainers = await listContainers(true, envIdNum);
const updatedContainer = updatedContainers.find(c => c.name === containerName);
if (updatedContainer) {
newContainerId = updatedContainer.id;
}
}
if (!updateSuccess) {
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: 'Container recreation failed'
});
failCount++;
continue;
}
// Audit log
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
// Done with this container - use original containerId for UI consistency
safeEnqueue({
type: 'progress',
containerId,
containerName,
step: 'done',
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `${containerName} updated successfully`
message: `Skipping ${containerName} - cannot update Dockhand itself`
});
successCount++;
skippedCount++;
continue;
}
// Clear pending update indicator from database
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
// Ignore errors - record may not exist
});
}
// Skip digest-pinned images - they are explicitly locked to a specific version
if (isDigestBasedImage(imageName)) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'skipped',
current: i + 1,
total: containerIds.length,
success: true,
message: `Skipping ${containerName} - image pinned to specific digest`
});
skippedCount++;
continue;
}
} catch (error: any) {
safeEnqueue({
// Step 1: Pull latest image
sendData({
type: 'progress',
containerId,
containerName,
step: 'pulling',
current: i + 1,
total: containerIds.length,
message: `Pulling ${imageName}...`
});
try {
await pullImage(imageName, (data: any) => {
if (data.status) {
sendData({
type: 'pull_log',
containerId,
containerName,
pullStatus: data.status,
pullId: data.id,
pullProgress: data.progress
});
}
}, envIdNum);
} catch (pullError: any) {
sendData({
type: 'progress',
containerId,
containerName,
@@ -520,53 +221,310 @@ export const POST: RequestHandler = async (event) => {
current: i + 1,
total: containerIds.length,
success: false,
error: error.message
error: `Pull failed: ${pullError.message}`
});
failCount++;
continue;
}
}
// Send complete event
safeEnqueue({
type: 'complete',
summary: {
// SAFE-PULL FLOW with vulnerability scanning
if (shouldScan && !isDigestBasedImage(imageName)) {
const tempTag = getTempImageTag(imageName);
// Get new image ID
const newImageId = await getImageIdByTag(imageName, envIdNum);
if (!newImageId) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: 'Failed to get new image ID after pull'
});
failCount++;
continue;
}
// Restore original tag to old image (safety)
const [oldRepo, oldTag] = parseImageNameAndTag(imageName);
try {
await tagImage(currentImageId, oldRepo, oldTag, envIdNum);
} catch {
// Ignore - old image might have been removed
}
// Tag new image with temp suffix
const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
await tagImage(newImageId, tempRepo, tempTagName, envIdNum);
// Step 2: Scan temp image
sendData({
type: 'scan_start',
containerId,
containerName,
step: 'scanning',
current: i + 1,
total: containerIds.length,
message: `Scanning ${imageName} for vulnerabilities...`
});
let scanBlocked = false;
let blockReason = '';
let finalScanResult: ScanResult | undefined;
let individualScannerResults: ScannerResult[] = [];
try {
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
if (progress.output || progress.message) {
sendData({
type: 'scan_log',
containerId,
containerName,
scanner: progress.scanner,
message: progress.output || progress.message
});
}
});
if (scanResults.length > 0) {
const scanSummary = combineScanSummaries(scanResults);
finalScanResult = {
critical: scanSummary.critical,
high: scanSummary.high,
medium: scanSummary.medium,
low: scanSummary.low,
negligible: scanSummary.negligible,
unknown: scanSummary.unknown
};
// Build individual scanner results
individualScannerResults = scanResults.map(result => ({
scanner: result.scanner as 'grype' | 'trivy',
critical: result.summary.critical,
high: result.summary.high,
medium: result.summary.medium,
low: result.summary.low,
negligible: result.summary.negligible,
unknown: result.summary.unknown
}));
// Save scan results
for (const result of scanResults) {
try {
await saveVulnerabilityScan({
environmentId: envIdNum,
imageId: newImageId,
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
});
} catch { /* ignore save errors */ }
}
// Check if blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
if (blocked) {
scanBlocked = true;
blockReason = reason;
}
}
// Collect vulnerabilities from all scanners (cap at 100)
const vulnerabilities = scanResults
.flatMap(r => r.vulnerabilities || [])
.slice(0, 100)
.map(v => ({
id: v.id,
severity: v.severity,
package: v.package,
version: v.version,
fixedVersion: v.fixedVersion,
link: v.link,
scanner: v.scanner
}));
sendData({
type: 'scan_complete',
containerId,
containerName,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
message: finalScanResult
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
: 'Scan complete: no vulnerabilities found'
});
} catch (scanErr: any) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: `Scan failed: ${scanErr.message}`
});
// Clean up temp image on scan failure
try {
await removeTempImage(newImageId, envIdNum);
} catch { /* ignore cleanup errors */ }
failCount++;
continue;
}
if (scanBlocked) {
// BLOCKED - Remove temp image and skip this container
sendData({
type: 'blocked',
containerId,
containerName,
step: 'blocked',
current: i + 1,
total: containerIds.length,
success: false,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
blockReason,
message: `Update blocked: ${blockReason}`
});
try {
await removeTempImage(newImageId, envIdNum);
} catch { /* ignore cleanup errors */ }
blockedCount++;
continue;
}
// APPROVED - Re-tag to original
await tagImage(newImageId, oldRepo, oldTag, envIdNum);
try {
await removeTempImage(tempTag, envIdNum);
} catch { /* ignore cleanup errors */ }
}
// Progress logging function for shared functions
const logProgress = (message: string) => {
sendData({
type: 'progress',
containerId,
containerName,
step: 'creating',
current: i + 1,
total: containerIds.length,
message
});
};
let newContainerId = containerId;
sendData({
type: 'progress',
containerId,
containerName,
step: 'creating',
current: i + 1,
total: containerIds.length,
success: successCount,
failed: failCount,
blocked: blockedCount,
skipped: skippedCount
},
message: skippedCount > 0 || blockedCount > 0
? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
: `Updated ${successCount} of ${containerIds.length} containers`
});
message: `Recreating ${containerName}...`
});
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
}
if (!controllerClosed) {
try {
controller.close();
controllerClosed = true;
} catch {
// Controller already closed - ignore
controllerClosed = true;
const recreateResult = await recreateContainer(containerName, envIdNum, logProgress, imageName);
if (recreateResult.success) {
const updatedContainers = await listContainers(true, envIdNum);
const updatedContainer = updatedContainers.find(c => c.name === containerName);
if (updatedContainer) {
newContainerId = updatedContainer.id;
}
}
}
},
cancel() {
controllerClosed = true;
if (keepaliveInterval) {
clearInterval(keepaliveInterval);
if (!recreateResult.success) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: recreateResult.error || 'Container recreation failed'
});
failCount++;
continue;
}
// Audit log
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
// Done with this container - use original containerId for UI consistency
sendData({
type: 'progress',
containerId,
containerName,
step: 'done',
current: i + 1,
total: containerIds.length,
success: true,
message: `${containerName} updated successfully`
});
successCount++;
// Clear pending update indicator from database
if (envIdNum) {
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
// Ignore errors - record may not exist
});
}
} catch (error: any) {
sendData({
type: 'progress',
containerId,
containerName,
step: 'failed',
current: i + 1,
total: containerIds.length,
success: false,
error: error.message
});
failCount++;
}
}
// Send complete event
const completeData: UpdateProgress = {
type: 'complete',
summary: {
total: containerIds.length,
success: successCount,
failed: failCount,
blocked: blockedCount,
skipped: skippedCount
},
message: skippedCount > 0 || blockedCount > 0
? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
: `Updated ${successCount} of ${containerIds.length} containers`
};
sendData(completeData);
completeJob(job, completeData);
})().catch((err) => {
failJob(job, err instanceof Error ? err.message : String(err));
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
return json({ jobId: job.id });
};
@@ -75,11 +75,10 @@ export const POST: RequestHandler = async (event) => {
continue;
}
let updateSuccess = false;
let newContainerId = containerId;
updateSuccess = await recreateContainer(containerName, envIdNum);
if (updateSuccess) {
const recreateResult = await recreateContainer(containerName, envIdNum);
if (recreateResult.success) {
const updatedContainers = await listContainers(true, envIdNum);
const updatedContainer = updatedContainers.find(c => c.name === containerName);
if (updatedContainer) {
@@ -87,12 +86,12 @@ export const POST: RequestHandler = async (event) => {
}
}
if (!updateSuccess) {
if (!recreateResult.success) {
results.push({
containerId,
containerName,
success: false,
error: 'Container recreation failed'
error: recreateResult.error || 'Container recreation failed'
});
continue;
}
+2 -2
View File
@@ -104,7 +104,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
// Get all running containers with timeout
const containers = await withTimeout(
listContainers(true, envIdNum),
5000, // 5 second timeout
10000, // 10 second timeout
[]
);
const runningContainers = containers.filter(c => c.state === 'running');
@@ -127,7 +127,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
try {
const stats = await withTimeout(
getContainerStats(container.id, envIdNum) as Promise<any>,
3000, // 3 second timeout per container
8000, // 8 second timeout per container (TLS proxy + Docker CPU sampling needs ~2s)
null
);
@@ -0,0 +1,182 @@
import type { RequestHandler } from './$types';
import { listContainers, getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import type { ContainerStats } from '$lib/types';
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
if (systemDelta > 0 && cpuDelta > 0) {
return (cpuDelta / systemDelta) * cpuCount * 100;
}
return 0;
}
function calculateNetworkIO(stats: any): { rx: number; tx: number } {
let rx = 0;
let tx = 0;
if (stats.networks) {
for (const iface of Object.values(stats.networks) as any[]) {
rx += iface.rx_bytes || 0;
tx += iface.tx_bytes || 0;
}
}
return { rx, tx };
}
function calculateBlockIO(stats: any): { read: number; write: number } {
let read = 0;
let write = 0;
const ioStats = stats.blkio_stats?.io_service_bytes_recursive;
if (Array.isArray(ioStats)) {
for (const entry of ioStats) {
if (entry.op === 'read' || entry.op === 'Read') {
read += entry.value || 0;
} else if (entry.op === 'write' || entry.op === 'Write') {
write += entry.value || 0;
}
}
}
return { read, write };
}
function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
const raw = memoryStats?.usage || 0;
const stats = memoryStats?.stats || {};
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
return { usage, raw, cache };
}
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<T>((resolve) => {
timeoutId = setTimeout(() => resolve(fallback), ms);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timeoutId !== null) clearTimeout(timeoutId);
});
}
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
status: 403,
headers: { 'Content-Type': 'application/json' }
});
}
if (!await hasEnvironments() || !envIdNum) {
return new Response('event: done\ndata: {}\n\n', {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
}
let controllerClosed = false;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
try {
const containers = await withTimeout(
listContainers(true, envIdNum),
10000,
[]
);
const runningContainers = containers.filter(c => c.state === 'running');
const statsPromises = runningContainers.map(async (container) => {
try {
const stats = await withTimeout(
getContainerStats(container.id, envIdNum) as Promise<any>,
8000,
null
);
if (!stats) return;
const cpuPercent = calculateCpuPercent(stats);
const memory = calculateMemoryUsage(stats.memory_stats);
const memoryLimit = stats.memory_stats?.limit || 1;
const memoryPercent = (memory.usage / memoryLimit) * 100;
const networkIO = calculateNetworkIO(stats);
const blockIO = calculateBlockIO(stats);
const stat: ContainerStats = {
id: container.id,
name: container.name,
cpuPercent: Math.round(cpuPercent * 100) / 100,
memoryUsage: memory.usage,
memoryRaw: memory.raw,
memoryCache: memory.cache,
memoryLimit,
memoryPercent: Math.round(memoryPercent * 100) / 100,
networkRx: networkIO.rx,
networkTx: networkIO.tx,
blockRead: blockIO.read,
blockWrite: blockIO.write
};
safeEnqueue(`event: stat\ndata: ${JSON.stringify(stat)}\n\n`);
} catch {
// Skip failed containers silently
}
});
await Promise.all(statsPromises);
} catch (error: any) {
if (error instanceof EnvironmentNotFoundError) {
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'Environment not found' })}\n\n`);
}
}
if (!controllerClosed) {
safeEnqueue(`event: done\ndata: {}\n\n`);
try {
controller.close();
} catch {
// Already closed
}
}
},
cancel() {
controllerClosed = true;
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
};
@@ -3,6 +3,7 @@ import {
getEnvironments,
getLatestHostMetrics,
getHostMetrics,
getMetricsCollectionInterval,
getContainerEventStats,
getContainerEvents,
getEnvSetting,
@@ -14,13 +15,17 @@ import {
listImages,
listNetworks,
getContainerStats,
getDiskUsage
getDiskUsage,
dockerPing,
DockerConnectionError
} from '$lib/server/docker';
import { listComposeStacks } from '$lib/server/stacks';
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';
// Skip disk usage collection (Synology NAS performance fix)
const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
@@ -68,6 +73,9 @@ setInterval(() => {
}
}, 10 * 60 * 1000); // Every 10 minutes
// Register cache reporter for memory monitoring
async function getCachedDiskUsage(envId: number): Promise<any> {
const cached = diskUsageCache.get(envId);
const now = Date.now();
@@ -125,10 +133,14 @@ function calculateMemoryUsage(memoryStats: any): number {
return usage;
}
// Target time window for metrics history charts (15 minutes)
const METRICS_HISTORY_WINDOW_MS = 15 * 60 * 1000;
// Progressive stats loading - returns stats object and emits partial updates via callback
async function getEnvironmentStatsProgressive(
env: any,
onPartialUpdate: (stats: Partial<EnvironmentStats> & { id: number }) => void
onPartialUpdate: (stats: Partial<EnvironmentStats> & { id: number }) => void,
metricsPointCount: number
): Promise<EnvironmentStats> {
const envStats: EnvironmentStats = {
id: env.id,
@@ -187,7 +199,7 @@ async function getEnvironmentStatsProgressive(
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
getContainerEvents({ environmentId: env.id, limit: 10 }),
getHostMetrics(30, env.id),
getHostMetrics(metricsPointCount, env.id),
getPendingContainerUpdates(env.id)
]);
@@ -237,6 +249,20 @@ async function getEnvironmentStatsProgressive(
loading: { ...envStats.loading }
});
// Quick reachability check — if ping fails, skip all expensive Docker API calls
if (!await dockerPing(env.id)) {
envStats.online = false;
envStats.error = 'Environment offline';
envStats.loading = undefined;
onPartialUpdate({
id: env.id,
online: false,
error: 'Environment offline',
loading: undefined
});
return envStats;
}
// Helper to get valid size
const getValidSize = (size: number | undefined | null): number => {
return size && size > 0 ? size : 0;
@@ -511,7 +537,7 @@ async function getEnvironmentStatsProgressive(
return envStats;
}
export const GET: RequestHandler = async ({ cookies }) => {
export const GET: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'view')) {
return new Response(JSON.stringify({ error: 'Permission denied' }), {
@@ -535,6 +561,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
let controllerClosed = false;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Safe enqueue that checks if controller is still open
@@ -573,17 +600,23 @@ export const GET: RequestHandler = async ({ cookies }) => {
}));
safeEnqueue(`event: environments\ndata: ${JSON.stringify(envList)}\n\n`);
// Calculate metrics point count based on configured interval
const metricsIntervalMs = await getMetricsCollectionInterval();
const metricsPointCount = Math.ceil(METRICS_HISTORY_WINDOW_MS / metricsIntervalMs);
// Fetch stats for each environment with progressive updates
const promises = environments.map(async (env) => {
try {
await getEnvironmentStatsProgressive(env, (partialStats) => {
// Send partial update as it arrives
safeEnqueue(`event: partial\ndata: ${JSON.stringify(partialStats)}\n\n`);
});
}, metricsPointCount);
// Send final complete stats event for this environment
safeEnqueue(`event: complete\ndata: ${JSON.stringify({ id: env.id })}\n\n`);
} catch (error) {
console.error(`Failed to get stats for ${env.name}:`, error);
if (!(error instanceof DockerConnectionError)) {
console.error(`Failed to get stats for ${env.name}:`, error);
}
// Convert technical error to user-friendly message
const errorStr = String(error);
let friendlyError = 'Connection error';
@@ -611,19 +644,25 @@ export const GET: RequestHandler = async ({ cookies }) => {
} catch {
// Already closed
}
}
},
cancel() {
// Called when the client disconnects
controllerClosed = true;
}
});
return new Response(stream, {
const sseResponse = new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
if (prefersJSON(request)) return sseToJSON(sseResponse);
return sseResponse;
};
+121
View File
@@ -0,0 +1,121 @@
/**
* Memory Debug Endpoint
*
* Returns Node.js memory stats for monitoring.
* Only available when MEMORY_MONITOR=true environment variable is set.
*
* GET /api/debug/memory - Memory stats (with optional ?gc=true to force GC first)
* GET /api/debug/memory?gc=true - Force garbage collection before reporting
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import v8 from 'node:v8';
import os from 'node:os';
import { getRssStats, dumpHeapSnapshot, listHeapSnapshots } from '$lib/server/rss-tracker';
// Track startup time and initial RSS for growth rate calculation
const startupTime = Date.now();
const startupRss = process.memoryUsage().rss;
export const GET: RequestHandler = async ({ url }) => {
if (process.env.MEMORY_MONITOR !== 'true') {
return json({ error: 'Memory monitor not enabled. Set MEMORY_MONITOR=true.' }, { status: 403 });
}
// Trigger manual heap snapshot
if (url.searchParams.has('snapshot')) {
const filename = dumpHeapSnapshot();
return json({
snapshot: filename ? { filename, message: 'Heap snapshot saved' } : { error: 'Failed to save snapshot' }
});
}
// List saved snapshots
if (url.searchParams.has('snapshots')) {
return json({ snapshots: listHeapSnapshots() });
}
// Force GC if requested and available
const forceGc = url.searchParams.get('gc') === 'true';
if (forceGc && typeof globalThis.gc === 'function') {
globalThis.gc();
}
const mem = process.memoryUsage();
const heap = v8.getHeapStatistics();
const uptimeMs = Date.now() - startupTime;
const uptimeHours = uptimeMs / (1000 * 60 * 60);
const rssGrowth = mem.rss - startupRss;
const rssGrowthPerHour = uptimeHours > 0.01 ? rssGrowth / uptimeHours : 0;
return json({
timestamp: new Date().toISOString(),
uptime: {
ms: uptimeMs,
hours: Math.round(uptimeHours * 100) / 100,
human: formatUptime(uptimeMs),
},
gcForced: forceGc && typeof globalThis.gc === 'function',
gcAvailable: typeof globalThis.gc === 'function',
process: {
rss: formatBytes(mem.rss),
heapTotal: formatBytes(mem.heapTotal),
heapUsed: formatBytes(mem.heapUsed),
external: formatBytes(mem.external),
arrayBuffers: formatBytes(mem.arrayBuffers),
rssRaw: mem.rss,
heapTotalRaw: mem.heapTotal,
heapUsedRaw: mem.heapUsed,
externalRaw: mem.external,
arrayBuffersRaw: mem.arrayBuffers,
},
growth: {
rssSinceStartup: formatBytes(rssGrowth),
rssPerHour: formatBytes(Math.round(rssGrowthPerHour)),
startupRss: formatBytes(startupRss),
},
v8Heap: {
totalHeapSize: formatBytes(heap.total_heap_size),
usedHeapSize: formatBytes(heap.used_heap_size),
heapSizeLimit: formatBytes(heap.heap_size_limit),
totalPhysicalSize: formatBytes(heap.total_physical_size),
totalAvailableSize: formatBytes(heap.total_available_size),
mallocedMemory: formatBytes(heap.malloced_memory),
peakMallocedMemory: formatBytes(heap.peak_malloced_memory),
externalMemory: formatBytes(heap.external_memory),
numberOfNativeContexts: heap.number_of_native_contexts,
numberOfDetachedContexts: heap.number_of_detached_contexts,
},
system: {
totalMemory: formatBytes(os.totalmem()),
freeMemory: formatBytes(os.freemem()),
cpus: os.cpus().length,
platform: os.platform(),
arch: os.arch(),
nodeVersion: process.version,
},
rssTracker: getRssStats(),
});
};
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const sign = bytes < 0 ? '-' : '';
const abs = Math.abs(bytes);
if (abs < 1024) return `${sign}${abs} B`;
if (abs < 1024 * 1024) return `${sign}${(abs / 1024).toFixed(1)} KB`;
if (abs < 1024 * 1024 * 1024) return `${sign}${(abs / (1024 * 1024)).toFixed(1)} MB`;
return `${sign}${(abs / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function formatUptime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}
+1 -1
View File
@@ -113,7 +113,7 @@ export const POST: RequestHandler = async (event) => {
await setEnvironmentPublicIp(env.id, data.publicIp);
}
// Notify subprocesses to pick up the new environment
// Notify event collectors to pick up the new environment
refreshSubprocessEnvironments();
// Auto-assign Admin role to creator (Enterprise only)
+2 -2
View File
@@ -91,7 +91,7 @@ export const PUT: RequestHandler = async (event) => {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Notify subprocesses if collectActivity or collectMetrics setting changed
// Notify event collectors if collectActivity or collectMetrics setting changed
if (data.collectActivity !== undefined || data.collectMetrics !== undefined) {
refreshSubprocessEnvironments();
}
@@ -178,7 +178,7 @@ export const DELETE: RequestHandler = async (event) => {
await deleteImagePruneSettings(id);
unregisterSchedule(id, 'image_prune');
// Notify subprocesses to stop collecting from deleted environment
// Notify event collectors to stop collecting from deleted environment
refreshSubprocessEnvironments();
// Audit log
@@ -71,7 +71,7 @@ export const POST: RequestHandler = async ({ params }) => {
}
// For Hawser Standard mode, fetch Docker info and Hawser info in parallel
// (sequential calls can fail due to Bun TLS connection reuse issues)
// (parallel calls are more efficient and avoid sequential connection issues)
let info: any;
let hawserInfo = null;
if (env.connectionType === 'hawser-standard') {
@@ -8,7 +8,7 @@ import {
} from '$lib/server/db';
import { refreshSchedulesForEnvironment } from '$lib/server/scheduler';
/** Map of modern IANA timezone names to their canonical equivalents recognized by Bun/ICU */
/** Map of modern IANA timezone names to their canonical equivalents recognized by ICU */
const TIMEZONE_ALIASES: Record<string, string> = {
'Europe/Kyiv': 'Europe/Kiev',
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
+36 -51
View File
@@ -1,4 +1,6 @@
import { json } from '@sveltejs/kit';
import { unixSocketRequest, httpsAgentRequest } from '$lib/server/docker';
import type { DockerClientConfig } from '$lib/server/docker';
import type { RequestHandler } from './$types';
interface TestConnectionRequest {
@@ -22,29 +24,19 @@ function cleanPem(pem: string): string {
.join('\n');
}
function buildTlsOptions(config: TestConnectionRequest): Record<string, any> | undefined {
function buildDockerClientConfig(config: TestConnectionRequest): DockerClientConfig | null {
const protocol = config.protocol || 'http';
if (protocol !== 'https') return undefined;
if (protocol !== 'https') return null;
const tls: Record<string, any> = {
sessionTimeout: 0,
servername: config.host
return {
type: 'https',
host: config.host || 'localhost',
port: config.port || 2376,
ca: config.tlsCa ? cleanPem(config.tlsCa) || undefined : undefined,
cert: config.tlsCert ? cleanPem(config.tlsCert) || undefined : undefined,
key: config.tlsKey ? cleanPem(config.tlsKey) || undefined : undefined,
skipVerify: config.tlsSkipVerify || false
};
if (config.tlsSkipVerify) {
tls.rejectUnauthorized = false;
} else {
tls.rejectUnauthorized = true;
if (config.tlsCa) {
tls.ca = [cleanPem(config.tlsCa)];
}
}
if (config.tlsCert) {
tls.cert = [cleanPem(config.tlsCert)];
}
if (config.tlsKey) {
tls.key = cleanPem(config.tlsKey);
}
return tls;
}
/**
@@ -59,11 +51,7 @@ export const POST: RequestHandler = async ({ request }) => {
if (config.connectionType === 'socket') {
const socketPath = config.socketPath || '/var/run/docker.sock';
response = await fetch('http://localhost/info', {
// @ts-ignore - Bun supports unix socket
unix: socketPath,
signal: AbortSignal.timeout(10000)
});
response = await unixSocketRequest(socketPath, '/info');
} else if (config.connectionType === 'hawser-edge') {
// Edge mode - cannot test directly, agent connects to us
return json({
@@ -83,7 +71,6 @@ export const POST: RequestHandler = async ({ request }) => {
return json({ success: false, error: 'Host is required' }, { status: 400 });
}
const url = `${protocol}://${host}:${port}/info`;
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
@@ -92,19 +79,17 @@ export const POST: RequestHandler = async ({ request }) => {
headers['X-Hawser-Token'] = config.hawserToken;
}
const fetchOptions: any = {
headers,
signal: AbortSignal.timeout(10000),
keepalive: false
};
const tls = buildTlsOptions(config);
if (tls) {
fetchOptions.tls = tls;
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
const tlsConfig = buildDockerClientConfig(config);
if (tlsConfig) {
response = await httpsAgentRequest(tlsConfig, '/info', {}, false, headers);
} else {
const url = `http://${host}:${port}/info`;
response = await fetch(url, {
headers,
signal: AbortSignal.timeout(10000),
keepalive: false
});
}
response = await fetch(url, fetchOptions);
}
if (!response.ok) {
@@ -123,21 +108,19 @@ export const POST: RequestHandler = async ({ request }) => {
if (config.hawserToken) {
hawserHeaders['X-Hawser-Token'] = config.hawserToken;
}
const hawserUrl = `${protocol}://${config.host}:${config.port || 2375}/_hawser/info`;
const fetchOptions: any = {
headers: hawserHeaders,
signal: AbortSignal.timeout(5000),
keepalive: false
};
const tls = buildTlsOptions(config);
if (tls) {
fetchOptions.tls = tls;
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
let hawserResp: Response;
const tlsConfig = buildDockerClientConfig(config);
if (tlsConfig) {
hawserResp = await httpsAgentRequest(tlsConfig, '/_hawser/info', {}, false, hawserHeaders);
} else {
const hawserUrl = `http://${config.host}:${config.port || 2375}/_hawser/info`;
hawserResp = await fetch(hawserUrl, {
headers: hawserHeaders,
signal: AbortSignal.timeout(5000),
keepalive: false
});
}
const hawserResp = await fetch(hawserUrl, fetchOptions);
if (hawserResp.ok) {
hawserInfo = await hawserResp.json();
}
@@ -182,6 +165,8 @@ export const POST: RequestHandler = async ({ request }) => {
message = 'Connection failed - check host and port';
} else if (rawMessage.includes('self signed certificate') || rawMessage.includes('UNABLE_TO_VERIFY_LEAF_SIGNATURE')) {
message = 'TLS certificate error - provide CA certificate for self-signed certs';
} else if (rawMessage.includes('CERT_ALTNAME_INVALID') || rawMessage.includes('ERR_TLS_CERT_ALTNAME_INVALID')) {
message = 'Certificate hostname mismatch - your certificate\'s Subject Alternative Name (SAN) doesn\'t match the host. Regenerate with: -addext "subjectAltName=DNS:hostname,IP:x.x.x.x"';
} else if (rawMessage.includes('certificate') || rawMessage.includes('SSL') || rawMessage.includes('TLS')) {
message = 'TLS/SSL error - check certificate configuration';
}
+21 -7
View File
@@ -14,6 +14,7 @@ import { authorize } from '$lib/server/authorize';
import { registerSchedule } from '$lib/server/scheduler';
import { secureRandomBytes } from '$lib/server/crypto-fallback';
import { auditGitStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
@@ -156,20 +157,33 @@ export const POST: RequestHandler = async (event) => {
}
}
// If deployNow is set, deploy immediately
// If deployNow is set, deploy immediately via SSE to keep connection alive
if (data.deployNow) {
const deployResult = await deployGitStack(gitStack.id);
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
return json({
...gitStack,
deployResult: deployResult
});
return createJobResponse(async (send) => {
try {
const deployResult = await deployGitStack(gitStack.id);
await auditGitStack(event, 'deploy', gitStack.id, gitStack.stackName, gitStack.environmentId);
send('result', {
...gitStack,
deployResult: deployResult
});
} catch (error) {
console.error('Failed to deploy git stack:', error);
send('result', {
...gitStack,
deployResult: { success: false, error: 'Failed to deploy git stack' }
});
}
}, request);
}
return json(gitStack);
} catch (error: any) {
console.error('Failed to create git stack:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
if (error.message?.includes('stack_environment_variables')) {
return json({ error: 'Duplicate environment variable keys detected' }, { status: 400 });
}
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
}
return json({ error: 'Failed to create git stack' }, { status: 500 });
+25 -8
View File
@@ -1,11 +1,12 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars } from '$lib/server/db';
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName, setStackEnvVars, getStackEnvVars, deleteStackEnvVars } from '$lib/server/db';
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { auditGitStack } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
import { createJobResponse } from '$lib/server/sse';
// Stack name validation: must start with alphanumeric, can contain alphanumeric, hyphens, underscores
const STACK_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
@@ -131,20 +132,33 @@ export const PUT: RequestHandler = async (event) => {
await setStackEnvVars(stackName, envId, varsToSave as any);
}
// If deployNow is set, deploy after saving
// If deployNow is set, deploy after saving via SSE to keep connection alive
if (data.deployNow) {
const deployResult = await deployGitStack(id);
await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId);
return json({
...updated,
deployResult
});
return createJobResponse(async (send) => {
try {
const deployResult = await deployGitStack(id);
await auditGitStack(event, 'deploy', updated.id, updated.stackName, updated.environmentId);
send('result', {
...updated,
deployResult
});
} catch (error) {
console.error('Failed to deploy git stack:', error);
send('result', {
...updated,
deployResult: { success: false, error: 'Failed to deploy git stack' }
});
}
}, request);
}
return json(updated);
} catch (error: any) {
console.error('Failed to update git stack:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
if (error.message?.includes('stack_environment_variables')) {
return json({ error: 'Duplicate environment variable keys detected' }, { status: 400 });
}
return json({ error: 'A git stack with this name already exists for this environment' }, { status: 400 });
}
return json({ error: 'Failed to update git stack' }, { status: 500 });
@@ -176,6 +190,9 @@ export const DELETE: RequestHandler = async (event) => {
// Delete the stack_sources record to free up the stack name
await deleteStackSource(existing.stackName, existing.environmentId);
// Delete all env var overrides for this stack (all environments)
await deleteStackEnvVars(existing.stackName);
// Delete from database
await deleteGitStack(id);
@@ -3,8 +3,10 @@ import type { RequestHandler } from './$types';
import { getGitStack } from '$lib/server/db';
import { deployGitStackWithProgress } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
import { prefersJSON, sseToJSON } from '$lib/server/sse';
export const POST: RequestHandler = async ({ params, cookies }) => {
export const POST: RequestHandler = async ({ params, cookies, request }) => {
const auth = await authorize(cookies);
const id = parseInt(params.id);
@@ -25,30 +27,43 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
});
}
// Create a readable stream for SSE
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const sendEvent = (data: any) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
try {
await deployGitStackWithProgress(id, sendEvent);
} catch (error: any) {
sendEvent({ status: 'error', error: error.message || 'Unknown error' });
} finally {
controller.close();
// Backward compat: API clients sending Accept: application/json get synchronous SSE result
if (prefersJSON(request)) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const sendEvent = (data: unknown) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
};
try {
await deployGitStackWithProgress(id, sendEvent);
} catch (error: any) {
sendEvent({ status: 'error', error: error.message || 'Unknown error' });
} finally {
controller.close();
}
}
}
});
});
const sseResponse = new Response(stream, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }
});
return sseToJSON(sseResponse);
}
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
// Job pattern: fire and forget, return jobId immediately
const job = createJob();
deployGitStackWithProgress(id, (data: unknown) => {
appendLine(job, { data });
})
.then(() => {
const lastLine = job.lines[job.lines.length - 1];
const lastData = lastLine?.data as any;
completeJob(job, lastData ?? { status: 'complete' });
})
.catch((err: unknown) => {
failJob(job, err instanceof Error ? err.message : String(err));
});
return json({ jobId: job.id });
};
@@ -4,6 +4,7 @@ import { getGitStack } from '$lib/server/db';
import { deployGitStack } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { auditGitStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
export const POST: RequestHandler = async (event) => {
const { params, cookies } = event;
@@ -21,12 +22,19 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Permission denied' }, { status: 403 });
}
const result = await deployGitStack(id);
return createJobResponse(async (send) => {
try {
const result = await deployGitStack(id);
// Audit log
await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId);
// Audit log
await auditGitStack(event, 'deploy', id, gitStack.stackName, gitStack.environmentId);
return json(result);
send('result', result);
} catch (error) {
console.error('Failed to deploy git stack:', error);
send('result', { success: false, error: 'Failed to deploy git stack' });
}
}, event.request);
} catch (error) {
console.error('Failed to deploy git stack:', error);
return json({ error: 'Failed to deploy git stack' }, { status: 500 });
+2 -2
View File
@@ -2,7 +2,7 @@
* Hawser Edge WebSocket Connect Endpoint
*
* This endpoint handles WebSocket connections from Hawser agents running in Edge mode.
* In development: WebSocket is handled by Bun.serve in vite.config.ts on port 5174
* In development: WebSocket is handled by ws.WebSocketServer in vite.config.ts on port 5174
* In production: WebSocket is handled by the server wrapper in server.ts
*
* The HTTP GET endpoint returns connection info for clients.
@@ -28,7 +28,7 @@ export const GET: RequestHandler = async () => {
hostname: conn.hostname,
capabilities: conn.capabilities,
connectedAt: conn.connectedAt.toISOString(),
lastHeartbeat: conn.lastHeartbeat.toISOString()
lastHeartbeat: new Date(conn.lastHeartbeat).toISOString()
}));
return json({
+4 -2
View File
@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import { listImages, EnvironmentNotFoundError } from '$lib/server/docker';
import { listImages, EnvironmentNotFoundError, DockerConnectionError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import type { RequestHandler } from './$types';
@@ -32,7 +32,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
console.error('Error listing images:', error);
if (!(error instanceof DockerConnectionError)) {
console.error('Error listing images:', error);
}
// Return empty array instead of error to allow UI to load
return json([]);
}
+56 -107
View File
@@ -1,11 +1,12 @@
import { json } from '@sveltejs/kit';
import { pullImage } from '$lib/server/docker';
import { pullImage, buildRegistryAuthHeader } from '$lib/server/docker';
import type { RequestHandler } from './$types';
import { getScannerSettings, scanImage } from '$lib/server/scanner';
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';
/**
* Check if environment is edge mode
@@ -73,49 +74,26 @@ export const POST: RequestHandler = async (event) => {
// Check if this is an edge environment
const edgeCheck = await isEdgeMode(envId);
const encoder = new TextEncoder();
let controllerClosed = false;
let controller: ReadableStreamDefaultController<Uint8Array>;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let cancelEdgeStream: (() => void) | null = null;
// Job pattern: create job, run in background, return jobId immediately
const job = createJob();
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
const cleanup = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (cancelEdgeStream) {
cancelEdgeStream();
cancelEdgeStream = null;
}
controllerClosed = true;
const sendData = (data: unknown) => {
appendLine(job, { data });
};
/**
* Handle scan-on-pull after image is pulled
*/
const handleScanOnPull = async () => {
// Skip if caller explicitly requested no scan (e.g., CreateContainerModal handles scanning separately)
if (skipScanOnPull) return;
const { scanner } = await getScannerSettings(envId);
// Scan if scanning is enabled (scanner !== 'none')
if (scanner !== 'none') {
safeEnqueue(`data: ${JSON.stringify({ status: 'scanning', message: 'Starting vulnerability scan...' })}\n\n`);
sendData({ status: 'scanning', message: 'Starting vulnerability scan...' });
try {
const results = await scanImage(image, envId, (progress) => {
safeEnqueue(`data: ${JSON.stringify({ status: 'scan-progress', ...progress })}\n\n`);
sendData({ status: 'scan-progress', ...progress });
});
for (const result of results) {
@@ -138,128 +116,99 @@ export const POST: RequestHandler = async (event) => {
}
const totalVulns = results.reduce((sum, r) => sum + r.vulnerabilities.length, 0);
safeEnqueue(`data: ${JSON.stringify({
sendData({
status: 'scan-complete',
message: `Scan complete - found ${totalVulns} vulnerabilities`,
results
})}\n\n`);
});
} catch (scanError) {
console.error('Scan-on-pull failed:', scanError);
safeEnqueue(`data: ${JSON.stringify({
sendData({
status: 'scan-error',
error: scanError instanceof Error ? scanError.message : String(scanError)
})}\n\n`);
});
}
}
};
const stream = new ReadableStream({
async start(ctrl) {
controller = ctrl;
// Run operation in background
(async () => {
console.log(`Starting pull for image: ${image}${edgeCheck.isEdge ? ' (edge mode)' : ''}`);
// Start heartbeat to keep connection alive through Traefik (10s idle timeout)
heartbeatInterval = setInterval(() => {
safeEnqueue(`: keepalive\n\n`);
}, 5000);
if (edgeCheck.isEdge && edgeCheck.environmentId) {
if (!isEdgeConnected(edgeCheck.environmentId)) {
sendData({ status: 'error', error: 'Edge agent not connected' });
failJob(job, 'Edge agent not connected');
return;
}
console.log(`Starting pull for image: ${image}${edgeCheck.isEdge ? ' (edge mode)' : ''}`);
// Handle edge mode with streaming
if (edgeCheck.isEdge && edgeCheck.environmentId) {
if (!isEdgeConnected(edgeCheck.environmentId)) {
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: 'Edge agent not connected' })}\n\n`);
cleanup();
controller.close();
return;
}
const pullUrl = buildPullUrl(image);
const pullUrl = buildPullUrl(image);
const authHeaders = await buildRegistryAuthHeader(image);
await new Promise<void>((resolve) => {
const { cancel } = sendEdgeStreamRequest(
edgeCheck.environmentId,
edgeCheck.environmentId!,
'POST',
pullUrl,
{
onData: (data: string) => {
// Data is base64 encoded JSON lines from Docker
try {
const decoded = Buffer.from(data, 'base64').toString('utf-8');
// Docker sends newline-delimited JSON
const lines = decoded.split('\n').filter(line => line.trim());
const lines = decoded.split('\n').filter((line) => line.trim());
for (const line of lines) {
try {
const progress = JSON.parse(line);
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
sendData(JSON.parse(line));
} catch {
// Ignore parse errors for partial lines
}
}
} catch {
// If not base64, try as-is
try {
const progress = JSON.parse(data);
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
sendData(JSON.parse(data));
} catch {
// Ignore
}
}
},
onEnd: async () => {
safeEnqueue(`data: ${JSON.stringify({ status: 'complete' })}\n\n`);
// Handle scan-on-pull
sendData({ status: 'complete' });
await handleScanOnPull();
cleanup();
controller.close();
completeJob(job, { status: 'complete' });
resolve();
},
onError: (error: string) => {
console.error('Edge pull error:', error);
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error })}\n\n`);
cleanup();
controller.close();
sendData({ status: 'error', error });
failJob(job, error);
resolve();
}
}
},
undefined,
authHeaders
);
cancelEdgeStream = cancel;
} else {
// Non-edge mode: use existing pullImage function
try {
await pullImage(image, (progress) => {
const data = JSON.stringify(progress) + '\n';
safeEnqueue(`data: ${data}\n\n`);
}, envId);
// Store cancel reference (not used currently but available)
void cancel;
});
} else {
try {
await pullImage(image, (progress) => {
sendData(progress);
}, envId);
safeEnqueue(`data: ${JSON.stringify({ status: 'complete' })}\n\n`);
// Handle scan-on-pull
await handleScanOnPull();
cleanup();
controller.close();
} catch (error) {
console.error('Error pulling image:', error);
safeEnqueue(`data: ${JSON.stringify({
status: 'error',
error: String(error)
})}\n\n`);
cleanup();
controller.close();
}
sendData({ status: 'complete' });
await handleScanOnPull();
completeJob(job, { status: 'complete' });
} catch (error) {
console.error('Error pulling image:', error);
const errMsg = String(error);
sendData({ status: 'error', error: errMsg });
failJob(job, errMsg);
}
},
cancel() {
cleanup();
}
})().catch((err) => {
failJob(job, err instanceof Error ? err.message : String(err));
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
return json({ jobId: job.id });
};
+73 -153
View File
@@ -5,6 +5,8 @@ import { getRegistry, 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 { prefersJSON } from '$lib/server/sse';
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
/**
* Check if environment is edge mode
@@ -119,35 +121,6 @@ export const POST: RequestHandler = async (event) => {
// Check if this is an edge environment
const edgeCheck = await isEdgeMode(envIdNum);
// Stream the push progress
const encoder = new TextEncoder();
let controllerClosed = false;
let controller: ReadableStreamDefaultController<Uint8Array>;
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let cancelEdgeStream: (() => void) | null = null;
const safeEnqueue = (data: string) => {
if (!controllerClosed) {
try {
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
}
};
const cleanup = () => {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (cancelEdgeStream) {
cancelEdgeStream();
cancelEdgeStream = null;
}
controllerClosed = true;
};
const formatError = (error: any): string => {
const errorMessage = error.message || error || '';
let userMessage = errorMessage || 'Failed to push image';
@@ -163,140 +136,87 @@ export const POST: RequestHandler = async (event) => {
return userMessage;
};
const stream = new ReadableStream({
async start(ctrl) {
controller = ctrl;
// Core push logic — emit callback receives progress data objects
async function runPush(emit: (data: unknown) => void): Promise<void> {
emit({ status: 'tagging', message: 'Tagging image...' });
await tagImage(imageId, repo, tag, envIdNum);
emit({ status: 'pushing', message: 'Pushing to registry...' });
// Start heartbeat to keep connection alive through Traefik (10s idle timeout)
heartbeatInterval = setInterval(() => {
safeEnqueue(`: keepalive\n\n`);
}, 5000);
if (edgeCheck.isEdge && edgeCheck.environmentId) {
if (!isEdgeConnected(edgeCheck.environmentId)) {
emit({ status: 'error', error: 'Edge agent not connected' });
return;
}
try {
// Send tagging status
safeEnqueue(`data: ${JSON.stringify({ status: 'tagging', message: 'Tagging image...' })}\n\n`);
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
// Tag the image with the target registry
await tagImage(imageId, repo, tag, envIdNum);
// Send pushing status
safeEnqueue(`data: ${JSON.stringify({ status: 'pushing', message: 'Pushing to registry...' })}\n\n`);
// Handle edge mode with streaming
if (edgeCheck.isEdge && edgeCheck.environmentId) {
if (!isEdgeConnected(edgeCheck.environmentId)) {
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: 'Edge agent not connected' })}\n\n`);
cleanup();
controller.close();
return;
}
// Create X-Registry-Auth header
const authHeader = Buffer.from(JSON.stringify(authConfig)).toString('base64');
const { cancel } = sendEdgeStreamRequest(
edgeCheck.environmentId,
'POST',
`/images/${encodeURIComponent(targetTag)}/push`,
{
onData: (data: string) => {
// Data is base64 encoded JSON lines from Docker
try {
const decoded = Buffer.from(data, 'base64').toString('utf-8');
const lines = decoded.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const progress = JSON.parse(line);
if (progress.error) {
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(progress.error) })}\n\n`);
} else {
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
}
} catch {
// Ignore parse errors for partial lines
}
}
} catch {
// If not base64, try as-is
await new Promise<void>((resolve, reject) => {
sendEdgeStreamRequest(
edgeCheck.environmentId!,
'POST',
`/images/${encodeURIComponent(targetTag)}/push`,
{
onData: (data: string) => {
try {
const decoded = Buffer.from(data, 'base64').toString('utf-8');
for (const line of decoded.split('\n').filter((l) => l.trim())) {
try {
const progress = JSON.parse(data);
if (progress.error) {
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(progress.error) })}\n\n`);
} else {
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
}
} catch {
// Ignore
}
const progress = JSON.parse(line);
emit(progress.error ? { status: 'error', error: formatError(progress.error) } : progress);
} catch { /* ignore partial lines */ }
}
},
onEnd: async () => {
// Audit log
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
safeEnqueue(`data: ${JSON.stringify({
status: 'complete',
message: `Image pushed to ${targetTag}`,
targetTag
})}\n\n`);
cleanup();
controller.close();
},
onError: (error: string) => {
console.error('Edge push error:', error);
safeEnqueue(`data: ${JSON.stringify({ status: 'error', error: formatError(error) })}\n\n`);
cleanup();
controller.close();
} catch {
try {
const progress = JSON.parse(data);
emit(progress.error ? { status: 'error', error: formatError(progress.error) } : progress);
} catch { /* ignore */ }
}
},
undefined,
{ 'X-Registry-Auth': authHeader }
);
cancelEdgeStream = cancel;
} else {
// Non-edge mode: use existing pushImage function
await pushImage(targetTag, authConfig, (progress) => {
safeEnqueue(`data: ${JSON.stringify(progress)}\n\n`);
}, envIdNum);
// Audit log
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
// Send completion message
safeEnqueue(`data: ${JSON.stringify({
status: 'complete',
message: `Image pushed to ${targetTag}`,
targetTag
})}\n\n`);
cleanup();
controller.close();
}
} catch (error: any) {
console.error('Error pushing image:', error);
safeEnqueue(`data: ${JSON.stringify({
status: 'error',
error: formatError(error)
})}\n\n`);
cleanup();
controller.close();
}
},
cancel() {
cleanup();
onEnd: async () => {
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
emit({ status: 'complete', message: `Image pushed to ${targetTag}`, targetTag });
resolve();
},
onError: (error: string) => {
console.error('Edge push error:', error);
emit({ status: 'error', error: formatError(error) });
reject(new Error(error));
}
},
undefined,
{ 'X-Registry-Auth': authHeader }
);
});
} else {
await pushImage(targetTag, authConfig, (progress) => emit(progress), envIdNum);
await auditImage(event, 'push', imageId, imageName || targetTag, envIdNum, { targetTag, registry: registry.name });
emit({ status: 'complete', message: `Image pushed to ${targetTag}`, targetTag });
}
});
}
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
// Sync path for API clients sending Accept: application/json only
if (prefersJSON(request)) {
try {
let lastEvent: unknown = null;
await runPush((data) => { lastEvent = data; });
return json(lastEvent || { success: true });
} catch (error: any) {
return json({ status: 'error', error: formatError(error) }, { status: 500 });
}
});
}
// Job pattern: return jobId immediately, push runs in background
const job = createJob();
(async () => {
try {
await runPush((data) => appendLine(job, { data }));
completeJob(job, job.lines[job.lines.length - 1]?.data ?? { success: true });
} catch (error: any) {
appendLine(job, { data: { status: 'error', error: formatError(error) } });
failJob(job, error.message);
}
})();
return json({ jobId: job.id });
} catch (error: any) {
console.error('Error setting up push:', error);
return json({ error: error.message || 'Failed to push image' }, { status: 500 });
+36 -63
View File
@@ -2,6 +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';
// Helper to convert ScanResult to database format
function scanResultToDbFormat(result: ScanResult, envId?: number) {
@@ -23,7 +24,7 @@ function scanResultToDbFormat(result: ScanResult, envId?: number) {
};
}
// POST - Start a scan (returns SSE stream for progress)
// POST - Start a scan (returns { jobId } for progress polling)
export const POST: RequestHandler = async ({ request, url, cookies }) => {
const auth = await authorize(cookies);
@@ -42,75 +43,47 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
return json({ error: 'Image name is required' }, { status: 400 });
}
// Create a readable stream for SSE
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
let controllerClosed = false;
// Job pattern: create job, run in background, return jobId immediately
const job = createJob();
const sendProgress = (progress: ScanProgress) => {
if (controllerClosed) return;
try {
const data = `data: ${JSON.stringify(progress)}\n\n`;
controller.enqueue(encoder.encode(data));
} catch {
controllerClosed = true;
}
};
const sendProgress = (progress: ScanProgress) => {
appendLine(job, { data: progress });
};
// Send SSE keepalive comments every 5s to prevent Traefik timeout
const keepaliveInterval = setInterval(() => {
if (controllerClosed) return;
try {
controller.enqueue(encoder.encode(`: keepalive\n\n`));
} catch {
controllerClosed = true;
}
}, 5000);
(async () => {
try {
const results = await scanImage(imageName, envId, sendProgress, forceScannerType);
try {
const results = await scanImage(imageName, envId, sendProgress, forceScannerType);
// Save results to database
for (const result of results) {
await saveVulnerabilityScan(scanResultToDbFormat(result, envId));
}
// Send final complete message with all results
sendProgress({
stage: 'complete',
message: `Scan complete - found ${results.reduce((sum, r) => sum + r.vulnerabilities.length, 0)} vulnerabilities`,
progress: 100,
result: results[0],
results: results // Include all scanner results
});
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
sendProgress({
stage: 'error',
message: `Scan failed: ${errorMsg}`,
error: errorMsg
});
} finally {
clearInterval(keepaliveInterval);
if (!controllerClosed) {
try {
controller.close();
} catch {
// Already closed
}
}
// Save results to database
for (const result of results) {
await saveVulnerabilityScan(scanResultToDbFormat(result, envId));
}
// Send final complete message with all results
const completeProgress: ScanProgress = {
stage: 'complete',
message: `Scan complete - found ${results.reduce((sum, r) => sum + r.vulnerabilities.length, 0)} vulnerabilities`,
progress: 100,
result: results[0],
results: results // Include all scanner results
};
sendProgress(completeProgress);
completeJob(job, completeProgress);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
const errorProgress: ScanProgress = {
stage: 'error',
message: `Scan failed: ${errorMsg}`,
error: errorMsg
};
sendProgress(errorProgress);
failJob(job, errorMsg);
}
})().catch((err) => {
failJob(job, err instanceof Error ? err.message : String(err));
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
return json({ jobId: job.id });
};
// GET - Get cached scan results for an image
+23
View File
@@ -0,0 +1,23 @@
import { json } from '@sveltejs/kit';
import { getJob } from '$lib/server/jobs';
import type { RequestHandler } from './$types';
/**
* GET /api/jobs/[id]
* Poll a job's status and accumulated lines.
* Returns all lines every time — client tracks its own cursor locally.
* No auth required: job IDs are UUIDs (unguessable), no sensitive data beyond what the initiating user triggered.
*/
export const GET: RequestHandler = async ({ params }) => {
const job = getJob(params.id);
if (!job) {
return json({ error: 'Job not found' }, { status: 404 });
}
return json({
id: job.id,
status: job.status,
lines: job.lines,
result: job.result ?? null
});
};
+20 -53
View File
@@ -1,6 +1,8 @@
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getEnvironment } from '$lib/server/db';
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
@@ -424,37 +426,20 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
let inspectResponse: Response;
if (config.type === 'socket') {
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
// @ts-ignore - Bun supports unix socket
unix: config.socketPath
});
inspectResponse = await unixSocketRequest(config.socketPath, inspectPath);
} else if (config.type === 'https') {
const extraHeaders: Record<string, string> = {};
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
inspectResponse = await httpsAgentRequest(config as BaseDockerClientConfig, inspectPath, {}, false, extraHeaders);
} else {
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
const inspectUrl = `http://${config.host}:${config.port}${inspectPath}`;
const inspectHeaders: Record<string, string> = {};
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
// Build fetch options - only include tls for HTTPS
const fetchOptions: any = {
headers: inspectHeaders,
signal: AbortSignal.timeout(30000)
};
if (config.type === 'https') {
fetchOptions.tls = {
sessionTimeout: 0,
servername: config.host,
rejectUnauthorized: !config.skipVerify
};
if (config.ca) fetchOptions.tls.ca = [config.ca];
if (config.cert) fetchOptions.tls.cert = [config.cert];
if (config.key) fetchOptions.tls.key = config.key;
fetchOptions.keepalive = false;
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
}
inspectResponse = await fetch(inspectUrl, fetchOptions);
inspectResponse = await fetch(inspectUrl, { headers: inspectHeaders, signal: AbortSignal.timeout(30000) });
}
if (!inspectResponse.ok) {
await inspectResponse.arrayBuffer().catch(() => {});
console.log(`[merged-logs] Inspect failed for ${containerId.slice(0, 12)}, skipping`);
return null;
}
@@ -468,39 +453,20 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
let logsResponse: Response;
if (config.type === 'socket') {
logsResponse = await fetch(`http://localhost${logsPath}`, {
// @ts-ignore - Bun supports unix socket
unix: config.socketPath,
signal: abortController.signal
});
logsResponse = await unixSocketStreamRequest(config.socketPath, logsPath);
} else if (config.type === 'https') {
const extraHeaders: Record<string, string> = {};
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
logsResponse = await httpsAgentRequest(config as BaseDockerClientConfig, logsPath, {}, true, extraHeaders);
} else {
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
const logsUrl = `http://${config.host}:${config.port}${logsPath}`;
const logsHeaders: Record<string, string> = {};
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
// For logs streaming, use the cleanup abort controller without a timeout
// (the stream needs to stay open indefinitely)
const fetchOptions: any = {
headers: logsHeaders,
signal: abortController.signal
};
if (config.type === 'https') {
fetchOptions.tls = {
sessionTimeout: 0,
servername: config.host,
rejectUnauthorized: !config.skipVerify
};
if (config.ca) fetchOptions.tls.ca = [config.ca];
if (config.cert) fetchOptions.tls.cert = [config.cert];
if (config.key) fetchOptions.tls.key = config.key;
fetchOptions.keepalive = false;
if (process.env.DEBUG_TLS) fetchOptions.verbose = true;
}
logsResponse = await fetch(logsUrl, fetchOptions);
logsResponse = await fetch(logsUrl, { headers: logsHeaders, signal: abortController.signal });
}
if (!logsResponse.ok) {
await logsResponse.arrayBuffer().catch(() => {});
console.error(`[merged-logs] Failed to get logs for container ${containerId}: ${logsResponse.status}`);
return null;
}
@@ -647,7 +613,8 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
for (const source of sources) {
if (source.reader) {
try {
source.reader.releaseLock();
await source.reader.cancel().catch(() => {});
source.reader.releaseLock();
} catch {
// Ignore
}
-24
View File
@@ -1,24 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getHostMetrics } from '$lib/server/db';
export const GET: RequestHandler = async ({ url }) => {
try {
const limit = parseInt(url.searchParams.get('limit') || '60');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
const metrics = await getHostMetrics(limit, envIdNum);
// Return metrics in chronological order (oldest first) for graphing
const chronological = metrics.reverse();
return json({
metrics: chronological,
latest: metrics.length > 0 ? metrics[metrics.length - 1] : null
});
} catch (error) {
console.error('Failed to get host metrics:', error);
return json({ error: 'Failed to get host metrics' }, { status: 500 });
}
};
+4 -2
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listNetworks, createNetwork, EnvironmentNotFoundError, type CreateNetworkOptions } from '$lib/server/docker';
import { listNetworks, createNetwork, EnvironmentNotFoundError, DockerConnectionError, type CreateNetworkOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditNetwork } from '$lib/server/audit';
import { hasEnvironments } from '$lib/server/db';
@@ -33,7 +33,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
console.error('Failed to list networks:', error);
if (!(error instanceof DockerConnectionError)) {
console.error('Failed to list networks:', error);
}
return json({ error: 'Failed to list networks' }, { status: 500 });
}
};
+16 -13
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import { pruneImages } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { audit } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
@@ -17,19 +18,21 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const result = await pruneImages(danglingOnly, envIdNum);
return createJobResponse(async (send) => {
try {
const result = await pruneImages(danglingOnly, envIdNum);
// Audit log
await audit(event, 'prune', 'image', {
environmentId: envIdNum,
description: `Pruned ${danglingOnly ? 'dangling' : 'unused'} images`,
details: { danglingOnly, result }
});
// Audit log
await audit(event, 'prune', 'image', {
environmentId: envIdNum,
description: `Pruned ${danglingOnly ? 'dangling' : 'unused'} images`,
details: { danglingOnly, result }
});
return json({ success: true, result });
} catch (error) {
console.error('Error pruning images:', error);
return json({ error: 'Failed to prune images' }, { status: 500 });
}
send('result', { success: true, result });
} catch (error) {
console.error('Error pruning images:', error);
send('result', { success: false, error: 'Failed to prune images' });
}
}, event.request);
};
+2 -1
View File
@@ -340,7 +340,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
};
+19 -15
View File
@@ -1,22 +1,22 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnContainerId, getHostDockerSocket } 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 directly.
* Self-update always operates on the local engine — no environment routing needed.
*/
async function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return fetch(`http://localhost${path}`, {
...options,
// @ts-ignore - Bun supports unix sockets
unix: socketPath
});
/** Fetch from the local Docker socket (buffered). */
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
return unixSocketRequest(DOCKER_SOCKET, path, options);
}
/** Fetch from the local Docker socket (streaming body for pull progress). */
function localDockerStreamFetch(path: string, options: RequestInit = {}): Promise<Response> {
return unixSocketStreamRequest(DOCKER_SOCKET, path, options);
}
/**
@@ -34,9 +34,10 @@ async function pullImageLocal(imageName: string, onProgress?: (line: string) =>
}
}
const response = await localDockerFetch(
const authHeaders = await buildRegistryAuthHeader(imageName);
const response = await localDockerStreamFetch(
`/images/create?fromImage=${encodeURIComponent(fromImage)}&tag=${encodeURIComponent(tag)}`,
{ method: 'POST' }
{ method: 'POST', headers: authHeaders }
);
if (!response.ok) {
@@ -400,11 +401,14 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
}
});
return new Response(stream, {
const sseResponse = new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
}
});
if (prefersJSON(request)) return sseToJSON(sseResponse);
return sseResponse;
};
+96 -11
View File
@@ -1,19 +1,15 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnContainerId } from '$lib/server/host-path';
import { getRegistryManifestDigest } from '$lib/server/docker';
import { getRegistryManifestDigest, unixSocketRequest } from '$lib/server/docker';
import { compareVersions } from '$lib/utils/version';
import type { RequestHandler } from './$types';
/**
* Fetch from the local Docker socket directly (not through environment routing)
*/
async function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return fetch(`http://localhost${path}`, {
...options,
// @ts-ignore - Bun supports unix sockets
unix: socketPath
});
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
/** Fetch from the local Docker socket directly (not through environment routing) */
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
return unixSocketRequest(DOCKER_SOCKET, path, options);
}
/**
@@ -78,6 +74,92 @@ export const GET: RequestHandler = async ({ cookies }) => {
});
}
// Extract tag from image name
const colonIdx = currentImage.lastIndexOf(':');
const tag = colonIdx > -1 ? currentImage.substring(colonIdx + 1) : 'latest';
const imageWithoutTag = colonIdx > -1 ? currentImage.substring(0, colonIdx) : currentImage;
// Check if this is a versioned tag (e.g., v1.0.18, 1.0.18, v1.0.18-baseline)
const versionMatch = tag.match(/^(v?\d+\.\d+\.\d+)(-baseline)?$/);
if (versionMatch) {
// Version-based check: compare against latest released version from changelog
const currentTagVersion = versionMatch[1];
const suffix = versionMatch[2] || ''; // '-baseline' or ''
try {
const changelogResponse = await fetch(
'https://raw.githubusercontent.com/Finsys/dockhand/main/src/lib/data/changelog.json',
{ signal: AbortSignal.timeout(5000) }
);
if (!changelogResponse.ok) {
return json({
updateAvailable: false,
currentImage,
containerName,
isComposeManaged,
error: 'Could not fetch changelog from GitHub'
});
}
const changelog = await changelogResponse.json() as Array<{
version: string;
comingSoon?: boolean;
date?: string;
changes?: Array<{ type: string; text: string }>;
}>;
// Find latest released version (first entry without comingSoon)
const latestRelease = changelog.find(entry => !entry.comingSoon);
if (!latestRelease) {
return json({
updateAvailable: false,
currentImage,
containerName,
isComposeManaged,
error: 'No released version found in changelog'
});
}
const latestVersion = latestRelease.version;
const hasNewer = compareVersions(latestVersion, currentTagVersion) > 0;
if (hasNewer) {
// Build new image tag preserving registry prefix and suffix
const newTag = `v${latestVersion.replace(/^v/, '')}${suffix}`;
const newImage = `${imageWithoutTag}:${newTag}`;
return json({
updateAvailable: true,
currentImage,
newImage,
latestVersion: latestVersion.replace(/^v/, ''),
containerName,
isComposeManaged
});
}
return json({
updateAvailable: false,
currentImage,
containerName,
isComposeManaged
});
} catch (err) {
return json({
updateAvailable: false,
currentImage,
containerName,
isComposeManaged,
error: 'Version check failed: ' + String(err)
});
}
}
// Digest-based check for mutable tags (:latest, :baseline, etc.)
// Inspect image via local Docker socket to get RepoDigests
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
if (!imageResponse.ok) {
@@ -105,6 +187,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
return json({
updateAvailable: false,
currentImage,
newImage: currentImage,
containerName,
isComposeManaged,
isLocalImage: true
@@ -117,6 +200,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
return json({
updateAvailable: false,
currentImage,
newImage: currentImage,
containerName,
isComposeManaged,
error: 'Could not query registry'
@@ -128,6 +212,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
return json({
updateAvailable: hasUpdate,
currentImage,
newImage: currentImage,
currentDigest: localDigests[0],
newDigest: registryDigest,
containerName,
@@ -1,16 +1,13 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { unixSocketRequest } from '$lib/server/docker';
import type { RequestHandler } from './$types';
/**
* Fetch from the local Docker socket directly
*/
async function localDockerFetch(path: string): Promise<Response> {
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return fetch(`http://localhost${path}`, {
// @ts-ignore - Bun supports unix sockets
unix: socketPath
});
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
/** Fetch from the local Docker socket directly */
function localDockerFetch(path: string): Promise<Response> {
return unixSocketRequest(DOCKER_SOCKET, path);
}
/**
+1 -1
View File
@@ -29,7 +29,7 @@ import {
} from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { refreshSystemJobs } from '$lib/server/scheduler';
import { sendToEventSubprocess, sendToMetricsSubprocess, type UpdateIntervalCommand } from '$lib/server/subprocess-manager';
import { sendToEventSubprocess, sendToMetricsSubprocess } from '$lib/server/subprocess-manager';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
+31 -19
View File
@@ -1,9 +1,10 @@
import { json } from '@sveltejs/kit';
import { listComposeStacks, deployStack, saveStackComposeFile, writeStackEnvFile, writeRawStackEnvFile, saveStackEnvVarsToDb } from '$lib/server/stacks';
import { EnvironmentNotFoundError } from '$lib/server/docker';
import { EnvironmentNotFoundError, DockerConnectionError } from '$lib/server/docker';
import { upsertStackSource, getStackSources } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, cookies }) => {
@@ -62,8 +63,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
// Silently return empty for connection errors (offline environments)
if (error instanceof DockerConnectionError) {
return json([]);
}
console.error('Error listing compose stacks:', error);
// Return empty array instead of error to allow UI to load
return json([]);
}
};
@@ -162,20 +166,7 @@ export const POST: RequestHandler = async (event) => {
}
}
// Deploy and start the stack
const result = await deployStack({
name,
compose,
envId: envIdNum,
composePath: composePath || undefined,
envPath: envPath || undefined
});
if (!result.success) {
return json({ error: result.error, output: result.output }, { status: 400 });
}
// Record the stack as internally created with custom paths if provided
// Record the stack in DB before deploying - ensures it exists even if deploy fails
await upsertStackSource({
stackName: name,
environmentId: envIdNum,
@@ -184,10 +175,31 @@ export const POST: RequestHandler = async (event) => {
envPath: envPath || undefined
});
// Audit log (create + deploy in one action)
await auditStack(event, 'deploy', name, envIdNum);
// Deploy via SSE to keep connection alive during long operations
return createJobResponse(async (send) => {
try {
const result = await deployStack({
name,
compose,
envId: envIdNum,
composePath: composePath || undefined,
envPath: envPath || undefined
});
return json({ success: true, started: true, output: result.output });
if (!result.success) {
send('result', { success: false, error: result.error, output: result.output });
return;
}
// Audit log (create + deploy in one action)
await auditStack(event, 'deploy', name, envIdNum);
send('result', { success: true, started: true, output: result.output });
} catch (error: any) {
console.error('Error deploying compose stack:', error);
send('result', { success: false, error: error.message || 'Failed to deploy stack' });
}
}, request);
} catch (error: any) {
console.error('Error creating compose stack:', error);
return json({ error: error.message || 'Failed to create stack' }, { status: 500 });
+27 -12
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getStackComposeFile, deployStack, saveStackComposeFile } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { createJobResponse } from '$lib/server/sse';
// GET /api/stacks/[name]/compose - Get compose file content
export const GET: RequestHandler = async ({ params, url, cookies }) => {
@@ -66,7 +67,6 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
? { composePath, envPath, moveFromDir, oldComposePath, oldEnvPath }
: undefined;
let result;
if (restart) {
// Deploy with docker compose up -d --force-recreate
// Force recreate ensures env var changes are applied
@@ -79,19 +79,34 @@ export const PUT: RequestHandler = async ({ params, request, url, cookies }) =>
}
// Get authoritative paths from DB/filesystem for deploy
const composeInfo = await getStackComposeFile(name, envIdNum);
result = await deployStack({
name,
compose: content,
envId: envIdNum,
forceRecreate: true,
composePath: composeInfo.composePath || undefined,
envPath: composeInfo.envPath || undefined
});
} else {
// Just save the file without restarting (update operation, not create)
result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
// Deploy via SSE to keep connection alive during long operations
return createJobResponse(async (send) => {
try {
const result = await deployStack({
name,
compose: content,
envId: envIdNum,
forceRecreate: true,
composePath: composeInfo.composePath || undefined,
envPath: composeInfo.envPath || undefined
});
if (!result.success) {
send('result', { success: false, error: result.error });
return;
}
send('result', { success: true });
} catch (error: any) {
console.error(`Error deploying stack ${name}:`, error);
send('result', { success: false, error: error.message || 'Failed to deploy stack' });
}
}, request);
}
// Just save the file without restarting (update operation, not create)
const result = await saveStackComposeFile(name, content, false, envIdNum, pathOptions);
if (!result.success) {
return json({ error: result.error }, { status: 500 });
}
+30 -25
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import { downStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
@@ -21,31 +22,35 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
// Parse body BEFORE creating SSE response (body can only be read once)
let removeVolumes = false;
try {
// Parse body for optional removeVolumes flag
let removeVolumes = false;
try {
const body = await request.json();
removeVolumes = body.removeVolumes === true;
} catch {
// No body or invalid JSON - use defaults
}
const stackName = decodeURIComponent(params.name);
const result = await downStack(stackName, envIdNum, removeVolumes);
// Audit log
await auditStack(event, 'down', stackName, envIdNum, { removeVolumes });
if (!result.success) {
return json({ success: false, error: result.error }, { status: 400 });
}
return json({ success: true, output: result.output });
} catch (error) {
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
console.error('Error downing compose stack:', error);
return json({ error: 'Failed to down compose stack' }, { status: 500 });
const body = await request.json();
removeVolumes = body.removeVolumes === true;
} catch {
// No body or invalid JSON - use defaults
}
return createJobResponse(async (send) => {
try {
const stackName = decodeURIComponent(params.name);
const result = await downStack(stackName, envIdNum, removeVolumes);
// Audit log
await auditStack(event, 'down', stackName, envIdNum, { removeVolumes });
if (!result.success) {
send('result', { success: false, error: result.error });
return;
}
send('result', { success: true, output: result.output });
} catch (error) {
if (error instanceof ComposeFileNotFoundError) {
send('result', { success: false, error: error.message });
return;
}
console.error('Error downing compose stack:', error);
send('result', { success: false, error: 'Failed to down compose stack' });
}
}, request);
};
+2 -2
View File
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
import { getStackEnvVars, setStackEnvVars, getStackSource } from '$lib/server/db';
import { findStackDir } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { existsSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import type { RequestHandler } from './$types';
@@ -94,7 +94,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
// Internal/adopted stacks: non-secrets from file, secrets from DB
if (envFilePath && existsSync(envFilePath)) {
try {
const content = await Bun.file(envFilePath).text();
const content = readFileSync(envFilePath, 'utf-8');
const fileVars = parseEnvFile(content);
for (const [key, value] of Object.entries(fileVars)) {
variables.push({ key, value, isSecret: false });
+3 -3
View File
@@ -2,7 +2,7 @@ import { json } from '@sveltejs/kit';
import { findStackDir, getStackDir } from '$lib/server/stacks';
import { getStackSource } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { existsSync, rmSync } from 'node:fs';
import { existsSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import type { RequestHandler } from './$types';
@@ -58,7 +58,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let content = '';
if (envFilePath && existsSync(envFilePath)) {
try {
content = await Bun.file(envFilePath).text();
content = readFileSync(envFilePath, 'utf-8');
} catch {
// File read failed
}
@@ -154,7 +154,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
content += '\n';
}
await Bun.write(envFilePath, content);
writeFileSync(envFilePath, content);
return json({ success: true });
} catch (error) {
+20 -15
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import { restartStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
@@ -21,22 +22,26 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const stackName = decodeURIComponent(params.name);
const result = await restartStack(stackName, envIdNum);
return createJobResponse(async (send) => {
try {
const stackName = decodeURIComponent(params.name);
const result = await restartStack(stackName, envIdNum);
// Audit log
await auditStack(event, 'restart', stackName, envIdNum);
// Audit log
await auditStack(event, 'restart', stackName, envIdNum);
if (!result.success) {
return json({ success: false, error: result.error }, { status: 400 });
if (!result.success) {
send('result', { success: false, error: result.error });
return;
}
send('result', { success: true, output: result.output });
} catch (error) {
if (error instanceof ComposeFileNotFoundError) {
send('result', { success: false, error: error.message });
return;
}
console.error('Error restarting compose stack:', error);
send('result', { success: false, error: 'Failed to restart compose stack' });
}
return json({ success: true, output: result.output });
} catch (error) {
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
console.error('Error restarting compose stack:', error);
return json({ error: 'Failed to restart compose stack' }, { status: 500 });
}
}, event.request);
};
+20 -15
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import { startStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
@@ -21,22 +22,26 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const stackName = decodeURIComponent(params.name);
const result = await startStack(stackName, envIdNum);
return createJobResponse(async (send) => {
try {
const stackName = decodeURIComponent(params.name);
const result = await startStack(stackName, envIdNum);
// Audit log
await auditStack(event, 'start', stackName, envIdNum);
// Audit log
await auditStack(event, 'start', stackName, envIdNum);
if (!result.success) {
return json({ success: false, error: result.error }, { status: 400 });
if (!result.success) {
send('result', { success: false, error: result.error });
return;
}
send('result', { success: true, output: result.output });
} catch (error) {
if (error instanceof ComposeFileNotFoundError) {
send('result', { success: false, error: error.message });
return;
}
console.error('Error starting compose stack:', error);
send('result', { success: false, error: 'Failed to start compose stack' });
}
return json({ success: true, output: result.output });
} catch (error) {
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
console.error('Error starting compose stack:', error);
return json({ error: 'Failed to start compose stack' }, { status: 500 });
}
}, event.request);
};
+20 -15
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import { stopStack, ComposeFileNotFoundError } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
import { auditStack } from '$lib/server/audit';
import { createJobResponse } from '$lib/server/sse';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
@@ -21,22 +22,26 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Access denied to this environment' }, { status: 403 });
}
try {
const stackName = decodeURIComponent(params.name);
const result = await stopStack(stackName, envIdNum);
return createJobResponse(async (send) => {
try {
const stackName = decodeURIComponent(params.name);
const result = await stopStack(stackName, envIdNum);
// Audit log
await auditStack(event, 'stop', stackName, envIdNum);
// Audit log
await auditStack(event, 'stop', stackName, envIdNum);
if (!result.success) {
return json({ success: false, error: result.error }, { status: 400 });
if (!result.success) {
send('result', { success: false, error: result.error });
return;
}
send('result', { success: true, output: result.output });
} catch (error) {
if (error instanceof ComposeFileNotFoundError) {
send('result', { success: false, error: error.message });
return;
}
console.error('Error stopping compose stack:', error);
send('result', { success: false, error: 'Failed to stop compose stack' });
}
return json({ success: true, output: result.output });
} catch (error) {
if (error instanceof ComposeFileNotFoundError) {
return json({ error: error.message }, { status: 404 });
}
console.error('Error stopping compose stack:', error);
return json({ error: 'Failed to stop compose stack' }, { status: 500 });
}
}, event.request);
};
+1
View File
@@ -23,6 +23,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Detect which stacks are already running on any environment
const discoveredWithRunning = await detectRunningStacks(result.discovered);
discoveredWithRunning.sort((a, b) => a.name.localeCompare(b.name));
return json({
...result,
+31 -16
View File
@@ -13,6 +13,7 @@ import { isPostgres, isSqlite, getDatabaseSchemaVersion, getPostgresConnectionIn
import { hasEnvironments } from '$lib/server/db';
import type { RequestHandler } from './$types';
import { existsSync, readFileSync } from 'node:fs';
import * as http from 'node:http';
import os from 'node:os';
import { authorize } from '$lib/server/authorize';
@@ -47,12 +48,12 @@ function detectContainerRuntime(): { inContainer: boolean; runtime?: string; con
return { inContainer: false };
}
// Get Bun runtime info
function getBunInfo() {
// Get runtime info
function getRuntimeInfo() {
const memUsage = process.memoryUsage();
return {
version: typeof Bun !== 'undefined' ? Bun.version : null,
revision: typeof Bun !== 'undefined' ? Bun.revision?.slice(0, 7) : null,
name: 'Node.js',
version: process.version,
memory: {
heapUsed: memUsage.heapUsed,
heapTotal: memUsage.heapTotal,
@@ -67,7 +68,6 @@ async function getOwnContainerInfo(containerId: string | undefined): Promise<any
if (!containerId) return null;
try {
// Try to inspect our own container via local socket
const socketPaths = [
'/var/run/docker.sock',
process.env.DOCKER_SOCKET
@@ -77,18 +77,33 @@ async function getOwnContainerInfo(containerId: string | undefined): Promise<any
if (!socketPath || !existsSync(socketPath)) continue;
try {
const response = await fetch(`http://localhost/containers/${containerId}/json`, {
// @ts-ignore - Bun supports unix socket
unix: socketPath
const info = await new Promise<any>((resolve, reject) => {
const req = http.request({
socketPath,
path: `/containers/${containerId}/json`,
method: 'GET',
}, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
if (res.statusCode === 200) {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
} else {
resolve(null);
}
});
res.on('error', () => resolve(null));
});
req.on('error', () => resolve(null));
req.end();
});
if (response.ok) {
const info = await response.json();
if (info) {
return {
id: info.Id?.slice(0, 12),
name: info.Name?.replace(/^\//, ''),
image: info.Config?.Image,
imageId: info.Image?.slice(7, 19), // Remove 'sha256:' prefix
imageId: info.Image?.slice(7, 19),
created: info.Created,
status: info.State?.Status,
restartCount: info.RestartCount,
@@ -153,7 +168,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const runningContainers = containers.filter(c => c.state === 'running').length;
const stoppedContainers = containers.length - runningContainers;
const bunInfo = getBunInfo();
const runtimeInfo = getRuntimeInfo();
const containerRuntime = detectContainerRuntime();
const ownContainer = containerRuntime.inContainer
? await getOwnContainerInfo(containerRuntime.containerId || os.hostname())
@@ -181,13 +196,13 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
storageDriver: dockerInfo.Driver
} : null,
runtime: {
bun: bunInfo.version,
bunRevision: bunInfo.revision,
nodeVersion: process.version,
runtimeName: runtimeInfo.name,
runtimeVersion: runtimeInfo.version,
nodeVersion: runtimeInfo.version,
platform: os.platform(),
arch: os.arch(),
kernel: os.release(),
memory: bunInfo.memory,
memory: runtimeInfo.memory,
container: containerRuntime,
ownContainer
},
+2 -2
View File
@@ -114,7 +114,7 @@ export const POST: RequestHandler = async (event) => {
// Auto-login if this is the first user being created (and auth is enabled)
let autoLoggedIn = false;
if (isFirstUser && auth.authEnabled) {
await createUserSession(user.id, 'local', cookies);
await createUserSession(user.id, 'local', cookies, event.request);
autoLoggedIn = true;
}
@@ -139,7 +139,7 @@ export const POST: RequestHandler = async (event) => {
name: error.name,
stack: error.stack
});
if (error.message?.includes('UNIQUE constraint failed') || error.code === '23505') {
if (error.message?.includes('UNIQUE constraint failed') || error.code === '23505' || (error as any).cause?.code === '23505') {
return json({ error: 'Username already exists' }, { status: 409 });
}
return json({ error: 'Failed to create user', details: error.message }, { status: 500 });
+1 -1
View File
@@ -229,7 +229,7 @@ export const PUT: RequestHandler = async (event) => {
});
} catch (error: any) {
console.error('Failed to update user:', error);
if (error.message?.includes('UNIQUE constraint failed')) {
if (error.message?.includes('UNIQUE constraint failed') || (error as any).cause?.code === '23505') {
return json({ error: 'Username already exists' }, { status: 409 });
}
return json({ error: 'Failed to update user' }, { status: 500 });
+4 -2
View File
@@ -1,6 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listVolumes, createVolume, EnvironmentNotFoundError, type CreateVolumeOptions } from '$lib/server/docker';
import { listVolumes, createVolume, EnvironmentNotFoundError, DockerConnectionError, type CreateVolumeOptions } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditVolume } from '$lib/server/audit';
import { hasEnvironments } from '$lib/server/db';
@@ -33,7 +33,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
if (error instanceof EnvironmentNotFoundError) {
return json({ error: 'Environment not found' }, { status: 404 });
}
console.error('Failed to list volumes:', error);
if (!(error instanceof DockerConnectionError)) {
console.error('Failed to list volumes:', error);
}
return json({ error: 'Failed to list volumes' }, { status: 500 });
}
};
@@ -1,3 +1,4 @@
import { gzipSync } from 'node:zlib';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVolumeArchive } from '$lib/server/docker';
@@ -31,9 +32,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
let body: ReadableStream<Uint8Array> | Uint8Array = response.body!;
if (format === 'tar.gz') {
// Compress with gzip using Bun's native implementation
// Compress with gzip
const tarData = new Uint8Array(await response.arrayBuffer());
body = Bun.gzipSync(tarData);
body = gzipSync(tarData);
contentType = 'application/gzip';
extension = '.tar.gz';
}