// Every widget type, then every sensor on this machine, each drawn once and live. It is // the catalog made visible: what exists, what it's called, and what it looks like. import { useEffect, useMemo, useState } from 'react' import { Sensor, fetchCatalog, fraction, useSensorStream } from './sensors' import { WIDGET_TYPES, Widget, WidgetData, WidgetSpec, WidgetType } from './widgets' import { useRefreshMs } from './prefs' import { Loading, toast } from './ui' const FAMILY: Record = { cpu: 'CPU', load: 'Load', uptime: 'Uptime', mem: 'Memory', swap: 'Swap', temp: 'Temperatures', fan: 'Fans', net: 'Network', disk: 'Disks', fs: 'Filesystems', ups: 'UPS', zfs: 'ZFS', smart: 'Drive health', services: 'Services', service: 'Each service', containers: 'Containers', container: 'Each container', updates: 'Updates', hamlab: 'Ham Lab', } const ORDER = Object.keys(FAMILY) // The widget a sensor suits best: a gauge for a percentage, a bar for anything else with a // scale, a plain number or word for the rest. function best(s: Sensor): WidgetType { if (fraction(s) === null) return 'stat' return s.unit === '%' ? 'gauge' : 'bar' } const copy = async (text: string) => { try { await navigator.clipboard.writeText(text); toast(`copied ${text}`) } catch { toast(text) } } function Tile({ spec, data, note }: { spec: WidgetSpec; data: WidgetData; note: string }) { return ( ) } export default function Gallery() { const [cat, setCat] = useState(null) const [err, setErr] = useState('') const rate = useRefreshMs() const [find, setFind] = useState('') useEffect(() => { fetchCatalog().then(setCat).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, []) // Values only, at most once a second: the catalog already holds everything that doesn't change. const live = useSensorStream<[Record]>(['*'], { full: false, everyMs: Math.max(1000, rate) }) const now = Date.now() const sensors = useMemo(() => { const v = live.data?.[0] return (cat ?? []).map((s) => (v && v[s.id] !== undefined ? { ...s, value: v[s.id], t: now } : s)) }, [cat, live.data]) // eslint-disable-line react-hooks/exhaustive-deps if (!cat) return
{err ?
{err}
: }
const byId = new Map(sensors.map((s) => [s.id, s])) const families = new Map() const needle = find.trim().toLowerCase() const shown = needle ? sensors.filter((s) => s.id.includes(needle) || s.name.toLowerCase().includes(needle) || Object.values(s.labels ?? {}).some((l) => l.toLowerCase().includes(needle))) : sensors for (const s of shown) { const f = s.id.split('.')[0]; families.set(f, [...(families.get(f) ?? []), s]) } const names = [...families.keys()].sort((a, b) => (ORDER.indexOf(a) + 1 || 99) - (ORDER.indexOf(b) + 1 || 99) || a.localeCompare(b)) const demo = byId.get('cpu.usage') const cores = Object.fromEntries(sensors.filter((s) => /^cpu\.core\.\d+\.usage$/.test(s.id)).map((s) => [s.id, s])) return (
These widgets are for dashboards outside Ham Lab — Glance, Home Assistant, a wall display. Each one is a page at /embed/widget you put in an iframe, reading this machine through a read-only sensor token (Settings → Access tokens). Ham Lab shows you these numbers on its own pages; this tab is for handing them to something else.
{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
{!needle &&

Widget types

} {!needle &&
{demo && WIDGET_TYPES.filter((t) => !t.many).map((t) => )}
} {needle && shown.length === 0 &&
No sensor matches “{find}”.
} {names.map((f) => (

{FAMILY[f] ?? f} · {families.get(f)!.length} ·

{families.get(f)!.map((s) => )}
))}
) }