Files
hamlab/web/src/Updates.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 668e716601 Top wears the skin; the refresh rate reaches every machine; bug bash 4
The process table's columns fit the box they are in, the rate control saves, curves are
timed on the viewer's clock and keep to the stride, the power icon is drawn, and a young
history draws in from the right instead of being stretched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 10:12:45 -07:00

111 lines
6.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { api, TaskInfo, UpdatePackage, UpdatesStatus } from './api'
import TaskOutput from './TaskOutput'
import { Btn, Loading, Spinner, toast } from './ui'
function ago(ms?: number): string {
if (!ms) return 'never'
const s = Math.max(0, (Date.now() - ms) / 1000)
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`) + ' ago'
}
export default function Updates() {
const [st, setSt] = useState<UpdatesStatus | null>(null)
const [err, setErr] = useState('')
const [reloading, setReloading] = useState(false)
const [sel, setSel] = useState<Set<string>>(new Set())
const [task, setTask] = useState<string | null>(null)
const [taskRunning, setTaskRunning] = useState(false)
const [open, setOpen] = useState<string | null>(null)
const [changelog, setChangelog] = useState<Record<string, string>>({})
const taskBox = useRef<HTMLDivElement>(null)
const load = useCallback(async () => {
setReloading(true)
try {
const s = await api.updates()
setSt(s); setErr('')
if (s.running_task) { setTask(s.running_task); setTaskRunning(true) }
setSel((prev) => new Set([...prev].filter((n) => s.packages.some((p) => p.name === n))))
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
finally { setReloading(false) }
}, [])
useEffect(() => { load() }, [load])
async function start(fn: () => Promise<TaskInfo>) {
setErr('')
try {
const t = await fn()
setTask(t.id); setTaskRunning(true)
window.setTimeout(() => taskBox.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), 50)
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
function done(t: TaskInfo) {
setTaskRunning(false)
const ok = t.exit_code === 0 && !t.error
toast(`${t.title}: ${ok ? 'finished' : t.error || `exited ${t.exit_code}`}`, ok ? 'ok' : 'err')
load()
}
async function showChangelog(p: UpdatePackage) {
if (open === p.name) { setOpen(null); return }
setOpen(p.name)
if (!changelog[p.name]) {
try { const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) }
catch (e) { setChangelog((c) => ({ ...c, [p.name]: e instanceof Error ? e.message : 'unavailable' })) }
}
}
const toggle = (n: string) => setSel((s) => { const x = new Set(s); if (x.has(n)) x.delete(n); else x.add(n); return x })
if (!st) return <div className="dash">{err ? <div className="error">{err}</div> : <Loading label="Checking packages…" />}</div>
const canAct = st.can_upgrade && !taskRunning
return (
<div className="dash updates">
{!st.manager && <div className="notice">No supported package manager found (looked for apt, dnf, zypper, pacman, apk).</div>}
{st.manager && !st.sudo && <div className="notice">Read-only: refreshing and upgrading need root. Run Ham Lab as root, or give its user passwordless sudo or doas.</div>}
{st.reboot_needed && (
<div className="notice attn">
<b>Reboot required</b>{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}</>} — use the power button in the header.
</div>
)}
{err && <div className="error">{err}</div>}
<div className="kpis">
<div className="tile"><div className="label">Upgradable</div><div className="value">{st.packages.length}</div><div className="sub">{st.security_count ? `${st.security_count} security` : 'no security updates'}</div></div>
<div className="tile"><div className="label">Index refreshed</div><div className="value">{ago(st.last_refresh)}</div><div className="sub">{st.manager}</div></div>
<div className="tile"><div className="label">Last upgrade</div><div className="value">{ago(st.last_upgrade)}</div><div className="sub">from {st.manager} history</div></div>
<div className="tile"><div className="label">Unattended</div><div className="value">{st.auto_upgrades ? 'on' : 'off'}</div><div className="sub">automatic upgrades</div></div>
</div>
<div className="svc-actions">
<Btn className="btn" disabled={!canAct} onClick={() => start(api.refreshUpdates)}>Refresh index</Btn>
<Btn className="btn on" disabled={!canAct || st.packages.length === 0} confirm={`Upgrade all ${st.packages.length} packages?`} onClick={() => start(() => api.upgrade([]))}>Upgrade all</Btn>
<Btn className="btn" disabled={!canAct || sel.size === 0 || !st.selective} title={st.selective ? undefined : `${st.manager} upgrades everything or nothing`} onClick={() => start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})</Btn>
<span className="spacer" />
{st.packages.length > 0 && <button className="btn" onClick={() => setSel(sel.size === st.packages.length ? new Set() : new Set(st.packages.map((p) => p.name)))}>{sel.size === st.packages.length ? 'None' : 'All'}</button>}
<button className="btn" title="reload" disabled={reloading} onClick={load}>{reloading ? <Spinner /> : '↻'}</button>
</div>
<div ref={taskBox}>{task && <TaskOutput id={task} onDone={done} />}</div>
<div className="card pkgs">
{st.packages.length === 0 && <div className="empty">Everything is up to date.</div>}
{st.packages.map((p) => (
<div key={p.name}>
<div className={'pkg' + (sel.has(p.name) ? ' sel' : '')}>
<input type="checkbox" checked={sel.has(p.name)} onChange={() => toggle(p.name)} />
<span className="pkg-name" onClick={() => showChangelog(p)}>
<span className="n">{p.name}{p.security && <span className="badge bad">security</span>}</span>
<span className="d">{p.description}</span>
</span>
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}<span className="muted"> · {p.suite}</span></span>
</div>
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? <span className="muted"><Spinner /> fetching changelog…</span>}</pre>}
</div>
))}
</div>
</div>
)
}