Multi-host phase 1: API tokens, auto TLS with pinned fingerprints, hub-side node manager + reverse proxy (WebSockets included), machine switcher in the rail, Machines page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 11:35:29 -07:00
co-authored by Claude Fable 5.1
parent fc42675e9f
commit 7dcdb5dc98
15 changed files with 960 additions and 56 deletions
+91
View File
@@ -0,0 +1,91 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api, MachineStatus, Me } from './api'
import { Btn, Loading, toast } from './ui'
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 [pw, setPw] = useState('')
const [adding, setAdding] = useState(false)
const [addErr, setAddErr] = useState('')
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([]) }
}, [])
useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t) }, [load])
async function add(e: FormEvent) {
e.preventDefault()
setAdding(true); setAddErr('')
try {
const st = await api.addMachine(name.trim().toLowerCase(), url.trim(), pw)
toast(`${st.name} added${st.online ? ` · ${st.host} v${st.version}` : ' (offline)'}`)
setName(''); setUrl('https://'); setPw('')
await load(); onChanged()
} catch (e) { setAddErr(e instanceof Error ? e.message : 'failed') }
finally { setAdding(false) }
}
async function remove(m: MachineStatus) {
if (!window.confirm(`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">{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>
</section>
<div className="dash-bar"><h2 className="section">Managed machines · {list.length}</h2></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">{m.name}</span>
<span className="muted small mono">{m.url}</span>
<span className="spacer" />
{m.reboot_needed && <span className="badge" title="reboot required">⟳</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>
{m.host && <div><span className="k">Host</span><span>{m.host} · 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>
<div className="svc-actions">
<button className="btn on" disabled={!m.online} onClick={() => onOpen(m.name)}>Open</button>
<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-]*" required /></label>
<label>Address <input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://10.0.0.214:8100" required /></label>
<label>Its Ham Lab 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 machine's certificate is pinned on first contact.</span>
</div>
</form>
</div>
)
}