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(null) const [fp, setFp] = useState() 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
{err ?
{err}
: }
return (

This host

{hub.host}hubv{hub.version.replace(/^v/, '')} · up {fmtUptime(hub.uptime)}
{fp ? <>Certificate {fp} : '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.'}

Managed machines · {list.length}

{list.some(outdated) && Update {list.filter(outdated).length} outdated}
{err &&
{err}
} {list.length === 0 &&
No machines yet. Install Ham Lab on another host, set its password, then add it below.
}
{list.map((m) => (
{m.host || m.name} {m.url} {m.reboot_needed && ⟳} {outdated(m) && outdated}
Status{m.online ? 'online' : m.error || 'offline'}
{/* the name it was added under is its identity here: it is what Remove, Update and the URL use */}
Added as{m.name}{m.host && m.host !== m.name ? ' · renamed itself since' : ''}
{m.version &&
Versionv{m.version.replace(/^v/, '')}
} {m.online &&
Load{m.load} · up {fmtUptime(m.uptime ?? 0)}
}
Seen{ago(m.last_seen)}
{m.fingerprint &&
Cert{shortFp(m.fingerprint)}
}
Added{new Date(m.added).toLocaleDateString()}
{outdated(m) && update(m)}>Update} remove(m)}>Remove
))}

Add a machine

{addErr &&
{addErr}
}
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.
) } // 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 (
{ACCENTS.map((c) =>
) }