import { useCallback, useEffect, useState } from 'react' import { fetchCatalog } from './sensors' import { api, Me, TokenInfo, TranscodeSetting, UpdateStatus } from './api' import { savePrefs } from './accents' import { applyTheme, currentThemeId, THEMES } from './themes' import { Btn, Loading, Spinner, askYes, toast, useEscape } from './ui' import { fmtMs, fmtWindowSec, getHistorySec, getRefreshMs, HISTORY_OPTIONS, HISTORY_POINTS, REFRESH_OPTIONS, setHistorySec, setRefreshMs, strideMs } from './prefs' const fmtUptime = (s: number) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` } // Small painted preview of a theme: rail, header, a tile and a bar, in its own colours. function Preview({ vars }: { vars: Record }) { const v = (k: string) => vars[k] return (
Ham Lab
CPU12%
$ _
) } export default function Settings({ me }: { me: Me }) { const [theme, setTheme] = useState(currentThemeId()) const [refresh, setRefresh] = useState(getRefreshMs()) const [history, setHistory] = useState(getHistorySec()) const [histErr, setHistErr] = useState('') const [tokens, setTokens] = useState(null) const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null) const [making, setMaking] = useState(false) const loadTokens = useCallback(async () => { try { setTokens(await api.tokens()) } catch { setTokens([]) } }, []) useEffect(() => { loadTokens() }, [loadTokens]) const pick = async (id: string) => { applyTheme(id); setTheme(id) await savePrefs() } const pickRefresh = async (ms: number) => { setRefreshMs(ms); setRefresh(ms) await savePrefs() } // The window is the machine's own setting, not a look: the hub takes it and passes it on. useEffect(() => { api.historyWindow().then((h) => { setHistory(h.seconds); setHistorySec(h.seconds) }).catch(() => {}) }, []) const pickHistory = async (sec: number) => { const was = history setHistory(sec); setHistorySec(sec); setHistErr('') try { const h = await api.setHistoryWindow(sec); setHistory(h.seconds); setHistorySec(h.seconds) } catch (e) { setHistory(was); setHistorySec(was); setHistErr(e instanceof Error ? e.message : 'failed') } } async function revoke(name: string) { if (!await askYes(`Revoke the token "${name}"? The hub holding it loses access to this host until it re-adds it.`)) return try { await api.revokeToken(name); toast(`${name} revoked`); loadTokens() } catch (err) { toast(err instanceof Error ? err.message : 'failed', 'err') } } return (

Appearance

follows you to every device you sign in on
{THEMES.map((t) => ( ))}

Refresh rate

every live view, on every machine
{REFRESH_OPTIONS.map((ms) => )}
Every page polls at exactly this rate: dashboard, processes, disks, services, cron, updates, machines. The sampler on each machine follows it too, so it is also the finest a graph can be — how far back the graphs reach is the next setting down. Fast rates cost CPU on the host, especially on the list pages, which run commands to refresh. Nothing is throttled behind your back.

Graph history

every graph, on every machine
{HISTORY_OPTIONS.map((sec) => )}
{histErr &&
{histErr}
}
How far back every sparkline reaches — the dashboard, the processes page, the disks page and any sparkline widget. Each machine keeps {HISTORY_POINTS} points per curve whatever you pick, so a wider window is a coarser one: {fmtWindowSec(history)} puts a point every{' '} {fmtWindowSec(strideMs(history, refresh) / 1000)}, each the average of what happened in between rather than one instant out of it. Memory on the machine doesn't change. The history is kept in memory, so a machine that restarts starts its graphs again.

Access tokens

A full token lets another Ham Lab (a hub) manage this host; hubs issue and revoke their own. A read-only token can only read sensors — that's the one to give a dashboard.
{tokens === null && } {tokens && tokens.length === 0 &&
No tokens. No hub manages this host.
} {tokens && tokens.map((t) => (
{t.name} {t.scope ? (t.sensors?.length ? `read-only · ${t.sensors.join(' ')}` : 'read-only') : 'full'} issued {new Date(t.created).toLocaleString()}{t.issued_by ? ` by ${t.issued_by}` : ''} revoke(t.name)}>Revoke
))} {fresh && (
Token for {fresh.name} — shown once, copy it now:
{fresh.token}
{ try { await navigator.clipboard.writeText(fresh.token); toast('copied') } catch { toast('select and copy it by hand', 'err') } }}>Copy{' '} setFresh(null)}>Done
)}
setMaking(true)}>+ Read-only sensor token
{making && setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}

About

Version{me.version}
Host{me.host} · {me.arch}
Runs as{me.user}
Uptime{fmtUptime(me.uptime)}
{me.fingerprint &&
Certificate{me.fingerprint}
}
) } // What is published, and installing it. An update replaces the binary and restarts the // daemon, which drops open terminals — so it only ever happens when it is asked for. function Updates({ me }: { me: Me }) { const [st, setSt] = useState(null) const [busy, setBusy] = useState('') const [err, setErr] = useState('') const load = useCallback(async () => { try { setSt(await api.update()) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, []) useEffect(() => { load() }, [load]) const check = async () => { setBusy('check'); setErr('') try { setSt(await api.checkUpdate()) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy('') } } const install = async (force = false) => { if (!st) return if (!await askYes(`Install ${st.latest} on ${me.host}? Ham Lab restarts, which closes open terminals on this machine.`, 'Install')) return setBusy('install'); setErr('') try { await api.installUpdate(st.latest, force) toast(`${st.latest} installed — reconnecting`) setTimeout(() => window.location.reload(), 4000) } catch (e) { const m = e instanceof Error ? e.message : 'failed' setBusy('') if (/^busy:/.test(m) && await askYes(m.replace(/^busy: /, '') + '. Install anyway?', 'Install anyway')) { install(true); return } setErr(m) } } return ( <>

Updates

{st?.auto ? 'checked daily' : st?.enabled ? 'checked when you ask' : 'switched off'}
Installed{st?.current ?? me.version}
Published{st?.checking ? : st?.latest || '—'}
Checked{st?.checked ? new Date(st.checked).toLocaleString() : 'not yet'}
Source{st?.enabled ? sourceName(st.url) : 'off'}
{st?.behind && (
{st.latest} is published; this machine runs {st.current}. {st.published && Released {new Date(st.published).toLocaleDateString()}.} {st.notes &&
{st.notes.trim()}
}
)} {st && !st.behind && st.latest &&
{st.current.replace(/^v/, '') === st.latest.replace(/^v/, '') ? 'This is the newest published build.' : `This build is newer than the last published release (${st.latest}).`}
} {(err || st?.error) &&
{err || st?.error}
}
Check for updates {st?.behind && install()}>Install {st.latest}} Installing restarts Ham Lab on this machine. Terminals here close; the shells do not survive it.
) } // A source shown as something readable: the repo it is, or the host it is on. function sourceName(url: string): string { try { const u = new URL(url) const m = u.pathname.match(/\/api\/v1\/repos\/(.+)$/) return m ? `${u.host}/${m[1]}` : u.host + u.pathname } catch { return url } } // One dialog for a read-only token: what will use it, and (optionally) which sensor // families it may see — picked from this machine's own catalog, or typed as patterns. function TokenDialog({ onClose, onMade }: { onClose: () => void; onMade: (t: { name: string; token: string }) => void }) { const [name, setName] = useState('glance') const [pats, setPats] = useState('') const [fams, setFams] = useState([]) const [err, setErr] = useState('') useEscape(onClose) useEffect(() => { fetchCatalog({ machine: null }).then((c) => setFams([...new Set(c.map((s) => s.id.split('.')[0]))].sort())).catch(() => {}) }, []) const list = pats.split(',').map((x) => x.trim()).filter(Boolean) const toggle = (f: string) => { const p = f === 'uptime' ? f : f + '.*'; setPats((list.includes(p) ? list.filter((x) => x !== p) : [...list, p]).join(', ')) } const make = async () => { try { onMade(await api.createReadToken(name.trim(), list)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } return (
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); make() }}>

Read-only sensor token

{fams.map((f) => )}
{err &&
{err}
}
) } // Where videos played in Files are made: one device on one machine, for every machine's files. // Every machine's devices are asked for live; one that can't be used says why. function TranscodeDevice() { const [t, setT] = useState(null) const [err, setErr] = useState('') const [busy, setBusy] = useState('') useEffect(() => { api.transcode().then(setT).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, []) const set = async (node: string, device: string) => { setBusy(node + '|' + device); setErr('') try { setT(await api.setTranscode(node, device)) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } setBusy('') } const sel = t?.selected return ( <>

Transcode device

videos in Files, on every machine
{!t && !err &&
asking every machine what it can encode on…
} {t && (
Anywhere
{t.machines.map((m) => (
{m.host}{m.node === '' && · this hub}
{m.error &&
{m.error}
}
{(m.devices ?? []).map((d) => { const on = !!sel && sel.node === m.node && sel.device === d.id return ( ) })}
))}
)} {err &&
{err}
}
Opening a video in Files plays it YouTube's way — Auto picks the quality the connection takes, any quality can be picked by hand, or the file as it is — and every quality is made on the fly, from wherever you seek to, on this device. When it is on another machine than the file, that machine reads the file through this hub. If it can't be reached, the file's own machine makes it instead.
) }