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:
co-authored by
Claude Fable 5.1
parent
fc42675e9f
commit
7dcdb5dc98
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user