166 lines
4.6 KiB
TypeScript
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>
|
|
)
|
|
}
|