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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user