Bug bash fixes: hash routing + back button, hub-only pages reset on machine switch, exited terminals reap after 10 min, self-update refuses while busy unless forced, touch-sized controls, power confirm by machine name, down interfaces collapsed and empty modules hidden, load label, offline backoff, cron log newest first, procs empty message, timer timestamp parsing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 17:46:16 -07:00
co-authored by Claude Fable 5.1
parent 27f9665606
commit f093b322ee
14 changed files with 126 additions and 36 deletions
+22 -3
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
@@ -328,6 +329,20 @@ func (s *Server) removeNode(w http.ResponseWriter, r *http.Request) {
// selfUpdate receives a new binary from a hub, checks it runs, installs it over the
// current executable (keeping a .prev copy) and restarts the service.
func (s *Server) selfUpdate(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("force") != "1" {
if t, ok := s.tasks.RunningOf("updates"); ok {
jsonErr(w, 409, "busy: a package task is running ("+t.Title+"); retry with force to interrupt it")
return
}
attached := 0
for _, t := range s.mgr.List() {
attached += t.Clients
}
if attached > 0 {
jsonErr(w, 409, fmt.Sprintf("busy: %d terminal client(s) attached; retry with force to drop them", attached))
return
}
}
exe, err := os.Executable()
if err != nil {
jsonErr(w, 500, err.Error())
@@ -396,10 +411,14 @@ func copyFile(src, dst string) error {
func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) {
exe, _ := os.Executable()
res := s.nodes.Push(r.Context(), r.PathValue("name"), exe, runtime.GOARCH)
res := s.nodes.Push(r.Context(), r.PathValue("name"), exe, runtime.GOARCH, r.URL.Query().Get("force") == "1")
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", res.Name, res.OK, res.Version, res.Error, clientIP(r))
if !res.OK {
jsonErr(w, 400, res.Error)
code := 400
if strings.HasPrefix(res.Error, "busy:") {
code = 409
}
jsonErr(w, code, res.Error)
return
}
jsonOK(w, 200, res)
@@ -407,7 +426,7 @@ func (s *Server) updateMachine(w http.ResponseWriter, r *http.Request) {
func (s *Server) updateAllMachines(w http.ResponseWriter, r *http.Request) {
exe, _ := os.Executable()
res := s.nodes.PushAll(r.Context(), exe, runtime.GOARCH)
res := s.nodes.PushAll(r.Context(), exe, runtime.GOARCH, r.URL.Query().Get("force") == "1")
for _, x := range res {
log.Printf("machines: update %s -> ok=%v %s %s (from %s)", x.Name, x.OK, x.Version, x.Error, clientIP(r))
}
+6 -5
View File
@@ -499,15 +499,16 @@ func (m *Manager) OutputTail(ctx context.Context, j Job, n int) (string, error)
// timers: one `systemctl show` over every loaded timer instead of three list commands.
func (m *Manager) timers(ctx context.Context) []Timer {
out, err := run(ctx, "", "systemctl", "show", "*.timer", "--timestamp=unix", "--no-pager",
out, err := run(ctx, "", "systemctl", "show", "*.timer", "--no-pager",
"-p", "Id,Description,ActiveState,SubState,UnitFileState,NextElapseUSecRealtime,LastTriggerUSec,Triggers")
if err != nil {
return []Timer{}
}
unix := func(v string) int64 { // "@1789711405" (seconds) or "n/a"
if strings.HasPrefix(v, "@") {
s, _ := strconv.ParseInt(strings.TrimPrefix(v, "@"), 10, 64)
return s * 1000
unix := func(v string) int64 { // "Sat 2026-09-19 05:47:13 PDT", "n/a" or ""
for _, layout := range []string{"Mon 2006-01-02 15:04:05 MST", "2006-01-02 15:04:05 MST", "Mon 2006-01-02 15:04:05"} {
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
return t.UnixMilli()
}
}
return 0
}
+8 -4
View File
@@ -291,7 +291,7 @@ type PushResult struct {
// Push uploads binPath (normally the hub's own executable) to the node's self-update
// endpoint. The node verifies it, installs it and restarts itself. Refused when the
// node's architecture differs from ours.
func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushResult {
func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string, force bool) PushResult {
res := PushResult{Name: name}
n, ok := m.node(name)
if !ok {
@@ -328,7 +328,11 @@ func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushR
}
defer f.Close()
st, _ := f.Stat()
up, _ := http.NewRequestWithContext(ctx, "POST", n.URL+"/api/self-update", f)
target := n.URL + "/api/self-update"
if force {
target += "?force=1"
}
up, _ := http.NewRequestWithContext(ctx, "POST", target, f)
up.ContentLength = st.Size()
up.Header.Set("Authorization", "Bearer "+n.Token)
up.Header.Set("Content-Type", "application/octet-stream")
@@ -356,7 +360,7 @@ func (m *Manager) Push(ctx context.Context, name, binPath, ourArch string) PushR
}
// PushAll updates every node, in parallel.
func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string) []PushResult {
func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string, force bool) []PushResult {
var list []config.Node
m.cfg.View(func(c *config.Config) { list = append(list, c.Nodes...) })
out := make([]PushResult, len(list))
@@ -365,7 +369,7 @@ func (m *Manager) PushAll(ctx context.Context, binPath, ourArch string) []PushRe
wg.Add(1)
go func(i int, name string) {
defer wg.Done()
out[i] = m.Push(ctx, name, binPath, ourArch)
out[i] = m.Push(ctx, name, binPath, ourArch, force)
}(i, n.Name)
}
wg.Wait()
+9
View File
@@ -130,6 +130,15 @@ func (m *Manager) pump(s *Session) {
s.mu.Unlock()
_ = s.ptmx.Close()
m.changed()
// An exited shell stays listed for a while so the exit is visible, then reaps itself.
time.AfterFunc(10*time.Minute, func() {
m.mu.Lock()
cur, ok := m.sessions[s.ID]
m.mu.Unlock()
if ok && cur == s && !s.running {
_ = m.Close(s.ID)
}
})
}
// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY.