-
setQ(e.target.value)} />
+
setQ(e.target.value)} />↻
{sources.map((s) => )}
@@ -208,7 +209,8 @@ export default function Cron() {
{err &&
{err}
}
{ov && !ov.logging &&
cron isn’t logging to the journal on this host, so “last run” is unavailable for cron jobs.
}
-
Cron jobs · {jobs.length}
+ {!ov && !err &&
}
+ {ov &&
Cron jobs · {jobs.length}
}
{jobs.map((j) => (
{ setEditing(null); setSel({ kind: 'job', id: j.id }) }}>
@@ -218,7 +220,7 @@ export default function Cron() {
))}
{ov && jobs.length === 0 &&
No cron jobs.
}
-
systemd timers · {timers.length}
+ {ov &&
systemd timers · {timers.length}
}
{timers.map((t) => (
{ setEditing(null); setSel({ kind: 'timer', id: t.unit }) }}>
diff --git a/web/src/Dashboard.tsx b/web/src/Dashboard.tsx
index b5c6724..a4c8d22 100644
--- a/web/src/Dashboard.tsx
+++ b/web/src/Dashboard.tsx
@@ -1,6 +1,7 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { api, DashPrefs, Sample, Series, Temp } from './api'
import Sparkline from './Sparkline'
+import { Loading, Spinner, toast } from './ui'
const MAX = 300
const S1 = 'var(--s1)', S2 = 'var(--s2)'
@@ -261,6 +262,8 @@ export default function Dashboard() {
const [layout, setLayout] = useState
(() => normalize({}))
const [editing, setEditing] = useState(false)
const [loaded, setLoaded] = useState(false)
+ const [saving, setSaving] = useState<'' | 'saving' | 'saved'>('')
+ const savedTimer = useRef(undefined)
const hist = useRef(null)
const saveTimer = useRef(undefined)
const intervalRef = useRef(DEFAULT_INTERVAL)
@@ -271,7 +274,15 @@ export default function Dashboard() {
setLayout((prev) => {
const next = fn(prev)
clearTimeout(saveTimer.current)
- saveTimer.current = window.setTimeout(() => { api.saveDashPrefs(toPrefs(next)).catch(() => setErr('could not save layout')) }, 300)
+ setSaving('saving')
+ saveTimer.current = window.setTimeout(async () => {
+ try {
+ await api.saveDashPrefs(toPrefs(next))
+ setSaving('saved')
+ clearTimeout(savedTimer.current)
+ savedTimer.current = window.setTimeout(() => setSaving(''), 1500)
+ } catch (e) { setSaving(''); toast(`layout not saved — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
+ }, 300)
return next
})
}, [])
@@ -317,7 +328,7 @@ export default function Dashboard() {
return () => { stop = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVis) }
}, [])
- if (!latest) return
+ if (!latest) return
const edit: Edit = {
on: editing,
@@ -369,6 +380,8 @@ export default function Dashboard() {
) : last {fmtWindow(latest.interval_ms || layout.intervalMs)} · {fmtMs(latest.interval_ms || layout.intervalMs)}}
+ {saving === 'saving' && saving}
+ {saving === 'saved' && saved ✓}
{editing && }
diff --git a/web/src/Login.tsx b/web/src/Login.tsx
index 28b4c84..cd06feb 100644
--- a/web/src/Login.tsx
+++ b/web/src/Login.tsx
@@ -1,5 +1,6 @@
import { FormEvent, useState } from 'react'
import { api } from './api'
+import { Spinner } from './ui'
export default function Login({ onLogin }: { onLogin: () => void }) {
const [pw, setPw] = useState('')
@@ -10,8 +11,7 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
e.preventDefault()
setBusy(true); setErr('')
try { await api.login(pw); onLogin() }
- catch (e) { setErr(e instanceof Error ? e.message : 'login failed') }
- finally { setBusy(false) }
+ catch (e) { setErr(e instanceof Error ? e.message : 'login failed'); setBusy(false) }
}
return (
@@ -20,8 +20,8 @@ export default function Login({ onLogin }: { onLogin: () => void }) {
Ham Lab
setPw(e.target.value)} />
-
+ value={pw} onChange={(e) => setPw(e.target.value)} disabled={busy} />
+
{err &&
{err}
}
diff --git a/web/src/Power.tsx b/web/src/Power.tsx
index 6b11c62..451947b 100644
--- a/web/src/Power.tsx
+++ b/web/src/Power.tsx
@@ -1,30 +1,31 @@
import { useState } from 'react'
import { api } from './api'
+import { Btn, Spinner } from './ui'
// 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 [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
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('') }
+ setErr('')
+ try { await api.power(action, typed.trim()); setSent(action) }
+ catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
return (
<>
-
+
{open && (
-
busy !== 'sent' && setOpen(false)}>
+
!sent && setOpen(false)}>
e.stopPropagation()}>
Power · {host}
{rebootNeeded &&
A reboot is pending from package updates.
}
- {busy === 'sent' ? (
-
Command sent. The console will reconnect when the host is back.
+ {sent ? (
+
{sent === 'reboot' ? 'Rebooting. The console will reconnect when the host is back.' : 'Shutting down. Power it back on by hand.'}
) : (
<>
{err &&
{err}
}
-
-
+ go('reboot')}>Reboot
+ go('poweroff')}>Shut down
diff --git a/web/src/Rail.tsx b/web/src/Rail.tsx
index b314bbd..849c95b 100644
--- a/web/src/Rail.tsx
+++ b/web/src/Rail.tsx
@@ -1,17 +1,18 @@
import { useState } from 'react'
import { Term } from './api'
+import { Spinner } from './ui'
// A group is either a page (clicking the head opens it — Dashboard) or a folder of
// leaves (Terminal). `soon` groups are inert placeholders for the roadmap.
export interface Group { id: string; label: string; icon: string; soon?: boolean; page?: boolean }
interface Props {
- groups: Group[]; terms: Term[]; active: string | null; view: string
+ groups: Group[]; terms: Term[]; active: string | null; view: string; creating?: boolean
onSelect: (id: string) => void; onView: (id: string) => void; onNew: () => void
onRename: (t: Term) => void; onClose: (t: Term) => void
}
-export default function Rail({ groups, terms, active, view, onSelect, onView, onNew, onRename, onClose }: Props) {
+export default function Rail({ groups, terms, active, view, creating, onSelect, onView, onNew, onRename, onClose }: Props) {
const [open, setOpen] = useState
('terminal')
return (
)
})}
-
+
)}
diff --git a/web/src/Services.tsx b/web/src/Services.tsx
index 659a761..ab00ac0 100644
--- a/web/src/Services.tsx
+++ b/web/src/Services.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
+import { Btn, Loading, Spinner, toast } from './ui'
type Filter = 'all' | 'running' | 'failed' | 'enabled'
@@ -37,22 +38,25 @@ export function Logs({ unit }: { unit: string }) {
const [live, setLive] = useState(true)
const [paused, setPaused] = useState(false)
const [err, setErr] = useState('')
+ const [ready, setReady] = useState(false)
const box = useRef
(null)
const pausedRef = useRef(false)
pausedRef.current = paused
const pending = useRef([])
useEffect(() => {
- setLines([]); setErr(''); pending.current = []
+ setLines([]); setErr(''); setReady(false); pending.current = []
let closed = false
+ let readyTimer: number | undefined
let sock: WebSocket | null = null
let retry: number | undefined
const connect = () => {
if (closed) return
const s = new WebSocket(wsURLPath(`/api/services/${encodeURIComponent(unit)}/logs/tail?n=200`))
sock = s
- s.onopen = () => { setLive(true); setErr('') }
+ s.onopen = () => { setLive(true); setErr(''); readyTimer = window.setTimeout(() => setReady(true), 1500) }
s.onmessage = (ev) => {
+ setReady(true)
const e = JSON.parse(ev.data) as LogEntry
if (pausedRef.current) { pending.current.push(e); return }
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), e] : [...prev, e]))
@@ -61,7 +65,7 @@ export function Logs({ unit }: { unit: string }) {
s.onerror = () => { setErr('log stream disconnected'); s.close() }
}
connect()
- return () => { closed = true; clearTimeout(retry); sock?.close() }
+ return () => { closed = true; clearTimeout(retry); clearTimeout(readyTimer); sock?.close() }
}, [unit])
// Resume: flush what arrived while paused.
@@ -82,7 +86,7 @@ export function Logs({ unit }: { unit: string }) {
- {lines.length === 0 &&
No journal entries.
}
+ {lines.length === 0 && (ready ?
No journal entries.
:
loading journal…
)}
{lines.map((l, i) => (
= 7 ? ' debug' : '')}>
{fmtTime(l.t)}
@@ -114,16 +118,16 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
const confirmFor: Record
= { stop: 'Stop', restart: 'Restart', disable: 'Disable' }
if (confirmFor[action] && !window.confirm(`${confirmFor[action]} ${name}?`)) return
setBusy(action); setErr('')
- try { await api.serviceAction(name, action); await load(); onChanged() }
- catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
+ try { await api.serviceAction(name, action); await load(); onChanged(); toast(`${name}: ${action} ok`) }
+ catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${name}: ${action} failed`, 'err') }
finally { setBusy('') }
}
- if (!d) return
+ if (!d) return {err ?
{err}
:
}
const p = d.props
const running = d.active === 'active'
const enabled = d.enabled === 'enabled'
- const Btn = ({ a, label }: { a: string; label: string }) =>
+ const Btn = ({ a, label }: { a: string; label: string }) =>
return (
@@ -159,7 +163,7 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
- {tab === 'logs' ? : {file || '…'}}
+ {tab === 'logs' ? : {file || loading…}}
)
}
@@ -171,9 +175,11 @@ export default function Services() {
const [q, setQ] = useState('')
const [filter, setFilter] = useState
('all')
const [sel, setSel] = useState(null)
+ const [loaded, setLoaded] = useState(false)
const load = useCallback(async () => {
try { setUnits(await api.services()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
+ finally { setLoaded(true) }
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 10000); return () => clearInterval(t) }, [load])
@@ -195,7 +201,7 @@ export default function Services() {
-
setQ(e.target.value)} />
+
setQ(e.target.value)} />↻
{([['all', `All ${units.length}`], ['running', `Running ${counts.running}`], ['failed', `Failed ${counts.failed}`], ['enabled', `Enabled ${counts.enabled}`]] as [Filter, string][]).map(([f, label]) => (
@@ -204,6 +210,7 @@ export default function Services() {
{err &&
{err}
}
+ {!loaded &&
}
{shown.map((u) => (
setSel(u.name)}>
@@ -214,7 +221,7 @@ export default function Services() {
{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}
))}
- {shown.length === 0 &&
Nothing matches.
}
+ {loaded && shown.length === 0 &&
Nothing matches.
}
{sel ?
setSel(null)} /> : Select a service.
}
diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx
index 9490b7a..37f2f0e 100644
--- a/web/src/Shell.tsx
+++ b/web/src/Shell.tsx
@@ -8,6 +8,7 @@ import Services from './Services'
import Cron from './Cron'
import Updates from './Updates'
import Power from './Power'
+import { Btn, NetBar, Toasts, toast } from './ui'
const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
@@ -31,6 +32,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
const [view, setView] = useState('dashboard')
const [drawer, setDrawer] = useState(false)
const [online, setOnline] = useState(true)
+ const [creating, setCreating] = useState(false)
const handles = useRef>({})
const reload = useCallback(async () => {
@@ -42,21 +44,26 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
useEffect(() => { reload() }, [reload])
async function create() {
- const t = await api.createTerminal()
- await reload()
- setActive(t.id)
- setView('terminal')
- setDrawer(false)
+ setCreating(true)
+ try {
+ const t = await api.createTerminal()
+ await reload()
+ setActive(t.id)
+ setView('terminal')
+ setDrawer(false)
+ } catch (e) { toast(`new terminal — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
+ finally { setCreating(false) }
}
async function rename(t: Term) {
const name = window.prompt('Rename terminal', t.name)
- if (name && name.trim() && name !== t.name) { await api.renameTerminal(t.id, name.trim()); await reload() }
+ if (name && name.trim() && name !== t.name) {
+ try { await api.renameTerminal(t.id, name.trim()); await reload() } catch (e) { toast(`rename — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
+ }
}
async function close(t: Term) {
if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
- await api.deleteTerminal(t.id)
- delete handles.current[t.id]
- await reload()
+ try { await api.deleteTerminal(t.id); delete handles.current[t.id]; await reload() }
+ catch (e) { toast(`close — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}
async function logout() { await api.logout(); onLoggedOut() }
@@ -65,6 +72,8 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
return (
+
+
Ham Lab
@@ -73,11 +82,11 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
{me.load}
-
+ logout
-
{ setActive(id); setView('terminal'); setDrawer(false) }}
onView={(id) => { setView(id as View); setDrawer(false) }}
onNew={create} onRename={rename} onClose={close} />
@@ -91,7 +100,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
{termView && terms.length === 0 && (
No terminals.
-
+
+ New Terminal
)}
{terms.map((t) => (
diff --git a/web/src/TaskOutput.tsx b/web/src/TaskOutput.tsx
index 81826ef..191e60f 100644
--- a/web/src/TaskOutput.tsx
+++ b/web/src/TaskOutput.tsx
@@ -1,21 +1,23 @@
import { useEffect, useRef, useState } from 'react'
import { api, TaskInfo, wsURLPath } from './api'
+import { Btn, Spinner } from './ui'
// 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(null)
const [text, setText] = useState('')
+ const [connected, setConnected] = useState(false)
const box = useRef(null)
const doneRef = useRef(onDone)
doneRef.current = onDone
useEffect(() => {
- setText(''); setTask(null)
- let closed = false
+ setText(''); setTask(null); setConnected(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.onopen = () => setConnected(true)
s.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) { setText((t) => t + dec.decode(ev.data, { stream: true })); return }
try {
@@ -23,22 +25,24 @@ export default function TaskOutput({ id, onDone }: { id: string; onDone?: (t: Ta
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 }
+ return () => s.close()
}, [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}`
+ const running = !task || task.running
+ const failed = !!task && !task.running && (task.exit_code !== 0 || !!task.error)
+ const status = !task ? 'starting…' : task.running ? 'running…' : task.error ? `failed: ${task.error}` : task.exit_code === 0 ? 'finished' : `exited ${task.exit_code}`
return (
-
+
-
+ {running ? : }
{task?.title ?? '…'}
· {status}
- {task?.running && }
+ {task?.running && api.cancelTask(id)}>Cancel}
-
{text || (task?.running ? '' : '(no output)')}
+
{text || (running ? (connected ? 'waiting for output…' : 'connecting…') : '(no output)')}
)
}
diff --git a/web/src/TerminalPane.tsx b/web/src/TerminalPane.tsx
index eeb5155..8d373c4 100644
--- a/web/src/TerminalPane.tsx
+++ b/web/src/TerminalPane.tsx
@@ -1,8 +1,9 @@
-import { useEffect, useRef } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { wsURL } from './api'
+import { Spinner } from './ui'
export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void }
@@ -28,6 +29,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
const cbs = useRef({ onStatus, onExit })
cbs.current = { onStatus, onExit }
const fitRef = useRef<() => void>(() => {})
+ const [conn, setConn] = useState<'connecting' | 'open' | 'reconnecting' | 'exited'>('connecting')
useEffect(() => {
const el = box.current!
@@ -67,12 +69,12 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
const sock = new WebSocket(wsURL(id))
sock.binaryType = 'arraybuffer'
ws = sock
- sock.onopen = () => { retry = 0; cbs.current.onStatus(true); lastSize = ''; doFit() }
+ sock.onopen = () => { retry = 0; setConn('open'); cbs.current.onStatus(true); lastSize = ''; doFit() }
sock.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return }
try {
const m = JSON.parse(ev.data)
- if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; cbs.current.onExit() }
+ if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; setConn('exited'); cbs.current.onExit() }
if (m.type === 'closed') closed = true
} catch { /* ignore */ }
}
@@ -80,6 +82,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
if (ws !== sock) return
cbs.current.onStatus(false)
if (closed) return
+ setConn('reconnecting')
retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000))
}
sock.onerror = () => sock.close()
@@ -124,5 +127,12 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
return () => clearTimeout(t)
}, [active])
- return
+ return (
+
+
+ {(conn === 'connecting' || conn === 'reconnecting') && (
+
{conn === 'connecting' ? 'connecting…' : 'reconnecting…'}
+ )}
+
+ )
}
diff --git a/web/src/Updates.tsx b/web/src/Updates.tsx
index 7544422..494201a 100644
--- a/web/src/Updates.tsx
+++ b/web/src/Updates.tsx
@@ -1,6 +1,7 @@
-import { useCallback, useEffect, useState } from 'react'
-import { api, UpdatePackage, UpdatesStatus } from './api'
+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'
@@ -12,39 +13,52 @@ function ago(ms?: number): string {
export default function Updates() {
const [st, setSt] = useState
(null)
const [err, setErr] = useState('')
+ const [reloading, setReloading] = useState(false)
const [sel, setSel] = useState>(new Set())
const [task, setTask] = useState(null)
+ const [taskRunning, setTaskRunning] = useState(false)
const [open, setOpen] = useState(null)
const [changelog, setChangelog] = useState>({})
- const [busy, setBusy] = useState(false)
+ const taskBox = useRef(null)
const load = useCallback(async () => {
+ setReloading(true)
try {
const s = await api.updates()
setSt(s); setErr('')
- if (s.running_task) setTask(s.running_task)
+ 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<{ 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 start(fn: () => Promise) {
+ 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 { 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) })) }
+ 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
- const running = !!st.running_task || busy
- const canAct = st.can_upgrade && !running
+ if (!st) return
+ const canAct = st.can_upgrade && !taskRunning
return (
@@ -66,13 +80,16 @@ export default function Updates() {
-
-
-
+ start(api.refreshUpdates)}>Refresh index
+ start(() => api.upgrade([]))}>Upgrade all
+ start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})
{st.packages.length > 0 && }
+
+ {task && }
+
{st.packages.length === 0 &&
Everything is up to date.
}
{st.packages.map((p) => (
@@ -85,12 +102,10 @@ export default function Updates() {
{p.current} → {p.candidate} · {p.suite}
- {open === p.name && {changelog[p.name] ?? '…'}}
+ {open === p.name && {changelog[p.name] ?? fetching changelog…}}
))}
-
- {task &&
load()} />}
)
}
diff --git a/web/src/api.ts b/web/src/api.ts
index 31ee376..0e82729 100644
--- a/web/src/api.ts
+++ b/web/src/api.ts
@@ -1,12 +1,26 @@
export class Unauthenticated extends Error {}
+let inflight = 0
+function track(delta: number) {
+ inflight += delta
+ window.dispatchEvent(new CustomEvent('hamlab:inflight', { detail: inflight }))
+}
+
async function req(method: string, path: string, body?: unknown): Promise {
- const r = await fetch(path, {
- method,
- credentials: 'same-origin',
- headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
- body: body !== undefined ? JSON.stringify(body) : undefined,
- })
+ track(1)
+ let r: Response
+ try {
+ r = await fetch(path, {
+ method,
+ credentials: 'same-origin',
+ headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ })
+ } catch (e) {
+ track(-1)
+ throw new Error('network error')
+ }
+ track(-1)
if (r.status === 401) throw new Unauthenticated()
if (!r.ok) {
let msg = r.statusText
diff --git a/web/src/styles.css b/web/src/styles.css
index 054170d..982c8c5 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -59,6 +59,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
/* main + terminal */
.main { flex: 1; min-width: 0; position: relative; background: #0d1117; }
.term { position: absolute; inset: 0; padding: 6px 0 0 6px; }
+.term-inner { height: 100%; }
.term .xterm { height: 100%; }
.xterm-viewport::-webkit-scrollbar { width: 8px; } .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border); }
@@ -207,3 +208,24 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.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; }
+
+/* feedback: spinners, buttons, toasts, activity bar */
+.spin { display: inline-block; border: 2px solid rgba(128,128,128,.35); border-top-color: currentColor; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: -2px; margin-right: 6px; flex-shrink: 0; box-sizing: border-box; }
+@keyframes spin { to { transform: rotate(360deg); } }
+.btn, .primary, .login button { display: inline-flex; align-items: center; justify-content: center; }
+.btn.busy, .primary.busy { opacity: .85; cursor: progress; }
+.loading { gap: 10px; padding: 24px; height: auto; min-height: 120px; }
+.loading .spin { margin: 0; }
+.tools-row { display: flex; gap: 6px; } .tools-row input { flex: 1; min-width: 0; }
+.toasts { position: fixed; left: 50%; bottom: calc(var(--footer-h) + 16px + env(safe-area-inset-bottom)); transform: translateX(-50%); z-index: 60; display: flex; flex-direction: column; gap: 6px; align-items: center; pointer-events: none; }
+.toast { pointer-events: auto; padding: 8px 14px; border-radius: 8px; background: var(--bg3); border: 1px solid var(--border); font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,.45); max-width: 90vw; cursor: pointer; animation: toast-in .15s ease-out; }
+.toast.ok { border-color: var(--ok); } .toast.err { border-color: var(--bad); color: var(--bad); }
+@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
+.netbar { position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 70; background: var(--accent2); opacity: 0; transition: opacity .2s .3s; pointer-events: none; }
+.netbar.on { opacity: 1; animation: netbar 1.1s ease-in-out infinite; }
+@keyframes netbar { 0% { transform: scaleX(0); transform-origin: left; } 50% { transform: scaleX(1); transform-origin: left; } 51% { transform-origin: right; } 100% { transform: scaleX(0); transform-origin: right; } }
+.term-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(13,17,23,.55); color: var(--muted); z-index: 2; gap: 4px; font-size: 13px; }
+.saved { font-size: 11px; color: var(--muted); display: inline-flex; align-items: center; }
+.task.running .logs-bar { background: rgba(57,135,229,.12); }
+.task.failed .logs-bar { background: rgba(255,123,114,.12); }
+.task.done .logs-bar { background: rgba(63,185,80,.10); }
diff --git a/web/src/ui.tsx b/web/src/ui.tsx
new file mode 100644
index 0000000..30e3120
--- /dev/null
+++ b/web/src/ui.tsx
@@ -0,0 +1,70 @@
+import { ReactNode, useEffect, useState } from 'react'
+
+// ---- spinners ----
+export function Spinner({ size = 14 }: { size?: number }) {
+ return
+}
+export function Loading({ label = 'Loading…' }: { label?: string }) {
+ return
+}
+
+// Button that shows a spinner while its async handler runs; `confirm` asks first.
+export function Btn({ onClick, busy, confirm, className = 'btn', disabled, title, children }: {
+ onClick: () => unknown | Promise; busy?: boolean; confirm?: string; className?: string; disabled?: boolean; title?: string; children: ReactNode
+}) {
+ const [inner, setInner] = useState(false)
+ const b = !!busy || inner
+ return (
+
+ )
+}
+
+// ---- toasts: short feedback for actions ----
+interface Toast { id: number; kind: 'ok' | 'err' | 'info'; msg: string }
+const listeners = new Set<(t: Toast[]) => void>()
+let toasts: Toast[] = []
+let seq = 0
+function emit() { listeners.forEach((l) => l(toasts)) }
+export function dismissToast(id: number) { toasts = toasts.filter((t) => t.id !== id); emit() }
+export function toast(msg: string, kind: Toast['kind'] = 'ok') {
+ const t = { id: ++seq, kind, msg }
+ toasts = [...toasts.slice(-4), t]
+ emit()
+ window.setTimeout(() => dismissToast(t.id), kind === 'err' ? 8000 : 3500)
+}
+export function Toasts() {
+ const [list, setList] = useState(toasts)
+ useEffect(() => { listeners.add(setList); return () => { listeners.delete(setList) } }, [])
+ return (
+
+ {list.map((t) =>
dismissToast(t.id)}>{t.msg}
)}
+
+ )
+}
+
+// act runs an action and toasts its outcome. Returns whether it succeeded.
+export async function act(label: string, fn: () => Promise): Promise {
+ try { await fn(); toast(label); return true }
+ catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return false }
+}
+
+// ---- thin activity bar at the top while any request is in flight ----
+export function NetBar() {
+ const [n, setN] = useState(0)
+ useEffect(() => {
+ const h = (e: Event) => setN((e as CustomEvent).detail)
+ window.addEventListener('hamlab:inflight', h)
+ return () => window.removeEventListener('hamlab:inflight', h)
+ }, [])
+ return 0 ? ' on' : '')} />
+}