diff --git a/PLAN.md b/PLAN.md index e825dc9..12adfef 100644 --- a/PLAN.md +++ b/PLAN.md @@ -119,6 +119,18 @@ something long in one, switch away and back, refresh — it's all still there. ## Roadmap (not MVP — notes only) +- **Updates tab** — **shipped 2026-09-17** (`internal/updates`, `internal/tasks`, + `Updates.tsx`): upgradable packages with current → candidate, suite, security badge + and description; index age, last upgrade (from `/var/log/apt/history.log`), + unattended-upgrades on/off, reboot-required banner with the packages that caused it; + Refresh index, Upgrade all (dist-upgrade), Upgrade selected; changelog per package on + demand. Runs go through the new **tasks** package: background command, output kept, + followed live over `/api/tasks/{id}/stream`, cancellable, listed at `/api/tasks` (the + seed of a Task History tab). apt is fully supported; dnf/pacman list only. +- **Power** — header ⏻ button (amber when a reboot is pending), dialog that requires the + hostname typed back, then `systemctl reboot` / `poweroff`. `/api/me` carries + `reboot_needed` so the header knows. + - **tmux-backed sessions** so shells survive a daemon restart / redeploy. Would spawn `tmux new -A -s hamlab-`; needs `mouse on` and status-bar handling in xterm. - **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 3becd5f..3e91e44 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -25,7 +25,9 @@ import ( "hamlab/internal/metrics" "hamlab/internal/services" "hamlab/internal/store" + "hamlab/internal/tasks" hterm "hamlab/internal/term" + "hamlab/internal/updates" "hamlab/internal/web" ) @@ -145,7 +147,9 @@ func serve(cfg *config.Config) error { svc := services.New() cr := cron.New(svc.Sudo(), svc.JournalSudo()) - srv := api.New(cfg, sessions, mgr, col, st, svc, cr, web.FS(), version) + tm := tasks.New() + up := updates.New(svc.Sudo(), tm) + srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, 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 3d35b07..81e0567 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) { diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go new file mode 100644 index 0000000..6aab193 --- /dev/null +++ b/internal/tasks/tasks.go @@ -0,0 +1,229 @@ +// Package tasks runs long commands (apt upgrades, and later anything else the UI kicks +// off) in the background, keeps their output, and lets any number of clients follow it +// live. It is also the task history: what ran, when, and how it ended. +package tasks + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "os/exec" + "sort" + "sync" + "time" +) + +const ( + maxOutput = 1 << 20 // bytes kept per task + maxHistory = 200 +) + +type Task struct { + ID string + Kind string // "updates", "cron", … + Title string + Command string + Started time.Time + Finished time.Time + Running bool + ExitCode int + Err string + + mu sync.Mutex + buf bytes.Buffer + subs map[chan []byte]struct{} + cancel context.CancelFunc +} + +type Snapshot struct { + ID string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Command string `json:"command"` + Started time.Time `json:"started"` + Finished *time.Time `json:"finished,omitempty"` + Running bool `json:"running"` + ExitCode int `json:"exit_code"` + Err string `json:"error,omitempty"` + Output string `json:"output,omitempty"` +} + +type Manager struct { + mu sync.Mutex + tasks map[string]*Task +} + +func New() *Manager { return &Manager{tasks: map[string]*Task{}} } + +func newID() string { + b := make([]byte, 6) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// Start launches cmd with its output captured into a new task. cmd must not have +// Stdout/Stderr set. The task ends when the process exits or Cancel is called. +func (m *Manager) Start(kind, title string, cmd *exec.Cmd) (*Task, error) { + ctx, cancel := context.WithCancel(context.Background()) + t := &Task{ID: newID(), Kind: kind, Title: title, Command: cmd.String(), Started: time.Now(), Running: true, + subs: map[chan []byte]struct{}{}, cancel: cancel} + w := &writer{t: t} + cmd.Stdout, cmd.Stderr = w, w + if err := cmd.Start(); err != nil { + cancel() + return nil, err + } + m.mu.Lock() + m.tasks[t.ID] = t + m.prune() + m.mu.Unlock() + + go func() { + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var err error + select { + case err = <-done: + case <-ctx.Done(): + _ = cmd.Process.Kill() + err = <-done + t.mu.Lock() + t.Err = "cancelled" + t.mu.Unlock() + } + t.mu.Lock() + t.Running, t.Finished = false, time.Now() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) { + t.ExitCode = ee.ExitCode() + } else { + t.ExitCode = -1 + if t.Err == "" { + t.Err = err.Error() + } + } + } + for ch := range t.subs { + close(ch) + } + t.subs = map[chan []byte]struct{}{} + t.mu.Unlock() + cancel() + }() + return t, nil +} + +// prune keeps the newest maxHistory finished tasks. Caller holds m.mu. +func (m *Manager) prune() { + if len(m.tasks) <= maxHistory { + return + } + var finished []*Task + for _, t := range m.tasks { + if !t.Running { + finished = append(finished, t) + } + } + sort.Slice(finished, func(i, j int) bool { return finished[i].Started.Before(finished[j].Started) }) + for i := 0; i < len(finished) && len(m.tasks) > maxHistory; i++ { + delete(m.tasks, finished[i].ID) + } +} + +func (m *Manager) Get(id string) (*Task, bool) { + m.mu.Lock() + defer m.mu.Unlock() + t, ok := m.tasks[id] + return t, ok +} + +// List returns every task newest first, without output. +func (m *Manager) List() []Snapshot { + m.mu.Lock() + all := make([]*Task, 0, len(m.tasks)) + for _, t := range m.tasks { + all = append(all, t) + } + m.mu.Unlock() + sort.Slice(all, func(i, j int) bool { return all[i].Started.After(all[j].Started) }) + out := make([]Snapshot, 0, len(all)) + for _, t := range all { + out = append(out, t.Snapshot(false)) + } + return out +} + +// RunningOf reports whether a task of the given kind is in flight. +func (m *Manager) RunningOf(kind string) (*Task, bool) { + m.mu.Lock() + defer m.mu.Unlock() + for _, t := range m.tasks { + if t.Kind == kind && t.Running { + return t, true + } + } + return nil, false +} + +func (t *Task) Snapshot(withOutput bool) Snapshot { + t.mu.Lock() + defer t.mu.Unlock() + s := Snapshot{ID: t.ID, Kind: t.Kind, Title: t.Title, Command: t.Command, Started: t.Started, Running: t.Running, ExitCode: t.ExitCode, Err: t.Err} + if !t.Running { + f := t.Finished + s.Finished = &f + } + if withOutput { + s.Output = t.buf.String() + } + return s +} + +// Subscribe returns the output so far plus a channel of further chunks, closed when the +// task ends. Call unsub when done following. +func (t *Task) Subscribe() (backlog []byte, ch chan []byte, unsub func()) { + t.mu.Lock() + defer t.mu.Unlock() + backlog = append([]byte(nil), t.buf.Bytes()...) + if !t.Running { + c := make(chan []byte) + close(c) + return backlog, c, func() {} + } + ch = make(chan []byte, 256) + t.subs[ch] = struct{}{} + return backlog, ch, func() { + t.mu.Lock() + if _, ok := t.subs[ch]; ok { + delete(t.subs, ch) + close(ch) + } + t.mu.Unlock() + } +} + +func (t *Task) Cancel() { t.cancel() } + +type writer struct{ t *Task } + +func (w *writer) Write(p []byte) (int, error) { + t := w.t + t.mu.Lock() + defer t.mu.Unlock() + t.buf.Write(p) + if t.buf.Len() > maxOutput { + b := t.buf.Bytes() + t.buf = *bytes.NewBuffer(append([]byte("…\n"), b[len(b)-maxOutput*3/4:]...)) + } + chunk := append([]byte(nil), p...) + for ch := range t.subs { + select { + case ch <- chunk: + default: // slow follower: drop the chunk rather than stall the process + } + } + return len(p), nil +} diff --git a/internal/updates/updates.go b/internal/updates/updates.go new file mode 100644 index 0000000..1d2eef6 --- /dev/null +++ b/internal/updates/updates.go @@ -0,0 +1,342 @@ +// Package updates is the package-manager view: what can be upgraded, when the index +// was last refreshed, whether a reboot is pending, and the refresh/upgrade actions, +// which run as background tasks so their output can be followed live. +// +// apt is fully supported. dnf and pacman get a read-only list so the tab still says +// something useful on those hosts; upgrades there are a later addition. +package updates + +import ( + "bufio" + "context" + "errors" + "os" + "os/exec" + "regexp" + "sort" + "strings" + "time" + + "hamlab/internal/tasks" +) + +type Package struct { + Name string `json:"name"` + Current string `json:"current"` + Candidate string `json:"candidate"` + Suite string `json:"suite,omitempty"` + Security bool `json:"security"` + Description string `json:"description,omitempty"` +} + +type Status struct { + Manager string `json:"manager"` // apt | dnf | pacman | "" + CanUpgrade bool `json:"can_upgrade"` + Packages []Package `json:"packages"` + SecurityCount int `json:"security_count"` + LastRefresh int64 `json:"last_refresh,omitempty"` // unix ms + LastUpgrade int64 `json:"last_upgrade,omitempty"` + RebootNeeded bool `json:"reboot_needed"` + RebootPkgs []string `json:"reboot_pkgs"` + AutoUpgrades bool `json:"auto_upgrades"` + RunningTask string `json:"running_task,omitempty"` + Sudo bool `json:"sudo"` +} + +type Manager struct { + sudo bool + tasks *tasks.Manager + pm string +} + +func New(sudo bool, tm *tasks.Manager) *Manager { + m := &Manager{sudo: sudo, tasks: tm} + for _, pm := range []string{"apt-get", "dnf", "pacman"} { + if _, err := exec.LookPath(pm); err == nil { + m.pm = strings.TrimSuffix(pm, "-get") + break + } + } + return m +} + +func (m *Manager) sudoArgs(args ...string) []string { + if m.sudo { + return append([]string{"sudo", "-n"}, args...) + } + return args +} + +func out(ctx context.Context, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Env = append(os.Environ(), "LC_ALL=C", "DEBIAN_FRONTEND=noninteractive") + b, err := cmd.Output() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) && len(ee.Stderr) > 0 { + return string(b), errors.New(strings.TrimSpace(string(ee.Stderr))) + } + return string(b), err + } + return string(b), nil +} + +func mtimeMs(p string) int64 { + st, err := os.Stat(p) + if err != nil { + return 0 + } + return st.ModTime().UnixMilli() +} + +// ---- status ---- + +func (m *Manager) Status(ctx context.Context) Status { + s := Status{Manager: m.pm, Packages: []Package{}, RebootPkgs: []string{}, Sudo: m.sudo} + if t, ok := m.tasks.RunningOf("updates"); ok { + s.RunningTask = t.ID + } + switch m.pm { + case "apt": + s.CanUpgrade = m.sudo + s.Packages = aptUpgradable(ctx) + s.LastRefresh = mtimeMs("/var/lib/apt/periodic/update-success-stamp") + if s.LastRefresh == 0 { + s.LastRefresh = mtimeMs("/var/lib/apt/lists/partial") + } + if s.LastRefresh == 0 { + s.LastRefresh = mtimeMs("/var/lib/apt/lists") + } + s.LastUpgrade = aptLastUpgrade() + s.AutoUpgrades = aptAutoUpgrades(ctx) + case "dnf": + s.Packages = dnfUpgradable(ctx) + s.LastRefresh = mtimeMs("/var/cache/dnf") + case "pacman": + s.Packages = pacmanUpgradable(ctx) + s.LastRefresh = mtimeMs("/var/lib/pacman/sync") + } + for _, p := range s.Packages { + if p.Security { + s.SecurityCount++ + } + } + if _, err := os.Stat("/run/reboot-required"); err == nil { + s.RebootNeeded = true + if b, err := os.ReadFile("/run/reboot-required.pkgs"); err == nil { + for _, l := range strings.Split(strings.TrimSpace(string(b)), "\n") { + if l = strings.TrimSpace(l); l != "" { + s.RebootPkgs = append(s.RebootPkgs, l) + } + } + } + } else if m.pm != "apt" { + s.RebootNeeded = kernelChanged() + } + return s +} + +// kernelChanged: the running kernel isn't the newest installed one (dnf/pacman hosts). +func kernelChanged() bool { + b, err := os.ReadFile("/proc/sys/kernel/osrelease") + if err != nil { + return false + } + running := strings.TrimSpace(string(b)) + ents, err := os.ReadDir("/lib/modules") + if err != nil { + return false + } + newest := "" + for _, e := range ents { + if e.Name() > newest { + newest = e.Name() + } + } + return newest != "" && newest != running +} + +// ---- apt ---- + +var aptListRe = regexp.MustCompile(`^(\S+)/(\S+)\s+(\S+)\s+\S+\s+\[upgradable from: (\S+)\]`) + +func aptUpgradable(ctx context.Context) []Package { + text, _ := out(ctx, "apt", "list", "--upgradable") + var pkgs []Package + for _, line := range strings.Split(text, "\n") { + m := aptListRe.FindStringSubmatch(line) + if m == nil { + continue + } + pkgs = append(pkgs, Package{Name: m[1], Suite: m[2], Candidate: m[3], Current: m[4], + Security: strings.Contains(m[2], "security")}) + } + if len(pkgs) == 0 { + return []Package{} + } + // One apt-cache call for all the descriptions. + args := append([]string{"apt-cache", "show", "--no-all-versions"}, names(pkgs)...) + if text, err := out(ctx, args...); err == nil { + desc := map[string]string{} + cur := "" + for _, line := range strings.Split(text, "\n") { + if strings.HasPrefix(line, "Package: ") { + cur = strings.TrimPrefix(line, "Package: ") + } else if strings.HasPrefix(line, "Description-en: ") || strings.HasPrefix(line, "Description: ") { + if _, ok := desc[cur]; !ok { + _, v, _ := strings.Cut(line, ": ") + desc[cur] = v + } + } + } + for i := range pkgs { + pkgs[i].Description = desc[pkgs[i].Name] + } + } + sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Name < pkgs[j].Name }) + return pkgs +} + +func names(pkgs []Package) []string { + n := make([]string, len(pkgs)) + for i, p := range pkgs { + n[i] = p.Name + } + return n +} + +// aptLastUpgrade: the Start-Date of the last history.log entry that upgraded something. +func aptLastUpgrade() int64 { + f, err := os.Open("/var/log/apt/history.log") + if err != nil { + return 0 + } + defer f.Close() + var last, start string + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64<<10), 8<<20) + for sc.Scan() { + l := sc.Text() + switch { + case strings.HasPrefix(l, "Start-Date: "): + start = strings.TrimPrefix(l, "Start-Date: ") + case strings.HasPrefix(l, "Upgrade: "): + last = start + } + } + if last == "" { + return 0 + } + t, err := time.ParseInLocation("2006-01-02 15:04:05", last, time.Local) + if err != nil { + return 0 + } + return t.UnixMilli() +} + +func aptAutoUpgrades(ctx context.Context) bool { + text, _ := out(ctx, "apt-config", "dump", "APT::Periodic::Unattended-Upgrade") + return strings.Contains(text, `"1"`) +} + +// ---- dnf / pacman (read-only) ---- + +func dnfUpgradable(ctx context.Context) []Package { + text, _ := out(ctx, "dnf", "-q", "check-update") // exit 100 when updates exist; output still parses + var pkgs []Package + for _, line := range strings.Split(text, "\n") { + f := strings.Fields(line) + if len(f) != 3 || !strings.Contains(f[0], ".") { + continue + } + name := f[0][:strings.LastIndex(f[0], ".")] + pkgs = append(pkgs, Package{Name: name, Candidate: f[1], Suite: f[2], Security: strings.Contains(f[2], "security")}) + } + if pkgs == nil { + return []Package{} + } + return pkgs +} + +func pacmanUpgradable(ctx context.Context) []Package { + text, _ := out(ctx, "pacman", "-Qu") + var pkgs []Package + for _, line := range strings.Split(text, "\n") { + f := strings.Fields(line) // name old -> new + if len(f) == 4 && f[2] == "->" { + pkgs = append(pkgs, Package{Name: f[0], Current: f[1], Candidate: f[3]}) + } + } + if pkgs == nil { + return []Package{} + } + return pkgs +} + +// ---- actions (apt only) ---- + +var pkgNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]{1,127}$`) + +func (m *Manager) start(title string, args ...string) (*tasks.Task, error) { + if m.pm != "apt" { + return nil, errors.New("upgrades are only supported for apt so far") + } + if !m.sudo { + return nil, errors.New("needs passwordless sudo") + } + if t, ok := m.tasks.RunningOf("updates"); ok { + return nil, errors.New("another package task is running: " + t.Title) + } + full := append([]string{"sudo", "-n", "env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...) + cmd := exec.Command(full[0], full[1:]...) + return m.tasks.Start("updates", title, cmd) +} + +func (m *Manager) Refresh() (*tasks.Task, error) { + return m.start("apt-get update", "apt-get", "update") +} + +// Upgrade runs a full dist-upgrade, or only the named packages. Config-file prompts are +// answered "keep the current one" so the run can never block on a question. +func (m *Manager) Upgrade(pkgs []string) (*tasks.Task, error) { + base := []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"} + if len(pkgs) == 0 { + return m.start("apt-get dist-upgrade", append(base, "dist-upgrade")...) + } + for _, p := range pkgs { + if !pkgNameRe.MatchString(p) { + return nil, errors.New("bad package name: " + p) + } + } + args := append(base, "install", "--only-upgrade") + args = append(args, pkgs...) + return m.start("apt-get upgrade "+strings.Join(pkgs, " "), args...) +} + +func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) { + if !pkgNameRe.MatchString(pkg) { + return "", errors.New("bad package name") + } + switch m.pm { + case "apt": + text, err := out(ctx, "apt-get", "changelog", "-qq", pkg) + if len(text) > 64<<10 { + text = text[:64<<10] + "\n…" + } + return text, err + case "dnf": + return out(ctx, "dnf", "-q", "changelog", pkg) + } + return "", errors.New("changelogs not available for " + m.pm) +} + +// Power runs systemctl reboot / poweroff. +func (m *Manager) Power(ctx context.Context, action string) error { + if action != "reboot" && action != "poweroff" { + return errors.New("unknown power action") + } + _, err := out(ctx, m.sudoArgs("systemctl", action)...) + return err +} diff --git a/web/src/Power.tsx b/web/src/Power.tsx new file mode 100644 index 0000000..6b11c62 --- /dev/null +++ b/web/src/Power.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react' +import { api } from './api' + +// Header power control: a small button, a dialog that needs the hostname typed back. +export default function Power({ host, rebootNeeded }: { host: string; rebootNeeded?: boolean }) { + const [open, setOpen] = useState(false) + const [typed, setTyped] = useState('') + const [busy, setBusy] = useState('') + const [err, setErr] = useState('') + const ok = typed.trim() === host + + async function go(action: 'reboot' | 'poweroff') { + setBusy(action); setErr('') + try { await api.power(action, typed.trim()); setBusy('sent') } + catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy('') } + } + + return ( + <> + + {open && ( +
busy !== 'sent' && setOpen(false)}> +
e.stopPropagation()}> +

Power · {host}

+ {rebootNeeded &&
A reboot is pending from package updates.
} + {busy === 'sent' ? ( +
Command sent. The console will reconnect when the host is back.
+ ) : ( + <> + + {err &&
{err}
} +
+ + + + +
+ + )} +
+
+ )} + + ) +} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 346e1ec..a61a7b6 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -6,16 +6,19 @@ import KeyBar from './KeyBar' import Dashboard from './Dashboard' import Services from './Services' import Cron from './Cron' +import Updates from './Updates' +import Power from './Power' const GROUPS: Group[] = [ { id: 'dashboard', label: 'Dashboard', icon: '◔', page: true }, { id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'services', label: 'Services', icon: '⚙', page: true }, { id: 'cron', label: 'Cron', icon: '⏱', page: true }, + { id: 'updates', label: 'Updates', icon: '⇪', page: true }, { id: 'zfs', label: 'ZFS', icon: '▤', soon: true }, ] -type View = 'terminal' | 'dashboard' | 'services' | 'cron' +type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' function fmtUptime(s: number): string { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) @@ -69,6 +72,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => {me.load} + @@ -83,6 +87,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => {view === 'dashboard' && } {view === 'services' && } {view === 'cron' && } + {view === 'updates' && } {termView && terms.length === 0 && (
No terminals.
@@ -105,7 +110,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' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : activeTerm ? activeTerm.name : ''} + {view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : activeTerm ? activeTerm.name : ''}
) diff --git a/web/src/TaskOutput.tsx b/web/src/TaskOutput.tsx new file mode 100644 index 0000000..81826ef --- /dev/null +++ b/web/src/TaskOutput.tsx @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState } from 'react' +import { api, TaskInfo, wsURLPath } from './api' + +// Follows one background task: backlog, then live chunks over a WebSocket, until it exits. +export default function TaskOutput({ id, onDone }: { id: string; onDone?: (t: TaskInfo) => void }) { + const [task, setTask] = useState(null) + const [text, setText] = useState('') + const box = useRef(null) + const doneRef = useRef(onDone) + doneRef.current = onDone + + useEffect(() => { + setText(''); setTask(null) + let closed = false + api.task(id).then(setTask).catch(() => { /* shown as unknown */ }) + const s = new WebSocket(wsURLPath(`/api/tasks/${id}/stream`)) + s.binaryType = 'arraybuffer' + const dec = new TextDecoder() + s.onmessage = (ev) => { + if (ev.data instanceof ArrayBuffer) { setText((t) => t + dec.decode(ev.data, { stream: true })); return } + try { + const m = JSON.parse(ev.data) + if (m.type === 'exit') api.task(id).then((t) => { setTask(t); doneRef.current?.(t) }).catch(() => { /* ignore */ }) + } catch { /* ignore */ } + } + return () => { closed = true; s.close(); void closed } + }, [id]) + + useEffect(() => { if (box.current) box.current.scrollTop = box.current.scrollHeight }, [text]) + + const status = !task ? '' : task.running ? 'running…' : task.error ? `failed: ${task.error}` : task.exit_code === 0 ? 'done' : `exited ${task.exit_code}` + return ( +
+
+ + {task?.title ?? '…'} + · {status} + + {task?.running && } +
+
{text || (task?.running ? '' : '(no output)')}
+
+ ) +} diff --git a/web/src/Updates.tsx b/web/src/Updates.tsx new file mode 100644 index 0000000..7544422 --- /dev/null +++ b/web/src/Updates.tsx @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useState } from 'react' +import { api, UpdatePackage, UpdatesStatus } from './api' +import TaskOutput from './TaskOutput' + +function ago(ms?: number): string { + if (!ms) return 'never' + const s = Math.max(0, (Date.now() - ms) / 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}m`) + ' ago' +} + +export default function Updates() { + const [st, setSt] = useState(null) + const [err, setErr] = useState('') + const [sel, setSel] = useState>(new Set()) + const [task, setTask] = useState(null) + const [open, setOpen] = useState(null) + const [changelog, setChangelog] = useState>({}) + const [busy, setBusy] = useState(false) + + const load = useCallback(async () => { + try { + const s = await api.updates() + setSt(s); setErr('') + if (s.running_task) setTask(s.running_task) + setSel((prev) => new Set([...prev].filter((n) => s.packages.some((p) => p.name === n)))) + } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + }, []) + useEffect(() => { load() }, [load]) + + async function start(fn: () => Promise<{ id: string }>) { + setBusy(true); setErr('') + try { const t = await fn(); setTask(t.id) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } finally { setBusy(false) } + } + async function showChangelog(p: UpdatePackage) { + if (open === p.name) { setOpen(null); return } + setOpen(p.name) + if (!changelog[p.name]) { + try { setChangelog((c) => ({ ...c, [p.name]: '…' })); const r = await api.changelog(p.name); setChangelog((c) => ({ ...c, [p.name]: r.text || '(empty)' })) } + catch (e) { setChangelog((c) => ({ ...c, [p.name]: String(e) })) } + } + } + const toggle = (n: string) => setSel((s) => { const x = new Set(s); if (x.has(n)) x.delete(n); else x.add(n); return x }) + + if (!st) return
{err || '…'}
+ const running = !!st.running_task || busy + const canAct = st.can_upgrade && !running + + return ( +
+ {!st.manager &&
No supported package manager found (looked for apt, dnf, pacman).
} + {st.manager && st.manager !== 'apt' &&
{st.manager}: listing only; upgrading from here isn't supported yet.
} + {st.manager === 'apt' && !st.sudo &&
Read-only: refreshing and upgrading need passwordless sudo on the host.
} + {st.reboot_needed && ( +
+ Reboot required{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}} — use the ⏻ button in the header. +
+ )} + {err &&
{err}
} + +
+
Upgradable
{st.packages.length}
{st.security_count ? `${st.security_count} security` : 'no security updates'}
+
Index refreshed
{ago(st.last_refresh)}
{st.manager}
+
Last upgrade
{ago(st.last_upgrade)}
from apt history
+
Unattended
{st.auto_upgrades ? 'on' : 'off'}
automatic upgrades
+
+ +
+ + + + + {st.packages.length > 0 && } +
+ +
+ {st.packages.length === 0 &&
Everything is up to date.
} + {st.packages.map((p) => ( +
+
+ toggle(p.name)} /> + showChangelog(p)}> + {p.name}{p.security && security} + {p.description} + + {p.current} → {p.candidate} · {p.suite} +
+ {open === p.name &&
{changelog[p.name] ?? '…'}
} +
+ ))} +
+ + {task && load()} />} +
+ ) +} diff --git a/web/src/api.ts b/web/src/api.ts index 423d23f..31ee376 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -17,7 +17,7 @@ async function req(method: string, path: string, body?: unknown): Promise return r.json() } -export interface Me { host: string; user: string; version: string; load: string; uptime: number } +export interface Me { host: string; user: string; version: string; load: string; uptime: number; reboot_needed?: boolean } export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number } export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number } @@ -51,7 +51,19 @@ export interface CronTimer { unit: string; activates: string; description: strin export interface CronOverview { jobs: CronJob[]; timers: CronTimer[]; logging: boolean; anacron: boolean; sudo: boolean } export interface CronRun { id: string; job_id: string; command: string; user: string; started: string; finished?: string; running: boolean; exit_code: number; output: string } +export interface TaskInfo { id: string; kind: string; title: string; command: string; started: string; finished?: string; running: boolean; exit_code: number; error?: string; output?: string } +export interface UpdatePackage { name: string; current: string; candidate: string; suite?: string; security: boolean; description?: string } +export interface UpdatesStatus { manager: string; can_upgrade: boolean; packages: UpdatePackage[]; security_count: number; last_refresh?: number; last_upgrade?: number; reboot_needed: boolean; reboot_pkgs: string[]; auto_upgrades: boolean; running_task?: string; sudo: boolean } + export const api = { + tasks: () => req('GET', '/api/tasks'), + task: (id: string) => req('GET', `/api/tasks/${id}`), + cancelTask: (id: string) => req('POST', `/api/tasks/${id}/cancel`), + updates: () => req('GET', '/api/updates'), + refreshUpdates: () => req('POST', '/api/updates/refresh'), + upgrade: (packages: string[]) => req('POST', '/api/updates/upgrade', { packages }), + changelog: (pkg: string) => req<{ text: string }>('GET', `/api/updates/changelog?pkg=${encodeURIComponent(pkg)}`), + power: (action: 'reboot' | 'poweroff', confirm: string) => req('POST', `/api/power/${action}`, { confirm }), cron: () => req('GET', '/api/cron'), cronSource: (name: string) => req<{ text: string; editable: boolean }>('GET', `/api/cron/source?name=${encodeURIComponent(name)}`), cronWriteSource: (name: string, text: string) => req('PUT', `/api/cron/source?name=${encodeURIComponent(name)}`, { text }), diff --git a/web/src/styles.css b/web/src/styles.css index 6a21a35..054170d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -182,3 +182,28 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi .cron-list { width: 100%; border-right: 0; } .has-sel .cron-list { display: none; } .cron-detail .back { display: inline-block; } } + +/* updates */ +.updates .pkgs { padding: 0; } +.pkg { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 8px 12px; border-bottom: 1px solid var(--border); } +.pkg.sel { background: var(--bg3); } +.pkg input { width: 16px; height: 16px; } +.pkg-name { min-width: 0; display: flex; flex-direction: column; cursor: pointer; } +.pkg-name .n { font-family: ui-monospace, monospace; font-size: 13px; } +.pkg-name .d { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pkg-ver { font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; } +.badge.bad { color: var(--bad); border-color: var(--bad); margin-left: 8px; } +.changelog { margin: 0 12px 8px; max-height: 40vh; } +.notice.attn { border-color: var(--s2); color: var(--fg); } +.task { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } +.task-out { border: 0; border-radius: 0; max-height: 45vh; min-height: 120px; } +@media (max-width: 800px) { .pkg { grid-template-columns: auto 1fr; } .pkg-ver { grid-column: 2; white-space: normal; } } + +/* power + modal */ +.power { font-size: 15px; } .power.attn { color: var(--s2); } +.modal-back { position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; padding: 16px; } +.modal { width: min(420px, 100%); background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; padding: 16px; display: flex; flex-direction: column; gap: 12px; } +.modal h3 { margin: 0; font-size: 15px; } +.modal label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; } +.modal input { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; } +.btn.danger { color: var(--bad); border-color: var(--bad); } .btn.danger:disabled { opacity: .4; }