143 lines
4.8 KiB
TypeScript
143 lines
4.8 KiB
TypeScript
import React from 'react';
|
||
import { NodeMetric } from '@/store/metricsStore';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
interface Props {
|
||
node: string;
|
||
stats: NodeMetric;
|
||
prevStats?: NodeMetric | null;
|
||
/** Outer ring diameter in px (default 48). */
|
||
size?: number;
|
||
onClick?: () => void;
|
||
className?: string;
|
||
}
|
||
|
||
const CircleProgress: React.FC<{
|
||
percent: number;
|
||
size: number;
|
||
strokeWidth: number;
|
||
color: string;
|
||
offset?: number;
|
||
}> = ({ percent, size, strokeWidth, color, offset = 0 }) => {
|
||
const radius = (size - strokeWidth) / 2;
|
||
const circumference = 2 * Math.PI * radius;
|
||
const clamped = Math.min(100, Math.max(0, percent));
|
||
const dashOffset = circumference - (clamped / 100) * circumference;
|
||
|
||
return (
|
||
<svg
|
||
width={size}
|
||
height={size}
|
||
className="absolute -rotate-90"
|
||
style={{ top: offset, left: offset }}
|
||
aria-hidden
|
||
>
|
||
<circle
|
||
cx={size / 2}
|
||
cy={size / 2}
|
||
r={radius}
|
||
fill="none"
|
||
stroke="hsl(var(--muted))"
|
||
strokeWidth={strokeWidth}
|
||
/>
|
||
<circle
|
||
cx={size / 2}
|
||
cy={size / 2}
|
||
r={radius}
|
||
fill="none"
|
||
stroke={color}
|
||
strokeWidth={strokeWidth}
|
||
strokeDasharray={circumference}
|
||
strokeDashoffset={dashOffset}
|
||
strokeLinecap="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
};
|
||
|
||
const MetricIndicator: React.FC<Props> = ({
|
||
node,
|
||
stats,
|
||
prevStats,
|
||
size = 48,
|
||
onClick,
|
||
className,
|
||
}) => {
|
||
const cpu = stats.cpu_utilization ?? 0;
|
||
const totalMemory = (stats.memory_total ?? 0) + (stats.memory_available ?? 0);
|
||
const usedMemory = stats.memory_total ?? 0;
|
||
const memoryPercent = totalMemory > 0 ? (usedMemory / totalMemory) * 100 : 0;
|
||
|
||
const prevCpu = prevStats?.cpu_utilization;
|
||
const prevTotal = (prevStats?.memory_total ?? 0) + (prevStats?.memory_available ?? 0);
|
||
const prevUsed = prevStats?.memory_total ?? 0;
|
||
const prevMemoryPercent = prevTotal > 0 ? (prevUsed / prevTotal) * 100 : null;
|
||
|
||
const cpuColor = cpu > 80 ? '#ef4444' : '#22c55e';
|
||
const memColor = memoryPercent > 80 ? '#ef4444' : '#3b82f6';
|
||
|
||
const stroke = Math.max(3, Math.round(size * 0.12));
|
||
const innerSize = Math.round(size * (32 / 48));
|
||
const innerOffset = Math.round((size - innerSize) / 2);
|
||
const fontSize = size <= 36 ? 8 : 10;
|
||
|
||
const shellClass = cn(
|
||
'group relative shrink-0',
|
||
onClick && 'cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||
className
|
||
);
|
||
|
||
const body = (
|
||
<>
|
||
<CircleProgress percent={memoryPercent} size={size} strokeWidth={stroke} color={memColor} />
|
||
<CircleProgress
|
||
percent={cpu}
|
||
size={innerSize}
|
||
strokeWidth={stroke}
|
||
color={cpuColor}
|
||
offset={innerOffset}
|
||
/>
|
||
<div
|
||
className="absolute inset-0 flex items-center justify-center font-semibold tabular-nums"
|
||
style={{ fontSize }}
|
||
>
|
||
{cpu.toFixed(0)}%
|
||
</div>
|
||
<div className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 hidden w-max max-w-[240px] -translate-x-1/2 rounded-md border bg-popover px-2 py-1.5 text-left text-xs text-popover-foreground shadow-md group-hover:block group-focus-visible:block">
|
||
<div className="font-medium">{node}</div>
|
||
<div>
|
||
CPU: {cpu.toFixed(1)}%{' '}
|
||
{prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}
|
||
</div>
|
||
<div>
|
||
Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ (
|
||
{memoryPercent.toFixed(1)}%)
|
||
{prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
if (onClick) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className={shellClass}
|
||
style={{ width: size, height: size }}
|
||
onClick={onClick}
|
||
aria-label={`${node}: CPU ${cpu.toFixed(0)}%, память ${memoryPercent.toFixed(0)}%`}
|
||
>
|
||
{body}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={shellClass} style={{ width: size, height: size }} title={node}>
|
||
{body}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default MetricIndicator;
|