Widgets tab: every widget type and every sensor, live, one of each

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 08:29:15 -07:00
co-authored by Claude Fable 5.1
parent 4c2eb57ea6
commit b1c1404181
4 changed files with 97 additions and 3 deletions
+3
View File
@@ -150,6 +150,9 @@ sensor's own `min`/`max`/`warn`/`crit`.
- **In Ham Lab:** Monitors → Customize → **+ Add widget**. Saved with the layout, - **In Ham Lab:** Monitors → Customize → **+ Add widget**. Saved with the layout,
per machine; all of a page's widgets share one stream. per machine; all of a page's widgets share one stream.
- **Widgets tab:** every widget type, then every sensor on the machine drawn once, live,
grouped by family (`web/src/Gallery.tsx`). Tap a tile to copy its sensor ID — the
quickest way to find what to put in a widget, a query or a Glance tile.
- **Anywhere else:** `/embed/widget` is one widget alone on a page, live, in its own small - **Anywhere else:** `/embed/widget` is one widget alone on a page, live, in its own small
bundle (the console never loads), and the only path Ham Lab allows to be framed. bundle (the console never loads), and the only path Ham Lab allows to be framed.
+75
View File
@@ -0,0 +1,75 @@
// 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()
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[]>()
for (const s of sensors) { 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="dash-bar"><span className="hint">{sensors.length} sensors on this machine · live · tap a tile to copy its ID</span></div>
<h2 className="section">Widget types</h2>
<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>
{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>
)
}
+6 -3
View File
@@ -5,6 +5,7 @@ import TerminalPane, { TermHandle } from './TerminalPane'
import TermTabs from './TermTabs' import TermTabs from './TermTabs'
import KeyBar from './KeyBar' import KeyBar from './KeyBar'
import Dashboard from './Dashboard' import Dashboard from './Dashboard'
import Gallery from './Gallery'
import Services from './Services' import Services from './Services'
import Cron from './Cron' import Cron from './Cron'
import Updates from './Updates' import Updates from './Updates'
@@ -28,6 +29,7 @@ const FolderIcon = (
const GROUPS: Group[] = [ const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Monitors', icon: '◔' }, { id: 'dashboard', label: 'Monitors', icon: '◔' },
{ id: 'widgets', label: 'Widgets', icon: '▦' },
{ id: 'procs', label: 'Processes', icon: '≡' }, { id: 'procs', label: 'Processes', icon: '≡' },
{ id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '◈' }, { id: 'services', label: 'Services', icon: '◈' },
@@ -39,8 +41,8 @@ const GROUPS: Group[] = [
{ id: 'settings', label: 'Settings', icon: '⚙' }, { id: 'settings', label: 'Settings', icon: '⚙' },
] ]
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings' type View = 'terminal' | 'dashboard' | 'widgets' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
const VIEWS: View[] = ['terminal', 'dashboard', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings'] const VIEWS: View[] = ['terminal', 'dashboard', 'widgets', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings']
const HUB_ONLY: View[] = ['machines', 'settings'] const HUB_ONLY: View[] = ['machines', 'settings']
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and // URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
@@ -177,6 +179,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
{view === 'settings' && <Settings me={hubMe} />} {view === 'settings' && <Settings me={hubMe} />}
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />} {view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
{view === 'dashboard' && <Dashboard />} {view === 'dashboard' && <Dashboard />}
{view === 'widgets' && <Gallery />}
{view === 'procs' && <Procs />} {view === 'procs' && <Procs />}
{view === 'services' && <Services />} {view === 'services' && <Services />}
{view === 'cron' && <Cron />} {view === 'cron' && <Cron />}
@@ -207,7 +210,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
<span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span> <span>{machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
<span>up {fmtUptime(me.uptime)}</span> <span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" /> <span className="spacer" />
<span className="muted">{view === 'dashboard' ? 'Monitors' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span> <span className="muted">{view === 'dashboard' ? 'Monitors' : view === 'widgets' ? 'Widgets' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
</footer> </footer>
</div> </div>
) )
+13
View File
@@ -414,3 +414,16 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
.embed { min-height: 100vh; padding: 10px; background: var(--bg2); box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; } .embed { min-height: 100vh; padding: 10px; background: var(--bg2); box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; }
.embed.bare { background: transparent; } .embed.bare { background: transparent; }
.embed .tile { border: 0; padding: 0; background: none; } .embed .tile { border: 0; padding: 0; background: none; }
/* widget gallery */
.gallery { overflow-x: hidden; }
.gallery section { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.gallery h2.section { margin: 10px 0 0; font-size: 13px; }
.gallery-tile { min-width: 0; max-width: 100%; overflow: hidden; }
.gallery-tile .label, .kpis.widgets .tile .label { overflow-wrap: anywhere; } /* container names run long */
.gallery .kpis { grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); }
.gallery-tile { text-align: left; cursor: pointer; display: flex; flex-direction: column; color: inherit; font: inherit; }
.gallery-tile:hover { border-color: var(--accent2); }
.gallery-tile .value { font-size: 18px; overflow-wrap: anywhere; }
.gallery-id { margin-top: auto; padding-top: 8px; font-size: 10.5px; color: var(--muted); overflow-wrap: anywhere; }
@media (max-width: 800px) { .gallery .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }