mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-06 05:39:05 +00:00
Initial commit
This commit is contained in:
307
routes/api/dashboard/stats/+server.ts
Normal file
307
routes/api/dashboard/stats/+server.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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 });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user