Files
hamlab/web/src/widgets.tsx
T
Mitchell-JohnsonandClaude Opus 5 04dd98e2cd Settings: one window for how far back every graph reaches, 1 minute to 24 hours
Each machine keeps 300 points per curve whatever the window, so memory doesn't follow it;
a wider window means wider points, each the average of its bucket rather than one instant
out of it. The hub passes the setting to every machine it manages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 19:07:55 -07:00

110 lines
5.9 KiB
TypeScript

// Prebuilt widgets over the sensor API. A widget is a spec — a type and a selector —
// and nothing else; the same spec renders inside the dashboard or alone in an iframe.
import { useEffect, useRef, useState } from 'react'
import Sparkline from './Sparkline'
import { Sensor, Source, fetchHistory, fmtValue, fraction, severity } from './sensors'
export type WidgetType = 'stat' | 'gauge' | 'bar' | 'spark' | 'list'
export interface WidgetSpec { id: string; type: WidgetType; sensor: string; title?: string }
export const WIDGET_TYPES: { id: WidgetType; label: string; many?: boolean }[] = [
{ id: 'stat', label: 'Number' }, { id: 'gauge', label: 'Gauge' }, { id: 'bar', label: 'Bar' },
{ id: 'spark', label: 'Sparkline' }, { id: 'list', label: 'List (pattern, e.g. fan.*)', many: true },
]
// What the stream hands a widget: one sensor, a map of them (a glob), or null (no such sensor here).
export type WidgetData = Sensor | Record<string, Sensor> | null | undefined
const isSensor = (d: WidgetData): d is Sensor => !!d && typeof (d as Sensor).id === 'string'
// "cpu.core.*.usage" → "CPU core usage", "fan.*" → "Fans": what a pattern is called when nobody named it.
const FAMILY_TITLE: Record<string, string> = { cpu: 'CPU', mem: 'Memory', swap: 'Swap', temp: 'Temperatures', fan: 'Fans', net: 'Network', disk: 'Disks', fs: 'Filesystems', ups: 'UPS', zfs: 'ZFS', smart: 'Drive health', service: 'Services', services: 'Services', container: 'Containers', containers: 'Containers', updates: 'Updates', load: 'Load' }
export function patternTitle(sel: string): string {
const [first, ...rest] = sel.split('.')
const tail = rest.filter((x) => !/[*?[]/.test(x)).join(' ').replace(/_/g, ' ')
return [FAMILY_TITLE[first] ?? first, tail].filter(Boolean).join(' ')
}
const MAX_POINTS = 300
function Gauge({ s }: { s: Sensor }) {
const f = fraction(s) ?? 0, sev = severity(s)
const R = 40, C = Math.PI * R // half circle
return (
<svg viewBox="0 0 100 56" className="gauge-svg" role="img" aria-label={`${s.name} ${fmtValue(s.value, s.unit)}`}>
<path d="M10 50 A40 40 0 0 1 90 50" fill="none" stroke="var(--track)" strokeWidth="9" strokeLinecap="round" />
<path d="M10 50 A40 40 0 0 1 90 50" fill="none" stroke={sev === 'bad' ? 'var(--bad)' : sev === 'warn' ? 'var(--s2)' : 'var(--s1)'} strokeWidth="9" strokeLinecap="round"
strokeDasharray={`${f * C} ${C}`} style={{ transition: 'stroke-dasharray .3s' }} />
<text x="50" y="47" textAnchor="middle" className="w-gauge-v">{fmtValue(s.value, s.unit)}</text>
</svg>
)
}
function Spark({ s, src }: { s: Sensor; src?: Source }) {
const pts = useRef<number[]>([])
const lastT = useRef(0)
const stride = useRef(0) // how far apart the machine's own points are, as they arrive
const [, bump] = useState(0)
useEffect(() => {
let stop = false
pts.current = []; lastT.current = 0; stride.current = 0
fetchHistory(s.id, src).then((h) => {
if (stop || !h) return
pts.current = h.v.slice(-MAX_POINTS); lastT.current = h.t[h.t.length - 1] ?? 0
const t = h.t.slice(-MAX_POINTS)
const gaps = t.slice(1).map((x, i) => x - t[i]).sort((a, b) => a - b)
stride.current = gaps.length ? gaps[gaps.length >> 1] : 0 // the median: one long gap is a restart, not the spacing
bump((n) => n + 1)
})
return () => { stop = true }
}, [s.id, src?.machine, src?.token]) // eslint-disable-line react-hooks/exhaustive-deps
if (typeof s.value === 'number' && s.t > lastT.current) {
// a new point once a stride has passed; until then the newest point keeps being refined,
// so a wide window's curve isn't squeezed by a fast stream of live values
if (pts.current.length && s.t - lastT.current < stride.current) {
pts.current[pts.current.length - 1] = s.value
} else {
lastT.current = s.t
pts.current.push(s.value)
if (pts.current.length > MAX_POINTS) pts.current.shift()
}
}
return <Sparkline series={[{ name: s.name, values: pts.current, color: 'var(--s1)' }]} max={s.unit === '%' ? 100 : s.max} format={(v) => fmtValue(v, s.unit)} height={40} />
}
export function Widget({ spec, data, src }: { spec: WidgetSpec; data: WidgetData; src?: Source }) {
const title = spec.title || (isSensor(data) ? data.name : /[*?[]/.test(spec.sensor) ? patternTitle(spec.sensor) : spec.sensor)
let body
if (data === undefined) body = <div className="value muted">…</div>
else if (data === null) body = <div className="sub">no sensor <span className="mono">{spec.sensor}</span> on this machine</div>
else if (!isSensor(data)) {
const rows = Object.values(data).sort((a, b) => a.id.localeCompare(b.id))
body = rows.length === 0 ? <div className="sub">nothing matches <span className="mono">{spec.sensor}</span></div> : (
<div className="w-list">
{rows.map((s) => {
const f = fraction(s)
return (
<div className="w-row" key={s.id}>
<span className="k" title={s.id}>{s.name}</span>
<span className={'num ' + severity(s)}>{fmtValue(s.value, s.unit)}</span>
{f !== null && <div className="meter"><div className={'fill ' + severity(s)} style={{ width: `${f * 100}%` }} /></div>}
</div>
)
})}
</div>
)
} else if (spec.type === 'gauge' && fraction(data) !== null) body = <Gauge s={data} />
else {
const f = fraction(data)
body = (
<>
<div className={'value ' + severity(data)}>{fmtValue(data.value, data.unit)}</div>
{spec.type === 'bar' && f !== null && <div className="meter"><div className={'fill ' + severity(data)} style={{ width: `${f * 100}%` }} /></div>}
{spec.type === 'spark' && <Spark s={data} src={src} />}
</>
)
}
return <><div className="label" title={spec.sensor}>{title}</div>{body}</>
}
// The one query that feeds a set of widgets: each spec's id → its selector.
export const shapeOf = (specs: WidgetSpec[]) => (specs.length ? Object.fromEntries(specs.map((w) => [w.id, w.sensor])) : null)