Files
hamlab/web/src/Settings.tsx
T
Mitchell-JohnsonandClaude Opus 5 68bf3bb218 An updater: what is published on Gitea, and installing it from Settings
A new selfupdate package asks one URL what the newest build is — a Gitea/Forgejo repo
API base, or any directory holding VERSION, SHA256SUMS and hamlab-linux-<arch>, which is
what make release already writes and what install.sh already downloads. The binary is
checked against SHA256SUMS before it goes near the running one, and a token is sent when
the source is behind a login, so a private Gitea serves its own fleet without opening the
instance to everyone. Version ordering is install.sh's vnum rule, so the daemon and the
installer never disagree about which build is newer.

Settings gains an Updates card: installed, published, when it last looked, the source,
release notes, Check for updates, and Install. The rail badges Settings when a newer
build is out. Nothing installs unasked — it restarts the daemon and drops terminals.

scripts/gitea-release.sh (make gitea-release) publishes dist/ as a Gitea release.
Deliberate, and not part of deploy: deploy pushes to the machines this hub owns,
publishing offers the build to every install anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:31:32 -07:00

251 lines
15 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react'
import { fetchCatalog } from './sensors'
import { api, Me, TokenInfo, 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<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 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 (
<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">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() }} />}
<Updates me={me} />
<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>
)
}
// 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<UpdateStatus | null>(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 (
<>
<div className="dash-bar"><h2 className="section">Updates</h2><span className="spacer" />
<span className="muted small">{st?.auto ? 'checked daily' : st?.enabled ? 'checked when you ask' : 'switched off'}</span>
</div>
<section className="card">
<div className="props">
<div><span className="k">Installed</span><span className="mono">{st?.current ?? me.version}</span></div>
<div><span className="k">Published</span><span className="mono">{st?.checking ? <Spinner /> : st?.latest || '—'}</span></div>
<div><span className="k">Checked</span><span>{st?.checked ? new Date(st.checked).toLocaleString() : 'not yet'}</span></div>
<div><span className="k">Source</span><span className="mono small" title={st?.url}>{st?.enabled ? sourceName(st.url) : 'off'}</span></div>
</div>
{st?.behind && (
<div className="notice attn" style={{ marginTop: 10 }}>
<b>{st.latest}</b> is published; this machine runs <b>{st.current}</b>.
{st.published && <span className="muted"> Released {new Date(st.published).toLocaleDateString()}.</span>}
{st.notes && <div className="mono small" style={{ whiteSpace: 'pre-wrap', marginTop: 6, maxHeight: 140, overflow: 'auto' }}>{st.notes.trim()}</div>}
</div>
)}
{st && !st.behind && st.latest && <div className="muted small" style={{ marginTop: 8 }}>This is the newest published build.</div>}
{(err || st?.error) && <div className="error" style={{ marginTop: 8 }}>{err || st?.error}</div>}
<div className="svc-actions" style={{ marginTop: 10 }}>
<Btn className="btn" busy={busy === 'check'} disabled={!st?.enabled || !!busy} onClick={check}>Check for updates</Btn>
{st?.behind && <Btn className="btn on" busy={busy === 'install'} disabled={!!busy} onClick={() => install()}>Install {st.latest}</Btn>}
<span className="muted small">Installing restarts Ham Lab on this machine. Terminals here close; the shells do not survive it.</span>
</div>
</section>
</>
)
}
// 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<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>
)
}