Files
hamlab/web/src/widgets.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 b05a053920 Sensors: read-only tokens, live stream, Prometheus, status sensors, widgets, embeds
- sensors:read tokens open the sensor endpoints (here and on managed machines
  through the hub) and nothing else; full tokens are never accepted from a URL
- /api/sensors/stream: a shaped query that keeps answering, over SSE
- /metrics: Prometheus text format
- services, updates, ZFS and SMART as background status sensors
- widget library over the sensor API; custom widgets on the dashboard
- /embed/widget: one live widget in its own bundle, the only frameable path
- Dashboard tab is now Monitors

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 07:37:10 -07:00

88 lines
4.5 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'
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 [, bump] = useState(0)
useEffect(() => {
let stop = false
pts.current = []; lastT.current = 0
fetchHistory(s.id, src).then((h) => { if (!stop && h) { pts.current = h.v.slice(-MAX_POINTS); lastT.current = h.t[h.t.length - 1] ?? 0; 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) {
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 : 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)