// 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 | 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 ( {fmtValue(s.value, s.unit)} ) } function Spark({ s, src }: { s: Sensor; src?: Source }) { const pts = useRef([]) 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 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 =
…
else if (data === null) body =
no sensor {spec.sensor} on this machine
else if (!isSensor(data)) { const rows = Object.values(data).sort((a, b) => a.id.localeCompare(b.id)) body = rows.length === 0 ?
nothing matches {spec.sensor}
: (
{rows.map((s) => { const f = fraction(s) return (
{s.name} {fmtValue(s.value, s.unit)} {f !== null &&
}
) })}
) } else if (spec.type === 'gauge' && fraction(data) !== null) body = else { const f = fraction(data) body = ( <>
{fmtValue(data.value, data.unit)}
{spec.type === 'bar' && f !== null &&
} {spec.type === 'spark' && } ) } return <>
{title}
{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)