Files
hamlab/web/src/Gallery.tsx
T
Mitchell-JohnsonandClaude Opus 5 9bf60deffc Retire the Dashboard tab; Processes is the front page
The app repeated itself. The module list, /api/prefs/dashboard, applyPrefs and the
per-user dashboard blob are gone. Widgets stays and now says plainly that it is for
dashboards outside Ham Lab.

This also closes a bug: applyPrefs set the sampling rate from intervalMs inside the
dashboard layout, and reset it to the default when that field was absent — so with
per-user layouts, one account saving a dashboard could reset the sampling rate for the
whole machine. The sampler now has one source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 23:03:49 -07:00

91 lines
5.2 KiB
TypeScript

// 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<string, string> = {
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 (
<button className={'tile gallery-tile w-' + spec.type} title="copy the sensor ID" onClick={() => copy(spec.sensor)}>
<Widget spec={spec} data={data} />
<div className="gallery-id mono">{note}</div>
</button>
)
}
export default function Gallery() {
const [cat, setCat] = useState<Sensor[] | null>(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<string, Sensor['value']>]>(['*'], { 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 <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Reading the catalog…" />}</div>
const byId = new Map(sensors.map((s) => [s.id, s]))
const families = new Map<string, Sensor[]>()
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 (
<div className="dash gallery">
<div className="notice" style={{ marginBottom: 10 }}>
These widgets are for <b>dashboards outside Ham Lab</b> — Glance, Home Assistant, a wall
display. Each one is a page at <span className="mono">/embed/widget</span> you put in an
iframe, reading this machine through a <b>read-only sensor token</b> (Settings → Access
tokens). Ham Lab shows you these numbers on its own pages; this tab is for handing them
to something else.
</div>
<div className="dash-bar">
<span className="hint">{needle ? `${shown.length} of ${sensors.length}` : sensors.length} sensors on this machine · live · tap a tile to copy its ID</span>
<input className="gallery-find" value={find} onChange={(e) => setFind(e.target.value)} placeholder="find — gitea, temp, enp3s0…" spellCheck={false} autoCapitalize="off" />
<span className="spacer" />
</div>
{!needle && <h2 className="section">Widget types</h2>}
{!needle && <div className="kpis">
{demo && WIDGET_TYPES.filter((t) => !t.many).map((t) => <Tile key={t.id} spec={{ id: t.id, type: t.id, sensor: 'cpu.usage', title: t.label }} data={demo} note={`type=${t.id}`} />)}
<Tile spec={{ id: 'list', type: 'list', sensor: 'cpu.core.*.usage', title: 'List' }} data={cores} note="type=list · cpu.core.*.usage" />
</div>}
{needle && shown.length === 0 && <div className="muted">No sensor matches “{find}”.</div>}
{names.map((f) => (
<section key={f}>
<h2 className="section">{FAMILY[f] ?? f} <span className="muted">· {families.get(f)!.length} · <button className="link mono" onClick={() => copy(f + '.*')}>{f}.*</button></span></h2>
<div className="kpis">
{families.get(f)!.map((s) => <Tile key={s.id} spec={{ id: s.id, type: best(s), sensor: s.id }} data={s} note={s.id} />)}
</div>
</section>
))}
</div>
)
}