v1.0.19
This commit is contained in:
1 parent
4b430340db
commit
9c451aedf9
120 files changed
+7777
-6046
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user