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
+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) {