From b1c14041819950ae195eb177758e98be32b93e9d Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sat, 19 Sep 2026 08:29:15 -0700 Subject: [PATCH] Widgets tab: every widget type and every sensor, live, one of each Co-Authored-By: Claude Fable 5.1 --- SENSORS.md | 3 ++ web/src/Gallery.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++ web/src/Shell.tsx | 9 ++++-- web/src/styles.css | 13 ++++++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 web/src/Gallery.tsx diff --git a/SENSORS.md b/SENSORS.md index 377babc..2634379 100644 --- a/SENSORS.md +++ b/SENSORS.md @@ -150,6 +150,9 @@ sensor's own `min`/`max`/`warn`/`crit`. - **In Ham Lab:** Monitors → Customize → **+ Add widget**. Saved with the layout, 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 bundle (the console never loads), and the only path Ham Lab allows to be framed. diff --git a/web/src/Gallery.tsx b/web/src/Gallery.tsx new file mode 100644 index 0000000..e2a0500 --- /dev/null +++ b/web/src/Gallery.tsx @@ -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 = { + 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() + 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() + 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 ( +
+
{sensors.length} sensors on this machine · live · tap a tile to copy its ID
+ +

Widget types

+
+ {demo && WIDGET_TYPES.filter((t) => !t.many).map((t) => )} + +
+ + {names.map((f) => ( +
+

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

+
+ {families.get(f)!.map((s) => )} +
+
+ ))} +
+ ) +} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 21d2771..bbdfb79 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -5,6 +5,7 @@ import TerminalPane, { TermHandle } from './TerminalPane' import TermTabs from './TermTabs' import KeyBar from './KeyBar' import Dashboard from './Dashboard' +import Gallery from './Gallery' import Services from './Services' import Cron from './Cron' import Updates from './Updates' @@ -28,6 +29,7 @@ const FolderIcon = ( const GROUPS: Group[] = [ { id: 'dashboard', label: 'Monitors', icon: '◔' }, + { id: 'widgets', label: 'Widgets', icon: '▦' }, { id: 'procs', label: 'Processes', icon: '≡' }, { id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'services', label: 'Services', icon: '◈' }, @@ -39,8 +41,8 @@ const GROUPS: Group[] = [ { id: 'settings', label: 'Settings', icon: '⚙' }, ] -type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings' -const VIEWS: 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', 'widgets', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings'] const HUB_ONLY: View[] = ['machines', 'settings'] // URL hash is the source of truth for where you are: #//. Back and @@ -177,6 +179,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'settings' && } {view === 'machines' && { switchMachine(n); setView('dashboard') }} />} {view === 'dashboard' && } + {view === 'widgets' && } {view === 'procs' && } {view === 'services' && } {view === 'cron' && } @@ -207,7 +210,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {machine ? `${machine} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {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 : ''} + {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 : ''} ) diff --git a/web/src/styles.css b/web/src/styles.css index 72f6f6c..ab29c44 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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.bare { background: transparent; } .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)); } }