Files
hamlab/web/src/Services.tsx
T

224 lines
11 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'
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 ----
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 box = useRef<HTMLDivElement>(null)
const pausedRef = useRef(false)
pausedRef.current = paused
const pending = useRef<LogEntry[]>([])
useEffect(() => {
setLines([]); setErr(''); pending.current = []
let closed = false
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.onmessage = (ev) => {
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); 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 && <div className="muted">No journal entries.</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])
useEffect(() => { setD(null); setFile(''); setTab('logs'); load() }, [load])
useEffect(() => { const t = setInterval(load, 5000); return () => clearInterval(t) }, [load])
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] && !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') }
finally { setBusy('') }
}
if (!d) return <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Services</button><div className="muted">{err || '…'}</div></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 ? '…' : label}</button>
return (
<div className="svc-detail">
<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 || '…'}</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 [sel, setSel] = useState<string | null>(null)
const load = useCallback(async () => {
try { setUnits(await api.services()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}, [])
useEffect(() => { load() }, [load])
useEffect(() => { const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, 10000); return () => clearInterval(t) }, [load])
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">
<input placeholder="filter services" value={q} onChange={(e) => setQ(e.target.value)} />
<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">
{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>
))}
{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>
)
}