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>
111 lines
6.2 KiB
TypeScript
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>
|
|
)
|
|
}
|