Files
homarr/tools/homarr-dev/internal/task/task.go
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

361 lines
8.0 KiB
Go

// Package task runs the CLI's slow work — image pulls, local builds, container
// actions — on background goroutines and exposes it as immutable snapshots.
//
// Nothing in this package blocks the caller. A Manager owns every running job,
// coalesces their updates into a single wake-up channel, and hands the UI a
// consistent view whenever it asks for one. That separation is what keeps the
// terminal responsive while a fifteen minute Docker build streams output.
package task
import (
"context"
"sync"
"time"
)
// Kind classifies a job so the UI can pick an icon and a detail renderer.
type Kind int
const (
KindPull Kind = iota
KindBuild
KindAction
)
var kindNames = map[Kind]string{
KindPull: "pull",
KindBuild: "build",
KindAction: "action",
}
func (k Kind) String() string {
if name, found := kindNames[k]; found {
return name
}
return "action"
}
// State is the lifecycle stage of a job.
type State int
const (
StateRunning State = iota
StateSucceeded
StateFailed
StateCanceled
)
func (s State) Done() bool { return s != StateRunning }
var stateNames = map[State]string{
StateRunning: "running",
StateSucceeded: "succeeded",
StateFailed: "failed",
StateCanceled: "canceled",
}
func (s State) String() string {
if name, found := stateNames[s]; found {
return name
}
return "canceled"
}
// Step is one unit of progress inside a job: a Docker layer during a pull, or a
// BuildKit vertex during a build.
type Step struct {
ID string
Label string
Status string
Current int64
Total int64
Percent float64
Done bool
Failed bool
Note string
}
// Snapshot is an immutable view of a job. The UI only ever sees snapshots, so
// it can render without holding a lock or racing the worker goroutine.
type Snapshot struct {
ID int
Kind Kind
Title string
Target string
Detail string
State State
Err error
Percent float64
Steps []Step
Lines []string
Started time.Time
Ended time.Time
Cancellable bool
}
// Elapsed reports how long the job ran, or has been running.
func (s Snapshot) Elapsed() time.Duration {
if s.Ended.IsZero() {
return time.Since(s.Started)
}
return s.Ended.Sub(s.Started)
}
// Reporter is the handle a job body uses to publish progress. Every method is
// safe to call from any goroutine and never blocks on the UI.
type Reporter struct {
task *Task
}
// Log appends a line of output. Only the most recent lines are retained.
func (r *Reporter) Log(line string) {
r.task.mutate(func(t *Task) { t.lines = appendLine(t.lines, line) })
}
// SetSteps replaces the job's progress breakdown.
func (r *Reporter) SetSteps(steps []Step) {
r.task.mutate(func(t *Task) { t.steps = steps })
}
// SetPercent records overall progress in the range [0,1].
func (r *Reporter) SetPercent(percent float64) {
r.task.mutate(func(t *Task) { t.percent = clamp(percent) })
}
// SetDetail replaces the single-line summary shown next to the job.
func (r *Reporter) SetDetail(detail string) {
r.task.mutate(func(t *Task) { t.detail = detail })
}
// Lines returns the retained output, newest last.
func (r *Reporter) Lines() []string {
r.task.mu.Lock()
defer r.task.mu.Unlock()
return append([]string(nil), r.task.lines...)
}
// Body is the work a job performs. Returning an error fails the job; returning
// ctx.Err() after cancellation marks it canceled instead.
type Body func(ctx context.Context, report *Reporter) error
// Task is a single background job.
type Task struct {
mu sync.Mutex
manager *Manager
id int
kind Kind
title string
target string
detail string
state State
err error
percent float64
steps []Step
lines []string
started time.Time
ended time.Time
cancel context.CancelFunc
}
const retainedLines = 500
func appendLine(lines []string, line string) []string {
lines = append(lines, line)
if len(lines) > retainedLines {
lines = lines[len(lines)-retainedLines:]
}
return lines
}
func clamp(value float64) float64 {
if value < 0 {
return 0
}
if value > 1 {
return 1
}
return value
}
func (t *Task) mutate(apply func(*Task)) {
t.mu.Lock()
apply(t)
t.mu.Unlock()
t.manager.touch()
}
func (t *Task) snapshot() Snapshot {
t.mu.Lock()
defer t.mu.Unlock()
return Snapshot{
ID: t.id,
Kind: t.kind,
Title: t.title,
Target: t.target,
Detail: t.detail,
State: t.state,
Err: t.err,
Percent: t.percent,
Steps: append([]Step(nil), t.steps...),
Lines: append([]string(nil), t.lines...),
Started: t.started,
Ended: t.ended,
Cancellable: t.cancel != nil && t.state == StateRunning,
}
}
// ID returns the job's identifier, stable for the lifetime of the process.
func (t *Task) ID() int { return t.id }
// Manager owns every background job and notifies a single listener when any of
// them changes.
type Manager struct {
mu sync.Mutex
tasks []*Task
nextID int
notify chan struct{}
}
func NewManager() *Manager {
return &Manager{notify: make(chan struct{}, 1)}
}
// Changed is a coalescing wake-up channel. It receives at most one pending
// notification, so a job that emits thousands of progress updates per second
// still costs the UI a single redraw per frame.
func (m *Manager) Changed() <-chan struct{} { return m.notify }
func (m *Manager) touch() {
select {
case m.notify <- struct{}{}:
default:
}
}
// Start launches a job and returns immediately.
func (m *Manager) Start(kind Kind, title, target string, body Body) *Task {
ctx, cancel := context.WithCancel(context.Background())
m.mu.Lock()
m.nextID++
task := &Task{
manager: m,
id: m.nextID,
kind: kind,
title: title,
target: target,
state: StateRunning,
started: time.Now(),
cancel: cancel,
}
m.tasks = append(m.tasks, task)
m.mu.Unlock()
go func() {
err := body(ctx, &Reporter{task: task})
task.mu.Lock()
task.ended = time.Now()
task.cancel = nil
switch {
case ctx.Err() != nil:
task.state = StateCanceled
case err != nil:
task.state, task.err = StateFailed, err
default:
task.state, task.percent = StateSucceeded, 1
}
task.mu.Unlock()
cancel()
m.touch()
}()
m.touch()
return task
}
// Snapshots returns every job, oldest first.
func (m *Manager) Snapshots() []Snapshot {
m.mu.Lock()
tasks := append([]*Task(nil), m.tasks...)
m.mu.Unlock()
snapshots := make([]Snapshot, 0, len(tasks))
for _, task := range tasks {
snapshots = append(snapshots, task.snapshot())
}
return snapshots
}
// Snapshot returns a single job by identifier.
func (m *Manager) Snapshot(id int) (Snapshot, bool) {
m.mu.Lock()
defer m.mu.Unlock()
for _, task := range m.tasks {
if task.id == id {
return task.snapshot(), true
}
}
return Snapshot{}, false
}
// Cancel asks a running job to stop.
func (m *Manager) Cancel(id int) {
m.mu.Lock()
defer m.mu.Unlock()
for _, task := range m.tasks {
if task.id != id {
continue
}
task.mu.Lock()
cancel := task.cancel
task.mu.Unlock()
if cancel != nil {
cancel()
}
}
}
// CancelAll stops every running job, used when the program exits.
func (m *Manager) CancelAll() {
for _, snapshot := range m.Snapshots() {
if snapshot.State == StateRunning {
m.Cancel(snapshot.ID)
}
}
}
// Running counts jobs that have not finished.
func (m *Manager) Running() int {
count := 0
for _, snapshot := range m.Snapshots() {
if snapshot.State == StateRunning {
count++
}
}
return count
}
// RunningFor reports whether a job is already working on the given target, so
// the UI can refuse to start a duplicate build or pull.
func (m *Manager) RunningFor(target string) (Snapshot, bool) {
for _, snapshot := range m.Snapshots() {
if snapshot.State == StateRunning && snapshot.Target == target {
return snapshot, true
}
}
return Snapshot{}, false
}
// Prune drops finished jobs, keeping the history short.
func (m *Manager) Prune() {
m.mu.Lock()
kept := m.tasks[:0]
for _, task := range m.tasks {
if task.snapshot().State == StateRunning {
kept = append(kept, task)
}
}
m.tasks = kept
m.mu.Unlock()
m.touch()
}