Updates tab (apt list/refresh/upgrade/changelog, reboot flag) on a new background tasks package with live WS streaming; header power button with hostname confirmation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 17:40:29 -07:00
co-authored by Claude Fable 5.1
parent 41c2f05535
commit acb11193b9
11 changed files with 975 additions and 6 deletions
+96
View File
@@ -0,0 +1,96 @@
import { useCallback, useEffect, useState } from 'react'
import { api, UpdatePackage, UpdatesStatus } from './api'
import TaskOutput from './TaskOutput'
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 [sel, setSel] = useState<Set<string>>(new Set())
const [task, setTask] = useState<string | null>(null)
const [open, setOpen] = useState<string | null>(null)
const [changelog, setChangelog] = useState<Record<string, string>>({})
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
try {
const s = await api.updates()
setSt(s); setErr('')
if (s.running_task) setTask(s.running_task)
setSel((prev) => new Set([...prev].filter((n) => s.packages.some((p) => p.name === n))))
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { load() }, [load])
async function start(fn: () => Promise<{ id: string }>) {
setBusy(true); setErr('')
try { const t = await fn(); setTask(t.id) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy(false) }
}
async function showChangelog(p: UpdatePackage) {
if (open === p.name) { setOpen(null); return }
setOpen(p.name)
if (!changelog[p.name]) {
try { setChangelog((c) => ({ ...c, [p.name]: '…' })); const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) }
catch (e) { setChangelog((c) => ({ ...c, [p.name]: String(e) })) }
}
}
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"><div className="center muted">{err || '…'}</div></div>
const running = !!st.running_task || busy
const canAct = st.can_upgrade && !running
return (
<div className="dash updates">
{!st.manager && <div className="notice">No supported package manager found (looked for apt, dnf, pacman).</div>}
{st.manager && st.manager !== 'apt' && <div className="notice">{st.manager}: listing only; upgrading from here isn't supported yet.</div>}
{st.manager === 'apt' && !st.sudo && <div className="notice">Read-only: refreshing and upgrading need passwordless sudo on the host.</div>}
{st.reboot_needed && (
<div className="notice attn">
<b>Reboot required</b>{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}</>} — use the ⏻ 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 apt 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">
<button className="btn" disabled={!canAct} onClick={() => start(api.refreshUpdates)}>Refresh index</button>
<button className="btn on" disabled={!canAct || st.packages.length === 0} onClick={() => window.confirm(`Upgrade all ${st.packages.length} packages?`) && start(() => api.upgrade([]))}>Upgrade all</button>
<button className="btn" disabled={!canAct || sel.size === 0} onClick={() => start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})</button>
<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>}
</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] ?? '…'}</pre>}
</div>
))}
</div>
{task && <TaskOutput id={task} onDone={() => load()} />}
</div>
)
}