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

+1
View File
@@ -920,6 +920,7 @@
}
unsubscribeDashboardData();
unsubscribePrefs();
mobileWatcher.destroy();
});
</script>
+7 -6
View File
@@ -521,16 +521,12 @@
// Add to beginning of events (prepend new events) - use Set for fast duplicate check
if (!eventIds.has(newEvent.id)) {
eventIds.add(newEvent.id);
// Use unshift() for in-place mutation instead of spread for O(n) copy
events.unshift(newEvent);
events = events; // Trigger Svelte reactivity
events = [newEvent, ...events];
total = total + 1;
// Add container to list if not already there
if (newEvent.containerName && !containers.includes(newEvent.containerName)) {
containers.push(newEvent.containerName);
containers.sort();
containers = containers; // Trigger Svelte reactivity
containers = [...containers, newEvent.containerName].sort();
}
}
} catch {
@@ -624,6 +620,11 @@
}).then(() => {
connectSSE();
initialLoadDone = true;
}).catch((err) => {
console.error('[Activity] Init chain failed:', err);
// Connect SSE anyway so live events still work
connectSSE();
initialLoadDone = true;
});
// Note: In Svelte 5, cleanup must be in onDestroy, not returned from onMount
});
@@ -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';
}
+142 -241
View File
@@ -73,9 +73,10 @@
import FileBrowserModal from './FileBrowserModal.svelte';
import BatchUpdateModal from './BatchUpdateModal.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import type { ContainerInfo, ContainerStats } from '$lib/types';
import type { ContainerInfo } from '$lib/types';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import { containerStore } from '$lib/stores/containers';
import { onDockerEvent, isContainerListChange } from '$lib/stores/events';
import { appSettings } from '$lib/stores/settings';
import { canAccess } from '$lib/stores/auth';
@@ -87,8 +88,7 @@
import type { ColumnConfig } from '$lib/types';
import type { DataGridRowState } from '$lib/components/data-grid/types';
// Track previous stats for change detection
let previousStats = $state<Map<string, ContainerStats>>(new Map());
// Track change detection for stat highlighting (UI-only, stays in component)
let changedFields = $state<Map<string, Set<string>>>(new Map());
// Format bytes to human readable
@@ -100,21 +100,26 @@
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + sizes[i];
}
type SortField = 'name' | 'image' | 'state' | 'health' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory';
type SortField = 'name' | 'image' | 'state' | 'health' | 'uptime' | 'stack' | 'ip' | 'cpu' | 'memory' | 'ports';
type SortDirection = 'asc' | 'desc';
let containers = $state<ContainerInfo[]>([]);
let containerStats = $state<Map<string, ContainerStats>>(new Map());
let autoUpdateSettings = $state<Map<string, { enabled: boolean; label: string; tooltip: string; vulnerabilityCriteria?: string }>>(new Map());
// Data from persistent store (survives page navigation)
const containers = $derived($containerStore.data);
const containerStats = $derived($containerStore.stats);
const autoUpdateSettings = $derived($containerStore.autoUpdateSettings);
const envHasScanning = $derived($containerStore.envHasScanning);
const envVulnerabilityCriteria = $derived($containerStore.envVulnerabilityCriteria);
const loading = $derived($containerStore.loading);
let envId = $state<number | null>(null);
let envHasScanning = $state(false);
let envVulnerabilityCriteria = $state<'never' | 'any' | 'critical_high' | 'critical' | 'more_than_current'>('never');
// Derived: current environment details for reactive port URL generation
const currentEnvDetails = $derived($environments.find(e => e.id === $currentEnvironment?.id) ?? null);
// Search and sort state
let searchQuery = $state('');
// Search and sort state - initialize from URL for persistence across navigation
const initialSearch = $page.url.searchParams.get('search')
?? $page.url.searchParams.get('image') ?? '';
let searchQuery = $state(initialSearch);
let sortField = $state<SortField>('name');
let sortDirection = $state<SortDirection>('asc');
@@ -167,6 +172,18 @@
saveStatusFilter();
});
// Sync search query to URL for persistence across navigation
$effect(() => {
const q = searchQuery;
const url = new URL($page.url);
if (q) url.searchParams.set('search', q);
else url.searchParams.delete('search');
url.searchParams.delete('image'); // clean up legacy param
if (url.toString() !== $page.url.toString()) {
goto(url.toString(), { replaceState: true, noScroll: true, keepFocus: true });
}
});
// Track if initial fetch has been done
let initialFetchDone = $state(false);
@@ -177,29 +194,28 @@
// Only fetch if environment actually changed or this is initial load
if (env && (newEnvId !== envId || !initialFetchDone)) {
const isEnvSwitch = envId !== null && newEnvId !== envId;
envId = newEnvId;
initialFetchDone = true;
// Clear update state from previous environment
batchUpdateContainerIds = [];
batchUpdateContainerNames = new Map();
updateCheckStatus = 'idle';
// Clear shell detection cache for new environment
shellDetectionCache = {};
fetchContainers();
fetchStats();
loadPendingUpdates();
if (isEnvSwitch) {
// Full env switch — invalidate cache, show spinner
containerStore.invalidate();
}
// Refresh data (store handles loading state internally)
containerStore.refresh(newEnvId);
} else if (!env) {
// No environment - clear data and stop loading
envId = null;
containers = [];
loading = false;
batchUpdateContainerIds = [];
batchUpdateContainerNames = new Map();
updateCheckStatus = 'idle';
shellDetectionCache = {};
containerStore.clear();
}
});
let loading = $state(true);
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editContainerId = $state('');
@@ -247,8 +263,8 @@
// Update check state
let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle');
let showBatchUpdateModal = $state(false);
let batchUpdateContainerIds = $state<string[]>([]);
let batchUpdateContainerNames = $state<Map<string, string>>(new Map());
const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds);
const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames);
// Single container update mode (doesn't overwrite batch list)
let singleUpdateContainerId = $state<string | null>(null);
@@ -332,7 +348,7 @@
function handleBatchOpComplete() {
selectedContainers = new Set();
fetchContainers();
containerStore.refreshContainers(envId);
}
function bulkStart() {
@@ -353,9 +369,9 @@
function bulkRestart() {
startBatchOperation(
`Restarting ${selectedInFilter.length} container${selectedInFilter.length !== 1 ? 's' : ''}`,
`Restarting ${selectedNonSystem.length} container${selectedNonSystem.length !== 1 ? 's' : ''}`,
'restart',
selectedInFilter
selectedNonSystem
);
}
@@ -377,9 +393,9 @@
function bulkRemove() {
startBatchOperation(
`Removing ${selectedInFilter.length} container${selectedInFilter.length !== 1 ? 's' : ''}`,
`Removing ${selectedNonSystem.length} container${selectedNonSystem.length !== 1 ? 's' : ''}`,
'remove',
selectedInFilter,
selectedNonSystem,
{ force: true }
);
}
@@ -393,7 +409,7 @@
});
if (response.ok) {
pruneStatus = 'success';
await fetchContainers();
await containerStore.refreshContainers(envId);
} else {
pruneStatus = 'error';
}
@@ -418,23 +434,28 @@
}
const data = await response.json();
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
const failedChecks = data.results.filter((r: any) => r.error && !r.hasUpdate).length;
const failedSuffix = failedChecks > 0 ? ` (${failedChecks} failed to check)` : '';
if (containersWithUpdates.length === 0) {
updateCheckStatus = 'none';
batchUpdateContainerIds = [];
batchUpdateContainerNames.clear();
toast.success('All containers are up to date');
containerStore.setPendingUpdates([], new Map());
if (failedChecks > 0) {
toast.warning(`All containers are up to date${failedSuffix}`);
} else {
toast.success('All containers are up to date');
}
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
return;
}
// Prepare data for batch update modal (but don't open it yet)
batchUpdateContainerIds = containersWithUpdates.map((r: any) => r.containerId);
batchUpdateContainerNames = new Map(
containersWithUpdates.map((r: any) => [r.containerId, r.containerName])
containerStore.setPendingUpdates(
containersWithUpdates.map((r: any) => r.containerId),
new Map(containersWithUpdates.map((r: any) => [r.containerId, r.containerName]))
);
updateCheckStatus = 'found';
toast.info(`${containersWithUpdates.length} update(s) available`);
toast.info(`${containersWithUpdates.length} update(s) available${failedSuffix}`);
} catch (error) {
updateCheckStatus = 'error';
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
@@ -444,19 +465,10 @@
// Load pending updates from database (persisted from check-updates or scheduled jobs)
async function loadPendingUpdates() {
if (!envId) return;
try {
const response = await fetch(appendEnvParam('/api/containers/pending-updates', envId));
if (!response.ok) return;
const data = await response.json();
if (data.pendingUpdates && data.pendingUpdates.length > 0) {
batchUpdateContainerIds = data.pendingUpdates.map((u: any) => u.containerId);
batchUpdateContainerNames = new Map(
data.pendingUpdates.map((u: any) => [u.containerId, u.containerName])
);
updateCheckStatus = 'found';
}
} catch {
// Ignore errors - this is a background load
await containerStore.loadPendingUpdates(envId);
// Update local UI status if there are pending updates
if ($containerStore.pendingUpdateIds.length > 0) {
updateCheckStatus = 'found';
}
}
@@ -475,8 +487,7 @@
selectedNames.set(id, container.name);
}
}
batchUpdateContainerIds = selectedWithUpdates;
batchUpdateContainerNames = selectedNames;
containerStore.setPendingUpdates(selectedWithUpdates, selectedNames);
showBatchUpdateModal = true;
}
@@ -492,7 +503,7 @@
}
}
if (allNames.size === 0) return;
batchUpdateContainerNames = allNames;
containerStore.patch({ pendingUpdateNames: allNames });
showBatchUpdateModal = true;
}
@@ -529,7 +540,7 @@
// Reload pending updates from database to restore highlighting for remaining containers
loadPendingUpdates();
fetchContainers();
containerStore.refreshContainers(envId);
}
// Action in progress state (for animations)
@@ -717,6 +728,15 @@
const stackB = b.labels?.['com.docker.compose.project'] || '';
cmp = stackA.localeCompare(stackB);
break;
case 'ports':
const getLowestPort = (c: ContainerInfo) => {
const publicPorts = (c.ports || [])
.filter((p: any) => p.PublicPort)
.map((p: any) => p.PublicPort!);
return publicPorts.length > 0 ? Math.min(...publicPorts) : Infinity;
};
cmp = getLowestPort(a) - getLowestPort(b);
break;
case 'ip':
const ipA = getContainerIp(a.networks);
const ipB = getContainerIp(b.networks);
@@ -758,122 +778,15 @@
filteredContainers.filter(c => selectedContainers.has(c.id))
);
// Count by state for selected containers
const selectedRunning = $derived(selectedInFilter.filter(c => c.state === 'running'));
const selectedStopped = $derived(selectedInFilter.filter(c => c.state !== 'running' && c.state !== 'paused'));
const selectedPaused = $derived(selectedInFilter.filter(c => c.state === 'paused'));
// Count by state for selected containers (exclude system containers from destructive actions)
const selectedNonSystem = $derived(selectedInFilter.filter(c => !c.systemContainer));
const selectedRunning = $derived(selectedNonSystem.filter(c => c.state === 'running'));
const selectedStopped = $derived(selectedNonSystem.filter(c => c.state !== 'running' && c.state !== 'paused'));
const selectedPaused = $derived(selectedNonSystem.filter(c => c.state === 'paused'));
async function fetchContainers() {
loading = true;
try {
const response = await fetch(appendEnvParam('/api/containers', envId));
if (!response.ok) {
// Handle stale environment ID (e.g., after database reset)
if (response.status === 404 && envId) {
clearStaleEnvironment(envId);
environments.refresh();
return;
}
toast.error('Failed to load containers');
return;
}
containers = await response.json();
// Fetch auto-update settings for all containers
await fetchAutoUpdateSettings();
} catch (error) {
console.error('Failed to fetch containers:', error);
toast.error('Failed to load containers');
} finally {
loading = false;
}
}
async function checkScannerSettings() {
if (!envId) {
envHasScanning = false;
envVulnerabilityCriteria = 'never';
return;
}
try {
// Fetch scanner settings and environment update-check settings in parallel
const [scannerResponse, updateCheckResponse] = await Promise.all([
fetch(`/api/settings/scanner?env=${envId}&settingsOnly=true`),
fetch(`/api/environments/${envId}/update-check`)
]);
if (scannerResponse.ok) {
const data = await scannerResponse.json();
const settings = data.settings || data;
envHasScanning = settings.scanner !== 'none';
}
if (updateCheckResponse.ok) {
const data = await updateCheckResponse.json();
envVulnerabilityCriteria = data.settings?.vulnerabilityCriteria || 'never';
}
} catch {
envHasScanning = false;
envVulnerabilityCriteria = 'never';
}
}
async function fetchAutoUpdateSettings() {
const settings = new Map<string, { enabled: boolean; label: string; tooltip: string; vulnerabilityCriteria?: string }>();
const envParam = envId ? `?env=${envId}` : '';
// Check scanner settings first
await checkScannerSettings();
// Fetch all auto-update settings in a single request
try {
const response = await fetch(`/api/auto-update${envParam}`);
if (response.ok) {
const data = await response.json();
// data is a map of containerName -> settings
for (const [containerName, setting] of Object.entries(data)) {
if (setting && typeof setting === 'object' && 'enabled' in setting && setting.enabled) {
const s = setting as { enabled: boolean; scheduleType: string; cronExpression: string | null; vulnerabilityCriteria: string };
const { label, tooltip } = formatSchedule(s.scheduleType, s.cronExpression || '');
settings.set(containerName, {
enabled: true,
label,
tooltip,
vulnerabilityCriteria: s.vulnerabilityCriteria || 'never'
});
}
}
}
} catch (err) {
console.error('Failed to fetch auto-update settings:', err);
}
autoUpdateSettings = settings;
}
function formatSchedule(scheduleType: string, cronExpression: string): { label: string; tooltip: string } {
if (!cronExpression) return { label: 'on', tooltip: 'Auto-update enabled' };
const parts = cronExpression.split(' ');
if (parts.length < 5) return { label: 'cron', tooltip: cronExpression };
const [min, hr, , , dow] = parts;
const hourNum = parseInt(hr);
const minNum = parseInt(min);
const ampm = hourNum >= 12 ? 'PM' : 'AM';
const hour12 = hourNum === 0 ? 12 : hourNum > 12 ? hourNum - 12 : hourNum;
const timeStr = `${hour12}:${minNum.toString().padStart(2, '0')} ${ampm}`;
if (scheduleType === 'daily' || dow === '*') {
return { label: 'daily', tooltip: `Daily at ${timeStr}` };
}
if (scheduleType === 'weekly') {
const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const dayName = days[parseInt(dow)] || dow;
return { label: dayName, tooltip: `Every ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][parseInt(dow)] || dow} at ${timeStr}` };
}
return { label: 'cron', tooltip: cronExpression };
// Thin wrappers — delegate to persistent store
function fetchContainers() {
return containerStore.refreshContainers(envId);
}
// Check if highlightChanges is enabled for current environment
@@ -896,49 +809,37 @@
return changedFields.get(containerId)?.has(field) ?? false;
}
async function fetchStats() {
try {
const response = await fetch(appendEnvParam('/api/containers/stats', envId));
const stats: ContainerStats[] = await response.json();
const statsMap = new Map<string, ContainerStats>();
const newChangedFields = new Map<string, Set<string>>();
// Detect stat changes for highlighting when store stats update
$effect(() => {
const currentStats = $containerStore.stats;
const prevStats = $containerStore.previousStats;
for (const stat of stats) {
statsMap.set(stat.id, stat);
if (!highlightChangesEnabled || prevStats.size === 0) return;
// Track changes if highlighting is enabled
if (highlightChangesEnabled) {
const prev = previousStats.get(stat.id);
if (prev) {
const changes = new Set<string>();
if (hasFieldChanged(stat.id, 'cpu', prev.cpuPercent, stat.cpuPercent)) changes.add('cpu');
if (hasFieldChanged(stat.id, 'memory', prev.memoryUsage, stat.memoryUsage)) changes.add('memory');
if (hasFieldChanged(stat.id, 'networkRx', prev.networkRx, stat.networkRx)) changes.add('network');
if (hasFieldChanged(stat.id, 'networkTx', prev.networkTx, stat.networkTx)) changes.add('network');
if (hasFieldChanged(stat.id, 'blockRead', prev.blockRead, stat.blockRead)) changes.add('disk');
if (hasFieldChanged(stat.id, 'blockWrite', prev.blockWrite, stat.blockWrite)) changes.add('disk');
if (changes.size > 0) {
newChangedFields.set(stat.id, changes);
}
}
const newChangedFields = new Map<string, Set<string>>();
for (const [id, stat] of currentStats) {
const prev = prevStats.get(id);
if (prev) {
const changes = new Set<string>();
if (hasFieldChanged(id, 'cpu', prev.cpuPercent, stat.cpuPercent)) changes.add('cpu');
if (hasFieldChanged(id, 'memory', prev.memoryUsage, stat.memoryUsage)) changes.add('memory');
if (hasFieldChanged(id, 'networkRx', prev.networkRx, stat.networkRx)) changes.add('network');
if (hasFieldChanged(id, 'networkTx', prev.networkTx, stat.networkTx)) changes.add('network');
if (hasFieldChanged(id, 'blockRead', prev.blockRead, stat.blockRead)) changes.add('disk');
if (hasFieldChanged(id, 'blockWrite', prev.blockWrite, stat.blockWrite)) changes.add('disk');
if (changes.size > 0) {
newChangedFields.set(id, changes);
}
}
// Update changed fields and clear after animation duration
changedFields = newChangedFields;
if (newChangedFields.size > 0) {
pendingTimeouts.push(setTimeout(() => {
changedFields = new Map();
}, 1500));
}
// Store current stats as previous for next comparison
previousStats = new Map(statsMap);
containerStats = statsMap;
} catch (error) {
console.error('Failed to fetch container stats:', error);
}
}
changedFields = newChangedFields;
if (newChangedFields.size > 0) {
pendingTimeouts.push(setTimeout(() => {
changedFields = new Map();
}, 1500));
}
});
async function startContainer(id: string) {
operationError = null;
@@ -954,7 +855,7 @@
return;
}
toast.success(`Started ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to start container:', error);
operationError = { id, message: 'Failed to start container' };
@@ -978,7 +879,7 @@
return;
}
toast.success(`Stopped ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to stop container:', error);
operationError = { id, message: 'Failed to stop container' };
@@ -1003,7 +904,7 @@
return;
}
toast.success(`Paused ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to pause container:', error);
operationError = { id, message: 'Failed to pause container' };
@@ -1026,7 +927,7 @@
return;
}
toast.success(`Resumed ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to unpause container:', error);
operationError = { id, message: 'Failed to unpause container' };
@@ -1050,7 +951,7 @@
return;
}
toast.success(`Restarted ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to restart container:', error);
operationError = { id, message: 'Failed to restart container' };
@@ -1075,7 +976,7 @@
return;
}
toast.success(`Removed ${name}`);
await fetchContainers();
await containerStore.refreshContainers(envId);
} catch (error) {
console.error('Failed to remove container:', error);
operationError = { id, message: 'Failed to remove container' };
@@ -1379,8 +1280,8 @@
function handleVisibilityChange() {
if (document.visibilityState === 'visible' && envId) {
// Tab became visible - refresh data immediately
fetchContainers();
fetchStats();
containerStore.refreshContainers(envId);
containerStore.refreshStats(envId);
}
}
@@ -1388,12 +1289,6 @@
loadLayoutMode();
loadStatusFilter();
// Check for image filter from URL params (from images page link)
const imageParam = $page.url.searchParams.get('image');
if (imageParam) {
searchQuery = imageParam;
}
// Load persisted pending updates from database
loadPendingUpdates();
@@ -1405,14 +1300,14 @@
// Set up interval to refresh stats every 5 seconds (use module-scope var for cleanup)
statsInterval = setInterval(() => {
if (envId) fetchStats();
if (envId) containerStore.refreshStats(envId);
}, 5000);
// Subscribe to container events (SSE connection is global in layout)
unsubscribeDockerEvent = onDockerEvent((event) => {
if (envId && isContainerListChange(event)) {
fetchContainers();
fetchStats();
containerStore.refreshContainers(envId);
containerStore.refreshStats(envId);
}
});
@@ -1634,12 +1529,12 @@
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('containers', 'restart')}
{#if selectedNonSystem.length > 0 && $canAccess('containers', 'restart')}
<ConfirmPopover
open={confirmBulkRestart}
action="Restart"
itemType="{selectedInFilter.length} container{selectedInFilter.length !== 1 ? 's' : ''}"
title="Restart {selectedInFilter.length}"
itemType="{selectedNonSystem.length} container{selectedNonSystem.length !== 1 ? 's' : ''}"
title="Restart {selectedNonSystem.length}"
variant="secondary"
unstyled
onConfirm={bulkRestart}
@@ -1653,12 +1548,12 @@
{/snippet}
</ConfirmPopover>
{/if}
{#if $canAccess('containers', 'remove')}
{#if selectedNonSystem.length > 0 && $canAccess('containers', 'remove')}
<ConfirmPopover
open={confirmBulkRemove}
action="Remove"
itemType="{selectedInFilter.length} container{selectedInFilter.length !== 1 ? 's' : ''}"
title="Remove {selectedInFilter.length}"
itemType="{selectedNonSystem.length} container{selectedNonSystem.length !== 1 ? 's' : ''}"
title="Remove {selectedNonSystem.length}"
unstyled
onConfirm={bulkRemove}
onOpenChange={(open) => confirmBulkRemove = open}
@@ -1897,7 +1792,7 @@
{:else if column.id === 'ports'}
{#if ports.length > 0}
<div class="flex flex-wrap gap-1">
{#each ports.slice(0, 2) as port}
{#each ports as port}
{@const url = currentEnvDetails ? getPortUrl(port.publicPort) : null}
{#if url}
<a
@@ -1915,9 +1810,6 @@
<code class="text-xs bg-muted px-1 py-0.5 rounded">{port.display}</code>
{/if}
{/each}
{#if ports.length > 2}
<span class="text-xs text-muted-foreground">+{ports.length - 2}</span>
{/if}
</div>
{:else}
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
@@ -1943,13 +1835,20 @@
{/if}
{:else if column.id === 'stack'}
{#if stack}
<button
type="button"
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId)); }}
class="cursor-pointer"
>
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors">{stack}</Badge>
</button>
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/stacks?search=${encodeURIComponent(stack)}`, envId)); }}
class="cursor-pointer"
>
<Badge variant="outline" class="text-xs py-0 px-1.5 hover:bg-primary/10 hover:border-primary/50 transition-colors truncate max-w-full">{stack}</Badge>
</button>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-sm whitespace-nowrap">{stack}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="text-gray-400 dark:text-gray-600 text-xs">-</span>
{/if}
@@ -1965,6 +1864,7 @@
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
</button>
{/if}
{#if !container.systemContainer}
{#if container.state === 'running' || container.state === 'restarting'}
{#if $canAccess('containers', 'stop')}
<ConfirmPopover
@@ -2030,6 +1930,7 @@
{/snippet}
</ConfirmPopover>
{/if}
{/if}
<button
type="button"
onclick={() => inspectContainer(container)}
@@ -2171,7 +2072,7 @@
</Popover.Root>
{/if}
{/if}
{#if $canAccess('containers', 'remove')}
{#if !container.systemContainer && $canAccess('containers', 'remove')}
<ConfirmPopover
open={confirmDeleteId === container.id}
action="Delete"
+133 -171
View File
@@ -12,6 +12,7 @@
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
import UpdateSummaryStats from '$lib/components/UpdateSummaryStats.svelte';
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
import { watchJob } from '$lib/utils/sse-fetch';
interface Props {
open: boolean;
@@ -131,140 +132,123 @@
throw new Error(data.error || 'Failed to start update');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
const { jobId } = await response.json();
const successIds: string[] = [];
const failedIds: string[] = [];
const blockedIds: string[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
await watchJob(jobId, (line) => {
try {
const data = line.data as any;
scrollTick++;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
if (data.type === 'start') {
totalCount = data.total;
} else if (data.type === 'progress') {
currentIndex = data.current;
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
// Update or add progress entry
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
if (existingIndex >= 0) {
progress[existingIndex].step = data.step;
progress[existingIndex].success = data.success;
progress[existingIndex].error = data.error;
progress = [...progress]; // Trigger reactivity
} else {
progress = [...progress, {
containerId: data.containerId,
containerName: data.containerName,
step: data.step,
success: data.success,
error: data.error,
pullLogs: [],
scanLogs: [],
showLogs: true,
}];
}
try {
const data = JSON.parse(line.slice(6));
scrollTick++;
if (data.type === 'start') {
totalCount = data.total;
} else if (data.type === 'progress') {
currentIndex = data.current;
// Update or add progress entry
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
if (existingIndex >= 0) {
progress[existingIndex].step = data.step;
progress[existingIndex].success = data.success;
progress[existingIndex].error = data.error;
progress = [...progress]; // Trigger reactivity
} else {
progress = [...progress, {
containerId: data.containerId,
containerName: data.containerName,
step: data.step,
success: data.success,
error: data.error,
pullLogs: [],
scanLogs: [],
showLogs: true,
}];
}
// Track success/failed for onComplete callback
if (data.success === true) {
successIds.push(data.containerId);
} else if (data.success === false && data.step === 'failed') {
failedIds.push(data.containerId);
}
} else if (data.type === 'pull_log') {
// Add pull log to the container's log list
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
// For layer progress, update existing entry or add new
if (data.pullId) {
const existingLog = containerProgress.pullLogs.find(l => l.id === data.pullId);
if (existingLog) {
existingLog.status = data.pullStatus;
existingLog.progress = data.pullProgress;
} else {
containerProgress.pullLogs.push({
status: data.pullStatus,
id: data.pullId,
progress: data.pullProgress
});
}
// Track success/failed for onComplete callback
if (data.success === true) {
successIds.push(data.containerId);
} else if (data.success === false && data.step === 'failed') {
failedIds.push(data.containerId);
}
} else if (data.type === 'pull_log') {
// Add pull log to the container's log list
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
// For layer progress, update existing entry or add new
if (data.pullId) {
const existingLog = containerProgress.pullLogs.find((l: any) => l.id === data.pullId);
if (existingLog) {
existingLog.status = data.pullStatus;
existingLog.progress = data.pullProgress;
} else {
// General status message (no layer ID)
containerProgress.pullLogs.push({
status: data.pullStatus
status: data.pullStatus,
id: data.pullId,
progress: data.pullProgress
});
}
progress = [...progress]; // Trigger reactivity
}
} else if (data.type === 'scan_start') {
// Update step to scanning
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.step = 'scanning';
progress = [...progress];
}
} else if (data.type === 'scan_log') {
// Add scan log to the container's log list
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanLogs.push({
scanner: data.scanner,
message: data.message
} else {
// General status message (no layer ID)
containerProgress.pullLogs.push({
status: data.pullStatus
});
progress = [...progress];
}
} else if (data.type === 'scan_complete') {
// Store scan result, individual scanner results, and vulnerabilities
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanResult = data.scanResult;
containerProgress.scannerResults = data.scannerResults;
containerProgress.vulnerabilities = data.vulnerabilities;
progress = [...progress];
}
} else if (data.type === 'blocked') {
// Mark container as blocked
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
if (existingIndex >= 0) {
progress[existingIndex].step = 'blocked';
progress[existingIndex].success = false;
progress[existingIndex].scanResult = data.scanResult;
progress[existingIndex].scannerResults = data.scannerResults;
progress[existingIndex].blockReason = data.blockReason;
progress = [...progress];
}
blockedIds.push(data.containerId);
currentIndex = data.current;
} else if (data.type === 'complete') {
status = 'complete';
summary = data.summary;
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
} else if (data.type === 'error') {
status = 'error';
errorMessage = data.error || 'Unknown error occurred';
progress = [...progress]; // Trigger reactivity
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
} else if (data.type === 'scan_start') {
// Update step to scanning
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.step = 'scanning';
progress = [...progress];
}
} else if (data.type === 'scan_log') {
// Add scan log to the container's log list
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanLogs.push({
scanner: data.scanner,
message: data.message
});
progress = [...progress];
}
} else if (data.type === 'scan_complete') {
// Store scan result, individual scanner results, and vulnerabilities
const containerProgress = progress.find(p => p.containerId === data.containerId);
if (containerProgress) {
containerProgress.scanResult = data.scanResult;
containerProgress.scannerResults = data.scannerResults;
containerProgress.vulnerabilities = data.vulnerabilities;
progress = [...progress];
}
} else if (data.type === 'blocked') {
// Mark container as blocked
const existingIndex = progress.findIndex(p => p.containerId === data.containerId);
if (existingIndex >= 0) {
progress[existingIndex].step = 'blocked';
progress[existingIndex].success = false;
progress[existingIndex].scanResult = data.scanResult;
progress[existingIndex].scannerResults = data.scannerResults;
progress[existingIndex].blockReason = data.blockReason;
progress = [...progress];
}
blockedIds.push(data.containerId);
currentIndex = data.current;
} else if (data.type === 'complete') {
status = 'complete';
summary = data.summary;
onComplete({ success: successIds, failed: failedIds, blocked: blockedIds });
} else if (data.type === 'error') {
status = 'error';
errorMessage = data.error || 'Unknown error occurred';
}
} catch (e) {
console.error('Failed to process job line:', e);
}
}
});
} catch (error: any) {
console.error('Failed to update containers:', error);
status = 'error';
@@ -343,63 +327,41 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
throw new Error(data.error || 'Failed to start update');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const { jobId } = await response.json();
await watchJob(jobId, (line) => {
const data = line.data as any;
const decoder = new TextDecoder();
let buffer = '';
if (data.type === 'progress') {
item.step = data.step;
item.success = data.success;
item.error = data.error;
progress = [...progress];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'progress') {
item.step = data.step;
item.success = data.success;
item.error = data.error;
progress = [...progress];
// Update summary if container succeeded
if (data.success === true && summary) {
summary.blocked--;
summary.success++;
summary = { ...summary };
}
} else if (data.type === 'pull_log') {
if (data.pullId) {
const existingLog = item.pullLogs.find(l => l.id === data.pullId);
if (existingLog) {
existingLog.status = data.pullStatus;
existingLog.progress = data.pullProgress;
} else {
item.pullLogs.push({
status: data.pullStatus,
id: data.pullId,
progress: data.pullProgress
});
}
} else {
item.pullLogs.push({ status: data.pullStatus });
}
progress = [...progress];
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
// Update summary if container succeeded
if (data.success === true && summary) {
summary.blocked--;
summary.success++;
summary = { ...summary };
}
} else if (data.type === 'pull_log') {
if (data.pullId) {
const existingLog = item.pullLogs.find(l => l.id === data.pullId);
if (existingLog) {
existingLog.status = data.pullStatus;
existingLog.progress = data.pullProgress;
} else {
item.pullLogs.push({
status: data.pullStatus,
id: data.pullId,
progress: data.pullProgress
});
}
} else {
item.pullLogs.push({ status: data.pullStatus });
}
progress = [...progress];
}
}
});
} catch (error: any) {
console.error('Failed to force update container:', error);
item.step = 'failed';
@@ -452,9 +452,22 @@
healthcheckTimeout = Math.floor((healthcheck.Timeout || 30e9) / 1e9);
healthcheckRetries = healthcheck.Retries || 3;
healthcheckStartPeriod = Math.floor((healthcheck.StartPeriod || 0) / 1e9);
} else {
healthcheckEnabled = false;
healthcheckCommand = '';
healthcheckInterval = 30;
healthcheckTimeout = 30;
healthcheckRetries = 3;
healthcheckStartPeriod = 0;
}
// Parse advanced options - Resources
// Parse advanced options - Resources (reset first to avoid stale values)
memoryLimit = '';
memoryReservation = '';
cpuShares = '';
nanoCpus = '';
cpuQuota = '';
cpuPeriod = '';
if (data.HostConfig.Memory) {
memoryLimit = formatBytes(data.HostConfig.Memory);
}
@@ -119,9 +119,10 @@
<span class="text-muted-foreground text-2xs" title={formatDateTime(event.timestamp)}>
{formatTime(event.timestamp)}
</span>
{@const ActionIcon = getActionIcon(event.action)}
<!-- Action icon -->
<div class="flex items-center justify-center {getActionColor(event.action)}" title={event.action}>
<svelte:component this={getActionIcon(event.action)} class="w-3 h-3" />
<ActionIcon class="w-3 h-3" />
</div>
<!-- Container name -->
<span class="truncate text-foreground" title={event.container_name}>
+8 -7
View File
@@ -27,6 +27,7 @@
import { onDockerEvent, isImageListChange } from '$lib/stores/events';
import { canAccess } from '$lib/stores/auth';
import { formatDate, appSettings } from '$lib/stores/settings';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
@@ -485,9 +486,9 @@
confirmPrune = false;
try {
const response = await fetch(appendEnvParam('/api/prune/images', envId), { method: 'POST' });
if (response.ok) {
const data = await readJobResponse(response);
if (data.success) {
pruneStatus = 'success';
const data = await response.json();
const deleted = data.result?.ImagesDeleted;
const spaceReclaimed = data.result?.SpaceReclaimed ?? 0;
const count = deleted?.length ?? 0;
@@ -499,7 +500,7 @@
await fetchImages();
} else {
pruneStatus = 'error';
toast.error('Failed to prune images');
toast.error(data.error || 'Failed to prune images');
}
} catch (error) {
pruneStatus = 'error';
@@ -513,9 +514,9 @@
confirmPruneUnused = false;
try {
const response = await fetch(appendEnvParam('/api/prune/images?dangling=false', envId), { method: 'POST' });
if (response.ok) {
const data = await readJobResponse(response);
if (data.success) {
pruneUnusedStatus = 'success';
const data = await response.json();
const deleted = data.result?.ImagesDeleted;
const spaceReclaimed = data.result?.SpaceReclaimed ?? 0;
const count = deleted?.length ?? 0;
@@ -527,7 +528,7 @@
await fetchImages();
} else {
pruneUnusedStatus = 'error';
toast.error('Failed to prune unused images');
toast.error(data.error || 'Failed to prune unused images');
}
} catch (error) {
pruneUnusedStatus = 'error';
@@ -1060,7 +1061,7 @@
{:else if column.id === 'used'}
{#if tagInfo.containers > 0}
<a
href="/containers?image={encodeURIComponent(tagInfo.fullRef)}"
href="/containers?search={encodeURIComponent(tagInfo.fullRef)}"
class="text-muted-foreground hover:text-foreground hover:underline"
title="View containers using this image"
>
@@ -7,6 +7,7 @@
import { Progress } from '$lib/components/ui/progress';
import { tick } from 'svelte';
import ImageScanModal from './ImageScanModal.svelte';
import { watchJob } from '$lib/utils/sse-fetch';
interface Props {
imageName: string | (() => string);
@@ -113,103 +114,86 @@
throw new Error('Failed to start pull');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const { jobId } = await response.json();
const decoder = new TextDecoder();
let buffer = '';
await watchJob(jobId, (line) => {
try {
const data = line.data as any;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.status === 'complete') {
overallStatus = 'complete';
onComplete?.();
// Trigger scan if scanning is enabled
if (envHasScanning) {
// Close popover and open scan modal after short delay
setTimeout(() => {
scanImageName = displayImageName;
open = false;
showScanModal = true;
}, 500);
}
} else if (data.status === 'error') {
overallStatus = 'error';
errorMessage = data.error || 'Unknown error occurred';
} else if (data.id) {
// Layer progress update - only process if id looks like a layer hash (12 hex chars)
const isLayerId = /^[a-f0-9]{12}$/i.test(data.id);
if (!isLayerId) {
if (data.status) {
statusMessage = `${data.id}: ${data.status}`;
}
continue;
}
const existing = layers.get(data.id);
const statusLower = (data.status || '').toLowerCase();
// Only count "Pull complete" or "Already exists" as truly complete
const isFullyComplete = statusLower === 'pull complete' || statusLower === 'already exists';
if (!existing) {
totalLayers++;
layerOrder++;
if (isFullyComplete) {
completedLayers++;
}
const layerProgress: LayerProgress = {
id: data.id,
status: data.status || 'Processing',
progress: data.progress,
current: data.progressDetail?.current,
total: data.progressDetail?.total,
order: layerOrder,
isComplete: isFullyComplete
};
layers.set(data.id, layerProgress);
layers = new Map(layers);
scrollToBottom();
} else {
// Check if layer transitioned to complete (only count once)
if (isFullyComplete && !existing.isComplete) {
completedLayers++;
}
const layerProgress: LayerProgress = {
id: data.id,
status: data.status || 'Processing',
progress: data.progress,
current: data.progressDetail?.current,
total: data.progressDetail?.total,
order: existing.order,
isComplete: existing.isComplete || isFullyComplete
};
layers.set(data.id, layerProgress);
layers = new Map(layers);
}
} else if (data.status) {
statusMessage = data.status;
if (data.status === 'complete') {
overallStatus = 'complete';
onComplete?.();
// Trigger scan if scanning is enabled
if (envHasScanning) {
// Close popover and open scan modal after short delay
setTimeout(() => {
scanImageName = displayImageName;
open = false;
showScanModal = true;
}, 500);
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
} else if (data.status === 'error') {
overallStatus = 'error';
errorMessage = data.error || 'Unknown error occurred';
} else if (data.id) {
// Layer progress update - only process if id looks like a layer hash (12 hex chars)
const isLayerId = /^[a-f0-9]{12}$/i.test(data.id);
if (!isLayerId) {
if (data.status) {
statusMessage = `${data.id}: ${data.status}`;
}
return;
}
const existing = layers.get(data.id);
const statusLower = (data.status || '').toLowerCase();
// Only count "Pull complete" or "Already exists" as truly complete
const isFullyComplete = statusLower === 'pull complete' || statusLower === 'already exists';
if (!existing) {
totalLayers++;
layerOrder++;
if (isFullyComplete) {
completedLayers++;
}
const layerProgress: LayerProgress = {
id: data.id,
status: data.status || 'Processing',
progress: data.progress,
current: data.progressDetail?.current,
total: data.progressDetail?.total,
order: layerOrder,
isComplete: isFullyComplete
};
layers.set(data.id, layerProgress);
layers = new Map(layers);
scrollToBottom();
} else {
// Check if layer transitioned to complete (only count once)
if (isFullyComplete && !existing.isComplete) {
completedLayers++;
}
const layerProgress: LayerProgress = {
id: data.id,
status: data.status || 'Processing',
progress: data.progress,
current: data.progressDetail?.current,
total: data.progressDetail?.total,
order: existing.order,
isComplete: existing.isComplete || isFullyComplete
};
layers.set(data.id, layerProgress);
layers = new Map(layers);
}
} else if (data.status) {
statusMessage = data.status;
}
} catch (e) {
console.error('Failed to process job line:', e);
}
}
});
} catch (error: any) {
console.error('Failed to pull image:', error);
overallStatus = 'error';
+153 -83
View File
@@ -3,7 +3,7 @@
</svelte:head>
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { page } from '$app/stores';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -45,28 +45,91 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
let selectedContainerIds = $state<Set<string>>(new Set());
let groupedContainerInfo = $state<Map<string, { name: string; color: string }>>(new Map());
let mergedLogs = $state<Array<{ containerId: string; containerName: string; color: string; text: string; timestamp?: string; stream?: string }>>([]);
let mergedHtml = $state(''); // Pre-built HTML string for fast rendering (like single mode's `logs`)
let stackName = $state<string | null>(null);
// Batching for log updates to prevent UI blocking
// Batching for grouped mode log updates to prevent UI blocking
let pendingLogs: Array<{ containerId: string; containerName: string; color: string; text: string; timestamp?: string; stream?: string }> = [];
let batchTimeout: ReturnType<typeof setTimeout> | null = null;
const BATCH_INTERVAL = 50; // ms - batch logs for 50ms before updating state
// Initial buffering: accumulate all tail lines before first render to avoid line-by-line appearance
let initialBuffering = false;
let initialBufferTimeout: ReturnType<typeof setTimeout> | null = null;
const INITIAL_BUFFER_DELAY = 400; // ms - wait for all initial tail lines before first render
// Batching for single mode SSE logs
let pendingText = '';
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL = 100; // ms
// RAF-based auto-scroll
let scrollRafPending = false;
// Flush pending logs to state (called on timer)
function flushPendingLogs() {
if (pendingLogs.length === 0) return;
if (pendingLogs.length === 0) {
batchTimeout = null;
return;
}
// Batch all pending logs into a single state update
let newLogs = [...mergedLogs, ...pendingLogs];
// Build HTML for new lines and append (like single mode's logs += pendingText)
let newHtml = '';
for (const log of pendingLogs) {
const content = ansiToHtml(log.text);
newHtml += `<span style="color:${log.color};font-weight:600">[${escapeHtml(log.containerName)}]</span> ${content}`;
}
// Push into array (kept for copy/download)
mergedLogs.push(...pendingLogs);
pendingLogs = [];
// Keep only last 5000 lines to prevent memory issues
if (newLogs.length > 5000) {
newLogs = newLogs.slice(-4000);
// Keep only last 2000 lines to prevent memory issues
if (mergedLogs.length > 2000) {
const removed = mergedLogs.length - 1600;
mergedLogs.splice(0, removed);
// Rebuild HTML from trimmed array
mergedHtml = '';
for (const log of mergedLogs) {
mergedHtml += `<span style="color:${log.color};font-weight:600">[${escapeHtml(log.containerName)}]</span> ${ansiToHtml(log.text)}`;
}
} else {
mergedHtml += newHtml;
}
mergedLogs = newLogs;
batchTimeout = null;
scrollToBottom();
}
// Flush buffered single-mode text to state
function flushSingleLogs() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (!pendingText) return;
logs += pendingText;
pendingText = '';
// Apply log buffer size limit (convert KB to characters)
const maxSize = $appSettings.logBufferSizeKb * 1024;
if (logs.length > maxSize) {
logs = logs.substring(logs.length - Math.floor(maxSize * 0.8));
}
scrollToBottom();
}
// Scroll to bottom after Svelte finishes rendering.
// Uses tick() to wait for DOM update, then RAF for smooth visual timing.
async function scrollToBottom(force = false) {
if ((!force && !autoScroll) || !logsRef || scrollRafPending) return;
scrollRafPending = true;
await tick();
requestAnimationFrame(() => {
if (logsRef) logsRef.scrollTop = logsRef.scrollHeight;
scrollRafPending = false;
});
}
// Multi-mode selection state (for merge feature)
@@ -187,7 +250,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// selectedContainer stays as is, streaming continues
// Just clear grouped mode data
selectedContainerIds = new Set();
mergedLogs = [];
mergedLogs = []; mergedHtml = '';
// Save state if we have a container selected (carrying over selection)
if (selectedContainer) {
saveState();
@@ -202,7 +265,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Stop grouped streaming and start single streaming
stopStreaming();
selectedContainerIds = new Set();
mergedLogs = [];
mergedLogs = []; mergedHtml = '';
selectedContainer = container;
if (streamingEnabled) {
startStreaming(container);
@@ -213,7 +276,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Multiple containers - just stop streaming
stopStreaming();
selectedContainerIds = new Set();
mergedLogs = [];
mergedLogs = []; mergedHtml = '';
}
// If selectedContainer already exists (from multi mode), keep it streaming
// Save state if we have a container selected (carrying over selection)
@@ -230,8 +293,6 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
}
let logsRef: HTMLDivElement | undefined;
let envId = $state<number | null>(null);
let isInitialLoad = $state(true);
// Polling interval - module scope for cleanup in onDestroy
let containerInterval: ReturnType<typeof setInterval> | null = null;
@@ -249,7 +310,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
const fontSizeOptions = [10, 12, 14, 16];
// Subscribe to environment changes - restore state and fetch data
currentEnvironment.subscribe(async (env) => {
const unsubscribeEnv = currentEnvironment.subscribe(async (env) => {
envId = env?.id ?? null;
if (!env) return;
@@ -295,7 +356,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
} else {
// No running containers found - show empty state for this stack
selectedContainerIds = new Set();
mergedLogs = [];
mergedLogs = []; mergedHtml = '';
saveState();
}
return;
@@ -627,7 +688,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
const response = await fetch(appendEnvParam(`/api/containers/${selectedContainer.id}/logs?tail=${tail}`, envId));
const data = await response.json();
logs = data.logs || 'No logs available';
// Scrolling is handled by the $effect that watches logs changes
scrollToBottom(true); // Force scroll on initial load
} catch (error) {
console.error('Failed to fetch logs:', error);
logs = 'Failed to fetch logs: ' + String(error);
@@ -671,21 +732,17 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Add container name prefix to each line if available
let text = data.text;
if (data.containerName) {
// Split by lines, prefix each non-empty line, rejoin
const lines = text.split('\n');
text = lines.map((line: string, i: number) => {
// Don't prefix empty lines at the end
if (line === '' && i === lines.length - 1) return line;
if (line === '') return line;
return `[${data.containerName}] ${line}`;
}).join('\n');
}
logs += text;
// Apply log buffer size limit (convert KB to characters)
const maxSize = $appSettings.logBufferSizeKb * 1024;
if (logs.length > maxSize) {
logs = logs.substring(logs.length - Math.floor(maxSize * 0.8));
// Buffer text and schedule flush
pendingText += text;
if (!flushTimer) {
flushTimer = setTimeout(flushSingleLogs, FLUSH_INTERVAL);
}
}
} catch {
@@ -750,13 +807,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Parse and add logs
const lines = data.logs.split('\n').filter((line: string) => line.trim());
for (const line of lines) {
const text = line + '\n';
mergedLogs.push({
containerId,
containerName,
color,
text: line + '\n',
text,
timestamp: new Date().toISOString()
});
mergedHtml += `<span style="color:${color};font-weight:600">[${escapeHtml(containerName)}]</span> ${ansiToHtml(text)}`;
}
mergedLogs = mergedLogs;
}
@@ -770,6 +829,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
}
// Mark loading done if only stopped containers
loading = false;
scrollToBottom(true);
}
// Check if any selected container is running (for reconnection logic)
@@ -793,7 +853,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
loading = true;
// Clear container info for fresh selection (prevents icon accumulation)
groupedContainerInfo = new Map();
mergedLogs = [];
mergedLogs = []; mergedHtml = '';
// Separate running and stopped containers
const allIds = Array.from(selectedContainerIds);
@@ -826,7 +886,6 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
eventSource.addEventListener('connected', (event) => {
isConnected = true;
loading = false;
connectionError = null;
reconnectAttempts = 0;
@@ -845,6 +904,17 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
} catch {
// Ignore parse errors
}
// Start initial buffering phase: accumulate all tail lines,
// then flush once after a delay to avoid line-by-line rendering
initialBuffering = true;
if (initialBufferTimeout) clearTimeout(initialBufferTimeout);
initialBufferTimeout = setTimeout(() => {
initialBuffering = false;
initialBufferTimeout = null;
loading = false;
flushPendingLogs();
}, INITIAL_BUFFER_DELAY);
});
eventSource.addEventListener('log', (event) => {
@@ -862,8 +932,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
timestamp: data.timestamp,
stream: data.stream
});
// Schedule flush if not already scheduled
if (!batchTimeout) {
// During initial buffering, just accumulate - don't schedule flushes
// The initial buffer timeout will flush everything in one go
if (!initialBuffering && !batchTimeout) {
batchTimeout = setTimeout(flushPendingLogs, BATCH_INTERVAL);
}
}
@@ -885,6 +956,16 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
eventSource.addEventListener('end', () => {
isConnected = false;
connectionError = 'Stream ended';
// If we're still in initial buffering, flush immediately
if (initialBuffering) {
initialBuffering = false;
if (initialBufferTimeout) {
clearTimeout(initialBufferTimeout);
initialBufferTimeout = null;
}
loading = false;
flushPendingLogs();
}
});
eventSource.onerror = () => {
@@ -903,6 +984,16 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
isConnected = false;
loading = false;
// Cancel initial buffering on error and flush what we have
if (initialBuffering) {
initialBuffering = false;
if (initialBufferTimeout) {
clearTimeout(initialBufferTimeout);
initialBufferTimeout = null;
}
flushPendingLogs();
}
// Close the broken connection
if (eventSource) {
eventSource.close();
@@ -949,7 +1040,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
reconnectAttempts = 0;
connectionError = null;
logs = '';
mergedLogs = [];
mergedLogs = []; mergedHtml = '';
loading = true;
if (layoutMode === 'grouped') {
startGroupedStreaming();
@@ -960,6 +1051,14 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
// Stop SSE streaming
function stopStreaming(resetAttempts = true) {
// Flush any buffered data before stopping
initialBuffering = false;
if (initialBufferTimeout) {
clearTimeout(initialBufferTimeout);
initialBufferTimeout = null;
}
flushSingleLogs();
flushPendingLogs();
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
@@ -987,7 +1086,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
saveState();
if (streamingEnabled) {
logs = '';
mergedLogs = [];
mergedLogs = []; mergedHtml = '';
reconnectAttempts = 0;
connectionError = null;
loading = true;
@@ -1008,7 +1107,6 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
selectedContainer = container;
searchQuery = '';
dropdownOpen = false;
isInitialLoad = true; // Reset so we scroll to bottom on new container
logs = ''; // Clear previous logs
// Save selection for persistence
@@ -1043,14 +1141,14 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
startGroupedStreaming();
} else if (newSet.size === 0) {
stopStreaming();
mergedLogs = [];
mergedLogs = []; mergedHtml = '';
}
}
// Start grouped streaming with current selection
function startGroupedView() {
if (selectedContainerIds.size === 0) return;
mergedLogs = [];
mergedLogs = []; mergedHtml = '';
loading = true;
startGroupedStreaming();
}
@@ -1065,7 +1163,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
function clearContainerSelection() {
selectedContainerIds = new Set();
stopStreaming();
mergedLogs = [];
mergedLogs = []; mergedHtml = '';
}
// Multi-mode: toggle a container in the multi-select list
@@ -1336,34 +1434,26 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
return highlighted;
});
// Format merged logs with container prefixes
let formattedMergedLogs = $derived(() => {
return mergedLogs.map(log => {
const content = ansiToHtml(log.text);
let highlighted = content;
// Format merged logs HTML — uses pre-built mergedHtml string, only applies search highlighting when needed
let formattedMergedHtml = $derived(() => {
if (!mergedHtml) return '';
if (!logSearchQuery.trim()) return mergedHtml;
if (logSearchQuery.trim()) {
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(query);
const parts = content.split(/(<[^>]*>)/);
highlighted = parts.map(part => {
if (part.startsWith('<')) return part;
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return part.replace(regex, '<mark class="search-match">$1</mark>');
}).join('');
}
return {
...log,
formattedText: highlighted
};
});
// Apply search highlighting (same approach as single mode's highlightedLogs)
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escapedQuery = escapeHtml(query);
const searchRegex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = mergedHtml.split(/(<[^>]*>)/);
return parts.map(part => {
if (part.startsWith('<')) return part;
return part.replace(searchRegex, '<mark class="search-match">$1</mark>');
}).join('');
});
// Update match count after render
$effect(() => {
// Track highlighted logs to re-run when content changes
const html = layoutMode === 'grouped' ? formattedMergedLogs() : highlightedLogs();
const html = layoutMode === 'grouped' ? formattedMergedHtml() : highlightedLogs();
if (logSearchQuery && logsRef) {
setTimeout(() => {
@@ -1383,23 +1473,6 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
});
// Scroll to bottom when logs change (for initial load or auto-scroll)
$effect(() => {
// Track logs to trigger on content change
const _ = layoutMode === 'grouped' ? mergedLogs : logs;
if (logsRef && (logs || mergedLogs.length > 0) && (isInitialLoad || autoScroll)) {
// Use tick/setTimeout to wait for DOM update
setTimeout(() => {
if (logsRef) {
logsRef.scrollTop = logsRef.scrollHeight;
if (isInitialLoad) {
isInitialLoad = false;
}
}
}, 100);
}
});
onMount(() => {
// All initialization is handled in currentEnvironment.subscribe
// This just sets up the refresh interval
@@ -1408,10 +1481,14 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
});
onDestroy(() => {
unsubscribeEnv();
if (containerInterval) {
clearInterval(containerInterval);
containerInterval = null;
}
// Flush pending text and clean up timers
flushSingleLogs();
flushPendingLogs();
stopStreaming();
});
</script>
@@ -1948,14 +2025,7 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<RefreshCw class="w-8 h-8 animate-spin {darkMode ? 'text-zinc-400' : 'text-gray-500'}" />
</div>
{/if}
<div class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'}" style="font-size: {fontSize}px;">
{#each formattedMergedLogs() as log}
<div class="flex">
<span class="shrink-0 font-semibold mr-2" style="color: {log.color}">[{log.containerName}]</span>
<span class="{darkMode ? 'text-zinc-50' : 'text-gray-900'}">{@html log.formattedText}</span>
</div>
{/each}
</div>
<pre class="font-mono {wordWrap ? 'whitespace-pre-wrap' : 'whitespace-pre'} {darkMode ? 'text-zinc-50' : 'text-gray-900'}" style="font-size: {fontSize}px;">{@html formattedMergedHtml()}</pre>
</div>
{/if}
{:else if !selectedContainer}
+10 -3
View File
@@ -33,6 +33,9 @@
let wordWrap = $state(true);
let fontSize = $state(12);
// RAF-based auto-scroll
let scrollRafPending = false;
// Search state
let logSearchActive = $state(false);
let logSearchQuery = $state('');
@@ -51,9 +54,13 @@
// Auto-scroll when logs change
$effect(() => {
if (autoScroll && logsRef && logs) {
setTimeout(() => {
logsRef.scrollTop = logsRef.scrollHeight;
}, 50);
if (!scrollRafPending) {
scrollRafPending = true;
requestAnimationFrame(() => {
if (logsRef) logsRef.scrollTop = logsRef.scrollHeight;
scrollRafPending = false;
});
}
}
});
+49 -23
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
@@ -39,6 +39,14 @@
const OFFLINE_POLL_INTERVAL = 5000; // Check every 5 seconds when offline
let offlinePollingInterval: ReturnType<typeof setInterval> | null = null;
// SSE batching - buffer incoming text and flush to state periodically
let pendingText = '';
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL = 100; // ms
// RAF-based auto-scroll
let scrollRafPending = false;
// Search state
let logSearchActive = $state(false);
let logSearchQuery = $state('');
@@ -146,6 +154,37 @@
return `${url}${separator}env=${envId}`;
}
// Flush buffered text to state
function flushLogs() {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (!pendingText) return;
logs += pendingText;
pendingText = '';
// Apply log buffer size limit (convert KB to characters, roughly 1 char = 1 byte)
const maxSize = $appSettings.logBufferSizeKb * 1024;
if (logs.length > maxSize) {
logs = logs.substring(logs.length - Math.floor(maxSize * 0.8));
}
scrollToBottom();
}
// RAF-based scroll to bottom (coalesces multiple calls into one frame)
async function scrollToBottom() {
if (!autoScroll || !logsRef || scrollRafPending) return;
scrollRafPending = true;
await tick();
requestAnimationFrame(() => {
if (logsRef) logsRef.scrollTop = logsRef.scrollHeight;
scrollRafPending = false;
});
}
// Start SSE streaming for logs
function startStreaming() {
if (!containerId || !streamingEnabled) return;
@@ -173,29 +212,17 @@
// Add container name prefix to each line if available
let text = data.text;
if (data.containerName) {
// Split by lines, prefix each non-empty line, rejoin
const lines = text.split('\n');
text = lines.map((line: string, i: number) => {
// Don't prefix empty lines at the end
if (line === '' && i === lines.length - 1) return line;
if (line === '') return line;
return `[${data.containerName}] ${line}`;
}).join('\n');
}
logs += text;
// Apply log buffer size limit (convert KB to characters, roughly 1 char = 1 byte)
const maxSize = $appSettings.logBufferSizeKb * 1024;
if (logs.length > maxSize) {
// Truncate from the beginning, keeping 80% of max size
logs = logs.substring(logs.length - Math.floor(maxSize * 0.8));
}
// Auto-scroll to bottom
if (autoScroll && logsRef) {
setTimeout(() => {
logsRef.scrollTop = logsRef.scrollHeight;
}, 50);
// Buffer text and schedule flush
pendingText += text;
if (!flushTimer) {
flushTimer = setTimeout(flushLogs, FLUSH_INTERVAL);
}
}
} catch {
@@ -293,6 +320,8 @@
// Stop SSE streaming
function stopStreaming(resetAttempts = true) {
// Flush any buffered text before stopping
flushLogs();
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
@@ -382,12 +411,7 @@
return;
}
logs = data.logs || '';
// Auto-scroll to bottom
if (autoScroll && logsRef) {
setTimeout(() => {
logsRef.scrollTop = logsRef.scrollHeight;
}, 50);
}
scrollToBottom();
} catch (error) {
console.error('Failed to fetch logs:', error);
logs = `Failed to fetch logs: ${error instanceof Error ? error.message : 'Unknown error'}`;
@@ -653,6 +677,8 @@
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
// Flush pending text and clean up timers
flushLogs();
stopStreaming();
});
</script>
+4
View File
@@ -1,3 +1,7 @@
<svelte:head>
<title>Registry - Dockhand</title>
</svelte:head>
<script lang="ts">
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button';
+9 -18
View File
@@ -85,49 +85,40 @@
</Tabs.Trigger>
</Tabs.List>
<!-- General Tab -->
<Tabs.Content value="general" class="flex-1 min-h-0 overflow-y-auto">
<GeneralTab />
{#if activeTab === 'general'}<GeneralTab />{/if}
</Tabs.Content>
<!-- Environments Tab -->
<Tabs.Content value="environments" class="flex-1 min-h-0 overflow-y-auto">
<EnvironmentsTab {editEnvId} {newEnv} />
{#if activeTab === 'environments'}<EnvironmentsTab {editEnvId} {newEnv} />{/if}
</Tabs.Content>
<!-- Registries Tab -->
<Tabs.Content value="registries" class="flex-1 min-h-0 overflow-y-auto">
<RegistriesTab />
{#if activeTab === 'registries'}<RegistriesTab />{/if}
</Tabs.Content>
<!-- Git Tab -->
<Tabs.Content value="git" class="flex-1 min-h-0 overflow-y-auto">
<GitTab />
{#if activeTab === 'git'}<GitTab />{/if}
</Tabs.Content>
<!-- Config Sets Tab -->
<Tabs.Content value="config-sets" class="flex-1 min-h-0 overflow-y-auto">
<ConfigSetsTab />
{#if activeTab === 'config-sets'}<ConfigSetsTab />{/if}
</Tabs.Content>
<!-- Notifications Tab -->
<Tabs.Content value="notifications" class="flex-1 min-h-0 overflow-y-auto">
<NotificationsTab />
{#if activeTab === 'notifications'}<NotificationsTab />{/if}
</Tabs.Content>
<!-- Auth Tab -->
<Tabs.Content value="auth" class="flex-1 min-h-0 flex flex-col">
<AuthTab onTabChange={handleTabChange} />
{#if activeTab === 'auth'}<AuthTab onTabChange={handleTabChange} />{/if}
</Tabs.Content>
<!-- License Tab -->
<Tabs.Content value="license" class="flex-1 min-h-0 overflow-y-auto">
<LicenseTab />
{#if activeTab === 'license'}<LicenseTab />{/if}
</Tabs.Content>
<!-- About Tab -->
<Tabs.Content value="about" class="flex-1 min-h-0 overflow-y-auto">
<AboutTab />
{#if activeTab === 'about'}<AboutTab />{/if}
</Tabs.Content>
</Tabs.Root>
</div>
+15 -7
View File
@@ -195,8 +195,8 @@
storageDriver: string;
} | null;
runtime: {
bun: string | null;
bunRevision: string | null;
runtimeName: string;
runtimeVersion: string;
nodeVersion: string;
platform: string;
arch: string;
@@ -266,6 +266,7 @@
newDigest: string;
containerName: string;
isComposeManaged: boolean;
latestVersion?: string;
error?: string;
} | null>(null);
let updateCheckError = $state<string | null>(null);
@@ -299,11 +300,12 @@
if (data.updateAvailable) {
updateInfo = {
currentImage: data.currentImage,
newImage: data.currentImage,
newImage: data.newImage || data.currentImage,
currentDigest: data.currentDigest || '',
newDigest: data.newDigest || '',
containerName: data.containerName,
isComposeManaged: data.isComposeManaged
isComposeManaged: data.isComposeManaged,
latestVersion: data.latestVersion
};
}
} catch (err) {
@@ -634,9 +636,9 @@
</div>
<div class="text-sm pl-5">
<div class="flex items-center gap-2 flex-wrap">
{#if systemInfo.runtime.bun}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/15 text-amber-600 dark:text-amber-400 shadow-sm ring-1 ring-amber-500/20">
Bun {systemInfo.runtime.bun}
{#if systemInfo.runtime.runtimeVersion}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/15 text-green-600 dark:text-green-400 shadow-sm ring-1 ring-green-500/20">
{systemInfo.runtime.runtimeName} {systemInfo.runtime.runtimeVersion}
</span>
{/if}
<span class="text-muted-foreground/50">|</span>
@@ -744,6 +746,11 @@
<MessageSquarePlus class="w-4 h-4" />
Submit issue or idea
</a>
<span class="text-muted-foreground/50">|</span>
<a href="https://discord.gg/rMxW9Y5cQw" target="_blank" rel="noopener noreferrer" class="hover:underline inline-flex items-center gap-1.5 font-medium text-indigo-500 dark:text-indigo-400">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
Discord
</a>
{#if !$licenseStore.isLicensed}
<span class="text-muted-foreground/50">|</span>
<a href="https://buymeacoffee.com/dockhand" target="_blank" rel="noopener noreferrer" class="hover:underline inline-flex items-center gap-1.5 font-medium text-amber-600 dark:text-yellow-400">
@@ -962,6 +969,7 @@
newDigest={updateInfo.newDigest}
containerName={updateInfo.containerName}
isComposeManaged={updateInfo.isComposeManaged}
latestVersion={updateInfo.latestVersion}
onclose={() => showSelfUpdateDialog = false}
/>
{/if}
@@ -22,10 +22,13 @@
newDigest?: string;
containerName: string;
isComposeManaged?: boolean;
latestVersion?: string;
onclose: () => void;
}
let { open = $bindable(), currentImage, newImage, currentDigest = '', newDigest = '', containerName, isComposeManaged = false, onclose }: Props = $props();
let { open = $bindable(), currentImage, newImage, currentDigest = '', newDigest = '', containerName, isComposeManaged = false, latestVersion, onclose }: Props = $props();
const isVersionUpdate = $derived(currentImage !== newImage);
// Phase management
type Phase = 'confirm' | 'preparing' | 'updating' | 'restarting' | 'completed' | 'error';
@@ -210,7 +213,7 @@
try {
const response = await fetch('/api/self-update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' },
body: JSON.stringify({ newImage })
});
@@ -511,19 +514,30 @@
{containerName}
</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Image</span>
<Badge variant="secondary" class="font-mono text-xs">{currentImage}</Badge>
</div>
{#if currentDigest || newDigest}
{#if isVersionUpdate}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Current digest</span>
<span class="font-mono text-xs text-muted-foreground">{currentDigest ? currentDigest.replace('sha256:', '').slice(0, 12) : 'unknown'}</span>
<span class="text-muted-foreground">Current image</span>
<Badge variant="secondary" class="font-mono text-xs">{currentImage}</Badge>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">New digest</span>
<span class="font-mono text-xs text-amber-500">{newDigest ? newDigest.replace('sha256:', '').slice(0, 12) : 'unknown'}</span>
<span class="text-muted-foreground">New image</span>
<Badge variant="default" class="font-mono text-xs">{newImage}</Badge>
</div>
{:else}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Image</span>
<Badge variant="secondary" class="font-mono text-xs">{currentImage}</Badge>
</div>
{#if currentDigest || newDigest}
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Current digest</span>
<span class="font-mono text-xs text-muted-foreground">{currentDigest ? currentDigest.replace('sha256:', '').slice(0, 12) : 'unknown'}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">New digest</span>
<span class="font-mono text-xs text-amber-500">{newDigest ? newDigest.replace('sha256:', '').slice(0, 12) : 'unknown'}</span>
</div>
{/if}
{/if}
</div>
@@ -1,5 +1,6 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import * as Dialog from '$lib/components/ui/dialog';
@@ -1114,18 +1115,9 @@
throw new Error('Failed to pull Grype image');
}
// Read SSE stream to completion
const reader = response.body?.getReader();
if (reader) {
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
if (text.includes('"status":"error"')) {
throw new Error('Pull failed');
}
}
const result = await readJobResponse(response);
if (result.success === false) {
throw new Error(result.error as string || 'Pull failed');
}
// Refresh scanner status after pull
@@ -1155,18 +1147,9 @@
throw new Error('Failed to pull Trivy image');
}
// Read SSE stream to completion
const reader = response.body?.getReader();
if (reader) {
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
if (text.includes('"status":"error"')) {
throw new Error('Pull failed');
}
}
const result = await readJobResponse(response);
if (result.success === false) {
throw new Error(result.error as string || 'Pull failed');
}
// Refresh scanner status after pull
@@ -177,25 +177,29 @@
testResult = 'idle';
try {
const config = getFormConfig();
// When editing with no password entered, use stored credentials via [id]/test
// to avoid sending blank password and getting "Missing credentials" from SMTP server
const useStoredCredentials = isEditing && formType === 'smtp' && !formSmtpPassword && notification?.id;
// For editing, if password is empty, we can't test without the original password
if (isEditing && formType === 'smtp' && !formSmtpPassword && notification?.config?.username) {
formError = 'Please enter the password to test the connection';
formTesting = false;
return;
let response: Response;
if (useStoredCredentials) {
response = await fetch(`/api/notifications/${notification!.id}/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
} else {
const config = getFormConfig();
response = await fetch('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: formType,
name: formName.trim() || 'Test',
config
})
});
}
const response = await fetch('/api/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: formType,
name: formName.trim() || 'Test',
config
})
});
const data = await response.json();
if (data.success) {
+290 -80
View File
@@ -12,7 +12,7 @@
import { Input } from '$lib/components/ui/input';
import * as Tooltip from '$lib/components/ui/tooltip';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import } from 'lucide-svelte';
import { Play, Square, Trash2, Plus, ArrowBigDown, Search, Pencil, ExternalLink, GitBranch, RefreshCw, Loader2, FileCode, FileText, FileOutput, Box, RotateCcw, ScrollText, Terminal, Eye, Network, HardDrive, Heart, HeartPulse, HeartOff, ChevronsUpDown, ChevronsDownUp, Rocket, AlertTriangle, X, Layers, Pause, CircleDashed, Skull, FolderOpen, Variable, Clock, RotateCw, Import, Ship, Cable, LayoutPanelLeft, Rows3, GripVertical } from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
import type { ComposeStackInfo, ContainerStats } from '$lib/types';
@@ -22,9 +22,11 @@
import GitDeployProgressPopover from './GitDeployProgressPopover.svelte';
import ContainerInspectModal from '../containers/ContainerInspectModal.svelte';
import FileBrowserModal from '../containers/FileBrowserModal.svelte';
import LogsPanel from '../logs/LogsPanel.svelte';
import { currentEnvironment, environments, appendEnvParam, clearStaleEnvironment } from '$lib/stores/environment';
import { onDockerEvent, isContainerListChange } from '$lib/stores/events';
import { canAccess } from '$lib/stores/auth';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { EmptyState, NoEnvironment } from '$lib/components/ui/empty-state';
import PageHeader from '$lib/components/PageHeader.svelte';
import { DataGrid } from '$lib/components/data-grid';
@@ -128,33 +130,74 @@
}
// Fetch container stats
let statsAbortController: AbortController | null = null;
async function fetchStats() {
// Abort any previous in-flight stream
statsAbortController?.abort();
statsAbortController = new AbortController();
try {
const response = await fetch(appendEnvParam('/api/containers/stats', envId));
const stats: ContainerStats[] = await response.json();
const statsMap = new Map<string, ContainerStats>();
const newHistory = new Map(containerStatsHistory);
const response = await fetch(
appendEnvParam('/api/containers/stats/stream', envId),
{ signal: statsAbortController.signal }
);
for (const stat of stats) {
if (!stat.id) continue;
statsMap.set(stat.id, stat);
if (!response.ok || !response.body) return;
// Update history for all metrics
const history = newHistory.get(stat.id) || { cpu: [], mem: [], netRx: [], netTx: [], diskR: [], diskW: [] };
history.cpu = [...history.cpu.slice(-(MAX_HISTORY - 1)), stat.cpuPercent];
history.mem = [...history.mem.slice(-(MAX_HISTORY - 1)), stat.memoryPercent];
history.netRx = [...history.netRx.slice(-(MAX_HISTORY - 1)), stat.networkRx];
history.netTx = [...history.netTx.slice(-(MAX_HISTORY - 1)), stat.networkTx];
history.diskR = [...history.diskR.slice(-(MAX_HISTORY - 1)), stat.blockRead];
history.diskW = [...history.diskW.slice(-(MAX_HISTORY - 1)), stat.blockWrite];
newHistory.set(stat.id, history);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let currentEvent = '';
for (const line of lines) {
if (line.startsWith(':')) continue;
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
if (currentEvent === 'stat') {
try {
const stat: ContainerStats = JSON.parse(line.slice(6));
if (!stat.id) continue;
// Merge into existing stats map
containerStats = new Map(containerStats).set(stat.id, stat);
// Append to history
const newHistory = new Map(containerStatsHistory);
const history = newHistory.get(stat.id) || { cpu: [], mem: [], netRx: [], netTx: [], diskR: [], diskW: [] };
history.cpu = [...history.cpu.slice(-(MAX_HISTORY - 1)), stat.cpuPercent];
history.mem = [...history.mem.slice(-(MAX_HISTORY - 1)), stat.memoryPercent];
history.netRx = [...history.netRx.slice(-(MAX_HISTORY - 1)), stat.networkRx];
history.netTx = [...history.netTx.slice(-(MAX_HISTORY - 1)), stat.networkTx];
history.diskR = [...history.diskR.slice(-(MAX_HISTORY - 1)), stat.blockRead];
history.diskW = [...history.diskW.slice(-(MAX_HISTORY - 1)), stat.blockWrite];
newHistory.set(stat.id, history);
containerStatsHistory = newHistory;
statsUpdateCount++;
} catch {
// Skip malformed data
}
}
currentEvent = '';
}
}
}
} catch (error: any) {
if (error?.name !== 'AbortError') {
console.error('Failed to fetch container stats:', error);
}
containerStats = statsMap;
containerStatsHistory = newHistory;
statsUpdateCount++; // Trigger reactivity for expanded rows
} catch (error) {
console.error('Failed to fetch container stats:', error);
}
}
@@ -229,9 +272,10 @@
return { cpuPercent, memoryUsage, memoryLimit, networkRx, networkTx, blockRead, blockWrite, runningCount };
}
// Search and sort state
let searchInput = $state('');
let searchQuery = $state('');
// Search and sort state - initialize from URL for persistence across navigation
const initialSearch = $page.url.searchParams.get('search') ?? '';
let searchInput = $state(initialSearch);
let searchQuery = $state(initialSearch);
let sortField = $state<SortField>('name');
let sortDirection = $state<SortDirection>('asc');
@@ -281,6 +325,93 @@
saveStatusFilter();
});
// Inline logs panel state
interface ActiveLogs {
containerId: string;
containerName: string;
}
let activeLogs = $state<ActiveLogs[]>([]);
let currentLogsContainerId = $state<string | null>(null);
function hasActiveLogs(containerId: string): boolean {
return activeLogs.some(l => l.containerId === containerId);
}
function showContainerLogs(container: { id: string; name: string }) {
if (hasActiveLogs(container.id)) {
currentLogsContainerId = container.id;
} else {
activeLogs = [...activeLogs, { containerId: container.id, containerName: container.name }];
currentLogsContainerId = container.id;
}
}
function closeLogs(containerId: string) {
activeLogs = activeLogs.filter(l => l.containerId !== containerId);
if (currentLogsContainerId === containerId) {
currentLogsContainerId = null;
}
}
// Layout state - horizontal (panels below) or vertical (panels on right)
type LayoutMode = 'horizontal' | 'vertical';
const LAYOUT_STORAGE_KEY = 'dockhand-stacks-layout';
const PANEL_WIDTH_STORAGE_KEY = 'dockhand-stacks-panel-width';
const DEFAULT_PANEL_WIDTH = 400;
const MIN_PANEL_WIDTH = 250;
const MAX_PANEL_WIDTH = 800;
let layoutMode = $state<LayoutMode>('horizontal');
let panelWidth = $state(DEFAULT_PANEL_WIDTH);
let isResizingWidth = $state(false);
let mainContentRef: HTMLDivElement | undefined;
function loadLayoutMode() {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(LAYOUT_STORAGE_KEY) as LayoutMode;
if (saved === 'horizontal' || saved === 'vertical') {
layoutMode = saved;
}
const savedWidth = localStorage.getItem(PANEL_WIDTH_STORAGE_KEY);
if (savedWidth) {
const w = parseInt(savedWidth);
if (!isNaN(w) && w >= MIN_PANEL_WIDTH && w <= MAX_PANEL_WIDTH) {
panelWidth = w;
}
}
}
}
function toggleLayoutMode() {
layoutMode = layoutMode === 'horizontal' ? 'vertical' : 'horizontal';
if (typeof window !== 'undefined') {
localStorage.setItem(LAYOUT_STORAGE_KEY, layoutMode);
}
}
function startWidthResize(e: MouseEvent) {
e.preventDefault();
isResizingWidth = true;
document.addEventListener('mousemove', handleWidthResize);
document.addEventListener('mouseup', stopWidthResize);
}
function handleWidthResize(e: MouseEvent) {
if (!isResizingWidth || !mainContentRef) return;
const containerRect = mainContentRef.getBoundingClientRect();
const newWidth = containerRect.right - e.clientX;
panelWidth = Math.max(MIN_PANEL_WIDTH, Math.min(MAX_PANEL_WIDTH, newWidth));
}
function stopWidthResize() {
isResizingWidth = false;
document.removeEventListener('mousemove', handleWidthResize);
document.removeEventListener('mouseup', stopWidthResize);
if (typeof window !== 'undefined') {
localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(panelWidth));
}
}
// Confirmation popover state
let confirmDeleteName = $state<string | null>(null);
let confirmStopName = $state<string | null>(null);
@@ -288,6 +419,7 @@
// Stack operation loading state
let stackActionLoading = $state<string | null>(null);
let stackDownLoading = $state<string | null>(null);
// Container-level confirmation popover state
let confirmStopContainerId = $state<string | null>(null);
@@ -520,6 +652,17 @@
return () => clearTimeout(searchTimeout);
});
// Sync search query to URL for persistence across navigation
$effect(() => {
const q = searchQuery;
const url = new URL($page.url);
if (q) url.searchParams.set('search', q);
else url.searchParams.delete('search');
if (url.toString() !== $page.url.toString()) {
goto(url.toString(), { replaceState: true, noScroll: true, keepFocus: true });
}
});
// Track last loaded environment to show skeleton on environment change
let lastLoadedEnvId = $state<number | null>(null);
@@ -657,6 +800,16 @@
return stackSources[stackName] || { sourceType: 'external' };
}
function getStackSystemType(stack: ComposeStackInfo): 'dockhand' | 'hawser' | null {
if (!stack.containerDetails) return null;
for (const c of stack.containerDetails) {
const img = (c.image || '').toLowerCase();
if (img.includes('fnsys/dockhand') || /(?:^|\/)dockhand(?::|$)/.test(img)) return 'dockhand';
if (img.includes('finsys/hawser') || img.includes('ghcr.io/finsys/hawser')) return 'hawser';
}
return null;
}
function getDisplayStatus(stack: ComposeStackInfo): string {
if (stack.status === 'created' && getStackSource(stack.name).sourceType === 'git') {
return 'not deployed';
@@ -687,16 +840,9 @@
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' });
if (!response.ok) {
const rawText = await response.text();
let errorMsg = 'Failed to start stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to start ${name}`, errorMsg);
const data = await readJobResponse(response);
if (!data.success) {
showErrorDialog(`Failed to start ${name}`, data.error || 'Failed to start stack');
return;
}
toast.success(`Started ${name}`);
@@ -715,16 +861,9 @@
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' });
if (!response.ok) {
const rawText = await response.text();
let errorMsg = 'Failed to stop stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to stop ${name}`, errorMsg);
const data = await readJobResponse(response);
if (!data.success) {
showErrorDialog(`Failed to stop ${name}`, data.error || 'Failed to stop stack');
return;
}
toast.success(`Stopped ${name}`);
@@ -743,16 +882,9 @@
stackActionLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId), { method: 'POST' });
if (!response.ok) {
const rawText = await response.text();
let errorMsg = 'Failed to restart stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to restart ${name}`, errorMsg);
const data = await readJobResponse(response);
if (!data.success) {
showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack');
return;
}
toast.success(`Restarted ${name}`);
@@ -769,22 +901,12 @@
async function downStack(name: string) {
operationError = null;
stackActionLoading = name;
stackDownLoading = name;
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' });
// Log raw response for debugging
const rawText = await response.text();
console.log(`[downStack] Response status: ${response.status}, raw body:`, rawText);
if (!response.ok) {
let errorMsg = 'Failed to bring down stack';
try {
const data = JSON.parse(rawText);
errorMsg = data.error || errorMsg;
} catch {
// Response may not be valid JSON
errorMsg = rawText || errorMsg;
}
showErrorDialog(`Failed to bring down ${name}`, errorMsg);
const data = await readJobResponse(response);
if (!data.success) {
showErrorDialog(`Failed to bring down ${name}`, data.error || 'Failed to bring down stack');
return;
}
toast.success(`Brought down ${name}`);
@@ -795,6 +917,7 @@
showErrorDialog(`Failed to bring down ${name}`, errorMsg);
} finally {
stackActionLoading = null;
stackDownLoading = null;
}
}
@@ -1060,13 +1183,7 @@
onMount(() => {
loadExpandedState();
loadStatusFilter();
// Check for search query param from URL (e.g., from container stack link)
const urlSearch = $page.url.searchParams.get('search');
if (urlSearch) {
searchInput = urlSearch;
searchQuery = urlSearch;
}
loadLayoutMode();
// Initial fetch is handled by $effect - no need to duplicate here
@@ -1115,6 +1232,11 @@
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
document.removeEventListener('mousemove', handleWidthResize);
document.removeEventListener('mouseup', stopWidthResize);
// Abort any in-flight stats stream
statsAbortController?.abort();
});
</script>
@@ -1161,6 +1283,19 @@
<RefreshCw class="w-3.5 h-3.5" />
Refresh
</Button>
<Button
size="sm"
variant="outline"
onclick={toggleLayoutMode}
class="h-8 w-8 p-0"
title={layoutMode === 'horizontal' ? 'Switch to vertical layout (logs on side)' : 'Switch to horizontal layout (logs below)'}
>
{#if layoutMode === 'horizontal'}
<LayoutPanelLeft class="w-4 h-4" />
{:else}
<Rows3 class="w-4 h-4" />
{/if}
</Button>
{#if $canAccess('stacks', 'create')}
<Button size="sm" variant="outline" onclick={() => openGitModal()}>
<GitBranch class="w-3.5 h-3.5" />
@@ -1300,6 +1435,11 @@
description="Create a stack or deploy from Git to get started"
/>
{:else}
<!-- Main content area - changes layout based on mode -->
<div
bind:this={mainContentRef}
class="flex-1 min-h-0 {layoutMode === 'vertical' ? 'flex gap-3' : 'flex flex-col gap-3'}"
>
<DataGrid
data={filteredStacks}
keyField="name"
@@ -1327,6 +1467,7 @@
{#snippet cell(column, stack, rowState)}
{@const source = getStackSource(stack.name)}
{#if column.id === 'name'}
{@const systemType = getStackSystemType(stack)}
{#if source.sourceType !== 'git'}
<!-- Internal stacks (including those needing file location) are clickable -->
<button
@@ -1340,6 +1481,23 @@
<!-- Git stacks open in GitStackModal instead -->
<span class="font-medium text-xs">{stack.name}</span>
{/if}
{#if systemType}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="secondary" class="text-2xs py-0 px-1 shrink-0 bg-blue-500/10 text-blue-600 dark:text-blue-400 hover:bg-blue-500/20 cursor-help flex items-center gap-0.5">
{#if systemType === 'dockhand'}
<Ship class="w-2.5 h-2.5" />
{:else}
<Cable class="w-2.5 h-2.5" />
{/if}
{systemType === 'dockhand' ? 'Dockhand' : 'Hawser'}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p class="text-sm whitespace-nowrap">{systemType === 'dockhand' ? 'Dockhand management container' : 'Hawser remote agent'}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if stackEnvVarCounts[stack.name]}
<Tooltip.Root>
<Tooltip.Trigger>
@@ -1650,7 +1808,7 @@
onOpenChange={(open) => confirmDownName = open ? stack.name : null}
>
{#snippet children({ open })}
<ArrowBigDown class="w-3 h-3 {open ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'}" />
<ArrowBigDown class="w-3 h-3 {stackDownLoading === stack.name ? 'animate-bounce text-orange-500' : open ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'}" />
{/snippet}
</ConfirmPopover>
{/if}
@@ -1834,11 +1992,19 @@
<div class="flex gap-1">
<button
type="button"
title="View logs"
title="Open logs inline"
onclick={(e) => { e.stopPropagation(); showContainerLogs(container); }}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer {currentLogsContainerId === container.id ? 'bg-muted text-blue-500' : ''}"
>
<FileText class="w-3.5 h-3.5 {currentLogsContainerId === container.id ? 'text-blue-500' : 'text-muted-foreground hover:text-foreground'}" />
</button>
<button
type="button"
title="Open logs in full view"
onclick={(e) => { e.stopPropagation(); goto(appendEnvParam(`/logs?container=${container.id}`, envId)); }}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<ScrollText class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
<FileOutput class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
</button>
{#if container.state === 'running' && $canAccess('containers', 'exec')}
<button
@@ -1968,6 +2134,50 @@
{/if}
{/snippet}
</DataGrid>
<!-- Panels section - in vertical mode this is a column on the right with resize handle -->
{#if layoutMode === 'vertical' && currentLogsContainerId}
{@const activeLog = activeLogs.find(l => l.containerId === currentLogsContainerId)}
{#if activeLog}
<!-- Vertical resize handle -->
<div
role="separator"
aria-orientation="vertical"
class="w-2 cursor-ew-resize flex items-center justify-center hover:bg-muted transition-colors {isResizingWidth ? 'bg-muted' : ''}"
onmousedown={startWidthResize}
>
<GripVertical class="w-4 h-8 text-zinc-600" />
</div>
<div class="flex flex-col gap-3 h-full overflow-hidden" style="width: {panelWidth}px; flex-shrink: 0;">
<div class="flex-1 min-h-0">
<LogsPanel
containerId={activeLog.containerId}
containerName={activeLog.containerName}
visible={true}
envId={envId}
fillHeight={true}
onClose={() => closeLogs(activeLog.containerId)}
/>
</div>
</div>
{/if}
{/if}
</div>
<!-- Panels for horizontal mode - below the table, full width -->
{#if layoutMode === 'horizontal' && currentLogsContainerId}
{@const activeLog = activeLogs.find(l => l.containerId === currentLogsContainerId)}
{#if activeLog}
<LogsPanel
containerId={activeLog.containerId}
containerName={activeLog.containerName}
visible={true}
envId={envId}
onClose={() => closeLogs(activeLog.containerId)}
/>
{/if}
{/if}
{/if}
</div>
+190 -190
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Progress } from '$lib/components/ui/progress';
import {
Rocket,
CheckCircle2,
@@ -12,11 +13,15 @@
FileCode,
Server,
Link,
AlertTriangle
AlertTriangle,
Copy,
Check,
Database,
KeyRound
} from 'lucide-svelte';
import type { Snippet } from 'svelte';
import { Progress } from '$lib/components/ui/progress';
import { appSettings } from '$lib/stores/settings';
import { watchJob } from '$lib/utils/sse-fetch';
interface Props {
stackId: number;
@@ -28,7 +33,7 @@
let { stackId, stackName, onComplete, children }: Props = $props();
interface StepProgress {
status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'deploying' | 'complete' | 'error';
status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'env' | 'secrets' | 'deploying' | 'complete' | 'error';
message?: string;
step?: number;
totalSteps?: number;
@@ -40,41 +45,29 @@
let currentStep = $state<StepProgress | null>(null);
let steps = $state<StepProgress[]>([]);
let errorMessage = $state('');
let copied = $state(false);
// Get the confirmDestructive setting from the store
const confirmDestructive = $derived($appSettings.confirmDestructive);
function getStepIcon(status: string) {
switch (status) {
case 'connecting':
return Link;
case 'cloning':
return GitBranch;
case 'fetching':
return GitBranch;
case 'reading':
return FileCode;
case 'deploying':
return Server;
case 'complete':
return CheckCircle2;
case 'error':
return XCircle;
default:
return Loader2;
case 'connecting': return Link;
case 'cloning': return GitBranch;
case 'fetching': return GitBranch;
case 'reading': return FileCode;
case 'env': return Database;
case 'secrets': return KeyRound;
case 'deploying': return Server;
case 'complete': return CheckCircle2;
case 'error': return XCircle;
default: return Loader2;
}
}
function getStepColor(status: string, isCurrentStep: boolean): string {
if (status === 'complete') {
return 'text-green-600 dark:text-green-400';
}
if (status === 'error') {
return 'text-red-600 dark:text-red-400';
}
if (isCurrentStep) {
return 'text-blue-600 dark:text-blue-400';
}
if (status === 'complete') return 'text-green-600 dark:text-green-400';
if (status === 'error') return 'text-red-600 dark:text-red-400';
if (isCurrentStep) return 'text-violet-600 dark:text-violet-400';
return 'text-muted-foreground';
}
@@ -83,11 +76,11 @@
currentStep = null;
overallStatus = 'deploying';
errorMessage = '';
open = true;
try {
const response = await fetch(`/api/git/stacks/${stackId}/deploy-stream`, {
method: 'POST'
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
@@ -95,46 +88,33 @@
throw new Error(data.error || 'Failed to start deployment');
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const { jobId } = await response.json();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim() || !line.startsWith('data: ')) continue;
try {
const data: StepProgress = JSON.parse(line.slice(6));
if (data.status === 'complete') {
overallStatus = 'complete';
currentStep = data;
steps = [...steps, data];
onComplete?.();
} else if (data.status === 'error') {
overallStatus = 'error';
errorMessage = data.error || 'Unknown error occurred';
currentStep = data;
steps = [...steps, data];
} else {
currentStep = data;
steps = [...steps, data];
}
} catch (e) {
console.error('Failed to parse SSE data:', e);
await watchJob(jobId, (line) => {
try {
const data = line.data as StepProgress;
if (data.status === 'complete') {
overallStatus = 'complete';
currentStep = data;
steps = [...steps, data];
onComplete?.();
} else if (data.status === 'error') {
overallStatus = 'error';
errorMessage = data.error || 'Unknown error occurred';
currentStep = data;
steps = [...steps, data];
} else {
currentStep = data;
steps = [...steps, data];
}
} catch (e) {
console.error('Failed to process job line:', e);
}
});
if (overallStatus === 'deploying') {
overallStatus = 'complete';
onComplete?.();
}
} catch (error: any) {
console.error('Failed to deploy git stack:', error);
@@ -143,25 +123,14 @@
}
}
function handleOpenChange(isOpen: boolean) {
// Only allow closing via the Close button (not by clicking outside)
// When confirming, deploying, complete, or error - require explicit close
if (!isOpen && overallStatus !== 'idle') {
return;
function handleTriggerClick() {
if (overallStatus !== 'idle') return;
if (confirmDestructive) {
overallStatus = 'confirming';
} else {
startDeploy();
}
// When opening, show confirmation first if enabled
if (isOpen && !open) {
if (confirmDestructive) {
overallStatus = 'confirming';
open = true;
} else {
startDeploy();
}
return;
}
open = isOpen;
open = true;
}
function handleConfirmDeploy() {
@@ -174,12 +143,21 @@
}
function handleClose() {
if (overallStatus === 'deploying') return;
open = false;
// Reset state when closed
overallStatus = 'idle';
steps = [];
currentStep = null;
errorMessage = '';
copied = false;
}
async function copyLogs() {
const lines = steps.map(s => s.message || s.status);
if (errorMessage) lines.push(`Error: ${errorMessage}`);
await navigator.clipboard.writeText(lines.join('\n'));
copied = true;
setTimeout(() => { copied = false; }, 2000);
}
const progressPercentage = $derived(
@@ -187,121 +165,143 @@
? Math.round((currentStep.step / currentStep.totalSteps) * 100)
: 0
);
const isDeploying = $derived(overallStatus === 'deploying');
</script>
<Popover.Root {open} onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
{@render children()}
</Popover.Trigger>
<Popover.Content
class="{overallStatus === 'confirming' ? 'w-auto' : 'w-80'} p-0 overflow-hidden flex flex-col z-[200]"
align="end"
sideOffset={8}
interactOutsideBehavior={overallStatus !== 'idle' ? 'ignore' : 'close'}
escapeKeydownBehavior={overallStatus !== 'idle' ? 'ignore' : 'close'}
<!-- Trigger wrapper: intercepts click to open the dialog -->
<span role="none" style="display:contents" onclick={handleTriggerClick}>
{@render children()}
</span>
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen) handleClose(); }}>
<Dialog.Content
class="max-w-2xl flex flex-col gap-0 p-0 overflow-hidden"
showCloseButton={false}
interactOutsideBehavior={isDeploying ? 'ignore' : 'close'}
escapeKeydownBehavior={isDeploying ? 'ignore' : 'close'}
>
{#if overallStatus === 'confirming'}
<!-- Confirmation Dialog -->
<div class="p-3 space-y-3">
<div class="flex items-start gap-2">
<AlertTriangle class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<!-- Header -->
<div class="px-6 py-4 border-b shrink-0">
<div class="flex items-center gap-2 min-w-0">
{#if overallStatus === 'complete'}
<CheckCircle2 class="w-5 h-5 text-green-500 shrink-0" />
{:else if overallStatus === 'error'}
<XCircle class="w-5 h-5 text-red-500 shrink-0" />
{:else if isDeploying}
<Loader2 class="w-5 h-5 text-violet-500 animate-spin shrink-0" />
{:else}
<Rocket class="w-5 h-5 text-violet-500 shrink-0" />
{/if}
<span class="text-base font-semibold">Git deploy</span>
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1 truncate">{stackName}</code>
{#if overallStatus === 'complete'}
<Badge variant="outline" class="ml-auto shrink-0 text-green-600 border-green-600/30">Complete</Badge>
{:else if overallStatus === 'error'}
<Badge variant="outline" class="ml-auto shrink-0 text-red-600 border-red-600/30">Failed</Badge>
{:else if isDeploying}
<Badge variant="secondary" class="ml-auto shrink-0 tabular-nums text-xs">
{#if currentStep?.step && currentStep?.totalSteps}
{currentStep.step}/{currentStep.totalSteps}
{:else}
Deploying...
{/if}
</Badge>
{/if}
</div>
{#if isDeploying && currentStep?.totalSteps}
<div class="mt-3">
<Progress value={progressPercentage} class="h-1.5 [&>[data-progress]]:bg-violet-600" />
</div>
{/if}
</div>
<!-- Body: steps log -->
<div class="overflow-y-auto px-4 py-3" style="max-height: 55vh; min-height: 12rem;">
{#if overallStatus === 'confirming'}
<div class="flex items-start gap-3 py-2 px-2">
<AlertTriangle class="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<div class="space-y-1">
<p class="text-sm font-medium">Sync from git?</p>
<p class="text-xs text-muted-foreground">
This will pull latest changes for <strong class="text-foreground">{stackName}</strong>. Containers will only restart if the configuration changed.
<p class="font-medium">Sync from git?</p>
<p class="text-sm text-muted-foreground">
This will pull the latest changes for <strong class="text-foreground">{stackName}</strong>.
Containers will only restart if the configuration changed.
</p>
</div>
</div>
<div class="flex justify-end gap-2">
<Button variant="outline" size="sm" class="h-7 text-xs" onclick={handleCancelConfirm}>
Cancel
</Button>
<Button variant="default" size="sm" class="h-7 text-xs" onclick={handleConfirmDeploy}>
Sync
</Button>
{:else if steps.length === 0 && isDeploying}
<div class="flex items-center gap-3 text-muted-foreground py-2 px-2">
<Loader2 class="w-4 h-4 animate-spin shrink-0" />
<span class="text-sm">Initializing...</span>
</div>
</div>
{:else}
<!-- Header -->
<div class="p-3 border-b space-y-2">
<div class="flex items-center gap-2 text-sm font-medium">
<Rocket class="w-4 h-4 text-violet-600" />
<span class="truncate">{stackName}</span>
{:else}
<div class="space-y-0.5">
{#each steps as step, index (index)}
{@const StepIcon = getStepIcon(step.status)}
{@const isCurrentStep = index === steps.length - 1 && isDeploying}
<div class="flex items-center gap-3 py-1.5 px-2 rounded-md text-sm hover:bg-muted/40 transition-colors">
<StepIcon
class="w-4 h-4 shrink-0 {getStepColor(step.status, isCurrentStep)} {isCurrentStep && step.status !== 'complete' && step.status !== 'error' ? 'animate-spin' : ''}"
/>
<span class="{getStepColor(step.status, isCurrentStep)}">
{step.message || step.status}
</span>
</div>
{/each}
</div>
{/if}
<!-- Overall Progress -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
{#if overallStatus === 'idle'}
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
<span class="text-sm text-muted-foreground">Initializing...</span>
{:else if overallStatus === 'deploying'}
<Loader2 class="w-4 h-4 animate-spin text-violet-600" />
<span class="text-sm">Deploying...</span>
{:else if overallStatus === 'complete'}
<CheckCircle2 class="w-4 h-4 text-green-600" />
<span class="text-sm text-green-600">Complete!</span>
{:else if overallStatus === 'error'}
<XCircle class="w-4 h-4 text-red-600" />
<span class="text-sm text-red-600">Failed</span>
{/if}
</div>
{#if currentStep?.step && currentStep?.totalSteps}
<Badge variant="secondary" class="text-xs">
{currentStep.step}/{currentStep.totalSteps}
</Badge>
{/if}
</div>
{#if currentStep?.message && overallStatus === 'deploying'}
<p class="text-xs text-muted-foreground truncate">{currentStep.message}</p>
{/if}
{#if currentStep?.totalSteps}
<Progress value={progressPercentage} class="h-1.5 [&>[data-progress]]:bg-violet-600" />
{/if}
{#if errorMessage}
<div class="flex items-start gap-2 text-xs text-red-600 dark:text-red-400">
<AlertCircle class="w-3 h-3 shrink-0 mt-0.5" />
{#if errorMessage}
<div class="mt-3 mx-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<div class="flex items-start gap-2 text-sm text-destructive">
<AlertCircle class="w-4 h-4 shrink-0 mt-0.5" />
<span class="break-all">{errorMessage}</span>
</div>
</div>
{/if}
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t shrink-0 flex items-center justify-between gap-2">
<!-- Left: copy / cancel -->
<div>
{#if overallStatus === 'confirming'}
<Button variant="outline" onclick={handleCancelConfirm}>Cancel</Button>
{:else if steps.length > 0}
<Button variant="outline" size="sm" onclick={copyLogs} class="gap-1.5">
{#if copied}
<Check class="w-3.5 h-3.5" />
Copied!
{:else}
<Copy class="w-3.5 h-3.5" />
Copy logs
{/if}
</Button>
{/if}
</div>
<!-- Steps List -->
{#if steps.length > 0}
<div class="p-2 max-h-48 overflow-auto">
<div class="space-y-1">
{#each steps as step, index (index)}
{@const StepIcon = getStepIcon(step.status)}
{@const isCurrentStep = index === steps.length - 1 && overallStatus === 'deploying'}
<div class="flex items-center gap-2 py-1 px-1 rounded text-xs hover:bg-muted/50">
<StepIcon
class="w-3.5 h-3.5 shrink-0 {getStepColor(step.status, isCurrentStep)} {isCurrentStep && step.status !== 'complete' && step.status !== 'error' ? 'animate-spin' : ''}"
/>
<span class="flex-1 {getStepColor(step.status, isCurrentStep)} truncate">
{step.message || step.status}
</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Footer -->
{#if overallStatus === 'complete' || overallStatus === 'error'}
<div class="p-2 border-t">
<Button
variant="outline"
size="sm"
class="w-full"
onclick={handleClose}
>
Close
<!-- Right: confirm / close -->
<div class="flex gap-2">
{#if overallStatus === 'confirming'}
<Button onclick={handleConfirmDeploy}>
<Rocket class="w-4 h-4" />
Deploy
</Button>
</div>
{/if}
{/if}
</Popover.Content>
</Popover.Root>
{:else}
<Button
variant={overallStatus === 'complete' ? 'default' : 'secondary'}
onclick={handleClose}
disabled={isDeploying}
>
{#if isDeploying}
<Loader2 class="w-4 h-4 animate-spin" />
Deploying...
{:else}
Close
{/if}
</Button>
{/if}
</div>
</div>
</Dialog.Content>
</Dialog.Root>
+2 -1
View File
@@ -14,6 +14,7 @@
import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte';
import { toast } from 'svelte-sonner';
import { focusFirstInput } from '$lib/utils';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
// Get sidebar state to adjust modal positioning
@@ -465,7 +466,7 @@
body: JSON.stringify(body)
});
const data = await response.json();
const data = await readJobResponse(response);
if (!response.ok) {
formError = data.error || 'Failed to save git stack';
+6 -1
View File
@@ -150,9 +150,14 @@
}
scanResults = discovered;
const skippedCount = (data.skipped || []).length;
if (discovered.length === 0) {
toast.info('No compose stacks found in this directory');
if (skippedCount > 0) {
toast.info(`All ${skippedCount} stack(s) in this directory are already adopted`);
} else {
toast.info('No compose stacks found in this directory');
}
} else {
const selections = new Map<string, boolean>();
for (const stack of discovered) {
+35 -5
View File
@@ -20,6 +20,8 @@
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Alert from '$lib/components/ui/alert';
import { ErrorDialog } from '$lib/components/ui/error-dialog';
import { readJobResponse } from '$lib/utils/sse-fetch';
import { toast } from 'svelte-sonner';
import ComposeGraphViewer from './ComposeGraphViewer.svelte';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte';
@@ -37,7 +39,11 @@
onSuccess: () => void; // Called after create or save
}
let { open = $bindable(), mode, stackName = '', onClose, onSuccess }: Props = $props();
let { open = $bindable(), mode: propMode, stackName: propStackName = '', onClose, onSuccess }: Props = $props();
// Local effective state - can transition from create → edit after failed deploy
let mode = $state(propMode);
let stackName = $state(propStackName);
// Form state
let newStackName = $state('');
@@ -930,11 +936,17 @@ services:
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const data = await response.json();
// When start=true, response is a job or JSON; when start=false, it's plain JSON
const data = start ? await readJobResponse(response) : await response.json();
if (!response.ok && !data.success) {
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to create stack');
}
if (data.success === false) {
throw new Error(data.error || 'Failed to create stack');
}
toast.success(`Created stack "${newStackName.trim()}"`);
onSuccess();
handleClose();
} catch (e: any) {
@@ -943,6 +955,13 @@ services:
message: e.message || 'An error occurred while creating the stack',
details: e.details
};
// If start=true, files were saved and stack is in DB — transition to edit mode
// so the user can fix and redeploy without leaving the modal
if (start) {
mode = 'edit';
stackName = newStackName.trim();
onSuccess(); // refresh stack list so the new stack appears
}
} finally {
saving = false;
}
@@ -1094,13 +1113,18 @@ services:
}
);
const data = await response.json();
// When restart=true, response is a job or JSON; when restart=false, it's plain JSON
const data = restart ? await readJobResponse(response) : await response.json();
if (!response.ok) {
if (!response.ok && !data.success) {
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to save compose file');
}
if (data.success === false) {
throw new Error(data.error || 'Failed to save compose file');
}
isDirty = false; // Reset dirty flag after successful save
toast.success(restart ? 'Stack applied' : 'Stack saved');
onSuccess();
if (!restart) {
@@ -1147,6 +1171,9 @@ services:
clearTimeout(validateTimer);
validateTimer = null;
}
// Reset mode back to prop values
mode = propMode;
stackName = propStackName;
// Reset all state
newStackName = '';
error = null;
@@ -1196,6 +1223,9 @@ services:
$effect(() => {
if (open && !hasInitialized) {
hasInitialized = true;
// Reset mode to prop values on each open
mode = propMode;
stackName = propStackName;
if (mode === 'edit' && stackName) {
loadComposeFile().then(() => {
// Auto-validate after loading
+2 -1
View File
@@ -62,7 +62,7 @@
let connectedPollInterval: ReturnType<typeof setInterval> | null = null;
// Subscribe to environment changes
currentEnvironment.subscribe((env) => {
const unsubscribeEnv = currentEnvironment.subscribe((env) => {
envId = env?.id ?? null;
if (env) {
fetchContainers();
@@ -201,6 +201,7 @@
});
onDestroy(() => {
unsubscribeEnv();
if (containerInterval) {
clearInterval(containerInterval);
containerInterval = null;