gauge.tsx123 lines · main
1'use client';
2
3import { motion } from 'motion/react';
4
5import { CountUp } from './stat-card';
6
7/**
8 * Donut gauge for host load (CPU / RAM / disk). The arc sweeps in with a
9 * motion tween and the % label counts up alongside it.
10 *
11 * HARD honesty rule: `percent === null` renders a muted empty ring with
12 * "—" in the middle and a "no data" note — never a fabricated 0%.
13 */
14
15export interface GaugeProps {
16 label: string;
17 /** 0–100, or null when the metric isn't available. */
18 percent: number | null;
19 /** small line under the label, e.g. "6.2 / 15.6 GB". */
20 detail?: string;
21 /** percent at/above which the ring turns red. default 85. */
22 redAt?: number;
23 /** percent at/above which the ring turns amber. default 70. */
24 amberAt?: number;
25 /** outer diameter in px. default 140. */
26 size?: number;
27 icon?: React.ReactNode;
28}
29
30const STROKE = 10;
31
32export function Gauge({
33 label,
34 percent,
35 detail,
36 redAt = 85,
37 amberAt = 70,
38 size = 140,
39 icon,
40}: GaugeProps) {
41 const clamped = percent === null ? null : Math.min(100, Math.max(0, percent));
42 const radius = (size - STROKE) / 2;
43 const circumference = 2 * Math.PI * radius;
44 const targetOffset =
45 clamped === null ? circumference : circumference * (1 - clamped / 100);
46
47 const ringColor =
48 clamped === null
49 ? 'var(--color-border-strong)'
50 : clamped >= redAt
51 ? 'var(--color-error)'
52 : clamped >= amberAt
53 ? 'var(--color-warning)'
54 : 'var(--color-primary)';
55
56 return (
57 <motion.div
58 initial={{ opacity: 0, y: 8 }}
59 animate={{ opacity: 1, y: 0 }}
60 transition={{ duration: 0.4, ease: 'easeOut' }}
61 className="flex h-full flex-col items-center gap-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
62 >
63 <p className="flex items-center gap-2 self-start font-mono text-[11px] uppercase tracking-wider text-[var(--color-text-subtle)]">
64 {icon ? <span className="text-[var(--color-text-muted)]">{icon}</span> : null}
65 {label}
66 </p>
67
68 <div className="relative" style={{ width: size, height: size }}>
69 <svg
70 width={size}
71 height={size}
72 viewBox={`0 0 ${size} ${size}`}
73 className="-rotate-90"
74 role="meter"
75 aria-valuenow={clamped === null ? undefined : Math.round(clamped)}
76 aria-valuemin={0}
77 aria-valuemax={100}
78 aria-label={label}
79 >
80 {/* track */}
81 <circle
82 cx={size / 2}
83 cy={size / 2}
84 r={radius}
85 fill="none"
86 stroke="var(--color-border-subtle)"
87 strokeWidth={STROKE}
88 />
89 {/* value arc — sweeps in from empty */}
90 <motion.circle
91 cx={size / 2}
92 cy={size / 2}
93 r={radius}
94 fill="none"
95 stroke={ringColor}
96 strokeWidth={STROKE}
97 strokeLinecap="round"
98 strokeDasharray={circumference}
99 initial={{ strokeDashoffset: circumference }}
100 animate={{ strokeDashoffset: targetOffset }}
101 transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1] }}
102 />
103 </svg>
104 <div className="absolute inset-0 flex flex-col items-center justify-center">
105 {clamped === null ? (
106 <span className="font-mono text-3xl text-[var(--color-text-subtle)]">—</span>
107 ) : (
108 <CountUp
109 value={clamped}
110 decimals={0}
111 suffix="%"
112 className="font-mono text-3xl tracking-tight text-[var(--color-text)]"
113 />
114 )}
115 </div>
116 </div>
117
118 <p className="font-mono text-[11px] text-[var(--color-text-subtle)]">
119 {clamped === null ? 'no data' : (detail ?? ' ')}
120 </p>
121 </motion.div>
122 );
123}