import { FormEvent, useCallback, useEffect, useState } from 'react' import { fetchCatalog } from './sensors' import { api, Me } from './api' import { applyTheme, currentThemeId, THEMES } from './themes' import { Btn, Loading, askYes, toast, useEscape } from './ui' import { fmtMs, getRefreshMs, REFRESH_OPTIONS, setRefreshMs } 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 [tokens, setTokens] = useState<{ name: string; created: string; scope?: string; sensors?: string[] }[] | null>(null) const [fresh, setFresh] = useState<{ name: string; token: string } | null>(null) const [making, setMaking] = useState(false) const [cur, setCur] = useState('') const [pw1, setPw1] = useState('') const [pw2, setPw2] = useState('') const [pwErr, setPwErr] = useState('') const loadTokens = useCallback(async () => { try { setTokens(await api.tokens()) } catch { setTokens([]) } }, []) useEffect(() => { loadTokens() }, [loadTokens]) const pick = async (id: string) => { applyTheme(id); setTheme(id) try { await api.saveUIPrefs({ theme: id, refreshMs: getRefreshMs() }) } catch { /* local still applied */ } } const pickRefresh = async (ms: number) => { setRefreshMs(ms); setRefresh(ms) try { await api.saveUIPrefs({ theme: currentThemeId(), refreshMs: ms }) } catch { /* local still applied */ } } async function changePassword(e: FormEvent) { e.preventDefault() setPwErr('') if (pw1 !== pw2) { setPwErr('New passwords differ.'); return } if (pw1.length < 12) { setPwErr('At least 12 characters.'); return } try { await api.changePassword(cur, pw1); toast('password changed'); setCur(''); setPw1(''); setPw2('') } catch (err) { setPwErr(err instanceof Error ? err.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 charts keep 300 points and cover 300 × rate. Fast rates cost CPU on the host, especially on the list pages, which run commands to refresh. Nothing is throttled behind your back.

Account · {me.host}

{pwErr &&
{pwErr}
}
Changes the login for this host only. Other machines keep their own password. Existing sessions stay signed in.

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()} 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}
}
) } // 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}
}
) }