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:
co-authored by
Claude Fable 5.1
parent
41c2f05535
commit
acb11193b9
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from './api'
|
||||
|
||||
// Header power control: a small button, a dialog that needs the hostname typed back.
|
||||
export default function Power({ host, rebootNeeded }: { host: string; rebootNeeded?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [typed, setTyped] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
const ok = typed.trim() === host
|
||||
|
||||
async function go(action: 'reboot' | 'poweroff') {
|
||||
setBusy(action); setErr('')
|
||||
try { await api.power(action, typed.trim()); setBusy('sent') }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy('') }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={'icon-btn power' + (rebootNeeded ? ' attn' : '')} title={rebootNeeded ? 'reboot required' : 'power'} onClick={() => { setOpen(true); setTyped(''); setBusy(''); setErr('') }}>⏻</button>
|
||||
{open && (
|
||||
<div className="modal-back" onClick={() => busy !== 'sent' && setOpen(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Power · {host}</h3>
|
||||
{rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>}
|
||||
{busy === 'sent' ? (
|
||||
<div className="muted">Command sent. The console will reconnect when the host is back.</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="muted">Type the hostname to confirm
|
||||
<input autoFocus value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={host} />
|
||||
</label>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
<button className="btn on" disabled={!ok || !!busy} onClick={() => go('reboot')}>{busy === 'reboot' ? '…' : 'Reboot'}</button>
|
||||
<button className="btn danger" disabled={!ok || !!busy} onClick={() => go('poweroff')}>{busy === 'poweroff' ? '…' : 'Shut down'}</button>
|
||||
<span className="spacer" />
|
||||
<button className="btn" onClick={() => setOpen(false)}>Cancel</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+7
-2
@@ -6,16 +6,19 @@ import KeyBar from './KeyBar'
|
||||
import Dashboard from './Dashboard'
|
||||
import Services from './Services'
|
||||
import Cron from './Cron'
|
||||
import Updates from './Updates'
|
||||
import Power from './Power'
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||
{ id: 'services', label: 'Services', icon: '⚙', page: true },
|
||||
{ id: 'cron', label: 'Cron', icon: '⏱', page: true },
|
||||
{ id: 'updates', label: 'Updates', icon: '⇪', page: true },
|
||||
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron'
|
||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates'
|
||||
|
||||
function fmtUptime(s: number): string {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
@@ -69,6 +72,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
<span className="spacer" />
|
||||
<span className="stat" title="load average">{me.load}</span>
|
||||
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
|
||||
<Power host={me.host} rebootNeeded={me.reboot_needed} />
|
||||
<button className="link" onClick={logout}>logout</button>
|
||||
</header>
|
||||
|
||||
@@ -83,6 +87,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
{view === 'dashboard' && <Dashboard />}
|
||||
{view === 'services' && <Services />}
|
||||
{view === 'cron' && <Cron />}
|
||||
{view === 'updates' && <Updates />}
|
||||
{termView && terms.length === 0 && (
|
||||
<div className="center muted">
|
||||
<div>No terminals.</div>
|
||||
@@ -105,7 +110,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : activeTerm ? activeTerm.name : ''}</span>
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, TaskInfo, wsURLPath } from './api'
|
||||
|
||||
// Follows one background task: backlog, then live chunks over a WebSocket, until it exits.
|
||||
export default function TaskOutput({ id, onDone }: { id: string; onDone?: (t: TaskInfo) => void }) {
|
||||
const [task, setTask] = useState<TaskInfo | null>(null)
|
||||
const [text, setText] = useState('')
|
||||
const box = useRef<HTMLPreElement>(null)
|
||||
const doneRef = useRef(onDone)
|
||||
doneRef.current = onDone
|
||||
|
||||
useEffect(() => {
|
||||
setText(''); setTask(null)
|
||||
let closed = false
|
||||
api.task(id).then(setTask).catch(() => { /* shown as unknown */ })
|
||||
const s = new WebSocket(wsURLPath(`/api/tasks/${id}/stream`))
|
||||
s.binaryType = 'arraybuffer'
|
||||
const dec = new TextDecoder()
|
||||
s.onmessage = (ev) => {
|
||||
if (ev.data instanceof ArrayBuffer) { setText((t) => t + dec.decode(ev.data, { stream: true })); return }
|
||||
try {
|
||||
const m = JSON.parse(ev.data)
|
||||
if (m.type === 'exit') api.task(id).then((t) => { setTask(t); doneRef.current?.(t) }).catch(() => { /* ignore */ })
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return () => { closed = true; s.close(); void closed }
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { if (box.current) box.current.scrollTop = box.current.scrollHeight }, [text])
|
||||
|
||||
const status = !task ? '' : task.running ? 'running…' : task.error ? `failed: ${task.error}` : task.exit_code === 0 ? 'done' : `exited ${task.exit_code}`
|
||||
return (
|
||||
<div className="task">
|
||||
<div className="logs-bar">
|
||||
<span className={'dot ' + (task?.running ? 'warn' : task && (task.exit_code !== 0 || task.error) ? 'bad' : 'ok')} />
|
||||
<span>{task?.title ?? '…'}</span>
|
||||
<span className="muted">· {status}</span>
|
||||
<span className="spacer" />
|
||||
{task?.running && <button className="btn" onClick={() => api.cancelTask(id)}>Cancel</button>}
|
||||
</div>
|
||||
<pre className="run-out task-out" ref={box}>{text || (task?.running ? '' : '(no output)')}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+13
-1
@@ -17,7 +17,7 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
|
||||
return r.json()
|
||||
}
|
||||
|
||||
export interface Me { host: string; user: string; version: string; load: string; uptime: number }
|
||||
export interface Me { host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean }
|
||||
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
|
||||
|
||||
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number }
|
||||
@@ -51,7 +51,19 @@ export interface CronTimer { unit: string; activates: string; description: strin
|
||||
export interface CronOverview { jobs: CronJob[]; timers: CronTimer[]; logging: boolean; anacron: boolean; sudo: boolean }
|
||||
export interface CronRun { id: string; job_id: string; command: string; user: string; started: string; finished?: string; running: boolean; exit_code: number; output: string }
|
||||
|
||||
export interface TaskInfo { id: string; kind: string; title: string; command: string; started: string; finished?: string; running: boolean; exit_code: number; error?: string; output?: string }
|
||||
export interface UpdatePackage { name: string; current: string; candidate: string; suite?: string; security: boolean; description?: string }
|
||||
export interface UpdatesStatus { manager: string; can_upgrade: boolean; packages: UpdatePackage[]; security_count: number; last_refresh?: number; last_upgrade?: number; reboot_needed: boolean; reboot_pkgs: string[]; auto_upgrades: boolean; running_task?: string; sudo: boolean }
|
||||
|
||||
export const api = {
|
||||
tasks: () => req<TaskInfo[]>('GET', '/api/tasks'),
|
||||
task: (id: string) => req<TaskInfo>('GET', `/api/tasks/${id}`),
|
||||
cancelTask: (id: string) => req<void>('POST', `/api/tasks/${id}/cancel`),
|
||||
updates: () => req<UpdatesStatus>('GET', '/api/updates'),
|
||||
refreshUpdates: () => req<TaskInfo>('POST', '/api/updates/refresh'),
|
||||
upgrade: (packages: string[]) => req<TaskInfo>('POST', '/api/updates/upgrade', { packages }),
|
||||
changelog: (pkg: string) => req<{ text: string }>('GET', `/api/updates/changelog?pkg=${encodeURIComponent(pkg)}`),
|
||||
power: (action: 'reboot' | 'poweroff', confirm: string) => req<void>('POST', `/api/power/${action}`, { confirm }),
|
||||
cron: () => req<CronOverview>('GET', '/api/cron'),
|
||||
cronSource: (name: string) => req<{ text: string; editable: boolean }>('GET', `/api/cron/source?name=${encodeURIComponent(name)}`),
|
||||
cronWriteSource: (name: string, text: string) => req<void>('PUT', `/api/cron/source?name=${encodeURIComponent(name)}`, { text }),
|
||||
|
||||
@@ -182,3 +182,28 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.cron-list { width: 100%; border-right: 0; } .has-sel .cron-list { display: none; }
|
||||
.cron-detail .back { display: inline-block; }
|
||||
}
|
||||
|
||||
/* updates */
|
||||
.updates .pkgs { padding: 0; }
|
||||
.pkg { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.pkg.sel { background: var(--bg3); }
|
||||
.pkg input { width: 16px; height: 16px; }
|
||||
.pkg-name { min-width: 0; display: flex; flex-direction: column; cursor: pointer; }
|
||||
.pkg-name .n { font-family: ui-monospace, monospace; font-size: 13px; }
|
||||
.pkg-name .d { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pkg-ver { font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; }
|
||||
.badge.bad { color: var(--bad); border-color: var(--bad); margin-left: 8px; }
|
||||
.changelog { margin: 0 12px 8px; max-height: 40vh; }
|
||||
.notice.attn { border-color: var(--s2); color: var(--fg); }
|
||||
.task { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||
.task-out { border: 0; border-radius: 0; max-height: 45vh; min-height: 120px; }
|
||||
@media (max-width: 800px) { .pkg { grid-template-columns: auto 1fr; } .pkg-ver { grid-column: 2; white-space: normal; } }
|
||||
|
||||
/* power + modal */
|
||||
.power { font-size: 15px; } .power.attn { color: var(--s2); }
|
||||
.modal-back { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; padding: 16px; }
|
||||
.modal { width: min(420px, 100%); background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.modal h3 { margin: 0; font-size: 15px; }
|
||||
.modal label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; }
|
||||
.modal input { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||
.btn.danger { color: var(--bad); border-color: var(--bad); } .btn.danger:disabled { opacity: .4; }
|
||||
|
||||
Reference in New Issue
Block a user