1.0.12
This commit is contained in:
1 parent
a3cc26d958
commit
86a06d9de0
32 files changed
+799
-522
No files matched your search
@@ -12,9 +12,11 @@ import {
|
||||
isAuthEnabled
|
||||
} from '$lib/server/auth';
|
||||
import { getUser, getUserByUsername } from '$lib/server/db';
|
||||
import { auditAuth } from '$lib/server/audit';
|
||||
|
||||
// POST /api/auth/login - Authenticate user
|
||||
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { request, cookies, getClientAddress } = event;
|
||||
// Check if auth is enabled
|
||||
if (!(await isAuthEnabled())) {
|
||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
||||
@@ -80,6 +82,12 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
||||
const session = await createUserSession(user.id, authProviderType, cookies);
|
||||
clearRateLimit(rateLimitKey);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'login', user.username, {
|
||||
provider: authProviderType,
|
||||
mfa: true
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: {
|
||||
@@ -97,6 +105,11 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress
|
||||
const session = await createUserSession(result.user.id, authProviderType, cookies);
|
||||
clearRateLimit(rateLimitKey);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'login', result.user.username, {
|
||||
provider: authProviderType
|
||||
});
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
user: {
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { destroySession } from '$lib/server/auth';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditAuth } from '$lib/server/audit';
|
||||
|
||||
// POST /api/auth/logout - End session
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const { cookies } = event;
|
||||
try {
|
||||
// Get current user before destroying session for audit log
|
||||
const auth = await authorize(cookies);
|
||||
const username = auth.user?.username || 'unknown';
|
||||
|
||||
await destroySession(cookies);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'logout', username);
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { json, redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
|
||||
import { auditAuth } from '$lib/server/audit';
|
||||
|
||||
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const { url, cookies } = event;
|
||||
// Check if auth is enabled
|
||||
if (!isAuthEnabled()) {
|
||||
throw redirect(302, '/login?error=auth_disabled');
|
||||
@@ -38,6 +40,13 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
// Create session
|
||||
await createUserSession(result.user.id, 'oidc', cookies);
|
||||
|
||||
// Audit log
|
||||
await auditAuth(event, 'login', result.user.username, {
|
||||
provider: 'oidc',
|
||||
providerId: result.providerId,
|
||||
providerName: result.providerName
|
||||
});
|
||||
|
||||
// Redirect to the original destination or home
|
||||
const redirectUrl = result.redirectUrl || '/';
|
||||
throw redirect(302, redirectUrl);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
downStack,
|
||||
removeStack
|
||||
} from '$lib/server/stacks';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
|
||||
// SSE Event types
|
||||
@@ -375,6 +375,15 @@ async function executeContainerOperation(
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
// Clean up pending container update if exists
|
||||
try {
|
||||
if (envIdNum) {
|
||||
await removePendingContainerUpdate(envIdNum, id);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported container operation: ${operation}`);
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
removeContainer,
|
||||
getContainerLogs
|
||||
} from '$lib/server/docker';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting } from '$lib/server/db';
|
||||
import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerUpdate } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { auditContainer } from '$lib/server/audit';
|
||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||
@@ -85,6 +85,16 @@ export const DELETE: RequestHandler = async (event) => {
|
||||
// Don't fail the deletion if schedule cleanup fails
|
||||
}
|
||||
|
||||
// Clean up pending container update if exists
|
||||
try {
|
||||
if (envIdNum) {
|
||||
await removePendingContainerUpdate(envIdNum, params.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup pending container update:', error);
|
||||
// Don't fail the deletion if cleanup fails
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing container:', error);
|
||||
|
||||
@@ -182,6 +182,22 @@ export const POST: RequestHandler = async (event) => {
|
||||
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({
|
||||
type: 'progress',
|
||||
@@ -559,8 +575,18 @@ export const POST: RequestHandler = async (event) => {
|
||||
: `Updated ${successCount} of ${containerIds.length} containers`
|
||||
});
|
||||
|
||||
clearInterval(keepaliveInterval);
|
||||
controller.close();
|
||||
if (keepaliveInterval) {
|
||||
clearInterval(keepaliveInterval);
|
||||
}
|
||||
if (!controllerClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
controllerClosed = true;
|
||||
} catch {
|
||||
// Controller already closed - ignore
|
||||
controllerClosed = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
controllerClosed = true;
|
||||
|
||||
@@ -20,6 +20,9 @@ import { listComposeStacks } from '$lib/server/stacks';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
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';
|
||||
|
||||
// Helper to add timeout to promises
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return Promise.race([
|
||||
@@ -200,13 +203,14 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
|
||||
envStats.online = true;
|
||||
|
||||
// Fetch all data in parallel (with 10 second timeout per operation)
|
||||
// Disk usage can be disabled with SKIP_DF_COLLECTION for Synology NAS devices
|
||||
const [containers, images, volumes, networks, stacks, diskUsage] = await Promise.all([
|
||||
withTimeout(listContainers(true, env.id).catch(() => []), 10000, []),
|
||||
withTimeout(listImages(env.id).catch(() => []), 10000, []),
|
||||
withTimeout(listVolumes(env.id).catch(() => []), 10000, []),
|
||||
withTimeout(listNetworks(env.id).catch(() => []), 10000, []),
|
||||
withTimeout(listComposeStacks(env.id).catch(() => []), 10000, []),
|
||||
withTimeout(getDiskUsage(env.id).catch(() => null), 10000, null)
|
||||
SKIP_DF_COLLECTION ? Promise.resolve(null) : withTimeout(getDiskUsage(env.id).catch(() => null), 10000, null)
|
||||
]);
|
||||
|
||||
// Process containers
|
||||
|
||||
@@ -21,6 +21,9 @@ import { authorize } from '$lib/server/authorize';
|
||||
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';
|
||||
|
||||
// Helper to add timeout to promises
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
||||
return Promise.race([
|
||||
@@ -31,6 +34,7 @@ function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T
|
||||
|
||||
// Disk usage cache - getDiskUsage() is very slow (30s timeout) but data changes rarely
|
||||
// Cache per environment with 5-minute TTL
|
||||
// DISABLED when SKIP_DF_COLLECTION is set (kills Synology NAS devices)
|
||||
interface DiskUsageCache {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
@@ -344,37 +348,49 @@ async function getEnvironmentStatsProgressive(
|
||||
});
|
||||
|
||||
// PHASE 3: Disk usage (slow - includes volumes) - uses cache for better performance
|
||||
const diskUsagePromise = getCachedDiskUsage(env.id)
|
||||
.then((diskUsage) => {
|
||||
if (diskUsage) {
|
||||
// Update images with disk usage data (more accurate)
|
||||
envStats.images.total = diskUsage.Images?.length || envStats.images.total;
|
||||
envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || envStats.images.totalSize;
|
||||
|
||||
// Volumes from disk usage
|
||||
envStats.volumes.total = diskUsage.Volumes?.length || 0;
|
||||
envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0;
|
||||
|
||||
// Containers disk size
|
||||
envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0;
|
||||
|
||||
// Build cache
|
||||
envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0;
|
||||
}
|
||||
// Can be disabled with SKIP_DF_COLLECTION env var for Synology NAS
|
||||
const diskUsagePromise = SKIP_DF_COLLECTION
|
||||
? Promise.resolve(null).then(() => {
|
||||
envStats.loading!.volumes = false;
|
||||
envStats.loading!.diskUsage = false;
|
||||
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
images: { ...envStats.images },
|
||||
volumes: { ...envStats.volumes },
|
||||
containersSize: envStats.containersSize,
|
||||
buildCacheSize: envStats.buildCacheSize,
|
||||
loading: { ...envStats.loading! }
|
||||
});
|
||||
return null;
|
||||
})
|
||||
: getCachedDiskUsage(env.id)
|
||||
.then((diskUsage) => {
|
||||
if (diskUsage) {
|
||||
// Update images with disk usage data (more accurate)
|
||||
envStats.images.total = diskUsage.Images?.length || envStats.images.total;
|
||||
envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || envStats.images.totalSize;
|
||||
|
||||
return diskUsage;
|
||||
});
|
||||
// Volumes from disk usage
|
||||
envStats.volumes.total = diskUsage.Volumes?.length || 0;
|
||||
envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0;
|
||||
|
||||
// Containers disk size
|
||||
envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0;
|
||||
|
||||
// Build cache
|
||||
envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0;
|
||||
}
|
||||
envStats.loading!.volumes = false;
|
||||
envStats.loading!.diskUsage = false;
|
||||
|
||||
onPartialUpdate({
|
||||
id: env.id,
|
||||
images: { ...envStats.images },
|
||||
volumes: { ...envStats.volumes },
|
||||
containersSize: envStats.containersSize,
|
||||
buildCacheSize: envStats.buildCacheSize,
|
||||
loading: { ...envStats.loading! }
|
||||
});
|
||||
|
||||
return diskUsage;
|
||||
});
|
||||
|
||||
// PHASE 4: Top containers (slow - requires per-container stats)
|
||||
// Limited to TOP_CONTAINERS_LIMIT containers to reduce API calls
|
||||
|
||||
@@ -36,11 +36,30 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
let controllerClosed = false;
|
||||
|
||||
// Safe close helper - prevents "Controller is already closed" errors
|
||||
const safeClose = () => {
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
controller.close();
|
||||
controllerClosed = true;
|
||||
} catch {
|
||||
// Controller already closed - ignore
|
||||
controllerClosed = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Send initial connection event
|
||||
const sendEvent = (type: string, data: any) => {
|
||||
const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(event));
|
||||
if (controllerClosed) return;
|
||||
try {
|
||||
const event = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(event));
|
||||
} catch {
|
||||
// Controller closed or errored - mark as closed
|
||||
controllerClosed = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Send heartbeat to keep connection alive (every 5s to prevent Traefik 10s idle timeout)
|
||||
@@ -64,7 +83,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
if (!eventStream) {
|
||||
sendEvent('error', { message: 'Failed to connect to Docker events' });
|
||||
clearInterval(heartbeatInterval);
|
||||
controller.close();
|
||||
safeClose();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,11 +127,17 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Docker event stream error:', error);
|
||||
sendEvent('error', { message: error.message });
|
||||
// Don't log full stack trace for expected connection errors
|
||||
const isConnectionError = error?.code === 'ECONNRESET' || error?.code === 'ECONNREFUSED';
|
||||
if (isConnectionError) {
|
||||
// Silent - these are handled by event-subprocess reconnection logic
|
||||
} else {
|
||||
console.error('Docker event stream error:', error?.message || error);
|
||||
}
|
||||
sendEvent('error', { message: error?.message || 'Stream connection lost' });
|
||||
} finally {
|
||||
clearInterval(heartbeatInterval);
|
||||
controller.close();
|
||||
safeClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,11 +147,15 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
// Expected error when environment doesn't exist - don't spam logs
|
||||
sendEvent('error', { message: 'Environment not found' });
|
||||
} else {
|
||||
console.error('Failed to connect to Docker events:', error);
|
||||
sendEvent('error', { message: error.message || 'Failed to connect to Docker' });
|
||||
// Don't log full stack trace for expected connection errors
|
||||
const isConnectionError = error?.code === 'ECONNRESET' || error?.code === 'ECONNREFUSED';
|
||||
if (!isConnectionError) {
|
||||
console.error('Failed to connect to Docker events:', error?.message || error);
|
||||
}
|
||||
sendEvent('error', { message: error?.message || 'Failed to connect to Docker' });
|
||||
}
|
||||
clearInterval(heartbeatInterval);
|
||||
controller.close();
|
||||
safeClose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName } from '$lib/server/db';
|
||||
import { getGitStack, updateGitStack, deleteGitStack, deleteStackSource, updateStackSourceName, updateStackEnvVarsName } from '$lib/server/db';
|
||||
import { deleteGitStackFiles, deployGitStack } from '$lib/server/git';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
|
||||
@@ -71,9 +71,10 @@ export const PUT: RequestHandler = async ({ params, request, cookies }) => {
|
||||
webhookSecret: data.webhookSecret
|
||||
});
|
||||
|
||||
// If stack name changed, update the stack_sources record too
|
||||
// If stack name changed, update related records
|
||||
if (data.stackName && data.stackName !== oldStackName) {
|
||||
await updateStackSourceName(oldStackName, data.stackName, existing.environmentId);
|
||||
await updateStackEnvVarsName(oldStackName, data.stackName, existing.environmentId);
|
||||
}
|
||||
|
||||
// Register or unregister schedule with croner
|
||||
|
||||
@@ -3,6 +3,9 @@ import { getDiskUsage } from '$lib/server/docker';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
// Skip disk usage collection (Synology NAS performance fix)
|
||||
const SKIP_DF_COLLECTION = process.env.SKIP_DF_COLLECTION === 'true' || process.env.SKIP_DF_COLLECTION === '1';
|
||||
|
||||
const DISK_USAGE_TIMEOUT = 15000; // 15 second timeout
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
@@ -23,6 +26,11 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
|
||||
// Skip disk usage when disabled (Synology NAS performance fix)
|
||||
if (SKIP_DF_COLLECTION) {
|
||||
return json({ diskUsage: null });
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch disk usage with timeout
|
||||
const diskUsagePromise = getDiskUsage(envId);
|
||||
|
||||
Reference in new issue
Block a user