Files
hamlab/web/src/Services.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 e0e2562f0a Bug bash 6: the phone fits ZFS, Usage and journal lines; a snapshot's default name is local time; twelve smaller faults
Found by a headless pass over every page on every machine at desktop and phone widths, then
every interaction on the hub. BUGS.md has the list, what was checked and found fine, and the
quality-of-life notes to discuss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 11:28:44 -07:00

249 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api'
import { askYes, Btn, CloseBtn, Loading, Spinner, toast } from './ui'
import { LIST_FLOOR, useRefreshMs } from './prefs'
import { useHashSel } from './hashsel'
type Filter = 'all' | 'running' | 'failed' | 'enabled'
function fmtBytes(b: number): string {
const u = ['B', 'KiB', 'MiB', 'GiB']
let i = 0
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
}
function fmtCPU(ns: number): string {
const s = ns / 1e9
return s >= 3600 ? `${(s / 3600).toFixed(1)} h` : s >= 60 ? `${(s / 60).toFixed(1)} min` : `${s.toFixed(1)} s`
}
// systemd writes "Fri 2026-10-02 23:48:25 PDT", which Chromium parses and Firefox does not; read
// the fields directly, in the browser's own zone (the machine's, on a LAN), and fall back to Date.parse.
function parseSystemdTime(ts: string): number {
const m = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(ts)
if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]).getTime()
return Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
}
function since(ts?: string): string {
if (!ts) return ''
const t = parseSystemdTime(ts)
if (isNaN(t)) return ts
const s = Math.max(0, (Date.now() - t) / 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 > 0 ? `${m}m` : `${Math.floor(s)}s`) + ' ago'
}
const fmtTime = (ms: number) => { const d = new Date(ms); return d.toLocaleDateString(undefined, { month: 'short', day: '2-digit' }) + ' ' + d.toLocaleTimeString() }
function stateClass(u: ServiceUnit): string {
if (u.failed) return 'bad'
if (u.active === 'active' && u.sub === 'running') return 'ok'
if (u.active === 'active') return 'exited'
if (u.active === 'activating' || u.active === 'deactivating') return 'warn'
return ''
}
// ---- log viewer: seeded from the REST fetch, then followed over a WebSocket ----
export function Logs({ unit }: { unit: string }) {
const [lines, setLines] = useState<LogEntry[]>([])
const [live, setLive] = useState(true)
const [paused, setPaused] = useState(false)
const [err, setErr] = useState('')
const [ready, setReady] = useState(false)
const box = useRef<HTMLDivElement>(null)
const pausedRef = useRef(false)
pausedRef.current = paused
const pending = useRef<LogEntry[]>([])
useEffect(() => {
setLines([]); setErr(''); setReady(false); pending.current = []
let closed = false
let readyTimer: number | undefined
let sock: WebSocket | null = null
let retry: number | undefined
// fixed when the log opens, for the same reason as a terminal's: a reconnect must not
// follow the console to another machine
const url = wsURLPath(`/api/services/${encodeURIComponent(unit)}/logs/tail?n=200`)
const connect = () => {
if (closed) return
const s = new WebSocket(url)
sock = s
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]))
}
s.onclose = () => { if (closed) return; setLive(false); retry = window.setTimeout(connect, 3000) }
s.onerror = () => { setErr('log stream disconnected'); s.close() }
}
connect()
return () => { closed = true; clearTimeout(retry); clearTimeout(readyTimer); sock?.close() }
}, [unit])
// Resume: flush what arrived while paused.
useEffect(() => {
if (paused || pending.current.length === 0) return
const p = pending.current; pending.current = []
setLines((prev) => [...prev, ...p].slice(-2000))
}, [paused])
useEffect(() => { if (!paused && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, paused])
return (
<div className="logs">
<div className="logs-bar">
<span className={'dot ' + (live ? 'ok' : 'bad')} /> <span className="muted">{live ? 'following' : 'reconnecting…'}{err && ` · ${err}`}</span>
<span className="spacer" />
<button className="btn" onClick={() => setPaused(!paused)}>{paused ? `Resume${pending.current.length ? ` (${pending.current.length})` : ''}` : 'Pause'}</button>
<button className="btn" onClick={() => setLines([])}>Clear</button>
</div>
<div className="log-lines" ref={box}>
{lines.length === 0 && (ready ? <div className="muted">No journal entries.</div> : <div className="muted"><Spinner /> loading journal…</div>)}
{lines.map((l, i) => (
<div key={i} className={'log' + (l.p <= 3 ? ' err' : l.p === 4 ? ' warn' : l.p >= 7 ? ' debug' : '')}>
<span className="lt">{fmtTime(l.t)}</span>
<span className="li">{l.ident}{l.pid ? `[${l.pid}]` : ''}</span>
<span className="lm">{l.msg}</span>
</div>
))}
</div>
</div>
)
}
// ---- detail pane ----
function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => void; onBack: () => void }) {
const [d, setD] = useState<ServiceDetail | null>(null)
const [tab, setTab] = useState<'logs' | 'file'>('logs')
const [file, setFile] = useState<string>('')
const [busy, setBusy] = useState('')
const [err, setErr] = useState('')
const load = useCallback(async () => {
try { setD(await api.service(name)); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [name])
const rate = useRefreshMs(LIST_FLOOR)
useEffect(() => { setD(null); setFile(''); setTab('logs'); load() }, [load])
useEffect(() => { const t = setInterval(load, rate); return () => clearInterval(t) }, [load, rate])
useEffect(() => { if (tab === 'file' && !file) api.serviceFile(name).then((r) => setFile(r.content)).catch((e) => setFile(String(e))) }, [tab, file, name])
async function act(action: string) {
const confirmFor: Record<string, string> = { stop: 'Stop', restart: 'Restart', disable: 'Disable' }
if (confirmFor[action] && !await askYes(`${confirmFor[action]} ${name}?`)) return
setBusy(action); setErr('')
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 <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Services</button>{err ? <div className="error">{err}</div> : <Loading label="Loading service…" />}</div>
const p = d.props
const running = d.active === 'active'
const enabled = d.enabled === 'enabled'
const Btn = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button>
return (
<div className="svc-detail">
<CloseBtn onClick={onBack} />
<button className="btn back" onClick={onBack}>‹ Services</button>
<div className="svc-head">
<span className={'sdot ' + stateClass(d)} />
<h2>{d.name}</h2>
</div>
<div className="muted">{d.description}</div>
<div className="svc-actions">
{running ? <Btn a="stop" label="Stop" /> : <Btn a="start" label="Start" />}
<Btn a="restart" label="Restart" />
{p.ExecReload || d.sub === 'running' ? <Btn a="reload" label="Reload" /> : null}
{enabled ? <Btn a="disable" label="Disable" /> : d.enabled === 'static' || d.enabled === 'masked' ? null : <Btn a="enable" label="Enable" />}
{d.failed && <Btn a="reset-failed" label="Reset failed" />}
</div>
{err && <div className="error">{err}</div>}
<div className="props">
<div><span className="k">State</span><span>{d.active} ({d.sub}){d.failed && p.Result ? ` · ${p.Result}` : ''}</span></div>
<div><span className="k">Enabled</span><span>{d.enabled || '—'}</span></div>
<div><span className="k">Since</span><span>{running ? since(p.ActiveEnterTimestamp) : since(p.InactiveEnterTimestamp)}</span></div>
{p.MainPID && p.MainPID !== '0' && <div><span className="k">PID</span><span>{p.MainPID}</span></div>}
{p.MemoryCurrent && <div><span className="k">Memory</span><span>{fmtBytes(+p.MemoryCurrent)}{p.MemoryPeak ? ` (peak ${fmtBytes(+p.MemoryPeak)})` : ''}</span></div>}
{p.CPUUsageNSec && <div><span className="k">CPU time</span><span>{fmtCPU(+p.CPUUsageNSec)}</span></div>}
{p.TasksCurrent && <div><span className="k">Tasks</span><span>{p.TasksCurrent}</span></div>}
{p.NRestarts && p.NRestarts !== '0' && <div><span className="k">Restarts</span><span>{p.NRestarts}</span></div>}
{p.User && <div><span className="k">User</span><span>{p.User}</span></div>}
{p.FragmentPath && <div><span className="k">Unit file</span><span className="mono">{p.FragmentPath}</span></div>}
</div>
<div className="tabs">
<button className={tab === 'logs' ? 'on' : ''} onClick={() => setTab('logs')}>Logs</button>
<button className={tab === 'file' ? 'on' : ''} onClick={() => setTab('file')}>Unit file</button>
</div>
{tab === 'logs' ? <Logs unit={name} /> : <pre className="unit-file">{file || <span className="muted"><Spinner /> loading…</span>}</pre>}
</div>
)
}
// ---- page ----
export default function Services() {
const [units, setUnits] = useState<ServiceUnit[]>([])
const [err, setErr] = useState('')
const [q, setQ] = useState('')
const [filter, setFilter] = useState<Filter>('all')
const [hashSel, setHashSel] = useHashSel('services') // the selected unit lives in the URL
const sel = hashSel || null, setSel = (v: string | null) => setHashSel(v ?? '')
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) }
}, [])
const rate = useRefreshMs(LIST_FLOOR)
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, rate); return () => clearInterval(t) }, [load, rate])
const needle = q.trim().toLowerCase()
const shown = units.filter((u) => {
if (filter === 'running' && !(u.active === 'active' && u.sub === 'running')) return false
if (filter === 'failed' && !u.failed) return false
if (filter === 'enabled' && u.enabled !== 'enabled') return false
return !needle || u.name.toLowerCase().includes(needle) || u.description.toLowerCase().includes(needle)
})
const counts = {
running: units.filter((u) => u.active === 'active' && u.sub === 'running').length,
failed: units.filter((u) => u.failed).length,
enabled: units.filter((u) => u.enabled === 'enabled').length,
}
return (
<div className={'svc' + (sel ? ' has-sel' : '')}>
<div className="svc-list">
<div className="svc-tools">
<div className="tools-row"><input placeholder="filter services" value={q} onChange={(e) => setQ(e.target.value)} /><Btn className="btn" title="reload" onClick={load}>↻</Btn></div>
<div className="chips">
{([['all', `All ${units.length}`], ['running', `Running ${counts.running}`], ['failed', `Failed ${counts.failed}`], ['enabled', `Enabled ${counts.enabled}`]] as [Filter, string][]).map(([f, label]) => (
<button key={f} className={'chip' + (filter === f ? ' on' : '') + (f === 'failed' && counts.failed ? ' bad' : '')} onClick={() => setFilter(f)}>{label}</button>
))}
</div>
</div>
{err && units.length === 0 && loaded
? <div className="notice" style={{ margin: 8 }}>{err}</div> // a host with no systemd, or one that is unreachable: not a filter result
: err && <div className="error">{err}</div>}
<div className="svc-rows">
{!loaded && <Loading label="Listing services…" />}
{shown.map((u) => (
<div key={u.name} className={'svc-row' + (u.name === sel ? ' active' : '')} onClick={() => setSel(u.name)}>
<span className={'sdot ' + stateClass(u)} />
<span className="svc-name">
<span className="n">{u.name.replace(/\.service$/, '')}</span>
<span className="d">{u.description}</span>
</span>
<span className="svc-state">{u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}</span>
</div>
))}
{loaded && shown.length === 0 && !(err && units.length === 0) && <div className="muted" style={{ padding: 12 }}>Nothing matches.</div>}
</div>
</div>
{sel ? <Detail name={sel} onChanged={load} onBack={() => setSel(null)} /> : <div className="svc-empty muted">Select a service.</div>}
</div>
)
}