Files
hamlab/web/src/Updates.tsx
T

112 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, 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 {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} 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>
)
}