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
@@ -119,6 +119,18 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
|
|
||||||
## Roadmap (not MVP — notes only)
|
## 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-backed sessions** so shells survive a daemon restart / redeploy. Would spawn
|
||||||
`tmux new -A -s hamlab-<id>`; needs `mouse on` and status-bar handling in xterm.
|
`tmux new -A -s hamlab-<id>`; needs `mouse on` and status-bar handling in xterm.
|
||||||
- **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal
|
- **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal
|
||||||
|
|||||||
+5
-1
@@ -25,7 +25,9 @@ import (
|
|||||||
"hamlab/internal/metrics"
|
"hamlab/internal/metrics"
|
||||||
"hamlab/internal/services"
|
"hamlab/internal/services"
|
||||||
"hamlab/internal/store"
|
"hamlab/internal/store"
|
||||||
|
"hamlab/internal/tasks"
|
||||||
hterm "hamlab/internal/term"
|
hterm "hamlab/internal/term"
|
||||||
|
"hamlab/internal/updates"
|
||||||
"hamlab/internal/web"
|
"hamlab/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,7 +147,9 @@ func serve(cfg *config.Config) error {
|
|||||||
|
|
||||||
svc := services.New()
|
svc := services.New()
|
||||||
cr := cron.New(svc.Sudo(), svc.JournalSudo())
|
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{
|
hs := &http.Server{
|
||||||
Addr: cfg.Listen,
|
Addr: cfg.Listen,
|
||||||
Handler: srv.Handler(),
|
Handler: srv.Handler(),
|
||||||
|
|||||||
+155
-2
@@ -23,7 +23,9 @@ import (
|
|||||||
"hamlab/internal/metrics"
|
"hamlab/internal/metrics"
|
||||||
"hamlab/internal/services"
|
"hamlab/internal/services"
|
||||||
"hamlab/internal/store"
|
"hamlab/internal/store"
|
||||||
|
"hamlab/internal/tasks"
|
||||||
"hamlab/internal/term"
|
"hamlab/internal/term"
|
||||||
|
"hamlab/internal/updates"
|
||||||
)
|
)
|
||||||
|
|
||||||
const cookieName = "hamlab_session"
|
const cookieName = "hamlab_session"
|
||||||
@@ -37,6 +39,8 @@ type Server struct {
|
|||||||
store *store.Store
|
store *store.Store
|
||||||
svc *services.Manager
|
svc *services.Manager
|
||||||
cron *cron.Manager
|
cron *cron.Manager
|
||||||
|
tasks *tasks.Manager
|
||||||
|
updates *updates.Manager
|
||||||
version string
|
version string
|
||||||
started time.Time
|
started time.Time
|
||||||
user string
|
user string
|
||||||
@@ -45,9 +49,9 @@ type Server struct {
|
|||||||
mux *http.ServeMux
|
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()
|
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")}
|
started: time.Now(), host: host, user: os.Getenv("USER")}
|
||||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||||
s.user = 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("POST /api/cron/jobs/{id}/{action}", s.auth(s.cronJobAction))
|
||||||
m.HandleFunc("GET /api/cron/runs/{id}", s.auth(s.cronRun))
|
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("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("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
|
||||||
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
|
||||||
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
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()
|
uptime, _ = json.Number(f[0]).Float64()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_, rebootErr := os.Stat("/run/reboot-required")
|
||||||
jsonOK(w, 200, map[string]any{
|
jsonOK(w, 200, map[string]any{
|
||||||
"host": s.host, "user": s.user, "version": s.version,
|
"host": s.host, "user": s.user, "version": s.version,
|
||||||
"load": load, "uptime": uptime, "service_started": s.started,
|
"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)
|
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) ----
|
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
|
||||||
|
|
||||||
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<button className={'icon-btn power' + (rebootNeeded ? ' attn' : '')} title={rebootNeeded ? 'reboot required' : 'power'} onClick={() => { setOpen(true); setTyped(''); setBusy(''); setErr('') }}>⏻</button>
|
||||||
|
{open && (
|
||||||
|
<div className="modal-back" onClick={() => busy !== 'sent' && setOpen(false)}>
|
||||||
|
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3>Power · {host}</h3>
|
||||||
|
{rebootNeeded && <div className="notice">A reboot is pending from package updates.</div>}
|
||||||
|
{busy === 'sent' ? (
|
||||||
|
<div className="muted">Command sent. The console will reconnect when the host is back.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="muted">Type the hostname to confirm
|
||||||
|
<input autoFocus value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={host} />
|
||||||
|
</label>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button className="btn on" disabled={!ok || !!busy} onClick={() => go('reboot')}>{busy === 'reboot' ? '…' : 'Reboot'}</button>
|
||||||
|
<button className="btn danger" disabled={!ok || !!busy} onClick={() => go('poweroff')}>{busy === 'poweroff' ? '…' : 'Shut down'}</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button className="btn" onClick={() => setOpen(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+7
-2
@@ -6,16 +6,19 @@ import KeyBar from './KeyBar'
|
|||||||
import Dashboard from './Dashboard'
|
import Dashboard from './Dashboard'
|
||||||
import Services from './Services'
|
import Services from './Services'
|
||||||
import Cron from './Cron'
|
import Cron from './Cron'
|
||||||
|
import Updates from './Updates'
|
||||||
|
import Power from './Power'
|
||||||
|
|
||||||
const GROUPS: Group[] = [
|
const GROUPS: Group[] = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
{ id: 'terminal', label: 'Terminal', icon: '>_' },
|
||||||
{ id: 'services', label: 'Services', icon: '⚙', page: true },
|
{ id: 'services', label: 'Services', icon: '⚙', page: true },
|
||||||
{ id: 'cron', label: 'Cron', icon: '⏱', page: true },
|
{ id: 'cron', label: 'Cron', icon: '⏱', page: true },
|
||||||
|
{ id: 'updates', label: 'Updates', icon: '⇪', page: true },
|
||||||
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: 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 {
|
function fmtUptime(s: number): string {
|
||||||
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
|
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: () =>
|
|||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="stat" title="load average">{me.load}</span>
|
<span className="stat" title="load average">{me.load}</span>
|
||||||
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
|
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
|
||||||
|
<Power host={me.host} rebootNeeded={me.reboot_needed} />
|
||||||
<button className="link" onClick={logout}>logout</button>
|
<button className="link" onClick={logout}>logout</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -83,6 +87,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
|||||||
{view === 'dashboard' && <Dashboard />}
|
{view === 'dashboard' && <Dashboard />}
|
||||||
{view === 'services' && <Services />}
|
{view === 'services' && <Services />}
|
||||||
{view === 'cron' && <Cron />}
|
{view === 'cron' && <Cron />}
|
||||||
|
{view === 'updates' && <Updates />}
|
||||||
{termView && terms.length === 0 && (
|
{termView && terms.length === 0 && (
|
||||||
<div className="center muted">
|
<div className="center muted">
|
||||||
<div>No terminals.</div>
|
<div>No terminals.</div>
|
||||||
@@ -105,7 +110,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
|
|||||||
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : activeTerm ? activeTerm.name : ''}</span>
|
<span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : activeTerm ? activeTerm.name : ''}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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<TaskInfo | null>(null)
|
||||||
|
const [text, setText] = useState('')
|
||||||
|
const box = useRef<HTMLPreElement>(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 (
|
||||||
|
<div className="task">
|
||||||
|
<div className="logs-bar">
|
||||||
|
<span className={'dot ' + (task?.running ? 'warn' : task && (task.exit_code !== 0 || task.error) ? 'bad' : 'ok')} />
|
||||||
|
<span>{task?.title ?? '…'}</span>
|
||||||
|
<span className="muted">· {status}</span>
|
||||||
|
<span className="spacer" />
|
||||||
|
{task?.running && <button className="btn" onClick={() => api.cancelTask(id)}>Cancel</button>}
|
||||||
|
</div>
|
||||||
|
<pre className="run-out task-out" ref={box}>{text || (task?.running ? '' : '(no output)')}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<UpdatesStatus | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [sel, setSel] = useState<Set<string>>(new Set())
|
||||||
|
const [task, setTask] = useState<string | null>(null)
|
||||||
|
const [open, setOpen] = useState<string | null>(null)
|
||||||
|
const [changelog, setChangelog] = useState<Record<string, string>>({})
|
||||||
|
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 <div className="dash"><div className="center muted">{err || '…'}</div></div>
|
||||||
|
const running = !!st.running_task || busy
|
||||||
|
const canAct = st.can_upgrade && !running
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dash updates">
|
||||||
|
{!st.manager && <div className="notice">No supported package manager found (looked for apt, dnf, pacman).</div>}
|
||||||
|
{st.manager && st.manager !== 'apt' && <div className="notice">{st.manager}: listing only; upgrading from here isn't supported yet.</div>}
|
||||||
|
{st.manager === 'apt' && !st.sudo && <div className="notice">Read-only: refreshing and upgrading need passwordless sudo on the host.</div>}
|
||||||
|
{st.reboot_needed && (
|
||||||
|
<div className="notice attn">
|
||||||
|
<b>Reboot required</b>{st.reboot_pkgs.length > 0 && <> · {st.reboot_pkgs.join(', ')}</>} — use the ⏻ button in the header.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
|
||||||
|
<div className="kpis">
|
||||||
|
<div className="tile"><div className="label">Upgradable</div><div className="value">{st.packages.length}</div><div className="sub">{st.security_count ? `${st.security_count} security` : 'no security updates'}</div></div>
|
||||||
|
<div className="tile"><div className="label">Index refreshed</div><div className="value">{ago(st.last_refresh)}</div><div className="sub">{st.manager}</div></div>
|
||||||
|
<div className="tile"><div className="label">Last upgrade</div><div className="value">{ago(st.last_upgrade)}</div><div className="sub">from apt history</div></div>
|
||||||
|
<div className="tile"><div className="label">Unattended</div><div className="value">{st.auto_upgrades ? 'on' : 'off'}</div><div className="sub">automatic upgrades</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button className="btn" disabled={!canAct} onClick={() => start(api.refreshUpdates)}>Refresh index</button>
|
||||||
|
<button className="btn on" disabled={!canAct || st.packages.length === 0} onClick={() => window.confirm(`Upgrade all ${st.packages.length} packages?`) && start(() => api.upgrade([]))}>Upgrade all</button>
|
||||||
|
<button className="btn" disabled={!canAct || sel.size === 0} onClick={() => start(() => api.upgrade([...sel]))}>Upgrade selected ({sel.size})</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
{st.packages.length > 0 && <button className="btn" onClick={() => setSel(sel.size === st.packages.length ? new Set() : new Set(st.packages.map((p) => p.name)))}>{sel.size === st.packages.length ? 'None' : 'All'}</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card pkgs">
|
||||||
|
{st.packages.length === 0 && <div className="empty">Everything is up to date.</div>}
|
||||||
|
{st.packages.map((p) => (
|
||||||
|
<div key={p.name}>
|
||||||
|
<div className={'pkg' + (sel.has(p.name) ? ' sel' : '')}>
|
||||||
|
<input type="checkbox" checked={sel.has(p.name)} onChange={() => toggle(p.name)} />
|
||||||
|
<span className="pkg-name" onClick={() => showChangelog(p)}>
|
||||||
|
<span className="n">{p.name}{p.security && <span className="badge bad">security</span>}</span>
|
||||||
|
<span className="d">{p.description}</span>
|
||||||
|
</span>
|
||||||
|
<span className="pkg-ver"><span className="muted">{p.current}</span> → {p.candidate}<span className="muted"> · {p.suite}</span></span>
|
||||||
|
</div>
|
||||||
|
{open === p.name && <pre className="run-out changelog">{changelog[p.name] ?? '…'}</pre>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task && <TaskOutput id={task} onDone={() => load()} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+13
-1
@@ -17,7 +17,7 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
|
|||||||
return r.json()
|
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 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 }
|
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 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 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 = {
|
export const api = {
|
||||||
|
tasks: () => req<TaskInfo[]>('GET', '/api/tasks'),
|
||||||
|
task: (id: string) => req<TaskInfo>('GET', `/api/tasks/${id}`),
|
||||||
|
cancelTask: (id: string) => req<void>('POST', `/api/tasks/${id}/cancel`),
|
||||||
|
updates: () => req<UpdatesStatus>('GET', '/api/updates'),
|
||||||
|
refreshUpdates: () => req<TaskInfo>('POST', '/api/updates/refresh'),
|
||||||
|
upgrade: (packages: string[]) => req<TaskInfo>('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<void>('POST', `/api/power/${action}`, { confirm }),
|
||||||
cron: () => req<CronOverview>('GET', '/api/cron'),
|
cron: () => req<CronOverview>('GET', '/api/cron'),
|
||||||
cronSource: (name: string) => req<{ text: string; editable: boolean }>('GET', `/api/cron/source?name=${encodeURIComponent(name)}`),
|
cronSource: (name: string) => req<{ text: string; editable: boolean }>('GET', `/api/cron/source?name=${encodeURIComponent(name)}`),
|
||||||
cronWriteSource: (name: string, text: string) => req<void>('PUT', `/api/cron/source?name=${encodeURIComponent(name)}`, { text }),
|
cronWriteSource: (name: string, text: string) => req<void>('PUT', `/api/cron/source?name=${encodeURIComponent(name)}`, { text }),
|
||||||
|
|||||||
@@ -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-list { width: 100%; border-right: 0; } .has-sel .cron-list { display: none; }
|
||||||
.cron-detail .back { display: inline-block; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user