Found by a headless pass over every page on every machine at desktop and phone widths, then every interaction on the hub. BUGS.md has the list, what was checked and found fine, and the quality-of-life notes to discuss. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
115 lines
6.4 KiB
TypeScript
115 lines
6.4 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) {
|
|
const m = e instanceof Error ? e.message : 'unavailable'
|
|
// apt's own words for a third-party repository that publishes none are a wall of "E: Failed to fetch"
|
|
setChangelog((c) => ({ ...c, [p.name]: /changelog unavailable|failed to fetch/i.test(m) ? 'No changelog is published for this package — its repository does not provide one.' : m }))
|
|
}
|
|
}
|
|
}
|
|
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}{p.suite && <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>
|
|
)
|
|
}
|