Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
224 lines
11 KiB
TypeScript
224 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|