Files
dockhand/routes/api/dashboard/stats/+server.ts
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

308 lines
10 KiB
TypeScript

import { json, type RequestHandler } from '@sveltejs/kit';
import {
getEnvironments,
getLatestHostMetrics,
getContainerEventStats,
getEnvSetting,
hasEnvironments,
getEnvUpdateCheckSettings
} from '$lib/server/db';
import {
listContainers,
listImages,
listVolumes,
listNetworks,
getDockerInfo,
getDiskUsage
} from '$lib/server/docker';
import { listComposeStacks } from '$lib/server/stacks';
import { authorize } from '$lib/server/authorize';
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))
]);
}
// Loading states for progressive tile updates
export interface LoadingStates {
containers?: boolean;
images?: boolean;
volumes?: boolean;
networks?: boolean;
stacks?: boolean;
diskUsage?: boolean;
topContainers?: boolean;
}
export interface EnvironmentStats {
id: number;
name: string;
host?: string;
port?: number | null;
icon: string;
socketPath?: string;
collectActivity: boolean;
collectMetrics: boolean;
scannerEnabled: boolean;
updateCheckEnabled: boolean;
updateCheckAutoUpdate: boolean;
labels?: string[];
connectionType: 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
online: boolean;
error?: string;
containers: {
total: number;
running: number;
stopped: number;
paused: number;
restarting: number;
unhealthy: number;
};
images: {
total: number;
totalSize: number;
};
volumes: {
total: number;
totalSize: number;
};
containersSize: number;
buildCacheSize: number;
networks: {
total: number;
};
stacks: {
total: number;
running: number;
partial: number;
stopped: number;
};
metrics: {
cpuPercent: number;
memoryPercent: number;
memoryUsed: number;
memoryTotal: number;
} | null;
events: {
total: number;
today: number;
};
topContainers: Array<{
id: string;
name: string;
cpuPercent: number;
memoryPercent: number;
}>;
metricsHistory?: Array<{
cpu_percent: number;
memory_percent: number;
timestamp: string;
}>;
recentEvents?: Array<{
container_name: string;
action: string;
timestamp: string;
}>;
// Progressive loading states
loading?: LoadingStates;
}
export const GET: RequestHandler = async ({ cookies, url }) => {
const auth = await authorize(cookies);
// Support single environment query for real-time updates
const envIdParam = url.searchParams.get('env');
const envIdNum = envIdParam ? parseInt(envIdParam) : undefined;
// Permission check with environment context
if (auth.authEnabled && !await auth.can('environments', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
// Early return if no environments configured (fresh install)
if (!await hasEnvironments()) {
return json([]);
}
try {
let environments = await getEnvironments();
// Filter to single environment if specified
if (envIdNum) {
environments = environments.filter(env => env.id === envIdNum);
if (environments.length === 0) {
return json({ error: 'Environment not found' }, { status: 404 });
}
}
// 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));
}
}
// Fetch stats for all environments in parallel
const promises = environments.map(async (env): 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: []
};
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';
return envStats;
}
envStats.online = true;
// Fetch all data in parallel (with 10 second timeout per operation)
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)
]);
// Process 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;
// Helper to get valid size (Docker API returns -1 for uncalculated sizes)
const getValidSize = (size: number | undefined | null): number => {
return size && size > 0 ? size : 0;
};
// Process disk usage from /system/df for accurate size data
if (diskUsage) {
// Images: use Size from /system/df
envStats.images.total = diskUsage.Images?.length || images.length;
envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || 0;
// Volumes: use UsageData.Size from /system/df
envStats.volumes.total = diskUsage.Volumes?.length || volumes.length;
envStats.volumes.totalSize = diskUsage.Volumes?.reduce((sum: number, vol: any) => sum + getValidSize(vol.UsageData?.Size), 0) || 0;
// Containers: use SizeRw (writable layer size)
envStats.containersSize = diskUsage.Containers?.reduce((sum: number, c: any) => sum + getValidSize(c.SizeRw), 0) || 0;
// Build cache: total size
envStats.buildCacheSize = diskUsage.BuildCache?.reduce((sum: number, bc: any) => sum + getValidSize(bc.Size), 0) || 0;
} else {
// Fallback to original method if /system/df failed
envStats.images.total = images.length;
envStats.images.totalSize = images.reduce((sum: number, img: any) => sum + getValidSize(img.size), 0);
envStats.volumes.total = volumes.length;
envStats.volumes.totalSize = 0;
}
// Process networks
envStats.networks.total = networks.length;
// Process 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;
// Get latest metrics and event stats in parallel
const [latestMetrics, eventStats] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(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
};
} catch (error) {
// Convert technical error messages to user-friendly ones
const errorStr = String(error);
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 {
const match = errorStr.match(/^(?:Error:\s*)?([^.!?]+[.!?]?)/);
envStats.error = match ? match[1].trim() : 'Connection error';
}
}
return envStats;
});
const results = await Promise.all(promises);
// Return single object if single env was requested
if (envIdParam && results.length === 1) {
return json(results[0]);
}
return json(results);
} catch (error: any) {
console.error('Failed to get dashboard stats:', error);
return json({ error: 'Failed to get dashboard stats' }, { status: 500 });
}
};