Files
hamlab/web/src/Services.tsx
T
Mitchell-JohnsonandClaude Fable 5.1 7e3e916f25 Arrange the rail: drag pages into your own order, kept with the account
The close cross on a detail pane is drawn, not a glyph, and sits in no box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 19:59:25 -07:00

240 lines
12 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`
}
function since(ts?: string): string {
if (!ts) return ''
const t = Date.parse(ts.replace(/^[A-Za-z]{3} /, ''))
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 && <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 && <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>
)
}