mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-03 13:18:56 +00:00
532 lines
18 KiB
TypeScript
532 lines
18 KiB
TypeScript
import type { RequestHandler } from '@sveltejs/kit';
|
|
import {
|
|
getEnvironments,
|
|
getLatestHostMetrics,
|
|
getHostMetrics,
|
|
getContainerEventStats,
|
|
getContainerEvents,
|
|
getEnvSetting,
|
|
getEnvUpdateCheckSettings
|
|
} from '$lib/server/db';
|
|
import {
|
|
listContainers,
|
|
listImages,
|
|
listNetworks,
|
|
getDockerInfo,
|
|
getContainerStats,
|
|
getDiskUsage
|
|
} from '$lib/server/docker';
|
|
import { listComposeStacks } from '$lib/server/stacks';
|
|
import { authorize } from '$lib/server/authorize';
|
|
import type { EnvironmentStats } from '../+server';
|
|
import { parseLabels } from '$lib/utils/label-colors';
|
|
|
|
// Helper to add timeout to promises
|
|
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
|
|
return Promise.race([
|
|
promise,
|
|
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms))
|
|
]);
|
|
}
|
|
|
|
// Disk usage cache - getDiskUsage() is very slow (30s timeout) but data changes rarely
|
|
// Cache per environment with 5-minute TTL
|
|
interface DiskUsageCache {
|
|
data: any;
|
|
timestamp: number;
|
|
}
|
|
const diskUsageCache: Map<number, DiskUsageCache> = new Map();
|
|
const DISK_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
const MAX_CACHE_SIZE = 100; // Maximum environments to cache
|
|
|
|
// Cleanup expired cache entries periodically to prevent unbounded growth
|
|
// Also limits cache size for environments that were deleted
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
// Remove expired entries
|
|
for (const [envId, cached] of diskUsageCache.entries()) {
|
|
if (now - cached.timestamp > DISK_CACHE_TTL_MS * 2) {
|
|
diskUsageCache.delete(envId);
|
|
}
|
|
}
|
|
// Enforce max size by removing oldest entries
|
|
if (diskUsageCache.size > MAX_CACHE_SIZE) {
|
|
const entries = Array.from(diskUsageCache.entries())
|
|
.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
|
const toRemove = entries.slice(0, entries.length - MAX_CACHE_SIZE);
|
|
for (const [envId] of toRemove) {
|
|
diskUsageCache.delete(envId);
|
|
}
|
|
}
|
|
}, 10 * 60 * 1000); // Every 10 minutes
|
|
|
|
async function getCachedDiskUsage(envId: number): Promise<any> {
|
|
const cached = diskUsageCache.get(envId);
|
|
const now = Date.now();
|
|
|
|
// Return cached data if still valid
|
|
if (cached && (now - cached.timestamp) < DISK_CACHE_TTL_MS) {
|
|
return cached.data;
|
|
}
|
|
|
|
// Fetch fresh data with timeout
|
|
const data = await withTimeout(getDiskUsage(envId).catch(() => null), 30000, null);
|
|
|
|
// Only cache successful results - if fetch failed, retry on next request
|
|
if (data !== null) {
|
|
diskUsageCache.set(envId, { data, timestamp: now });
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
// Limit for per-container stats (reduced from 15 to improve performance)
|
|
const TOP_CONTAINERS_LIMIT = 8;
|
|
|
|
// Calculate CPU percentage from Docker stats (same logic as container stats endpoint)
|
|
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;
|
|
}
|
|
|
|
// Progressive stats loading - returns stats object and emits partial updates via callback
|
|
async function getEnvironmentStatsProgressive(
|
|
env: any,
|
|
onPartialUpdate: (stats: Partial<EnvironmentStats> & { id: number }) => void
|
|
): Promise<EnvironmentStats> {
|
|
const envStats: EnvironmentStats = {
|
|
id: env.id,
|
|
name: env.name,
|
|
host: env.host ?? undefined,
|
|
port: env.port ?? undefined,
|
|
icon: env.icon || 'globe',
|
|
socketPath: env.socketPath ?? undefined,
|
|
collectActivity: env.collectActivity,
|
|
collectMetrics: env.collectMetrics ?? true,
|
|
scannerEnabled: false,
|
|
updateCheckEnabled: false,
|
|
updateCheckAutoUpdate: false,
|
|
labels: parseLabels(env.labels),
|
|
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
|
|
online: false,
|
|
containers: { total: 0, running: 0, stopped: 0, paused: 0, restarting: 0, unhealthy: 0 },
|
|
images: { total: 0, totalSize: 0 },
|
|
volumes: { total: 0, totalSize: 0 },
|
|
containersSize: 0,
|
|
buildCacheSize: 0,
|
|
networks: { total: 0 },
|
|
stacks: { total: 0, running: 0, partial: 0, stopped: 0 },
|
|
metrics: null,
|
|
events: { total: 0, today: 0 },
|
|
topContainers: [],
|
|
recentEvents: [],
|
|
// Loading states for progressive display
|
|
loading: {
|
|
containers: true,
|
|
images: true,
|
|
volumes: true,
|
|
networks: true,
|
|
stacks: true,
|
|
diskUsage: true,
|
|
topContainers: true
|
|
}
|
|
};
|
|
|
|
try {
|
|
// Check scanner settings - scanner type is stored in 'vulnerability_scanner'
|
|
const scannerType = await getEnvSetting('vulnerability_scanner', env.id);
|
|
envStats.scannerEnabled = scannerType && scannerType !== 'none';
|
|
|
|
// Check update check settings
|
|
const updateCheckSettings = await getEnvUpdateCheckSettings(env.id);
|
|
if (updateCheckSettings && updateCheckSettings.enabled) {
|
|
envStats.updateCheckEnabled = true;
|
|
envStats.updateCheckAutoUpdate = updateCheckSettings.autoUpdate;
|
|
}
|
|
|
|
// Check if Docker is accessible (with 5 second timeout)
|
|
const dockerInfo = await withTimeout(getDockerInfo(env.id), 5000, null);
|
|
if (!dockerInfo) {
|
|
envStats.error = 'Connection timeout or Docker not accessible';
|
|
envStats.loading = undefined; // Clear loading states on error
|
|
// Send offline status to client
|
|
onPartialUpdate({
|
|
id: env.id,
|
|
online: false,
|
|
error: envStats.error,
|
|
loading: undefined
|
|
});
|
|
return envStats;
|
|
}
|
|
envStats.online = true;
|
|
|
|
// Get all database stats in parallel for better performance
|
|
const [latestMetrics, eventStats, recentEventsResult, metricsHistory] = await Promise.all([
|
|
getLatestHostMetrics(env.id),
|
|
getContainerEventStats(env.id),
|
|
getContainerEvents({ environmentId: env.id, limit: 10 }),
|
|
getHostMetrics(30, env.id)
|
|
]);
|
|
|
|
if (latestMetrics) {
|
|
envStats.metrics = {
|
|
cpuPercent: latestMetrics.cpuPercent,
|
|
memoryPercent: latestMetrics.memoryPercent,
|
|
memoryUsed: latestMetrics.memoryUsed,
|
|
memoryTotal: latestMetrics.memoryTotal
|
|
};
|
|
}
|
|
|
|
envStats.events = {
|
|
total: eventStats.total,
|
|
today: eventStats.today
|
|
};
|
|
|
|
if (recentEventsResult.events.length > 0) {
|
|
envStats.recentEvents = recentEventsResult.events.map(e => ({
|
|
container_name: e.containerName || 'unknown',
|
|
action: e.action,
|
|
timestamp: e.timestamp
|
|
}));
|
|
}
|
|
|
|
if (metricsHistory.length > 0) {
|
|
envStats.metricsHistory = metricsHistory.reverse().map(m => ({
|
|
cpu_percent: m.cpuPercent,
|
|
memory_percent: m.memoryPercent,
|
|
timestamp: m.timestamp
|
|
}));
|
|
}
|
|
|
|
// Send initial update with DB data and online status
|
|
onPartialUpdate({
|
|
id: env.id,
|
|
online: true,
|
|
metrics: envStats.metrics,
|
|
events: envStats.events,
|
|
recentEvents: envStats.recentEvents,
|
|
metricsHistory: envStats.metricsHistory,
|
|
scannerEnabled: envStats.scannerEnabled,
|
|
updateCheckEnabled: envStats.updateCheckEnabled,
|
|
updateCheckAutoUpdate: envStats.updateCheckAutoUpdate,
|
|
loading: { ...envStats.loading }
|
|
});
|
|
|
|
// Helper to get valid size
|
|
const getValidSize = (size: number | undefined | null): number => {
|
|
return size && size > 0 ? size : 0;
|
|
};
|
|
|
|
// PHASE 1: Containers (usually fast)
|
|
const containersPromise = withTimeout(listContainers(true, env.id).catch(() => []), 10000, [])
|
|
.then(async (containers) => {
|
|
envStats.containers.total = containers.length;
|
|
envStats.containers.running = containers.filter((c: any) => c.state === 'running').length;
|
|
envStats.containers.stopped = containers.filter((c: any) => c.state === 'exited').length;
|
|
envStats.containers.paused = containers.filter((c: any) => c.state === 'paused').length;
|
|
envStats.containers.restarting = containers.filter((c: any) => c.state === 'restarting').length;
|
|
envStats.containers.unhealthy = containers.filter((c: any) => c.health === 'unhealthy').length;
|
|
envStats.loading!.containers = false;
|
|
|
|
onPartialUpdate({
|
|
id: env.id,
|
|
containers: { ...envStats.containers },
|
|
loading: { ...envStats.loading! }
|
|
});
|
|
|
|
return containers;
|
|
});
|
|
|
|
// PHASE 2: Images, Networks, Stacks (medium speed) - run in parallel
|
|
const imagesPromise = withTimeout(listImages(env.id).catch(() => []), 10000, [])
|
|
.then((images) => {
|
|
envStats.images.total = images.length;
|
|
envStats.images.totalSize = images.reduce((sum: number, img: any) => sum + getValidSize(img.size), 0);
|
|
envStats.loading!.images = false;
|
|
|
|
onPartialUpdate({
|
|
id: env.id,
|
|
images: { ...envStats.images },
|
|
loading: { ...envStats.loading! }
|
|
});
|
|
|
|
return images;
|
|
});
|
|
|
|
const networksPromise = withTimeout(listNetworks(env.id).catch(() => []), 10000, [])
|
|
.then((networks) => {
|
|
envStats.networks.total = networks.length;
|
|
envStats.loading!.networks = false;
|
|
|
|
onPartialUpdate({
|
|
id: env.id,
|
|
networks: { ...envStats.networks },
|
|
loading: { ...envStats.loading! }
|
|
});
|
|
|
|
return networks;
|
|
});
|
|
|
|
const stacksPromise = withTimeout(listComposeStacks(env.id).catch(() => []), 10000, [])
|
|
.then((stacks) => {
|
|
envStats.stacks.total = stacks.length;
|
|
envStats.stacks.running = stacks.filter((s: any) => s.status === 'running').length;
|
|
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
|
|
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
|
|
envStats.loading!.stacks = false;
|
|
|
|
onPartialUpdate({
|
|
id: env.id,
|
|
stacks: { ...envStats.stacks },
|
|
loading: { ...envStats.loading! }
|
|
});
|
|
|
|
return stacks;
|
|
});
|
|
|
|
// 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;
|
|
}
|
|
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
|
|
const topContainersPromise = containersPromise.then(async (containers) => {
|
|
const runningContainersList = containers.filter((c: any) => c.state === 'running');
|
|
|
|
const topContainersPromises = runningContainersList.slice(0, TOP_CONTAINERS_LIMIT).map(async (container: any) => {
|
|
try {
|
|
// 5 second timeout per container (increased from 2s for Hawser environments)
|
|
const stats = await withTimeout(
|
|
getContainerStats(container.id, env.id) as Promise<any>,
|
|
5000,
|
|
null
|
|
);
|
|
if (!stats) return null;
|
|
|
|
const cpuPercent = calculateCpuPercent(stats);
|
|
const memoryUsage = stats.memory_stats?.usage || 0;
|
|
const memoryLimit = stats.memory_stats?.limit || 1;
|
|
const memoryPercent = (memoryUsage / memoryLimit) * 100;
|
|
|
|
return {
|
|
name: container.name,
|
|
cpuPercent: Math.round(cpuPercent * 100) / 100,
|
|
memoryPercent: Math.round(memoryPercent * 100) / 100
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
});
|
|
|
|
const topContainersResults = await Promise.all(topContainersPromises);
|
|
envStats.topContainers = topContainersResults
|
|
.filter((c): c is { name: string; cpuPercent: number; memoryPercent: number } => c !== null)
|
|
.sort((a, b) => b.cpuPercent - a.cpuPercent)
|
|
.slice(0, 10);
|
|
envStats.loading!.topContainers = false;
|
|
|
|
onPartialUpdate({
|
|
id: env.id,
|
|
topContainers: [...envStats.topContainers],
|
|
loading: { ...envStats.loading! }
|
|
});
|
|
|
|
return envStats.topContainers;
|
|
});
|
|
|
|
// Wait for all to complete
|
|
await Promise.all([
|
|
containersPromise,
|
|
imagesPromise,
|
|
networksPromise,
|
|
stacksPromise,
|
|
diskUsagePromise,
|
|
topContainersPromise
|
|
]);
|
|
|
|
// Clear loading states when complete
|
|
envStats.loading = undefined;
|
|
|
|
} catch (error) {
|
|
// Convert technical error messages to user-friendly ones
|
|
const errorStr = String(error);
|
|
if (errorStr.includes('not connected') || errorStr.includes('Edge agent')) {
|
|
envStats.error = 'Agent not connected';
|
|
} else if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
|
|
envStats.error = 'Docker socket not accessible';
|
|
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
|
|
envStats.error = 'Connection lost';
|
|
} else if (errorStr.includes('verbose: true') || errorStr.includes('verbose')) {
|
|
envStats.error = 'Connection failed';
|
|
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
|
|
envStats.error = 'Connection timeout';
|
|
} else {
|
|
// Extract just the error message, not the full stack/details
|
|
const match = errorStr.match(/^(?:Error:\s*)?([^.!?]+[.!?]?)/);
|
|
envStats.error = match ? match[1].trim() : 'Connection error';
|
|
}
|
|
envStats.loading = undefined;
|
|
// Send offline status to client
|
|
onPartialUpdate({
|
|
id: env.id,
|
|
online: false,
|
|
error: envStats.error,
|
|
loading: undefined
|
|
});
|
|
}
|
|
|
|
return envStats;
|
|
}
|
|
|
|
export const GET: RequestHandler = async ({ cookies }) => {
|
|
const auth = await authorize(cookies);
|
|
if (auth.authEnabled && !await auth.can('environments', 'view')) {
|
|
return new Response(JSON.stringify({ error: 'Permission denied' }), {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
|
|
let environments = await getEnvironments();
|
|
|
|
// In enterprise mode, filter environments by user's accessible environments
|
|
if (auth.authEnabled && auth.isEnterprise && auth.isAuthenticated && !auth.isAdmin) {
|
|
const accessibleIds = await auth.getAccessibleEnvironmentIds();
|
|
// accessibleIds is null if user has access to all environments
|
|
if (accessibleIds !== null) {
|
|
environments = environments.filter(env => accessibleIds.includes(env.id));
|
|
}
|
|
}
|
|
|
|
// Create a readable stream that sends environment stats progressively
|
|
let controllerClosed = false;
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const encoder = new TextEncoder();
|
|
|
|
// Safe enqueue that checks if controller is still open
|
|
const safeEnqueue = (data: string) => {
|
|
if (!controllerClosed) {
|
|
try {
|
|
controller.enqueue(encoder.encode(data));
|
|
} catch {
|
|
controllerClosed = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
// First, send the list of environments so the UI can show skeletons with loading states
|
|
const envList = environments.map(env => ({
|
|
id: env.id,
|
|
name: env.name,
|
|
host: env.host ?? undefined,
|
|
port: env.port ?? undefined,
|
|
icon: env.icon || 'globe',
|
|
socketPath: env.socketPath ?? undefined,
|
|
collectActivity: env.collectActivity,
|
|
collectMetrics: env.collectMetrics ?? true,
|
|
labels: parseLabels(env.labels),
|
|
connectionType: (env.connectionType as 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge') || 'socket',
|
|
// Initial loading state for all sections
|
|
loading: {
|
|
containers: true,
|
|
images: true,
|
|
volumes: true,
|
|
networks: true,
|
|
stacks: true,
|
|
diskUsage: true,
|
|
topContainers: true
|
|
}
|
|
}));
|
|
safeEnqueue(`event: environments\ndata: ${JSON.stringify(envList)}\n\n`);
|
|
|
|
// 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`);
|
|
});
|
|
// 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);
|
|
// Convert technical error to user-friendly message
|
|
const errorStr = String(error);
|
|
let friendlyError = 'Connection error';
|
|
if (errorStr.includes('FailedToOpenSocket') || errorStr.includes('ECONNREFUSED')) {
|
|
friendlyError = 'Docker socket not accessible';
|
|
} else if (errorStr.includes('ECONNRESET') || errorStr.includes('connection was closed')) {
|
|
friendlyError = 'Connection lost';
|
|
} else if (errorStr.includes('verbose') || errorStr.includes('typo')) {
|
|
friendlyError = 'Connection failed';
|
|
} else if (errorStr.includes('timeout') || errorStr.includes('Timeout')) {
|
|
friendlyError = 'Connection timeout';
|
|
}
|
|
safeEnqueue(`event: error\ndata: ${JSON.stringify({ id: env.id, error: friendlyError })}\n\n`);
|
|
}
|
|
});
|
|
|
|
// Wait for all to complete
|
|
await Promise.all(promises);
|
|
|
|
// Send done event and close
|
|
if (!controllerClosed) {
|
|
safeEnqueue(`event: done\ndata: {}\n\n`);
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
// Already closed
|
|
}
|
|
}
|
|
},
|
|
cancel() {
|
|
// Called when the client disconnects
|
|
controllerClosed = true;
|
|
}
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive'
|
|
}
|
|
});
|
|
};
|