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:
co-authored by
Claude Fable 5.1
parent
4c2eb57ea6
commit
b1c1404181
@@ -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
@@ -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: #/<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 === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('dashboard') }} />}
|
||||
{view === 'dashboard' && <Dashboard />}
|
||||
{view === 'widgets' && <Gallery />}
|
||||
{view === 'procs' && <Procs />}
|
||||
{view === 'services' && <Services />}
|
||||
{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>up {fmtUptime(me.uptime)}</span>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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)); } }
|
||||
|
||||
Reference in New Issue
Block a user