v1.0.19
This commit is contained in:
1 parent
4b430340db
commit
9c451aedf9
120 files changed
+7777
-6046
No files matched your search
@@ -920,6 +920,7 @@
|
||||
}
|
||||
unsubscribeDashboardData();
|
||||
unsubscribePrefs();
|
||||
mobileWatcher.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { listContainers, createContainer, pullImage, EnvironmentNotFoundError, DockerConnectionError, type CreateContainerOptions } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
@@ -40,7 +40,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
return json({ error: 'Environment not found' }, { status: 404 });
|
||||
}
|
||||
console.error('Error listing containers:', error);
|
||||
if (!(error instanceof DockerConnectionError)) {
|
||||
console.error('Error listing containers:', error);
|
||||
}
|
||||
// Return empty array instead of error to allow UI to load
|
||||
return json([]);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
@@ -50,9 +51,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let extension = '.tar';
|
||||
|
||||
if (format === 'tar.gz') {
|
||||
// Compress with gzip using Bun's native implementation
|
||||
// Compress with gzip
|
||||
const tarData = new Uint8Array(await response.arrayBuffer());
|
||||
body = Bun.gzipSync(tarData);
|
||||
body = gzipSync(tarData);
|
||||
contentType = 'application/gzip';
|
||||
extension = '.tar.gz';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
|
||||
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
|
||||
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
@@ -56,6 +58,7 @@ async function getDockerConfig(envId?: number | null): Promise<DockerClientConfi
|
||||
return { type: 'hawser-edge', environmentId: envId };
|
||||
}
|
||||
const protocol = (env.protocol as 'http' | 'https') || 'http';
|
||||
|
||||
return {
|
||||
type: protocol,
|
||||
host: env.host || 'localhost',
|
||||
@@ -119,6 +122,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
@@ -226,6 +230,7 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
|
||||
cancelStream = cancel;
|
||||
},
|
||||
cancel() {
|
||||
|
||||
controllerClosed = true;
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
@@ -279,28 +284,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let inspectResponse: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
inspectResponse = await fetch(`http://localhost${inspectPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath
|
||||
});
|
||||
inspectResponse = await unixSocketRequest(config.socketPath, inspectPath);
|
||||
} else if (config.type === 'https') {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
inspectResponse = await httpsAgentRequest(config as BaseDockerClientConfig, inspectPath, {}, false, extraHeaders);
|
||||
} else {
|
||||
const inspectUrl = `${config.type}://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectUrl = `http://${config.host}:${config.port}${inspectPath}`;
|
||||
const inspectHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) inspectHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
const fetchOpts: any = { headers: inspectHeaders };
|
||||
if (config.type === 'https') {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.skipVerify
|
||||
};
|
||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||
if (config.key) fetchOpts.tls.key = config.key;
|
||||
fetchOpts.keepalive = false;
|
||||
if (process.env.DEBUG_TLS) fetchOpts.verbose = true;
|
||||
}
|
||||
inspectResponse = await fetch(inspectUrl, fetchOpts);
|
||||
inspectResponse = await fetch(inspectUrl, { headers: inspectHeaders });
|
||||
}
|
||||
|
||||
if (inspectResponse.ok) {
|
||||
@@ -322,6 +315,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
@@ -343,32 +337,16 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
let response: Response;
|
||||
|
||||
if (config.type === 'socket') {
|
||||
response = await fetch(`http://localhost${logsPath}`, {
|
||||
// @ts-ignore - Bun supports unix socket
|
||||
unix: config.socketPath,
|
||||
signal: abortController?.signal
|
||||
});
|
||||
response = await unixSocketStreamRequest(config.socketPath, logsPath);
|
||||
} else if (config.type === 'https') {
|
||||
const extraHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) extraHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
response = await httpsAgentRequest(config as BaseDockerClientConfig, logsPath, {}, true, extraHeaders);
|
||||
} else {
|
||||
const logsUrl = `${config.type}://${config.host}:${config.port}${logsPath}`;
|
||||
const logsUrl = `http://${config.host}:${config.port}${logsPath}`;
|
||||
const logsHeaders: Record<string, string> = {};
|
||||
if (config.hawserToken) logsHeaders['X-Hawser-Token'] = config.hawserToken;
|
||||
const fetchOpts: any = {
|
||||
headers: logsHeaders,
|
||||
signal: abortController?.signal
|
||||
};
|
||||
if (config.type === 'https') {
|
||||
fetchOpts.tls = {
|
||||
sessionTimeout: 0,
|
||||
servername: config.host,
|
||||
rejectUnauthorized: !config.skipVerify
|
||||
};
|
||||
if (config.ca) fetchOpts.tls.ca = [config.ca];
|
||||
if (config.cert) fetchOpts.tls.cert = [config.cert];
|
||||
if (config.key) fetchOpts.tls.key = config.key;
|
||||
fetchOpts.keepalive = false;
|
||||
if (process.env.DEBUG_TLS) fetchOpts.verbose = true;
|
||||
}
|
||||
response = await fetch(logsUrl, fetchOpts);
|
||||
response = await fetch(logsUrl, { headers: logsHeaders });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -434,7 +412,8 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
} catch (error) {
|
||||
if (!controllerClosed) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
@@ -444,7 +423,14 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up on normal stream end (not just cancel)
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
if (!controllerClosed) {
|
||||
controllerClosed = true;
|
||||
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
@@ -453,7 +439,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
if (!controllerClosed) {
|
||||
controllerClosed = true;
|
||||
|
||||
}
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getScannerSettings, scanImage } from '$lib/server/scanner';
|
||||
import { saveVulnerabilityScan, removePendingContainerUpdate, type VulnerabilityCriteria } from '$lib/server/db';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isDockhandContainer } from '$lib/server/scheduler/tasks/update-utils';
|
||||
import { recreateContainer } from '$lib/server/scheduler/tasks/container-update';
|
||||
import { createJob, appendLine, completeJob, failJob } from '$lib/server/jobs';
|
||||
|
||||
export interface ScanResult {
|
||||
critical: number;
|
||||
@@ -96,423 +97,123 @@ export const POST: RequestHandler = async (event) => {
|
||||
return json({ error: 'containerIds array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
let keepaliveInterval: ReturnType<typeof setInterval> | null = null;
|
||||
// Job pattern: create job, run in background, return jobId immediately
|
||||
const job = createJob();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const safeEnqueue = (data: UpdateProgress) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
const sendData = (data: UpdateProgress) => {
|
||||
appendLine(job, { data });
|
||||
};
|
||||
|
||||
// Send SSE keepalive comments every 5s to prevent Traefik (10s idle timeout) from closing connection
|
||||
keepaliveInterval = setInterval(() => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}, 5000);
|
||||
(async () => {
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let blockedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let blockedCount = 0;
|
||||
let skippedCount = 0;
|
||||
// Get scanner settings for this environment
|
||||
const scannerSettings = await getScannerSettings(envIdNum);
|
||||
// Scan if scanning is enabled (scanner !== 'none')
|
||||
// The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
|
||||
const shouldScan = scannerSettings.scanner !== 'none';
|
||||
|
||||
// Get scanner settings for this environment
|
||||
const scannerSettings = await getScannerSettings(envIdNum);
|
||||
// Scan if scanning is enabled (scanner !== 'none')
|
||||
// The vulnerabilityCriteria only controls whether to BLOCK updates, not whether to SCAN
|
||||
const shouldScan = scannerSettings.scanner !== 'none';
|
||||
// Send start event
|
||||
sendData({
|
||||
type: 'start',
|
||||
total: containerIds.length,
|
||||
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
|
||||
});
|
||||
|
||||
// Send start event
|
||||
safeEnqueue({
|
||||
type: 'start',
|
||||
total: containerIds.length,
|
||||
message: `Starting update of ${containerIds.length} container${containerIds.length > 1 ? 's' : ''}${shouldScan ? ' with vulnerability scanning' : ''}`
|
||||
});
|
||||
// Process containers sequentially
|
||||
for (let i = 0; i < containerIds.length; i++) {
|
||||
const containerId = containerIds[i];
|
||||
let containerName = 'unknown';
|
||||
|
||||
// Process containers sequentially
|
||||
for (let i = 0; i < containerIds.length; i++) {
|
||||
const containerId = containerIds[i];
|
||||
let containerName = 'unknown';
|
||||
try {
|
||||
// Find container
|
||||
const containers = await listContainers(true, envIdNum);
|
||||
const container = containers.find(c => c.id === containerId);
|
||||
|
||||
try {
|
||||
// Find container
|
||||
const containers = await listContainers(true, envIdNum);
|
||||
const container = containers.find(c => c.id === containerId);
|
||||
|
||||
if (!container) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName: 'unknown',
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Container not found'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
containerName = container.name;
|
||||
|
||||
// Get full container config
|
||||
const inspectData = await inspectContainer(containerId, envIdNum) as any;
|
||||
const wasRunning = inspectData.State.Running;
|
||||
const config = inspectData.Config;
|
||||
const hostConfig = inspectData.HostConfig;
|
||||
const imageName = config.Image;
|
||||
const currentImageId = inspectData.Image;
|
||||
|
||||
// Skip Dockhand container - cannot update itself
|
||||
if (isDockhandContainer(imageName)) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - cannot update Dockhand itself`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||
if (isDigestBasedImage(imageName)) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - image pinned to specific digest`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 1: Pull latest image
|
||||
safeEnqueue({
|
||||
if (!container) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'pulling',
|
||||
containerName: 'unknown',
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Pulling ${imageName}...`
|
||||
success: false,
|
||||
error: 'Container not found'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await pullImage(imageName, (data: any) => {
|
||||
if (data.status) {
|
||||
safeEnqueue({
|
||||
type: 'pull_log',
|
||||
containerId,
|
||||
containerName,
|
||||
pullStatus: data.status,
|
||||
pullId: data.id,
|
||||
pullProgress: data.progress
|
||||
});
|
||||
}
|
||||
}, envIdNum);
|
||||
} catch (pullError: any) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: `Pull failed: ${pullError.message}`
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
containerName = container.name;
|
||||
|
||||
// SAFE-PULL FLOW with vulnerability scanning
|
||||
if (shouldScan && !isDigestBasedImage(imageName)) {
|
||||
const tempTag = getTempImageTag(imageName);
|
||||
// Get full container config
|
||||
const inspectData = await inspectContainer(containerId, envIdNum) as any;
|
||||
const config = inspectData.Config;
|
||||
const imageName = config.Image;
|
||||
const currentImageId = inspectData.Image;
|
||||
|
||||
// Get new image ID
|
||||
const newImageId = await getImageIdByTag(imageName, envIdNum);
|
||||
if (!newImageId) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Failed to get new image ID after pull'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Restore original tag to old image (safety)
|
||||
const [oldRepo, oldTag] = parseImageNameAndTag(imageName);
|
||||
try {
|
||||
await tagImage(currentImageId, oldRepo, oldTag, envIdNum);
|
||||
} catch {
|
||||
// Ignore - old image might have been removed
|
||||
}
|
||||
|
||||
// Tag new image with temp suffix
|
||||
const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
|
||||
await tagImage(newImageId, tempRepo, tempTagName, envIdNum);
|
||||
|
||||
// Step 2: Scan temp image
|
||||
safeEnqueue({
|
||||
type: 'scan_start',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'scanning',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Scanning ${imageName} for vulnerabilities...`
|
||||
});
|
||||
|
||||
let scanBlocked = false;
|
||||
let blockReason = '';
|
||||
let finalScanResult: ScanResult | undefined;
|
||||
let individualScannerResults: ScannerResult[] = [];
|
||||
|
||||
try {
|
||||
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
|
||||
if (progress.output || progress.message) {
|
||||
safeEnqueue({
|
||||
type: 'scan_log',
|
||||
containerId,
|
||||
containerName,
|
||||
scanner: progress.scanner,
|
||||
message: progress.output || progress.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (scanResults.length > 0) {
|
||||
const scanSummary = combineScanSummaries(scanResults);
|
||||
finalScanResult = {
|
||||
critical: scanSummary.critical,
|
||||
high: scanSummary.high,
|
||||
medium: scanSummary.medium,
|
||||
low: scanSummary.low,
|
||||
negligible: scanSummary.negligible,
|
||||
unknown: scanSummary.unknown
|
||||
};
|
||||
|
||||
// Build individual scanner results
|
||||
individualScannerResults = scanResults.map(result => ({
|
||||
scanner: result.scanner as 'grype' | 'trivy',
|
||||
critical: result.summary.critical,
|
||||
high: result.summary.high,
|
||||
medium: result.summary.medium,
|
||||
low: result.summary.low,
|
||||
negligible: result.summary.negligible,
|
||||
unknown: result.summary.unknown
|
||||
}));
|
||||
|
||||
// Save scan results
|
||||
for (const result of scanResults) {
|
||||
try {
|
||||
await saveVulnerabilityScan({
|
||||
environmentId: envIdNum,
|
||||
imageId: newImageId,
|
||||
imageName: result.imageName,
|
||||
scanner: result.scanner,
|
||||
scannedAt: result.scannedAt,
|
||||
scanDuration: result.scanDuration,
|
||||
criticalCount: result.summary.critical,
|
||||
highCount: result.summary.high,
|
||||
mediumCount: result.summary.medium,
|
||||
lowCount: result.summary.low,
|
||||
negligibleCount: result.summary.negligible,
|
||||
unknownCount: result.summary.unknown,
|
||||
vulnerabilities: result.vulnerabilities,
|
||||
error: result.error ?? null
|
||||
});
|
||||
} catch { /* ignore save errors */ }
|
||||
}
|
||||
|
||||
// Check if blocked
|
||||
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
|
||||
if (blocked) {
|
||||
scanBlocked = true;
|
||||
blockReason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect vulnerabilities from all scanners (cap at 100)
|
||||
const vulnerabilities = scanResults
|
||||
.flatMap(r => r.vulnerabilities || [])
|
||||
.slice(0, 100)
|
||||
.map(v => ({
|
||||
id: v.id,
|
||||
severity: v.severity,
|
||||
package: v.package,
|
||||
version: v.version,
|
||||
fixedVersion: v.fixedVersion,
|
||||
link: v.link,
|
||||
scanner: v.scanner
|
||||
}));
|
||||
|
||||
safeEnqueue({
|
||||
type: 'scan_complete',
|
||||
containerId,
|
||||
containerName,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
|
||||
message: finalScanResult
|
||||
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
|
||||
: 'Scan complete: no vulnerabilities found'
|
||||
});
|
||||
|
||||
} catch (scanErr: any) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: `Scan failed: ${scanErr.message}`
|
||||
});
|
||||
|
||||
// Clean up temp image on scan failure
|
||||
try {
|
||||
await removeTempImage(newImageId, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scanBlocked) {
|
||||
// BLOCKED - Remove temp image and skip this container
|
||||
safeEnqueue({
|
||||
type: 'blocked',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'blocked',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
blockReason,
|
||||
message: `Update blocked: ${blockReason}`
|
||||
});
|
||||
|
||||
try {
|
||||
await removeTempImage(newImageId, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
blockedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// APPROVED - Re-tag to original
|
||||
await tagImage(newImageId, oldRepo, oldTag, envIdNum);
|
||||
try {
|
||||
await removeTempImage(tempTag, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
|
||||
// Progress logging function for shared functions
|
||||
const logProgress = (message: string) => {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message
|
||||
});
|
||||
};
|
||||
|
||||
let updateSuccess = false;
|
||||
let newContainerId = containerId;
|
||||
|
||||
safeEnqueue({
|
||||
// Skip Dockhand container - cannot update itself
|
||||
if (isDockhandContainer(imageName)) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Recreating ${containerName}...`
|
||||
});
|
||||
|
||||
updateSuccess = await recreateContainer(containerName, envIdNum, logProgress, imageName);
|
||||
if (updateSuccess) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
if (updatedContainer) {
|
||||
newContainerId = updatedContainer.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateSuccess) {
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Container recreation failed'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
|
||||
|
||||
// Done with this container - use original containerId for UI consistency
|
||||
safeEnqueue({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'done',
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `${containerName} updated successfully`
|
||||
message: `Skipping ${containerName} - cannot update Dockhand itself`
|
||||
});
|
||||
successCount++;
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clear pending update indicator from database
|
||||
if (envIdNum) {
|
||||
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
|
||||
// Ignore errors - record may not exist
|
||||
});
|
||||
}
|
||||
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||
if (isDigestBasedImage(imageName)) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'skipped',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `Skipping ${containerName} - image pinned to specific digest`
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
safeEnqueue({
|
||||
// Step 1: Pull latest image
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'pulling',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Pulling ${imageName}...`
|
||||
});
|
||||
|
||||
try {
|
||||
await pullImage(imageName, (data: any) => {
|
||||
if (data.status) {
|
||||
sendData({
|
||||
type: 'pull_log',
|
||||
containerId,
|
||||
containerName,
|
||||
pullStatus: data.status,
|
||||
pullId: data.id,
|
||||
pullProgress: data.progress
|
||||
});
|
||||
}
|
||||
}, envIdNum);
|
||||
} catch (pullError: any) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
@@ -520,53 +221,310 @@ export const POST: RequestHandler = async (event) => {
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: error.message
|
||||
error: `Pull failed: ${pullError.message}`
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Send complete event
|
||||
safeEnqueue({
|
||||
type: 'complete',
|
||||
summary: {
|
||||
// SAFE-PULL FLOW with vulnerability scanning
|
||||
if (shouldScan && !isDigestBasedImage(imageName)) {
|
||||
const tempTag = getTempImageTag(imageName);
|
||||
|
||||
// Get new image ID
|
||||
const newImageId = await getImageIdByTag(imageName, envIdNum);
|
||||
if (!newImageId) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: 'Failed to get new image ID after pull'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Restore original tag to old image (safety)
|
||||
const [oldRepo, oldTag] = parseImageNameAndTag(imageName);
|
||||
try {
|
||||
await tagImage(currentImageId, oldRepo, oldTag, envIdNum);
|
||||
} catch {
|
||||
// Ignore - old image might have been removed
|
||||
}
|
||||
|
||||
// Tag new image with temp suffix
|
||||
const [tempRepo, tempTagName] = parseImageNameAndTag(tempTag);
|
||||
await tagImage(newImageId, tempRepo, tempTagName, envIdNum);
|
||||
|
||||
// Step 2: Scan temp image
|
||||
sendData({
|
||||
type: 'scan_start',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'scanning',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message: `Scanning ${imageName} for vulnerabilities...`
|
||||
});
|
||||
|
||||
let scanBlocked = false;
|
||||
let blockReason = '';
|
||||
let finalScanResult: ScanResult | undefined;
|
||||
let individualScannerResults: ScannerResult[] = [];
|
||||
|
||||
try {
|
||||
const scanResults = await scanImage(tempTag, envIdNum, (progress) => {
|
||||
if (progress.output || progress.message) {
|
||||
sendData({
|
||||
type: 'scan_log',
|
||||
containerId,
|
||||
containerName,
|
||||
scanner: progress.scanner,
|
||||
message: progress.output || progress.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (scanResults.length > 0) {
|
||||
const scanSummary = combineScanSummaries(scanResults);
|
||||
finalScanResult = {
|
||||
critical: scanSummary.critical,
|
||||
high: scanSummary.high,
|
||||
medium: scanSummary.medium,
|
||||
low: scanSummary.low,
|
||||
negligible: scanSummary.negligible,
|
||||
unknown: scanSummary.unknown
|
||||
};
|
||||
|
||||
// Build individual scanner results
|
||||
individualScannerResults = scanResults.map(result => ({
|
||||
scanner: result.scanner as 'grype' | 'trivy',
|
||||
critical: result.summary.critical,
|
||||
high: result.summary.high,
|
||||
medium: result.summary.medium,
|
||||
low: result.summary.low,
|
||||
negligible: result.summary.negligible,
|
||||
unknown: result.summary.unknown
|
||||
}));
|
||||
|
||||
// Save scan results
|
||||
for (const result of scanResults) {
|
||||
try {
|
||||
await saveVulnerabilityScan({
|
||||
environmentId: envIdNum,
|
||||
imageId: newImageId,
|
||||
imageName: result.imageName,
|
||||
scanner: result.scanner,
|
||||
scannedAt: result.scannedAt,
|
||||
scanDuration: result.scanDuration,
|
||||
criticalCount: result.summary.critical,
|
||||
highCount: result.summary.high,
|
||||
mediumCount: result.summary.medium,
|
||||
lowCount: result.summary.low,
|
||||
negligibleCount: result.summary.negligible,
|
||||
unknownCount: result.summary.unknown,
|
||||
vulnerabilities: result.vulnerabilities,
|
||||
error: result.error ?? null
|
||||
});
|
||||
} catch { /* ignore save errors */ }
|
||||
}
|
||||
|
||||
// Check if blocked
|
||||
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
|
||||
if (blocked) {
|
||||
scanBlocked = true;
|
||||
blockReason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect vulnerabilities from all scanners (cap at 100)
|
||||
const vulnerabilities = scanResults
|
||||
.flatMap(r => r.vulnerabilities || [])
|
||||
.slice(0, 100)
|
||||
.map(v => ({
|
||||
id: v.id,
|
||||
severity: v.severity,
|
||||
package: v.package,
|
||||
version: v.version,
|
||||
fixedVersion: v.fixedVersion,
|
||||
link: v.link,
|
||||
scanner: v.scanner
|
||||
}));
|
||||
|
||||
sendData({
|
||||
type: 'scan_complete',
|
||||
containerId,
|
||||
containerName,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
|
||||
message: finalScanResult
|
||||
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
|
||||
: 'Scan complete: no vulnerabilities found'
|
||||
});
|
||||
|
||||
} catch (scanErr: any) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: `Scan failed: ${scanErr.message}`
|
||||
});
|
||||
|
||||
// Clean up temp image on scan failure
|
||||
try {
|
||||
await removeTempImage(newImageId, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scanBlocked) {
|
||||
// BLOCKED - Remove temp image and skip this container
|
||||
sendData({
|
||||
type: 'blocked',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'blocked',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
scanResult: finalScanResult,
|
||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||
blockReason,
|
||||
message: `Update blocked: ${blockReason}`
|
||||
});
|
||||
|
||||
try {
|
||||
await removeTempImage(newImageId, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
|
||||
blockedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// APPROVED - Re-tag to original
|
||||
await tagImage(newImageId, oldRepo, oldTag, envIdNum);
|
||||
try {
|
||||
await removeTempImage(tempTag, envIdNum);
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
|
||||
// Progress logging function for shared functions
|
||||
const logProgress = (message: string) => {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
message
|
||||
});
|
||||
};
|
||||
|
||||
let newContainerId = containerId;
|
||||
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'creating',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: successCount,
|
||||
failed: failCount,
|
||||
blocked: blockedCount,
|
||||
skipped: skippedCount
|
||||
},
|
||||
message: skippedCount > 0 || blockedCount > 0
|
||||
? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
|
||||
: `Updated ${successCount} of ${containerIds.length} containers`
|
||||
});
|
||||
message: `Recreating ${containerName}...`
|
||||
});
|
||||
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
}
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
controllerClosed = true;
|
||||
} catch {
|
||||
// Controller already closed - ignore
|
||||
controllerClosed = true;
|
||||
const recreateResult = await recreateContainer(containerName, envIdNum, logProgress, imageName);
|
||||
if (recreateResult.success) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
if (updatedContainer) {
|
||||
newContainerId = updatedContainer.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
|
||||
if (!recreateResult.success) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: recreateResult.error || 'Container recreation failed'
|
||||
});
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await auditContainer(event, 'update', newContainerId, containerName, envIdNum, { batchUpdate: true });
|
||||
|
||||
// Done with this container - use original containerId for UI consistency
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'done',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: true,
|
||||
message: `${containerName} updated successfully`
|
||||
});
|
||||
successCount++;
|
||||
|
||||
// Clear pending update indicator from database
|
||||
if (envIdNum) {
|
||||
await removePendingContainerUpdate(envIdNum, containerId).catch(() => {
|
||||
// Ignore errors - record may not exist
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
sendData({
|
||||
type: 'progress',
|
||||
containerId,
|
||||
containerName,
|
||||
step: 'failed',
|
||||
current: i + 1,
|
||||
total: containerIds.length,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Send complete event
|
||||
const completeData: UpdateProgress = {
|
||||
type: 'complete',
|
||||
summary: {
|
||||
total: containerIds.length,
|
||||
success: successCount,
|
||||
failed: failCount,
|
||||
blocked: blockedCount,
|
||||
skipped: skippedCount
|
||||
},
|
||||
message: skippedCount > 0 || blockedCount > 0
|
||||
? `Updated ${successCount} of ${containerIds.length} containers${blockedCount > 0 ? ` (${blockedCount} blocked)` : ''}${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`
|
||||
: `Updated ${successCount} of ${containerIds.length} containers`
|
||||
};
|
||||
sendData(completeData);
|
||||
completeJob(job, completeData);
|
||||
})().catch((err) => {
|
||||
failJob(job, err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
return json({ jobId: job.id });
|
||||
};
|
||||
@@ -75,11 +75,10 @@ export const POST: RequestHandler = async (event) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
let updateSuccess = false;
|
||||
let newContainerId = containerId;
|
||||
|
||||
updateSuccess = await recreateContainer(containerName, envIdNum);
|
||||
if (updateSuccess) {
|
||||
const recreateResult = await recreateContainer(containerName, envIdNum);
|
||||
if (recreateResult.success) {
|
||||
const updatedContainers = await listContainers(true, envIdNum);
|
||||
const updatedContainer = updatedContainers.find(c => c.name === containerName);
|
||||
if (updatedContainer) {
|
||||
@@ -87,12 +86,12 @@ export const POST: RequestHandler = async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateSuccess) {
|
||||
if (!recreateResult.success) {
|
||||
results.push({
|
||||
containerId,
|
||||
containerName,
|
||||
success: false,
|
||||
error: 'Container recreation failed'
|
||||
error: recreateResult.error || 'Container recreation failed'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
// Get all running containers with timeout
|
||||
const containers = await withTimeout(
|
||||
listContainers(true, envIdNum),
|
||||
5000, // 5 second timeout
|
||||
10000, // 10 second timeout
|
||||
[]
|
||||
);
|
||||
const runningContainers = containers.filter(c => c.state === 'running');
|
||||
@@ -127,7 +127,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
try {
|
||||
const stats = await withTimeout(
|
||||
getContainerStats(container.id, envIdNum) as Promise<any>,
|
||||
3000, // 3 second timeout per container
|
||||
8000, // 8 second timeout per container (TLS proxy + Docker CPU sampling needs ~2s)
|
||||
null
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { listContainers, getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { hasEnvironments } from '$lib/server/db';
|
||||
import type { ContainerStats } from '$lib/types';
|
||||
|
||||
function calculateCpuPercent(stats: any): number {
|
||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
||||
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
|
||||
const cpuCount = stats.cpu_stats.online_cpus || stats.cpu_stats.cpu_usage.percpu_usage?.length || 1;
|
||||
|
||||
if (systemDelta > 0 && cpuDelta > 0) {
|
||||
return (cpuDelta / systemDelta) * cpuCount * 100;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function calculateNetworkIO(stats: any): { rx: number; tx: number } {
|
||||
let rx = 0;
|
||||
let tx = 0;
|
||||
|
||||
if (stats.networks) {
|
||||
for (const iface of Object.values(stats.networks) as any[]) {
|
||||
rx += iface.rx_bytes || 0;
|
||||
tx += iface.tx_bytes || 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { rx, tx };
|
||||
}
|
||||
|
||||
function calculateBlockIO(stats: any): { read: number; write: number } {
|
||||
let read = 0;
|
||||
let write = 0;
|
||||
|
||||
const ioStats = stats.blkio_stats?.io_service_bytes_recursive;
|
||||
if (Array.isArray(ioStats)) {
|
||||
for (const entry of ioStats) {
|
||||
if (entry.op === 'read' || entry.op === 'Read') {
|
||||
read += entry.value || 0;
|
||||
} else if (entry.op === 'write' || entry.op === 'Write') {
|
||||
write += entry.value || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { read, write };
|
||||
}
|
||||
|
||||
function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; cache: number } {
|
||||
const raw = memoryStats?.usage || 0;
|
||||
const stats = memoryStats?.stats || {};
|
||||
const cache = stats.inactive_file ?? stats.total_inactive_file ?? 0;
|
||||
const usage = (cache > 0 && cache < raw) ? raw - cache : raw;
|
||||
return { usage, raw, cache };
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<T>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(fallback), ms);
|
||||
});
|
||||
return Promise.race([promise, timeoutPromise]).finally(() => {
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
});
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId) : undefined;
|
||||
|
||||
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
|
||||
return new Response(JSON.stringify({ error: 'Permission denied' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (!await hasEnvironments() || !envIdNum) {
|
||||
return new Response('event: done\ndata: {}\n\n', {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let controllerClosed = false;
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const safeEnqueue = (data: string) => {
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(data));
|
||||
} catch {
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const containers = await withTimeout(
|
||||
listContainers(true, envIdNum),
|
||||
10000,
|
||||
[]
|
||||
);
|
||||
const runningContainers = containers.filter(c => c.state === 'running');
|
||||
|
||||
const statsPromises = runningContainers.map(async (container) => {
|
||||
try {
|
||||
const stats = await withTimeout(
|
||||
getContainerStats(container.id, envIdNum) as Promise<any>,
|
||||
8000,
|
||||
null
|
||||
);
|
||||
|
||||
if (!stats) return;
|
||||
|
||||
const cpuPercent = calculateCpuPercent(stats);
|
||||
const memory = calculateMemoryUsage(stats.memory_stats);
|
||||
const memoryLimit = stats.memory_stats?.limit || 1;
|
||||
const memoryPercent = (memory.usage / memoryLimit) * 100;
|
||||
const networkIO = calculateNetworkIO(stats);
|
||||
const blockIO = calculateBlockIO(stats);
|
||||
|
||||
const stat: ContainerStats = {
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
||||
memoryUsage: memory.usage,
|
||||
memoryRaw: memory.raw,
|
||||
memoryCache: memory.cache,
|
||||
memoryLimit,
|
||||
memoryPercent: Math.round(memoryPercent * 100) / 100,
|
||||
networkRx: networkIO.rx,
|
||||
networkTx: networkIO.tx,
|
||||
blockRead: blockIO.read,
|
||||
blockWrite: blockIO.write
|
||||
};
|
||||
|
||||
safeEnqueue(`event: stat\ndata: ${JSON.stringify(stat)}\n\n`);
|
||||
} catch {
|
||||
// Skip failed containers silently
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(statsPromises);
|
||||
} catch (error: any) {
|
||||
if (error instanceof EnvironmentNotFoundError) {
|
||||
safeEnqueue(`event: error\ndata: ${JSON.stringify({ error: 'Environment not found' })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!controllerClosed) {
|
||||
safeEnqueue(`event: done\ndata: {}\n\n`);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user