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>
139 lines
9.6 KiB
TypeScript
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>
|
|
)
|
|
}
|