toes/src/client/components/Vitals.tsx
2026-02-13 09:02:21 -08:00

166 lines
4.6 KiB
TypeScript

import {
GaugeContainer,
GaugeSvg,
GaugeValue,
VitalCard,
VitalLabel,
VitalsSection,
} from '../styles'
import { update } from '../update'
interface AppMetrics {
cpu: number
mem: number
disk: number
}
interface SystemMetrics {
cpu: number
ram: { used: number, total: number, percent: number }
disk: { used: number, total: number, percent: number }
apps: Record<string, AppMetrics>
}
const SEGMENTS = 19
const START_ANGLE = -225
const END_ANGLE = 45
const SWEEP = END_ANGLE - START_ANGLE
const CX = 60
const CY = 60
const RADIUS = 44
const SEGMENT_GAP = 3
const SEGMENT_WIDTH = 8
const NEEDLE_LENGTH = 38
let _metrics: SystemMetrics = {
cpu: 0,
ram: { used: 0, total: 0, percent: 0 },
disk: { used: 0, total: 0, percent: 0 },
apps: {},
}
let _source: EventSource | undefined
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`
if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}M`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`
}
function updateTooltips(apps: Record<string, AppMetrics>) {
for (const [name, m] of Object.entries(apps)) {
document.querySelectorAll(`[data-app="${name}"]`).forEach(dot => {
const parent = dot.parentElement
if (parent?.hasAttribute('data-tooltip')) {
parent.setAttribute('data-tooltip',
`${name}\n${'─'.repeat(name.length)}\nCPU ${m.cpu}%\nMEM ${formatBytes(m.mem)}\nDISK ${formatBytes(m.disk)}`)
}
})
}
}
const toRad = (deg: number) => (deg * Math.PI) / 180
const segmentColor = (i: number, total: number): string => {
const t = i / (total - 1)
if (t < 0.4) return '#4caf50'
if (t < 0.6) return '#8bc34a'
if (t < 0.75) return '#ffc107'
if (t < 0.9) return '#ff9800'
return '#f44336'
}
function Gauge({ value }: { value: number }) {
const segmentSweep = SWEEP / SEGMENTS
const activeSegments = Math.round((value / 100) * SEGMENTS)
const segments = []
for (let i = 0; i < SEGMENTS; i++) {
const startDeg = START_ANGLE + i * segmentSweep + SEGMENT_GAP / 2
const endDeg = START_ANGLE + (i + 1) * segmentSweep - SEGMENT_GAP / 2
const innerR = RADIUS - SEGMENT_WIDTH / 2
const outerR = RADIUS + SEGMENT_WIDTH / 2
const x1 = CX + outerR * Math.cos(toRad(startDeg))
const y1 = CY + outerR * Math.sin(toRad(startDeg))
const x2 = CX + outerR * Math.cos(toRad(endDeg))
const y2 = CY + outerR * Math.sin(toRad(endDeg))
const x3 = CX + innerR * Math.cos(toRad(endDeg))
const y3 = CY + innerR * Math.sin(toRad(endDeg))
const x4 = CX + innerR * Math.cos(toRad(startDeg))
const y4 = CY + innerR * Math.sin(toRad(startDeg))
const color = i < activeSegments ? segmentColor(i, SEGMENTS) : 'var(--colors-border)'
segments.push(
<path
key={i}
d={`M ${x1} ${y1} A ${outerR} ${outerR} 0 0 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 0 0 ${x4} ${y4} Z`}
fill={color}
/>
)
}
// Needle
const needleAngle = START_ANGLE + (value / 100) * SWEEP
const nx = CX + NEEDLE_LENGTH * Math.cos(toRad(needleAngle))
const ny = CY + NEEDLE_LENGTH * Math.sin(toRad(needleAngle))
// Needle base width
const perpAngle = needleAngle + 90
const bw = 3
const bx1 = CX + bw * Math.cos(toRad(perpAngle))
const by1 = CY + bw * Math.sin(toRad(perpAngle))
const bx2 = CX - bw * Math.cos(toRad(perpAngle))
const by2 = CY - bw * Math.sin(toRad(perpAngle))
return (
<GaugeContainer>
<GaugeSvg viewBox="10 10 100 55">
{segments}
<polygon points={`${nx},${ny} ${bx1},${by1} ${bx2},${by2}`} fill="var(--colors-text)" />
<circle cx={CX} cy={CY} r="4" fill="var(--colors-textMuted)" />
</GaugeSvg>
<GaugeValue>{value}%</GaugeValue>
</GaugeContainer>
)
}
function VitalsContent() {
return (
<>
<VitalCard>
<VitalLabel>CPU</VitalLabel>
<Gauge value={_metrics.cpu} />
</VitalCard>
<VitalCard>
<VitalLabel>RAM</VitalLabel>
<Gauge value={_metrics.ram.percent} />
</VitalCard>
<VitalCard>
<VitalLabel>Disk</VitalLabel>
<Gauge value={_metrics.disk.percent} />
</VitalCard>
</>
)
}
export function initVitals() {
if (_source) return
_source = new EventSource('/api/system/metrics/stream')
_source.onmessage = e => {
try {
_metrics = JSON.parse(e.data)
update('#vitals', <VitalsContent />)
updateTooltips(_metrics.apps)
} catch {}
}
}
export function Vitals() {
return (
<VitalsSection id="vitals">
<VitalsContent />
</VitalsSection>
)
}