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>
249 lines
13 KiB
TypeScript
249 lines
13 KiB
TypeScript
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>
|
||
)
|
||
}
|