Updates tab (apt list/refresh/upgrade/changelog, reboot flag) on a new background tasks package with live WS streaming; header power button with hostname confirmation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
41c2f05535
commit
acb11193b9
+155
-2
@@ -23,7 +23,9 @@ import (
|
||||
"hamlab/internal/metrics"
|
||||
"hamlab/internal/services"
|
||||
"hamlab/internal/store"
|
||||
"hamlab/internal/tasks"
|
||||
"hamlab/internal/term"
|
||||
"hamlab/internal/updates"
|
||||
)
|
||||
|
||||
const cookieName = "hamlab_session"
|
||||
@@ -37,6 +39,8 @@ type Server struct {
|
||||
store *store.Store
|
||||
svc *services.Manager
|
||||
cron *cron.Manager
|
||||
tasks *tasks.Manager
|
||||
updates *updates.Manager
|
||||
version string
|
||||
started time.Time
|
||||
user string
|
||||
@@ -45,9 +49,9 @@ type Server struct {
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, 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, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, ui fs.FS, version string) *Server {
|
||||
host, _ := os.Hostname()
|
||||
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, ui: ui, version: version,
|
||||
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, 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
|
||||
@@ -86,6 +90,15 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
m.HandleFunc("POST /api/cron/jobs/{id}/{action}", s.auth(s.cronJobAction))
|
||||
m.HandleFunc("GET /api/cron/runs/{id}", s.auth(s.cronRun))
|
||||
m.HandleFunc("POST /api/cron/timers/{unit}/{action}", s.auth(s.cronTimerAction))
|
||||
m.HandleFunc("GET /api/tasks", s.auth(s.listTasks))
|
||||
m.HandleFunc("GET /api/tasks/{id}", s.auth(s.getTask))
|
||||
m.HandleFunc("GET /api/tasks/{id}/stream", s.auth(s.streamTask))
|
||||
m.HandleFunc("POST /api/tasks/{id}/cancel", s.auth(s.cancelTask))
|
||||
m.HandleFunc("GET /api/updates", s.auth(s.updatesStatus))
|
||||
m.HandleFunc("POST /api/updates/refresh", s.auth(s.updatesRefresh))
|
||||
m.HandleFunc("POST /api/updates/upgrade", s.auth(s.updatesUpgrade))
|
||||
m.HandleFunc("GET /api/updates/changelog", s.auth(s.updatesChangelog))
|
||||
m.HandleFunc("POST /api/power/{action}", s.auth(s.power))
|
||||
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") })
|
||||
@@ -192,9 +205,11 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
|
||||
uptime, _ = json.Number(f[0]).Float64()
|
||||
}
|
||||
}
|
||||
_, rebootErr := os.Stat("/run/reboot-required")
|
||||
jsonOK(w, 200, map[string]any{
|
||||
"host": s.host, "user": s.user, "version": s.version,
|
||||
"load": load, "uptime": uptime, "service_started": s.started,
|
||||
"reboot_needed": rebootErr == nil,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -435,6 +450,144 @@ func (s *Server) cronTimerAction(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// ---- tasks (background commands with followable output) ----
|
||||
|
||||
func (s *Server) listTasks(w http.ResponseWriter, _ *http.Request) { jsonOK(w, 200, s.tasks.List()) }
|
||||
|
||||
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.tasks.Get(r.PathValue("id"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such task")
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, t.Snapshot(true))
|
||||
}
|
||||
|
||||
func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.tasks.Get(r.PathValue("id"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such task")
|
||||
return
|
||||
}
|
||||
t.Cancel()
|
||||
log.Printf("task %s cancelled (from %s)", t.ID, clientIP(r))
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
// streamTask sends the output so far, then live chunks as binary frames, then {"type":"exit"}.
|
||||
func (s *Server) streamTask(w http.ResponseWriter, r *http.Request) {
|
||||
t, ok := s.tasks.Get(r.PathValue("id"))
|
||||
if !ok {
|
||||
jsonErr(w, 404, "no such task")
|
||||
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() {
|
||||
defer cancel()
|
||||
for {
|
||||
if _, _, err := conn.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
backlog, ch, unsub := t.Subscribe()
|
||||
defer unsub()
|
||||
if len(backlog) > 0 {
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, backlog); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
ping := time.NewTicker(25 * time.Second)
|
||||
defer ping.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ping.C:
|
||||
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
|
||||
err := conn.Ping(pctx)
|
||||
pc()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
case chunk, ok := <-ch:
|
||||
if !ok {
|
||||
snap := t.Snapshot(false)
|
||||
b, _ := json.Marshal(map[string]any{"type": "exit", "code": snap.ExitCode, "error": snap.Err})
|
||||
_ = conn.Write(ctx, websocket.MessageText, b)
|
||||
return
|
||||
}
|
||||
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- updates ----
|
||||
|
||||
func (s *Server) updatesStatus(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOK(w, 200, s.updates.Status(r.Context()))
|
||||
}
|
||||
|
||||
func (s *Server) updatesRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := s.updates.Refresh()
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("updates: refresh started as task %s (from %s)", t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
func (s *Server) updatesUpgrade(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Packages []string `json:"packages"`
|
||||
}
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body)
|
||||
t, err := s.updates.Upgrade(body.Packages)
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("updates: %s started as task %s (from %s)", t.Title, t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
func (s *Server) updatesChangelog(w http.ResponseWriter, r *http.Request) {
|
||||
text, err := s.updates.Changelog(r.Context(), r.URL.Query().Get("pkg"))
|
||||
if err != nil && text == "" {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, 200, map[string]string{"text": text})
|
||||
}
|
||||
|
||||
// power: reboot/poweroff, gated on the hostname being typed back.
|
||||
func (s *Server) power(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
|
||||
if body.Confirm != s.host {
|
||||
jsonErr(w, 400, "confirmation must be the hostname")
|
||||
return
|
||||
}
|
||||
action := r.PathValue("action")
|
||||
log.Printf("POWER: %s requested from %s", action, clientIP(r))
|
||||
if err := s.updates.Power(r.Context(), action); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
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