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
+119
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user