Files
hamlab/web/src/Machines.tsx
T

119 lines
8.1 KiB
TypeScript

import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api, MachineStatus, Me } from './api'
import { Btn, Loading, toast } from './ui'
import { LIST_FLOOR, useRefreshMs } 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` }
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 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(), 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 update(m: MachineStatus) {
if (!window.confirm(`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:') && window.confirm(`${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 (!window.confirm(`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 && window.confirm(`${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 (!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><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">{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>
{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>
{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\\-]*" 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>
)
}