Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
112 lines
6.1 KiB
TypeScript
112 lines
6.1 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, 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">
|
|
<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} 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>
|
|
)
|
|
}
|