import { FormEvent, useCallback, useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' import { fetchCatalog } from './sensors' import { api, Me, Session, TokenInfo, User } from './api' import { savePrefs } from './accents' import { applyTheme, currentThemeId, THEMES } from './themes' import { Btn, Loading, 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 [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) 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 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 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.

Account · {me.account ?? '?'}@{me.host}

{pwErr &&
{pwErr}
}
Your login on this host only. Other machines keep their own accounts. Your 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()}{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}
}
) } // Where this account is signed in. Revoking one signs that device out at its next request. function Sessions() { const [list, setList] = useState(null) const load = useCallback(async () => { try { setList(await api.sessions()) } catch { setList([]) } }, []) useEffect(() => { load() }, [load]) const revoke = async (s: Session) => { if (s.current && !await askYes('Sign out this device? You will be back at the login form.')) return try { await api.revokeSession(s.id); toast(s.current ? 'signed out' : 'session revoked'); if (s.current) window.dispatchEvent(new Event('hamlab:unauthenticated')); else load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } const others = list?.filter((s) => !s.current).length ?? 0 const revokeOthers = async () => { if (!await askYes(`Sign out ${others} other ${others === 1 ? 'device' : 'devices'}? This one stays signed in.`)) return try { const r = await api.revokeOtherSessions(); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } return ( <>

Sessions

where you are signed in
{list === null && } {list && list.map((s) => (
{device(s.agent)}{s.current && this device} {s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()} revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}
))} {others > 0 &&
Sign out everywhere else
}
) } // The accounts on this host. Anyone signed in may add one; removing one, or resetting a // password, asks for your own password first. function Users({ me }: { me: Me }) { const [list, setList] = useState(null) const [name, setName] = useState('') const [pw, setPw] = useState('') const [err, setErr] = useState('') const [reset, setReset] = useState(null) const load = useCallback(async () => { try { setList(await api.users()) } catch { setList([]) } }, []) useEffect(() => { load() }, [load]) const add = async (e: FormEvent) => { e.preventDefault(); setErr('') if (pw.length < 12) { setErr('At least 12 characters.'); return } try { await api.addUser(name.trim(), pw); toast(`${name.trim()} added`); setName(''); setPw(''); load() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } const remove = async (u: User) => { const cur = await askPassword(`Remove the account "${u.name}"? Their sessions end and their settings are gone. Your password to confirm:`) if (cur === null) return try { await api.removeUser(u.name, cur); toast(`${u.name} removed`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } return ( <>

Users · {me.host}

each with their own look and dashboard
{list === null && } {list && list.map((u) => (
{u.name}{u.self && you} {u.sessions} {u.sessions === 1 ? 'session' : 'sessions'} · seen {ago(u.last_seen)} · since {new Date(u.created).toLocaleDateString()} {!u.self && setReset(u)}>Reset password} {!u.self && remove(u)}>Remove}
))}
{err &&
{err}
}
Every user can do everything a signed-in console can. Accounts are this host's own; a hub reaches other machines with its token, not with these.
{reset && setReset(null)} onDone={() => { setReset(null); load() }} />} ) } function ResetDialog({ user, onClose, onDone }: { user: User; onClose: () => void; onDone: () => void }) { const [cur, setCur] = useState('') const [pw, setPw] = useState('') const [err, setErr] = useState('') useEscape(onClose) const go = async () => { setErr('') if (pw.length < 12) { setErr('At least 12 characters.'); return } try { await api.resetPassword(user.name, cur, pw); toast(`password of ${user.name} reset`); onDone() } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } } return (
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); go() }}>

Reset the password of {user.name}

They are signed out everywhere and sign in again with the new one.
{err &&
{err}
}
) } // A password typed into a modal; null when cancelled. function askPassword(prompt: string): Promise { return new Promise((resolve) => { const host = document.createElement('div') document.body.appendChild(host) const done = (v: string | null) => { root.unmount(); host.remove(); resolve(v) } const root = createRoot(host) root.render() }) } function PasswordDialog({ prompt, onDone }: { prompt: string; onDone: (v: string | null) => void }) { const [v, setV] = useState('') useEscape(() => onDone(null)) return (
onDone(null)}>
e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onDone(v) }}>
{prompt}
setV(e.target.value)} autoComplete="current-password" />
) } // A short name for a browser from its user agent: "Safari on iPhone", "Firefox on Linux". function device(ua?: string): string { if (!ua) return 'unknown device' const os = /iPhone/.test(ua) ? 'iPhone' : /iPad/.test(ua) ? 'iPad' : /Android/.test(ua) ? 'Android' : /Mac OS X/.test(ua) ? 'Mac' : /Windows/.test(ua) ? 'Windows' : /CrOS/.test(ua) ? 'ChromeOS' : /Linux/.test(ua) ? 'Linux' : '' const br = /Edg\//.test(ua) ? 'Edge' : /OPR\//.test(ua) ? 'Opera' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : /curl\//.test(ua) ? 'curl' : ua.split(/[\s/]/)[0] || 'browser' return os ? `${br} on ${os}` : br } function ago(iso?: string): string { if (!iso) return 'never' const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000) if (s < 60) return 'just now' if (s < 3600) return `${Math.floor(s / 60)} min ago` if (s < 86400) return `${Math.floor(s / 3600)} h ago` return `${Math.floor(s / 86400)} d ago` } // 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}
}
) }