diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 6acac33..8521769 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -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(), diff --git a/internal/api/server.go b/internal/api/server.go index fac3a03..ccb8f89 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) { diff --git a/internal/services/services.go b/internal/services/services.go new file mode 100644 index 0000000..11b651a --- /dev/null +++ b/internal/services/services.go @@ -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 +} diff --git a/web/src/Services.tsx b/web/src/Services.tsx new file mode 100644 index 0000000..9e23558 --- /dev/null +++ b/web/src/Services.tsx @@ -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([]) + const [live, setLive] = useState(true) + const [paused, setPaused] = useState(false) + const [err, setErr] = useState('') + const box = useRef(null) + const pausedRef = useRef(false) + pausedRef.current = paused + const pending = useRef([]) + + 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 ( +
+
+ {live ? 'following' : 'reconnecting…'}{err && ` · ${err}`} + + + +
+
+ {lines.length === 0 &&
No journal entries.
} + {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() } + catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + finally { setBusy('') } + } + + if (!d) return
{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 || '…'}
} +
+ ) +} + +// ---- 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 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 ( +
+
+
+ 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}
} +
+ {shown.map((u) => ( +
setSel(u.name)}> + + + {u.name.replace(/\.service$/, '')} + {u.description} + + {u.failed ? 'failed' : u.sub}{u.enabled === 'enabled' ? '' : u.enabled ? ` · ${u.enabled}` : ''} +
+ ))} + {shown.length === 0 &&
Nothing matches.
} +
+
+ {sel ? setSel(null)} /> :
Select a service.
} +
+ ) +} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 17e4f3b..5af4736 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -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: () =>
{view === 'dashboard' && } + {view === 'services' && } {termView && terms.length === 0 && (
No terminals.
@@ -101,7 +103,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => {me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'dashboard' ? 'Dashboard' : activeTerm ? activeTerm.name : ''} + {view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : activeTerm ? activeTerm.name : ''}
) diff --git a/web/src/api.ts b/web/src/api.ts index 96f3144..6fb40ff 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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 } +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('GET', `/api/services/${encodeURIComponent(unit)}`), + serviceFile: (unit: string) => req<{ content: string }>('GET', `/api/services/${encodeURIComponent(unit)}/file`), + serviceLogs: (unit: string, n = 200) => req('GET', `/api/services/${encodeURIComponent(unit)}/logs?n=${n}`), + serviceAction: (unit: string, action: string) => req('POST', `/api/services/${encodeURIComponent(unit)}/${action}`), dashPrefs: () => req('GET', '/api/prefs/dashboard'), saveDashPrefs: (p: DashPrefs) => req('PUT', '/api/prefs/dashboard', p), metrics: () => req('GET', '/api/metrics'), @@ -56,7 +65,8 @@ export const api = { deleteTerminal: (id: string) => req('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`) diff --git a/web/src/styles.css b/web/src/styles.css index 56e2244..9835d7e 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; } +}