Files
hamlab/web/src/Settings.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 2b54a06821 Users, with their own settings, and a list of where each is signed in
The login is a user name and a password. The old single password becomes the account
named after the OS user on first boot, along with its sessions and the machine-wide look.
Settings gains Users (add, remove, reset a password — your own password confirms) and
Sessions (device, IP, last seen; revoke one or sign out everywhere else). Theme, accents
and the dashboard layout follow the account; the refresh rate, graph history and alerts
stay the machine's. Pairing a machine takes a user on it. Log lines name the actor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 18:20:30 -07:00

350 lines
22 KiB
TypeScript

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<string, string> }) {
const v = (k: string) => vars[k]
return (
<div className="tprev" style={{ background: v('--bg'), borderColor: v('--border') }}>
<div className="tprev-head" style={{ background: v('--bg2'), borderColor: v('--border') }}><span style={{ color: v('--accent') }}>Ham Lab</span><span className="tprev-dot" style={{ background: v('--ok') }} /></div>
<div className="tprev-body">
<div className="tprev-rail" style={{ background: v('--bg2'), borderColor: v('--border') }}>
<span style={{ background: v('--bg3'), boxShadow: `inset 2px 0 0 ${v('--accent')}` }} /><span /><span />
</div>
<div className="tprev-main">
<div className="tprev-tile" style={{ background: v('--bg2'), borderColor: v('--border') }}>
<i style={{ color: v('--muted') }}>CPU</i><b style={{ color: v('--fg') }}>12%</b>
<div className="tprev-bar" style={{ background: v('--track') }}><div style={{ width: '40%', background: v('--s1') }} /></div>
</div>
<div className="tprev-term" style={{ background: v('--term-bg'), color: v('--accent2') }}>$ _</div>
</div>
</div>
</div>
)
}
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<TokenInfo[] | 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)
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 (
<div className="dash settings">
<div className="dash-bar"><h2 className="section">Appearance</h2><span className="spacer" /><span className="muted small">follows you to every device you sign in on</span></div>
<div className="themes">
{THEMES.map((t) => (
<button key={t.id} className={'theme-card' + (theme === t.id ? ' on' : '')} onClick={() => pick(t.id)}>
<Preview vars={t.vars} />
<div className="theme-name">{t.name}{theme === t.id && <span className="badge">active</span>}</div>
<div className="muted small">{t.blurb}</div>
</button>
))}
</div>
<div className="dash-bar"><h2 className="section">Refresh rate</h2><span className="spacer" /><span className="muted small">every live view, on every machine</span></div>
<section className="card">
<div className="chips">
{REFRESH_OPTIONS.map((ms) => <button key={ms} className={'chip' + (refresh === ms ? ' on' : '')} onClick={() => pickRefresh(ms)}>{fmtMs(ms)}</button>)}
</div>
<div className="muted small" style={{ marginTop: 8 }}>
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.
</div>
</section>
<div className="dash-bar"><h2 className="section">Graph history</h2><span className="spacer" /><span className="muted small">every graph, on every machine</span></div>
<section className="card">
<div className="chips">
{HISTORY_OPTIONS.map((sec) => <button key={sec} className={'chip' + (history === sec ? ' on' : '')} onClick={() => pickHistory(sec)}>{fmtWindowSec(sec)}</button>)}
</div>
{histErr && <div className="error" style={{ marginTop: 8 }}>{histErr}</div>}
<div className="muted small" style={{ marginTop: 8 }}>
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: <b>{fmtWindowSec(history)}</b> 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.
</div>
</section>
<div className="dash-bar"><h2 className="section">Account · {me.account ?? '?'}@{me.host}</h2></div>
<form className="card addform" onSubmit={changePassword}>
<label>Current password <input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" required /></label>
<label>New password <input type="password" value={pw1} onChange={(e) => setPw1(e.target.value)} autoComplete="new-password" required /></label>
<label>New password again <input type="password" value={pw2} onChange={(e) => setPw2(e.target.value)} autoComplete="new-password" required /></label>
{pwErr && <div className="error">{pwErr}</div>}
<div className="svc-actions"><button className="btn on" disabled={!cur || !pw1 || !pw2}>Change password</button><span className="muted small">Your login on this host only. Other machines keep their own accounts. Your sessions stay signed in.</span></div>
</form>
<Sessions />
<Users me={me} />
<div className="dash-bar"><h2 className="section">Access tokens</h2></div>
<section className="card">
<div className="muted small" style={{ marginBottom: 8 }}>A <b>full</b> token lets another Ham Lab (a hub) manage this host; hubs issue and revoke their own. A <b>read-only</b> token can only read sensors — that's the one to give a dashboard.</div>
{tokens === null && <Loading label="Loading…" />}
{tokens && tokens.length === 0 && <div className="empty">No tokens. No hub manages this host.</div>}
{tokens && tokens.map((t) => (
<div className="row" key={t.name}>
<span className="k mono">{t.name}</span>
<span className="badge" style={{ marginLeft: 0 }} title={t.sensors?.length ? t.sensors.join(', ') : undefined}>{t.scope ? (t.sensors?.length ? `read-only · ${t.sensors.join(' ')}` : 'read-only') : 'full'}</span>
<span className="muted small">issued {new Date(t.created).toLocaleString()}{t.issued_by ? ` by ${t.issued_by}` : ''}</span>
<Btn className="btn danger" onClick={() => revoke(t.name)}>Revoke</Btn>
</div>
))}
{fresh && (
<div className="notice attn" style={{ marginTop: 8 }}>
<div>Token for <b>{fresh.name}</b> — shown once, copy it now:</div>
<div className="mono small" style={{ wordBreak: 'break-all', userSelect: 'all', margin: '6px 0' }}>{fresh.token}</div>
<Btn className="btn" onClick={async () => { try { await navigator.clipboard.writeText(fresh.token); toast('copied') } catch { toast('select and copy it by hand', 'err') } }}>Copy</Btn>{' '}
<Btn className="btn" onClick={() => setFresh(null)}>Done</Btn>
</div>
)}
<div className="svc-actions" style={{ marginTop: 8 }}>
<Btn className="btn" onClick={() => setMaking(true)}>+ Read-only sensor token</Btn>
</div>
</section>
{making && <TokenDialog onClose={() => setMaking(false)} onMade={(t) => { setMaking(false); setFresh(t); loadTokens() }} />}
<div className="dash-bar"><h2 className="section">About</h2></div>
<section className="card">
<div className="props">
<div><span className="k">Version</span><span className="mono">{me.version}</span></div>
<div><span className="k">Host</span><span>{me.host} · {me.arch}</span></div>
<div><span className="k">Runs as</span><span>{me.user}</span></div>
<div><span className="k">Uptime</span><span>{fmtUptime(me.uptime)}</span></div>
{me.fingerprint && <div><span className="k">Certificate</span><span className="mono small">{me.fingerprint}</span></div>}
</div>
</section>
</div>
)
}
// Where this account is signed in. Revoking one signs that device out at its next request.
function Sessions() {
const [list, setList] = useState<Session[] | null>(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 (
<>
<div className="dash-bar"><h2 className="section">Sessions</h2><span className="spacer" /><span className="muted small">where you are signed in</span></div>
<section className="card">
{list === null && <Loading label="Loading…" />}
{list && list.map((s) => (
<div className="row" key={s.id}>
<span className="k">{device(s.agent)}{s.current && <span className="badge" style={{ marginLeft: 6 }}>this device</span>}</span>
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}</span>
<Btn className={'btn' + (s.current ? '' : ' danger')} onClick={() => revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}</Btn>
</div>
))}
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}><Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn></div>}
</section>
</>
)
}
// 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<User[] | null>(null)
const [name, setName] = useState('')
const [pw, setPw] = useState('')
const [err, setErr] = useState('')
const [reset, setReset] = useState<User | null>(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 (
<>
<div className="dash-bar"><h2 className="section">Users · {me.host}</h2><span className="spacer" /><span className="muted small">each with their own look and dashboard</span></div>
<section className="card">
{list === null && <Loading label="Loading…" />}
{list && list.map((u) => (
<div className="row" key={u.name}>
<span className="k mono">{u.name}{u.self && <span className="badge" style={{ marginLeft: 6 }}>you</span>}</span>
<span className="muted small">{u.sessions} {u.sessions === 1 ? 'session' : 'sessions'} · seen {ago(u.last_seen)} · since {new Date(u.created).toLocaleDateString()}</span>
<span style={{ display: 'flex', gap: 6 }}>
{!u.self && <Btn className="btn" onClick={() => setReset(u)}>Reset password</Btn>}
{!u.self && <Btn className="btn danger" onClick={() => remove(u)}>Remove</Btn>}
</span>
</div>
))}
</section>
<form className="card addform" onSubmit={add} style={{ marginTop: 8 }}>
<label>New user <input value={name} onChange={(e) => setName(e.target.value)} pattern="[a-z0-9][a-z0-9._\-]{0,31}" title="lower-case letters, digits, . _ and -" autoComplete="off" spellCheck={false} autoCapitalize="off" required /></label>
<label>Their password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" required /></label>
{err && <div className="error">{err}</div>}
<div className="svc-actions"><button className="btn on" disabled={!name || !pw}>Add user</button><span className="muted small">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.</span></div>
</form>
{reset && <ResetDialog user={reset} onClose={() => 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 (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); go() }}>
<h3>Reset the password of {user.name}</h3>
<label className="muted">Their new password<input type="password" autoFocus value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" /></label>
<label className="muted">Your password, to confirm<input type="password" value={cur} onChange={(e) => setCur(e.target.value)} autoComplete="current-password" /></label>
<div className="muted small">They are signed out everywhere and sign in again with the new one.</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions"><button type="submit" className="btn on" disabled={!cur || !pw}>Reset</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
</form>
</div>
)
}
// A password typed into a modal; null when cancelled.
function askPassword(prompt: string): Promise<string | null> {
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(<PasswordDialog prompt={prompt} onDone={done} />)
})
}
function PasswordDialog({ prompt, onDone }: { prompt: string; onDone: (v: string | null) => void }) {
const [v, setV] = useState('')
useEscape(() => onDone(null))
return (
<div className="modal-back" onClick={() => onDone(null)}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); onDone(v) }}>
<div>{prompt}</div>
<input type="password" autoFocus value={v} onChange={(e) => setV(e.target.value)} autoComplete="current-password" />
<div className="svc-actions"><button type="submit" className="btn danger" disabled={!v}>Confirm</button><span className="spacer" /><button type="button" className="btn" onClick={() => onDone(null)}>Cancel</button></div>
</form>
</div>
)
}
// 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<string[]>([])
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 (
<div className="modal-back" onClick={onClose}>
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); make() }}>
<h3>Read-only sensor token</h3>
<label className="muted">Name — what will use it
<input autoFocus value={name} onChange={(e) => setName(e.target.value)} maxLength={64} spellCheck={false} autoCapitalize="off" />
</label>
<label className="muted">Limit it to these sensors — empty means every sensor
<input className="mono" value={pats} onChange={(e) => setPats(e.target.value)} placeholder="cpu.*, temp.*, zfs.tank.*" spellCheck={false} autoCapitalize="off" />
</label>
<div className="chips">{fams.map((f) => <button type="button" key={f} className={'chip' + (list.includes(f === 'uptime' ? f : f + '.*') ? ' on' : '')} onClick={() => toggle(f)}>{f}</button>)}</div>
{err && <div className="error">{err}</div>}
<div className="svc-actions"><button type="submit" className="btn on" disabled={!name.trim()}>Create</button><span className="spacer" /><button type="button" className="btn" onClick={onClose}>Cancel</button></div>
</form>
</div>
)
}