import { useCallback, useEffect, useRef, useState } from 'react' import { api, LogEntry, ServiceDetail, ServiceUnit, wsURLPath } from './api' import { Btn, Loading, Spinner, toast } from './ui' 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([]) const [live, setLive] = useState(true) const [paused, setPaused] = useState(false) const [err, setErr] = useState('') const [ready, setReady] = useState(false) const box = useRef(null) const pausedRef = useRef(false) pausedRef.current = paused const pending = useRef([]) useEffect(() => { setLines([]); setErr(''); setReady(false); pending.current = [] let closed = false let readyTimer: number | undefined 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(''); 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 (
{live ? 'following' : 'reconnecting…'}{err && ` · ${err}`}
{lines.length === 0 && (ready ?
No journal entries.
:
loading journal…
)} {lines.map((l, i) => (
= 7 ? ' debug' : '')}> {fmtTime(l.t)} {l.ident}{l.pid ? `[${l.pid}]` : ''} {l.msg}
))}
) } // ---- detail pane ---- function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => void; onBack: () => void }) { const [d, setD] = useState(null) const [tab, setTab] = useState<'logs' | 'file'>('logs') const [file, setFile] = useState('') 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 = { 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(); toast(`${name}: ${action} ok`) } catch (e) { setErr(e instanceof Error ? e.message : 'failed'); toast(`${name}: ${action} failed`, 'err') } finally { setBusy('') } } if (!d) return
{err ?
{err}
: }
const p = d.props const running = d.active === 'active' const enabled = d.enabled === 'enabled' const Btn = ({ a, label }: { a: string; label: string }) => return (

{d.name}

{d.description}
{running ? : } {p.ExecReload || d.sub === 'running' ? : null} {enabled ? : d.enabled === 'static' || d.enabled === 'masked' ? null : } {d.failed && }
{err &&
{err}
}
State{d.active} ({d.sub}){d.failed && p.Result ? ` · ${p.Result}` : ''}
Enabled{d.enabled || '—'}
Since{running ? since(p.ActiveEnterTimestamp) : since(p.InactiveEnterTimestamp)}
{p.MainPID && p.MainPID !== '0' &&
PID{p.MainPID}
} {p.MemoryCurrent &&
Memory{fmtBytes(+p.MemoryCurrent)}{p.MemoryPeak ? ` (peak ${fmtBytes(+p.MemoryPeak)})` : ''}
} {p.CPUUsageNSec &&
CPU time{fmtCPU(+p.CPUUsageNSec)}
} {p.TasksCurrent &&
Tasks{p.TasksCurrent}
} {p.NRestarts && p.NRestarts !== '0' &&
Restarts{p.NRestarts}
} {p.User &&
User{p.User}
} {p.FragmentPath &&
Unit file{p.FragmentPath}
}
{tab === 'logs' ? :
{file ||  loading…}
}
) } // ---- page ---- export default function Services() { const [units, setUnits] = useState([]) const [err, setErr] = useState('') const [q, setQ] = useState('') const [filter, setFilter] = useState('all') const [sel, setSel] = useState(null) 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) } }, []) 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 (
setQ(e.target.value)} />↻
{([['all', `All ${units.length}`], ['running', `Running ${counts.running}`], ['failed', `Failed ${counts.failed}`], ['enabled', `Enabled ${counts.enabled}`]] as [Filter, string][]).map(([f, label]) => ( ))}
{err &&
{err}
}
{!loaded && } {shown.map((u) => (
setSel(u.name)}> {u.name.replace(/\.service$/, '')} {u.description} {u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''}
))} {loaded && shown.length === 0 &&
Nothing matches.
}
{sel ? setSel(null)} /> :
Select a service.
}
) }