Files
hamlab/web/src/Machines.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

139 lines
9.6 KiB
TypeScript

import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api, MachineStatus, Me } from './api'
import { Btn, Loading, askYes, toast } from './ui'
import { LIST_FLOOR, useRefreshMs } from './prefs'
import { ACCENTS, accentFor, getAccents, setAccents } from './accents'
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` }
const ago = (iso?: string) => { if (!iso) return 'never'; const s = (Date.now() - Date.parse(iso)) / 1000; return s < 90 ? 'just now' : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago` }
const shortFp = (fp?: string) => (fp ? fp.split(':').slice(0, 6).join(':') + '…' : '')
export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChanged: () => void; onOpen: (name: string) => void }) {
const [list, setList] = useState<MachineStatus[] | null>(null)
const [fp, setFp] = useState<string | undefined>()
const [err, setErr] = useState('')
const [name, setName] = useState('')
const [url, setUrl] = useState('https://')
const [user, setUser] = useState('')
const [pw, setPw] = useState('')
const [adding, setAdding] = useState(false)
const [addErr, setAddErr] = useState('')
const norm = (v?: string) => (v ?? '').replace(/^v/, '').replace(/-dirty$/, '')
const outdated = (m: MachineStatus) => m.online && norm(m.version) !== norm(hub.version)
const load = useCallback(async () => {
try { const r = await api.machines(); setList(r.nodes); setFp(r.fingerprint); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setList([]) }
}, [])
const rate = useRefreshMs(LIST_FLOOR)
useEffect(() => { load(); const t = setInterval(load, rate); return () => clearInterval(t) }, [load, rate])
async function add(e: FormEvent) {
e.preventDefault()
setAdding(true); setAddErr('')
try {
const st = await api.addMachine(name.trim().toLowerCase(), url.trim(), user.trim(), pw)
toast(`${st.name} added${st.online ? ` · ${st.host} v${st.version}` : ' (offline)'}`)
setName(''); setUrl('https://'); setUser(''); setPw('')
await load(); onChanged()
} catch (e) { setAddErr(e instanceof Error ? e.message : 'failed') }
finally { setAdding(false) }
}
async function update(m: MachineStatus) {
if (!await askYes(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return
try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) }
catch (e) {
const msg = e instanceof Error ? e.message : 'failed'
if (msg.startsWith('busy:') && await askYes(`${m.name} is ${msg.slice(6)}\n\nForce the update anyway?`)) {
try { const r = await api.updateMachine(m.name, true); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e2) { toast(`${m.name}: ${e2 instanceof Error ? e2.message : 'failed'}`, 'err') }
} else toast(`${m.name}: ${msg}`, 'err')
}
}
async function updateAll() {
const n = list?.filter(outdated).length ?? 0
if (!await askYes(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return
try {
let res = await api.updateAllMachines()
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
if (busy.length && await askYes(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
setTimeout(load, 4000)
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
async function remove(m: MachineStatus) {
if (!await askYes(`Remove ${m.name}? Its access token will be revoked. Nothing on ${m.name} itself is changed.`)) return
try { await api.removeMachine(m.name); toast(`${m.name} removed`); await load(); onChanged() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
if (!list) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Loading machines…" />}</div>
return (
<div className="dash machines">
<div className="dash-bar"><h2 className="section">This host</h2></div>
<section className="card">
<div className="sub-head"><span className="dot ok" /><span className="dname" style={{ color: accentFor(hub.host) }}>{hub.host}</span><span className="badge">hub</span><span className="spacer" /><span className="num muted">v{hub.version.replace(/^v/, '')} · up {fmtUptime(hub.uptime)}</span></div>
<div className="muted small">{fp ? <>Certificate <span className="mono">{fp}</span></> : 'Serving plain HTTP (behind a reverse proxy). Set "tls": "auto" in config.json for a self-signed certificate if another hub should manage this host.'}</div>
<Swatches name={hub.host} />
</section>
<div className="dash-bar"><h2 className="section">Managed machines · {list.length}</h2><span className="spacer" />{list.some(outdated) && <Btn className="btn on" onClick={updateAll}>Update {list.filter(outdated).length} outdated</Btn>}</div>
{err && <div className="error">{err}</div>}
{list.length === 0 && <div className="notice">No machines yet. Install Ham Lab on another host, set its password, then add it below.</div>}
<div className="cards">
{list.map((m) => (
<section className="card" key={m.name}>
<div className="sub-head">
<span className={'dot ' + (m.online ? 'ok' : 'bad')} />
<span className="dname" style={{ color: accentFor(m.name) }}>{m.host || m.name}</span>
<span className="muted small mono">{m.url}</span>
<span className="spacer" />
{m.reboot_needed && <span className="badge" title="reboot required">⟳</span>}
{outdated(m) && <span className="badge bad" title={`running ${norm(m.version)}, hub has ${norm(hub.version)}`}>outdated</span>}
</div>
<div className="props">
<div><span className="k">Status</span><span className={m.online ? 'good' : 'warn-text'}>{m.online ? 'online' : m.error || 'offline'}</span></div>
{/* the name it was added under is its identity here: it is what Remove, Update and the URL use */}
<div><span className="k">Added as</span><span className="mono">{m.name}{m.host && m.host !== m.name ? ' · renamed itself since' : ''}</span></div>
{m.version && <div><span className="k">Version</span><span>v{m.version.replace(/^v/, '')}</span></div>}
{m.online && <div><span className="k">Load</span><span>{m.load} · up {fmtUptime(m.uptime ?? 0)}</span></div>}
<div><span className="k">Seen</span><span>{ago(m.last_seen)}</span></div>
{m.fingerprint && <div><span className="k">Cert</span><span className="mono" title={m.fingerprint}>{shortFp(m.fingerprint)}</span></div>}
<div><span className="k">Added</span><span>{new Date(m.added).toLocaleDateString()}</span></div>
</div>
<Swatches name={m.name} />
<div className="svc-actions">
<button className="btn on" disabled={!m.online} onClick={() => onOpen(m.name)}>Open</button>
{outdated(m) && <Btn className="btn" onClick={() => update(m)}>Update</Btn>}
<Btn className="btn danger" onClick={() => remove(m)}>Remove</Btn>
</div>
</section>
))}
</div>
<div className="dash-bar"><h2 className="section">Add a machine</h2></div>
<form className="card addform" onSubmit={add}>
<label>Name <input value={name} onChange={(e) => setName(e.target.value)} placeholder="grid" pattern="[a-z0-9][a-z0-9_\-]*" title="lower-case letters, digits, - and _" required /></label>
<label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label>
<label>A user on it <input value={user} onChange={(e) => setUser(e.target.value)} placeholder="core" autoComplete="off" spellCheck={false} autoCapitalize="off" /></label>
<label>Their password <input type="password" value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="off" required /></label>
{addErr && <div className="error">{addErr}</div>}
<div className="svc-actions">
<button className="btn on" disabled={adding || !name || !pw}>{adding ? 'Pairing…' : 'Add machine'}</button>
<span className="muted small">The password is used once to obtain an access token and is not stored. The user may be left blank on a machine with a single account. The machine's certificate is pinned on first contact.</span>
</div>
</form>
</div>
)
}
// The colour a machine wears in the header stripe and the switcher: ten to tap, or any other.
export function Swatches({ name }: { name: string }) {
const [, bump] = useState(0)
const cur = accentFor(name)
const pick = (c: string) => { setAccents({ ...getAccents(), [name]: c }); bump((n) => n + 1) }
return (
<div className="swatches" title={`${name}'s colour — it marks the header whenever you are on this machine`}>
{ACCENTS.map((c) => <button key={c} className={'swatch' + (c.toLowerCase() === cur.toLowerCase() ? ' on' : '')} style={{ background: c }} aria-label={c} onClick={() => pick(c)} />)}
<input type="color" value={cur} onChange={(e) => pick(e.target.value)} aria-label="another colour" />
</div>
)
}