Pulse/frontend-modern/src/components/Docker/DockerSummaryStats.tsx

370 lines
11 KiB
TypeScript

import type { Component } from 'solid-js';
import { Show, createMemo } from 'solid-js';
import type { DockerHost } from '@/types/api';
interface StatCardProps {
label: string;
value: number | string;
sublabel?: string;
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
onClick?: () => void;
isActive?: boolean;
}
const StatCard: Component<StatCardProps> = (props) => {
const baseClass = 'flex flex-col gap-1 px-4 py-3 rounded-lg border transition-all duration-200';
const variantClass = () => {
if (props.isActive) {
return 'bg-blue-100 dark:bg-blue-900/40 border-blue-300 dark:border-blue-700 ring-2 ring-blue-500/20';
}
switch (props.variant) {
case 'success':
return 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800';
case 'warning':
return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800';
case 'error':
return 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800';
case 'info':
return 'bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800';
default:
return 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700';
}
};
const hoverClass = () => props.onClick ? 'cursor-pointer hover:shadow-md hover:scale-[1.02]' : '';
return (
<button
type="button"
class={`${baseClass} ${variantClass()} ${hoverClass()}`}
onClick={props.onClick}
disabled={!props.onClick}
>
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{props.value}
</div>
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
{props.label}
</div>
<Show when={props.sublabel}>
<div class="text-xs text-gray-500 dark:text-gray-500">
{props.sublabel}
</div>
</Show>
</button>
);
};
export interface DockerSummaryStats {
hosts: {
total: number;
online: number;
degraded: number;
offline: number;
};
containers: {
total: number;
running: number;
degraded: number;
stopped: number;
error: number;
};
services?: {
total: number;
healthy: number;
degraded: number;
};
resources?: {
avgCpu: number | null;
avgMemory: number | null;
};
}
type SummaryFilter = { type: 'host-status' | 'container-state' | 'service-health'; value: string } | null;
interface DockerSummaryStatsProps {
hosts: DockerHost[];
onFilterChange?: (filter: SummaryFilter) => void;
activeFilter?: { type: string; value: string } | null;
}
export const DockerSummaryStatsBar: Component<DockerSummaryStatsProps> = (props) => {
const stats = (): DockerSummaryStats => {
const hosts = props.hosts || [];
// Host stats
let onlineHosts = 0;
let degradedHosts = 0;
let offlineHosts = 0;
hosts.forEach((host) => {
const status = host.status?.toLowerCase() ?? 'unknown';
switch (status) {
case 'online':
onlineHosts++;
break;
case 'degraded':
case 'warning':
case 'maintenance':
degradedHosts++;
break;
case 'offline':
case 'error':
case 'unreachable':
offlineHosts++;
break;
default:
degradedHosts++;
break;
}
});
// Container stats
let totalContainers = 0;
let runningContainers = 0;
let degradedContainers = 0;
let stoppedContainers = 0;
let errorContainers = 0;
// Service stats
let totalServices = 0;
let healthyServices = 0;
let degradedServices = 0;
// Resource stats
let totalCpu = 0;
let totalMemory = 0;
let cpuSamples = 0;
let memorySamples = 0;
hosts.forEach(host => {
// Count containers
const containers = host.containers || [];
totalContainers += containers.length;
containers.forEach(container => {
const state = container.state?.toLowerCase();
const health = container.health?.toLowerCase();
if (state === 'running') {
if (health === 'unhealthy') {
degradedContainers++;
} else {
runningContainers++;
}
// Sum CPU/Memory for running containers
if (typeof container.cpuPercent === 'number' && !Number.isNaN(container.cpuPercent)) {
totalCpu += container.cpuPercent;
cpuSamples++;
}
if (typeof container.memoryPercent === 'number' && !Number.isNaN(container.memoryPercent)) {
totalMemory += container.memoryPercent;
memorySamples++;
}
} else if (state === 'exited' || state === 'stopped' || state === 'created' || state === 'paused') {
stoppedContainers++;
} else if (state === 'restarting' || state === 'dead' || state === 'removing' || state === 'error' || state === 'failed') {
errorContainers++;
}
});
// Count services
const services = host.services || [];
totalServices += services.length;
services.forEach(service => {
const desired = service.desiredTasks ?? 0;
const running = service.runningTasks ?? 0;
if (desired > 0 && running >= desired) {
healthyServices++;
} else if (desired > 0) {
degradedServices++;
}
});
});
return {
hosts: {
total: hosts.length,
online: onlineHosts,
degraded: degradedHosts,
offline: offlineHosts,
},
containers: {
total: totalContainers,
running: runningContainers,
degraded: degradedContainers,
stopped: stoppedContainers,
error: errorContainers,
},
services: totalServices > 0 ? {
total: totalServices,
healthy: healthyServices,
degraded: degradedServices,
} : undefined,
resources: (cpuSamples > 0 || memorySamples > 0) ? {
avgCpu: cpuSamples > 0 ? totalCpu / cpuSamples : null,
avgMemory: memorySamples > 0 ? totalMemory / memorySamples : null,
} : undefined,
};
};
const summary = createMemo(stats);
const hostSublabel = () => {
const hosts = summary().hosts;
const parts = [`${hosts.online} online`];
if (hosts.degraded > 0) {
parts.push(`${hosts.degraded} degraded`);
}
if (hosts.offline > 0) {
parts.push(`${hosts.offline} offline`);
}
return parts.join(', ');
};
const servicesSublabel = () => {
const services = summary().services;
if (!services) return '';
const parts = [`${services.healthy} healthy`];
if (services.degraded > 0) {
parts.push(`${services.degraded} degraded`);
}
return parts.join(', ');
};
const resourceValue = () => {
const avgCpu = summary().resources?.avgCpu ?? null;
return avgCpu !== null ? Math.round(avgCpu) : '—';
};
const resourceSublabel = () => {
const avgMemory = summary().resources?.avgMemory ?? null;
if (avgMemory === null) return undefined;
return `${Math.round(avgMemory)}% mem`;
};
const resourceVariant = (): StatCardProps['variant'] => {
const avgCpu = summary().resources?.avgCpu ?? null;
if (avgCpu === null) return 'info';
if (avgCpu > 80) return 'error';
if (avgCpu > 60) return 'warning';
return 'info';
};
const isActive = (type: string, value: string) => {
return props.activeFilter?.type === type && props.activeFilter?.value === value;
};
return (
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
Overview
</h2>
<Show when={props.activeFilter}>
<button
type="button"
onClick={() => props.onFilterChange?.(null)}
class="text-xs font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Clear filter
</button>
</Show>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{/* Hosts */}
<StatCard
label="Total Hosts"
value={summary().hosts.total}
sublabel={hostSublabel()}
variant="default"
/>
<Show when={summary().hosts.degraded > 0}>
<StatCard
label="Degraded Hosts"
value={summary().hosts.degraded}
variant="warning"
onClick={() => props.onFilterChange?.({ type: 'host-status', value: 'degraded' })}
isActive={isActive('host-status', 'degraded')}
/>
</Show>
<Show when={summary().hosts.offline > 0}>
<StatCard
label="Offline Hosts"
value={summary().hosts.offline}
variant="error"
onClick={() => props.onFilterChange?.({ type: 'host-status', value: 'offline' })}
isActive={isActive('host-status', 'offline')}
/>
</Show>
{/* Containers */}
<StatCard
label="Running"
value={summary().containers.running}
sublabel={`of ${summary().containers.total} containers`}
variant="success"
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'running' })}
isActive={isActive('container-state', 'running')}
/>
<Show when={summary().containers.degraded > 0}>
<StatCard
label="Degraded"
value={summary().containers.degraded}
variant="warning"
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'degraded' })}
isActive={isActive('container-state', 'degraded')}
/>
</Show>
<Show when={summary().containers.stopped > 0}>
<StatCard
label="Stopped"
value={summary().containers.stopped}
variant="warning"
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'stopped' })}
isActive={isActive('container-state', 'stopped')}
/>
</Show>
<Show when={summary().containers.error > 0}>
<StatCard
label="Error"
value={summary().containers.error}
variant="error"
onClick={() => props.onFilterChange?.({ type: 'container-state', value: 'error' })}
isActive={isActive('container-state', 'error')}
/>
</Show>
{/* Services */}
<Show when={summary().services}>
<StatCard
label="Services"
value={summary().services!.total}
sublabel={servicesSublabel()}
variant={summary().services!.degraded > 0 ? 'warning' : 'success'}
/>
</Show>
{/* Resources */}
<Show when={summary().resources}>
<StatCard
label="Avg CPU"
value={resourceValue()}
sublabel={resourceSublabel()}
variant={resourceVariant()}
/>
</Show>
</div>
</div>
);
};