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:
Mitchell-Johnson
2026-09-17 15:35:03 -07:00
co-authored by Claude Fable 5.1
parent cd3979efce
commit bc891b1042
7 changed files with 706 additions and 8 deletions
+2 -1
View File
@@ -22,6 +22,7 @@ import (
"hamlab/internal/auth"
"hamlab/internal/config"
"hamlab/internal/metrics"
"hamlab/internal/services"
"hamlab/internal/store"
hterm "hamlab/internal/term"
"hamlab/internal/web"
@@ -141,7 +142,7 @@ func serve(cfg *config.Config) error {
col := metrics.NewCollector()
go col.Run(context.Background())
srv := api.New(cfg, sessions, mgr, col, st, web.FS(), version)
srv := api.New(cfg, sessions, mgr, col, st, services.New(), web.FS(), version)
hs := &http.Server{
Addr: cfg.Listen,
Handler: srv.Handler(),
+119 -2
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"os"
"os/user"
"strconv"
"strings"
"time"
@@ -19,6 +20,7 @@ import (
"hamlab/internal/auth"
"hamlab/internal/config"
"hamlab/internal/metrics"
"hamlab/internal/services"
"hamlab/internal/store"
"hamlab/internal/term"
)
@@ -32,6 +34,7 @@ type Server struct {
mgr *term.Manager
metrics *metrics.Collector
store *store.Store
svc *services.Manager
version string
started time.Time
user string
@@ -40,9 +43,9 @@ type Server struct {
mux *http.ServeMux
}
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, ui fs.FS, version string) *Server {
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, ui fs.FS, version string) *Server {
host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, ui: ui, version: version,
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")}
if u, err := user.Current(); err == nil && u.Username != "" {
s.user = u.Username
@@ -67,6 +70,12 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach))
m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest))
m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory))
m.HandleFunc("GET /api/services", s.auth(s.listServices))
m.HandleFunc("GET /api/services/{unit}", s.auth(s.showService))
m.HandleFunc("GET /api/services/{unit}/file", s.auth(s.serviceFile))
m.HandleFunc("GET /api/services/{unit}/logs", s.auth(s.serviceLogs))
m.HandleFunc("GET /api/services/{unit}/logs/tail", s.auth(s.serviceTail))
m.HandleFunc("POST /api/services/{unit}/{action}", s.auth(s.serviceAction))
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
@@ -194,6 +203,114 @@ func (s *Server) metricsHistory(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, s.metrics.History())
}
// ---- services (systemd) ----
func (s *Server) listServices(w http.ResponseWriter, r *http.Request) {
list, err := s.svc.List(r.Context())
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, 200, map[string]any{"units": list, "sudo": s.svc.Sudo()})
}
func (s *Server) showService(w http.ResponseWriter, r *http.Request) {
d, err := s.svc.Show(r.Context(), r.PathValue("unit"))
if err != nil {
jsonErr(w, 404, err.Error())
return
}
jsonOK(w, 200, d)
}
func (s *Server) serviceFile(w http.ResponseWriter, r *http.Request) {
out, err := s.svc.Cat(r.Context(), r.PathValue("unit"))
if err != nil {
jsonErr(w, 404, err.Error())
return
}
jsonOK(w, 200, map[string]string{"content": out})
}
func logCount(r *http.Request) int {
n, _ := strconv.Atoi(r.URL.Query().Get("n"))
if n <= 0 {
n = 200
}
if n > 5000 {
n = 5000
}
return n
}
func (s *Server) serviceLogs(w http.ResponseWriter, r *http.Request) {
entries, err := s.svc.Journal(r.Context(), r.PathValue("unit"), logCount(r))
if err != nil {
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, 200, entries)
}
// serviceTail streams journal entries as JSON text frames until the client goes away.
func (s *Server) serviceTail(w http.ResponseWriter, r *http.Request) {
unit := r.PathValue("unit")
if !services.Valid(unit) {
jsonErr(w, 400, "invalid unit name")
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
if err != nil {
return
}
defer conn.Close(websocket.StatusNormalClosure, "")
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go func() { // reader: only to notice the close (and keep pings flowing)
defer cancel()
for {
if _, _, err := conn.Read(ctx); err != nil {
return
}
}
}()
go func() {
t := time.NewTicker(25 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pctx)
pc()
if err != nil {
cancel()
return
}
}
}
}()
_ = s.svc.Tail(ctx, unit, logCount(r), func(e services.Entry) {
b, _ := json.Marshal(e)
if err := conn.Write(ctx, websocket.MessageText, b); err != nil {
cancel()
}
})
}
func (s *Server) serviceAction(w http.ResponseWriter, r *http.Request) {
unit, action := r.PathValue("unit"), r.PathValue("action")
if err := s.svc.Action(r.Context(), action, unit); err != nil {
log.Printf("systemctl %s %s: %v", action, unit, err)
jsonErr(w, 400, err.Error())
return
}
log.Printf("systemctl %s %s ok (from %s)", action, unit, clientIP(r))
w.WriteHeader(204)
}
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
+308
View File
@@ -0,0 +1,308 @@
// Package services wraps systemctl and journalctl for the Services tab: list units,
// show one, read its unit file, read and tail its journal, and start/stop/etc.
//
// Reads run as the daemon's user (systemd exposes state to everyone; the journal needs
// the systemd-journal or adm group, or sudo). Actions go through passwordless sudo when
// it's available, otherwise plain systemctl so polkit gets its say.
package services
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
type Unit struct {
Name string `json:"name"`
Description string `json:"description"`
Load string `json:"load"`
Active string `json:"active"`
Sub string `json:"sub"`
Enabled string `json:"enabled"` // enabled | disabled | static | masked | … | "" (unknown)
Failed bool `json:"failed"`
}
type Detail struct {
Unit
Props map[string]string `json:"props"`
}
type Entry struct {
T int64 `json:"t"` // unix ms
Priority int `json:"p"` // syslog priority 0-7
Ident string `json:"ident"`
PID string `json:"pid,omitempty"`
Msg string `json:"msg"`
}
var unitRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9@._:\\-]{0,255}\.service$`)
func Valid(unit string) bool { return unitRe.MatchString(unit) }
var Actions = map[string]bool{"start": true, "stop": true, "restart": true, "reload": true, "enable": true, "disable": true, "reset-failed": true}
// Properties shown on the detail page, in display order.
var showProps = []string{"Id", "Description", "LoadState", "ActiveState", "SubState", "UnitFileState", "FragmentPath",
"MainPID", "ExecMainStartTimestamp", "ActiveEnterTimestamp", "InactiveEnterTimestamp", "NRestarts", "Result",
"MemoryCurrent", "MemoryPeak", "CPUUsageNSec", "TasksCurrent", "ExecStart", "User", "Restart"}
type Manager struct {
sudo bool // actions via sudo -n
journalSudo bool // journal needs sudo (user isn't in systemd-journal/adm)
mu sync.Mutex
cache []Unit
cacheAt time.Time
}
func New() *Manager {
m := &Manager{}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
m.sudo = exec.CommandContext(ctx, "sudo", "-n", "true").Run() == nil
// Prefer reading the journal as ourselves: a plain child can be killed cleanly when a
// tail ends, whereas a root journalctl under sudo can only be reached with sudo kill.
var errb bytes.Buffer
probe := exec.CommandContext(ctx, "journalctl", "--system", "-n", "1", "-q", "-o", "cat")
probe.Stderr = &errb
if err := probe.Run(); err != nil || strings.Contains(strings.ToLower(errb.String()), "permission") {
m.journalSudo = m.sudo
}
return m
}
// Sudo reports whether actions will run through passwordless sudo.
func (m *Manager) Sudo() bool { return m.sudo }
func run(ctx context.Context, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errb.String())
if msg == "" {
msg = err.Error()
}
return out.String(), errors.New(msg)
}
return out.String(), nil
}
// List merges `list-units --all` (runtime state) with `list-unit-files` (enablement).
// Cached for a few seconds because the UI polls it.
func (m *Manager) List(ctx context.Context) ([]Unit, error) {
m.mu.Lock()
if time.Since(m.cacheAt) < 4*time.Second && m.cache != nil {
c := m.cache
m.mu.Unlock()
return c, nil
}
m.mu.Unlock()
out, err := run(ctx, "systemctl", "list-units", "--type=service", "--all", "--output=json", "--no-pager", "--plain")
if err != nil {
return nil, err
}
var units []struct{ Unit, Load, Active, Sub, Description string }
if err := json.Unmarshal([]byte(out), &units); err != nil {
return nil, fmt.Errorf("parse list-units: %w", err)
}
files, _ := run(ctx, "systemctl", "list-unit-files", "--type=service", "--output=json", "--no-pager")
var uf []struct {
UnitFile string `json:"unit_file"`
State string `json:"state"`
}
_ = json.Unmarshal([]byte(files), &uf)
enabled := map[string]string{}
for _, f := range uf {
enabled[f.UnitFile] = f.State
}
seen := map[string]bool{}
list := make([]Unit, 0, len(units)+len(uf))
for _, u := range units {
if strings.Contains(u.Unit, "@.") { // uninstantiated template
continue
}
seen[u.Unit] = true
list = append(list, Unit{Name: u.Unit, Description: u.Description, Load: u.Load, Active: u.Active, Sub: u.Sub,
Enabled: enabled[u.Unit], Failed: u.Active == "failed"})
}
// Unit files that exist but were never loaded this boot (disabled and never started).
for _, f := range uf {
if seen[f.UnitFile] || strings.Contains(f.UnitFile, "@.") || f.State == "masked" || f.State == "alias" {
continue
}
list = append(list, Unit{Name: f.UnitFile, Load: "not-loaded", Active: "inactive", Sub: "dead", Enabled: f.State})
}
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
m.mu.Lock()
m.cache, m.cacheAt = list, time.Now()
m.mu.Unlock()
return list, nil
}
func (m *Manager) invalidate() {
m.mu.Lock()
m.cacheAt = time.Time{}
m.mu.Unlock()
}
func (m *Manager) Show(ctx context.Context, unit string) (Detail, error) {
if !Valid(unit) {
return Detail{}, errors.New("invalid unit name")
}
out, err := run(ctx, "systemctl", "show", unit, "-p", strings.Join(showProps, ","), "--no-pager")
if err != nil {
return Detail{}, err
}
props := map[string]string{}
for _, line := range strings.Split(out, "\n") {
if k, v, ok := strings.Cut(line, "="); ok && v != "" && v != "[not set]" && v != "infinity" {
props[k] = v
}
}
if props["LoadState"] == "not-found" {
return Detail{}, errors.New("no such unit")
}
d := Detail{Props: props}
d.Name, d.Description = unit, props["Description"]
d.Load, d.Active, d.Sub, d.Enabled = props["LoadState"], props["ActiveState"], props["SubState"], props["UnitFileState"]
d.Failed = d.Active == "failed"
return d, nil
}
func (m *Manager) Cat(ctx context.Context, unit string) (string, error) {
if !Valid(unit) {
return "", errors.New("invalid unit name")
}
return run(ctx, "systemctl", "cat", unit, "--no-pager")
}
func parseEntry(line []byte) (Entry, bool) {
var raw map[string]any
if json.Unmarshal(line, &raw) != nil {
return Entry{}, false
}
e := Entry{Priority: 6}
switch v := raw["MESSAGE"].(type) {
case string:
e.Msg = v
case []any: // non-UTF-8 payload comes as a byte array
b := make([]byte, 0, len(v))
for _, x := range v {
if f, ok := x.(float64); ok {
b = append(b, byte(f))
}
}
e.Msg = string(b)
default:
e.Msg = ""
}
if p, ok := raw["PRIORITY"].(string); ok {
e.Priority, _ = strconv.Atoi(p)
}
if ts, ok := raw["__REALTIME_TIMESTAMP"].(string); ok {
us, _ := strconv.ParseInt(ts, 10, 64)
e.T = us / 1000
}
if id, ok := raw["SYSLOG_IDENTIFIER"].(string); ok {
e.Ident = id
} else if c, ok := raw["_COMM"].(string); ok {
e.Ident = c
}
if pid, ok := raw["_PID"].(string); ok {
e.PID = pid
}
return e, true
}
func (m *Manager) journalArgs(unit string, n int) []string {
args := []string{"journalctl", "-u", unit, "-n", strconv.Itoa(n), "-o", "json", "--no-pager", "--all"}
if m.journalSudo {
args = append([]string{"sudo", "-n"}, args...)
}
return args
}
// Journal returns the last n entries.
func (m *Manager) Journal(ctx context.Context, unit string, n int) ([]Entry, error) {
if !Valid(unit) {
return nil, errors.New("invalid unit name")
}
out, err := run(ctx, m.journalArgs(unit, n)...)
if err != nil {
return nil, err
}
entries := []Entry{}
for _, line := range bytes.Split([]byte(out), []byte("\n")) {
if e, ok := parseEntry(line); ok {
entries = append(entries, e)
}
}
return entries, nil
}
// Tail streams entries (last n, then follow) to fn until ctx is cancelled.
func (m *Manager) Tail(ctx context.Context, unit string, n int, fn func(Entry)) error {
if !Valid(unit) {
return errors.New("invalid unit name")
}
args := append(m.journalArgs(unit, n), "-f")
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
// Own process group so the kill reaches journalctl itself, not just a sudo wrapper.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
pg := "-" + strconv.Itoa(cmd.Process.Pid)
if m.journalSudo { // children are root's; only sudo can signal them
return exec.Command("sudo", "-n", "kill", "-9", "--", pg).Run()
}
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64<<10), 4<<20)
for sc.Scan() {
if e, ok := parseEntry(sc.Bytes()); ok {
fn(e)
}
}
_ = cmd.Wait()
return nil
}
// Action runs start/stop/restart/reload/enable/disable/reset-failed.
func (m *Manager) Action(ctx context.Context, action, unit string) error {
if !Valid(unit) {
return errors.New("invalid unit name")
}
if !Actions[action] {
return errors.New("unknown action")
}
args := []string{"systemctl", action, unit, "--no-pager"}
if m.sudo {
args = append([]string{"sudo", "-n"}, args...)
}
_, err := run(ctx, args...)
m.invalidate()
return err
}
+223
View File
@@ -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
View File
@@ -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
View File
@@ -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`)
+37
View File
@@ -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; }
}