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([]) 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 // 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 (
{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]) 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 = { 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
{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 [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 (
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 && units.length === 0 && loaded ?
{err}
// a host with no systemd, or one that is unreachable: not a filter result : 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 && !(err && units.length === 0) &&
Nothing matches.
}
{sel ? setSel(null)} /> :
Select a service.
}
) }