Services tab: systemd unit list/filter, detail with start/stop/restart/reload/enable/disable, live journal tail over WS, unit file viewer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
cd3979efce
commit
bc891b1042
@@ -0,0 +1,223 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
+5
-3
@@ -4,16 +4,17 @@ import Rail, { Group } from './Rail'
|
||||
import TerminalPane, { TermHandle } from './TerminalPane'
|
||||
import KeyBar from './KeyBar'
|
||||
import Dashboard from './Dashboard'
|
||||
import Services from './Services'
|
||||
|
||||
const GROUPS: Group[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||
{ id: 'services', label: 'Services', icon: '⚙', soon: true },
|
||||
{ id: 'services', label: 'Services', icon: '⚙', page: true },
|
||||
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true },
|
||||
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
|
||||
]
|
||||
|
||||
type View = 'terminal' | 'dashboard'
|
||||
type View = 'terminal' | 'dashboard' | 'services'
|
||||
|
||||
function fmtUptime(s: number): string {
|
||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
||||
@@ -79,6 +80,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
|
||||
<main className="main">
|
||||
{view === 'dashboard' && <Dashboard />}
|
||||
{view === 'services' && <Services />}
|
||||
{termView && terms.length === 0 && (
|
||||
<div className="center muted">
|
||||
<div>No terminals.</div>
|
||||
@@ -101,7 +103,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
||||
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||
<span>up {fmtUptime(me.uptime)}</span>
|
||||
<span className="spacer" />
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : activeTerm ? activeTerm.name : ''}</span>
|
||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : activeTerm ? activeTerm.name : ''}</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
|
||||
+12
-2
@@ -42,7 +42,16 @@ export interface Series {
|
||||
// off; `hiddenItems` are individual rows ("net:wlp1s0", "fs:/boot/efi", "disk:sda", "temp:chip/label").
|
||||
export interface DashPrefs { order?: string[]; hidden?: string[]; hiddenItems?: string[]; intervalMs?: number }
|
||||
|
||||
export interface ServiceUnit { name: string; description: string; load: string; active: string; sub: string; enabled: string; failed: boolean }
|
||||
export interface ServiceDetail extends ServiceUnit { props: Record<string, string> }
|
||||
export interface LogEntry { t: number; p: number; ident: string; pid?: string; msg: string }
|
||||
|
||||
export const api = {
|
||||
services: async () => (await req<{ units: ServiceUnit[]; sudo: boolean }>('GET', '/api/services')).units,
|
||||
service: (unit: string) => req<ServiceDetail>('GET', `/api/services/${encodeURIComponent(unit)}`),
|
||||
serviceFile: (unit: string) => req<{ content: string }>('GET', `/api/services/${encodeURIComponent(unit)}/file`),
|
||||
serviceLogs: (unit: string, n = 200) => req<LogEntry[]>('GET', `/api/services/${encodeURIComponent(unit)}/logs?n=${n}`),
|
||||
serviceAction: (unit: string, action: string) => req<void>('POST', `/api/services/${encodeURIComponent(unit)}/${action}`),
|
||||
dashPrefs: () => req<DashPrefs>('GET', '/api/prefs/dashboard'),
|
||||
saveDashPrefs: (p: DashPrefs) => req<void>('PUT', '/api/prefs/dashboard', p),
|
||||
metrics: () => req<Sample>('GET', '/api/metrics'),
|
||||
@@ -56,7 +65,8 @@ export const api = {
|
||||
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
|
||||
}
|
||||
|
||||
export function wsURL(id: string): string {
|
||||
export function wsURLPath(path: string): string {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${location.host}/api/terminals/${id}/attach`
|
||||
return `${proto}//${location.host}${path}`
|
||||
}
|
||||
export const wsURL = (id: string) => wsURLPath(`/api/terminals/${id}/attach`)
|
||||
|
||||
@@ -123,3 +123,40 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
|
||||
.chip { padding: 3px 9px; background: var(--bg3); border: 1px solid var(--border); border-radius: 12px; font-size: 12px; color: var(--muted); }
|
||||
.chip:hover { color: var(--fg); border-color: var(--accent2); }
|
||||
.chips.inline { margin-top: 6px; }
|
||||
|
||||
/* services */
|
||||
.svc { position: absolute; inset: 0; display: flex; background: var(--bg); }
|
||||
.svc-list { width: 360px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--bg2); min-height: 0; }
|
||||
.svc-tools { padding: 10px; display: flex; flex-direction: column; gap: 8px; border-bottom: 1px solid var(--border); }
|
||||
.svc-tools input { padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; }
|
||||
.chip.on { color: var(--fg); border-color: var(--accent); } .chip.bad { color: var(--bad); }
|
||||
.svc-rows { flex: 1; overflow-y: auto; }
|
||||
.svc-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
||||
.svc-row:hover { background: var(--bg3); } .svc-row.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.svc-name { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.svc-name .n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.svc-name .d { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.svc-state { font-size: 11px; color: var(--muted); font-family: ui-monospace, monospace; white-space: nowrap; }
|
||||
.sdot.ok { background: var(--ok); } .sdot.bad { background: var(--bad); } .sdot.warn { background: var(--s2); } .sdot.exited { background: var(--accent2); }
|
||||
.svc-empty { flex: 1; display: flex; align-items: center; justify-content: center; }
|
||||
.svc-detail { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 12px; overflow-y: auto; }
|
||||
.svc-detail .back { display: none; align-self: flex-start; }
|
||||
.svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; }
|
||||
.svc-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.props { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 4px 16px; font-size: 13px; }
|
||||
.props > div { display: flex; gap: 8px; min-width: 0; } .props .k { color: var(--muted); width: 80px; flex-shrink: 0; }
|
||||
.props span:not(.k) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .mono { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); }
|
||||
.tabs button { padding: 6px 12px; color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tabs button.on { color: var(--fg); border-bottom-color: var(--accent); }
|
||||
.unit-file { margin: 0; padding: 10px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; word-break: break-all; }
|
||||
.logs { flex: 1; min-height: 240px; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; background: #0d1117; overflow: hidden; }
|
||||
.logs-bar { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-bottom: 1px solid var(--border); font-size: 12px; background: var(--bg2); }
|
||||
.log-lines { flex: 1; overflow: auto; padding: 6px 8px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.log { display: flex; gap: 8px; white-space: pre-wrap; word-break: break-word; }
|
||||
.log .lt { color: var(--muted); flex-shrink: 0; } .log .li { color: var(--accent2); flex-shrink: 0; }
|
||||
.log.err .lm { color: var(--bad); } .log.warn .lm { color: var(--s2); } .log.debug .lm { color: var(--muted); }
|
||||
@media (max-width: 800px) {
|
||||
.svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; }
|
||||
.svc-detail .back { display: inline-block; } .props { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user