Bug bash fixes: hash routing + back button, hub-only pages reset on machine switch, exited terminals reap after 10 min, self-update refuses while busy unless forced, touch-sized controls, power confirm by machine name, down interfaces collapsed and empty modules hidden, load label, offline backoff, cron log newest first, procs empty message, timer timestamp parsing
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
27f9665606
commit
f093b322ee
+1
-1
@@ -122,7 +122,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
|
||||
{tab === 'logs' && (
|
||||
<div className="logs"><div className="log-lines">
|
||||
{logs === null ? <div className="muted"><Spinner /> loading…</div> : logs.length === 0 ? <div className="muted">{logging ? 'No runs of this exact command in the journal.' : 'cron isn’t writing to the journal on this host, so there is no run history.'}</div>
|
||||
: logs.map((l, i) => <div key={i} className="log"><span className="lt">{fmtTime(l.t)}</span><span className="lm">{l.msg}</span></div>)}
|
||||
: [...logs].reverse().map((l, i) => <div key={i} className="log"><span className="lt">{fmtTime(l.t)}</span><span className="lm">{l.msg}</span></div>)}
|
||||
</div></div>
|
||||
)}
|
||||
{tab === 'output' && <pre className="run-out">{out ? out.text || '(empty)' : <span className="muted"><Spinner /> reading…</span>}</pre>}
|
||||
|
||||
@@ -176,17 +176,18 @@ function body(id: string, { s, h, edit }: Ctx): ReactNode {
|
||||
<div className="sub-head">
|
||||
<span className={'dot ' + (x.up ? 'ok' : '')} />
|
||||
<span>{x.iface}</span>
|
||||
{!x.up && <span className="muted small">down</span>}
|
||||
<span className="spacer" />
|
||||
<span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>
|
||||
{x.up && <span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>}
|
||||
<HideBtn k={`net:${x.iface}`} edit={edit} />
|
||||
</div>
|
||||
{h?.net[x.iface] && (
|
||||
{x.up && h?.net[x.iface] && (
|
||||
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
|
||||
)}
|
||||
<div className="legend">
|
||||
{x.up && <div className="legend">
|
||||
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
|
||||
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
))}
|
||||
<HiddenChips prefix="net" edit={edit} label={(k) => k} />
|
||||
@@ -345,7 +346,9 @@ export default function Dashboard() {
|
||||
unhideItem: (k) => update((l) => { const s = new Set(l.hiddenItems); s.delete(k); return { ...l, hiddenItems: s } }),
|
||||
}
|
||||
const ctx: Ctx = { s: latest, h: hist.current, edit }
|
||||
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id))
|
||||
// Modules with nothing to show on this host (no fans, no UPS…) stay out of the way unless customizing.
|
||||
const empty = (id: string) => (id === 'fans' && latest.fans.length === 0) || (id === 'ups' && !latest.ups) || (id === 'temps' && latest.temps.length === 0) || (id === 'disks' && latest.disks.length === 0)
|
||||
const visible = (kind: Kind) => layout.order.filter((id) => byId(id)?.kind === kind && !layout.hidden.has(id) && (editing || !empty(id)))
|
||||
const hiddenMods = layout.order.filter((id) => layout.hidden.has(id))
|
||||
|
||||
// Move a module past its nearest same-kind neighbour (tiles stay with tiles, cards with cards).
|
||||
|
||||
+10
-2
@@ -38,13 +38,21 @@ export default function Machines({ hub, onChanged, onOpen }: { hub: Me; onChange
|
||||
}
|
||||
async function update(m: MachineStatus) {
|
||||
if (!window.confirm(`Update ${m.name} to ${norm(hub.version)}? Its Ham Lab restarts; open terminals there are lost.`)) return
|
||||
try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e) { toast(`${m.name}: ${e instanceof Error ? e.message : 'failed'}`, 'err') }
|
||||
try { const r = await api.updateMachine(m.name); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) }
|
||||
catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed'
|
||||
if (msg.startsWith('busy:') && window.confirm(`${m.name} is ${msg.slice(6)}\n\nForce the update anyway?`)) {
|
||||
try { const r = await api.updateMachine(m.name, true); toast(`${m.name} updated to ${r.version}, restarting`); setTimeout(load, 4000) } catch (e2) { toast(`${m.name}: ${e2 instanceof Error ? e2.message : 'failed'}`, 'err') }
|
||||
} else toast(`${m.name}: ${msg}`, 'err')
|
||||
}
|
||||
}
|
||||
async function updateAll() {
|
||||
const n = list?.filter(outdated).length ?? 0
|
||||
if (!window.confirm(`Update ${n} machine${n === 1 ? '' : 's'} to ${norm(hub.version)}?`)) return
|
||||
try {
|
||||
const res = await api.updateAllMachines()
|
||||
let res = await api.updateAllMachines()
|
||||
const busy = res.filter((r) => !r.ok && (r.error ?? '').startsWith('busy:'))
|
||||
if (busy.length && window.confirm(`${busy.map((r) => r.name).join(', ')}: ${busy[0].error?.slice(6)}\n\nForce those too?`)) res = res.map((r) => r).concat(await api.updateAllMachines(true))
|
||||
for (const r of res) toast(r.ok ? `${r.name} → ${r.version}` : `${r.name}: ${r.error}`, r.ok ? 'ok' : 'err')
|
||||
setTimeout(load, 4000)
|
||||
} catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||
|
||||
+7
-6
@@ -3,16 +3,17 @@ 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 }) {
|
||||
// `label` is what the user types (the machine name on a remote); `host` is what the node checks.
|
||||
export default function Power({ host, label, rebootNeeded }: { host: string; label: string; rebootNeeded?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [typed, setTyped] = useState('')
|
||||
const [sent, setSent] = useState<'' | 'reboot' | 'poweroff'>('')
|
||||
const [err, setErr] = useState('')
|
||||
const ok = typed.trim() === host
|
||||
const ok = typed.trim() === label
|
||||
|
||||
async function go(action: 'reboot' | 'poweroff') {
|
||||
setErr('')
|
||||
try { await api.power(action, typed.trim()); setSent(action) }
|
||||
try { await api.power(action, host); setSent(action) }
|
||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
}
|
||||
|
||||
@@ -22,14 +23,14 @@ export default function Power({ host, rebootNeeded }: { host: string; rebootNeed
|
||||
{open && (
|
||||
<div className="modal-back" onClick={() => !sent && setOpen(false)}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Power · {host}</h3>
|
||||
<h3>Power · {label}</h3>
|
||||
{rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>}
|
||||
{sent ? (
|
||||
<div className="muted"><Spinner /> {sent === 'reboot' ? 'Rebooting. The console will reconnect when the host is back.' : 'Shutting down. Power it back on by hand.'}</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="muted">Type the hostname to confirm
|
||||
<input autoFocus value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={host} />
|
||||
<label className="muted">Type <b>{label}</b> to confirm
|
||||
<input autoFocus value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={label} />
|
||||
</label>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="svc-actions">
|
||||
|
||||
@@ -136,6 +136,7 @@ export default function Procs() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="ptable">
|
||||
{rows.length === 0 && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
|
||||
{rows.map((p) => (
|
||||
<div key={p.pid} className={'prow' + (p.pid === sel ? ' active' : '') + (p.state === 'Z' ? ' zombie' : p.state === 'R' ? ' running' : '')} onClick={() => setSel(p.pid === sel ? null : p.pid)} title={p.cmd || p.name}>
|
||||
<span className="num muted">{p.pid}</span>
|
||||
|
||||
+26
-5
@@ -39,6 +39,16 @@ const GROUPS: Group[] = [
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files' | 'procs' | 'machines' | 'settings'
|
||||
const VIEWS: View[] = ['terminal', 'dashboard', 'services', 'cron', 'updates', 'disks', 'files', 'procs', 'machines', 'settings']
|
||||
const HUB_ONLY: View[] = ['machines', 'settings']
|
||||
|
||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||
// forward work, reload restores the tab, and links can point at a page on a machine.
|
||||
function parseHash(): { machine: string | null; view: View } | null {
|
||||
const m = location.hash.match(/^#\/([a-z0-9-]+)\/([a-z]+)$/)
|
||||
if (!m || !VIEWS.includes(m[2] as View)) return null
|
||||
return { machine: m[1] === 'hub' ? null : m[1], view: m[2] as View }
|
||||
}
|
||||
|
||||
function fmtUptime(s: number): string {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
@@ -48,7 +58,7 @@ function fmtUptime(s: number): string {
|
||||
export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
|
||||
// Which machine we're looking at. null = the hub itself. Set before anything renders so
|
||||
// every page's first request already goes to the right place.
|
||||
const [machine, setMachineState] = useState<string | null>(() => { const m = sessionStorage.getItem('hamlab.machine'); setMachine(m); return m })
|
||||
const [machine, setMachineState] = useState<string | null>(() => { const m = parseHash()?.machine ?? sessionStorage.getItem('hamlab.machine'); setMachine(m); return m })
|
||||
const [machines, setMachines] = useState<MachineStatus[]>([])
|
||||
const [remoteMe, setRemoteMe] = useState<Me | null>(null)
|
||||
const [remoteErr, setRemoteErr] = useState('')
|
||||
@@ -56,7 +66,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
const listRate = useRefreshMs(LIST_FLOOR)
|
||||
const [terms, setTerms] = useState<Term[]>([])
|
||||
const [active, setActive] = useState<string | null>(null)
|
||||
const [view, setView] = useState<View>('dashboard')
|
||||
const [view, setView] = useState<View>(() => parseHash()?.view ?? 'dashboard')
|
||||
const [drawer, setDrawer] = useState(false)
|
||||
const [online, setOnline] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -81,8 +91,19 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
setMachine(name); sessionStorage.setItem('hamlab.machine', name ?? ''); if (!name) sessionStorage.removeItem('hamlab.machine')
|
||||
handles.current = {}; setTerms([]); setActive(null); setRemoteMe(null); setRemoteErr(''); setDrawer(false); setOnline(true)
|
||||
setMachineState(name)
|
||||
if (view === 'machines') setView('dashboard')
|
||||
if (name && HUB_ONLY.includes(view)) setView('dashboard') // hub-only pages make no sense on a remote machine
|
||||
}
|
||||
|
||||
// keep the hash in step, and follow it when the browser navigates
|
||||
useEffect(() => {
|
||||
const want = `#/${machine ?? 'hub'}/${view}`
|
||||
if (location.hash !== want) history.pushState(null, '', want)
|
||||
}, [machine, view])
|
||||
useEffect(() => {
|
||||
const h = () => { const p = parseHash(); if (!p) return; if (p.machine !== machine) switchMachine(p.machine); if (p.view !== view) setView(p.view) }
|
||||
window.addEventListener('popstate', h)
|
||||
return () => window.removeEventListener('popstate', h)
|
||||
}) // no deps: needs the latest switchMachine/view each time
|
||||
useEffect(() => {
|
||||
if (!machine) { setRemoteMe(null); return }
|
||||
let stop = false
|
||||
@@ -139,9 +160,9 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
||||
<span className="host">{me.user}@{me.host}</span>
|
||||
{machine && remoteErr && <span className="badge bad" title={remoteErr}>offline</span>}
|
||||
<span className="spacer" />
|
||||
<span className="stat" title="load average">{me.load}</span>
|
||||
<span className="stat" title="load average, 1 · 5 · 15 min">load {me.load}</span>
|
||||
<span className={'dot ' + (online && !remoteErr ? 'ok' : 'bad')} title={remoteErr ? `${machine} unreachable` : online ? 'connected' : 'disconnected'} />
|
||||
<Power host={me.host} rebootNeeded={me.reboot_needed} />
|
||||
<Power host={me.host} label={machine ?? me.host} rebootNeeded={me.reboot_needed} />
|
||||
<Btn className="link" onClick={logout}>logout</Btn>
|
||||
</header>
|
||||
|
||||
|
||||
+12
-3
@@ -7,6 +7,11 @@ let base = ''
|
||||
export function setMachine(name: string | null) { base = name ? `/api/nodes/${name}` : '' }
|
||||
export const apiPath = (p: string) => (base && p.startsWith('/api/') ? base + p.slice(4) : p)
|
||||
|
||||
// A machine that just failed to answer is left alone for a few seconds instead of
|
||||
// being hit again on every tick of every page.
|
||||
const downUntil: Record<string, number> = {}
|
||||
function machineOf(p: string): string | null { const m = p.match(/^\/api\/nodes\/([a-z0-9-]+)\//); return m ? m[1] : null }
|
||||
|
||||
let inflight = 0
|
||||
function track(delta: number) {
|
||||
inflight += delta
|
||||
@@ -14,10 +19,13 @@ function track(delta: number) {
|
||||
}
|
||||
|
||||
async function req<T>(method: string, path: string, body?: unknown, local = false): Promise<T> {
|
||||
const target = local ? path : apiPath(path)
|
||||
const mach = machineOf(target)
|
||||
if (mach && downUntil[mach] > Date.now()) throw new Error(`${mach} unreachable (retrying shortly)`)
|
||||
track(1)
|
||||
let r: Response
|
||||
try {
|
||||
r = await fetch(local ? path : apiPath(path), {
|
||||
r = await fetch(target, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
||||
@@ -28,6 +36,7 @@ async function req<T>(method: string, path: string, body?: unknown, local = fals
|
||||
throw new Error('network error')
|
||||
}
|
||||
track(-1)
|
||||
if (r.status === 502 && mach) downUntil[mach] = Date.now() + 5000
|
||||
if (r.status === 401) {
|
||||
let msg = 'unauthenticated'
|
||||
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
|
||||
@@ -167,8 +176,8 @@ export const api = {
|
||||
me: () => req<Me>('GET', '/api/me'),
|
||||
machines: () => req<{ nodes: MachineStatus[]; host: string; fingerprint?: string }>('GET', '/api/nodes', undefined, true),
|
||||
addMachine: (name: string, url: string, password: string) => req<MachineStatus>('POST', '/api/nodes', { name, url, password }, true),
|
||||
updateMachine: (name: string) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update`, undefined, true),
|
||||
updateAllMachines: () => req<PushResult[]>('POST', '/api/machines-update-all', undefined, true),
|
||||
updateMachine: (name: string, force = false) => req<PushResult>('POST', `/api/machines/${encodeURIComponent(name)}/update${force ? '?force=1' : ''}`, undefined, true),
|
||||
updateAllMachines: (force = false) => req<PushResult[]>('POST', `/api/machines-update-all${force ? '?force=1' : ''}`, undefined, true),
|
||||
changePassword: (current: string, password: string) => req<void>('POST', '/api/password', { current, password }, true),
|
||||
tokens: () => req<{ name: string; created: string }[]>('GET', '/api/tokens', undefined, true),
|
||||
revokeToken: (name: string) => req<void>('DELETE', `/api/tokens/${encodeURIComponent(name)}`, undefined, true),
|
||||
|
||||
@@ -379,3 +379,12 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
[data-theme="light"] .modal-back { background: rgba(0,0,0,.35); }
|
||||
|
||||
.disk-summary { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 2px 0 6px; }
|
||||
|
||||
/* touch: every control at least 36px tall (bug 22) */
|
||||
@media (pointer: coarse) {
|
||||
.btn, .chip, .icon-btn, .link, .th, .mod-tools button, .hide-item, .crumb, .files-tools select, .procs-tools select, .dash-bar select { min-height: 36px; padding-top: 6px; padding-bottom: 6px; }
|
||||
.icon-btn { min-width: 36px; }
|
||||
.leaf-actions .icon-btn { min-width: 34px; }
|
||||
.header .link, .header .icon-btn { min-height: 36px; display: inline-flex; align-items: center; }
|
||||
.mod-tools { margin-top: 0; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user