From 16548f93da8d8e7622df9b1f4434c8114e49b776 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 12 Dec 2025 09:22:44 +0100 Subject: [PATCH 1/2] Restored image layer download progress details on pull. Signed-off-by: Nicolas De Loof --- cmd/display/tty.go | 172 +++++++++++++++++++++++++++++--------------- pkg/api/event.go | 56 ++++++++------- pkg/compose/pull.go | 17 ++--- 3 files changed, 152 insertions(+), 93 deletions(-) diff --git a/cmd/display/tty.go b/cmd/display/tty.go index 0eb47bf07d..cc0cce3035 100644 --- a/cmd/display/tty.go +++ b/cmd/display/tty.go @@ -20,11 +20,13 @@ import ( "context" "fmt" "io" + "iter" "strings" "sync" "time" "github.com/buger/goterm" + "github.com/docker/compose/v5/pkg/utils" "github.com/docker/go-units" "github.com/morikuni/aec" @@ -60,7 +62,8 @@ type ttyWriter struct { type task struct { ID string - parentID string + parent string // the resource this task receives updates from - other parents will be ignored + parents utils.Set[string] // all resources to depend on this task startTime time.Time endTime time.Time text string @@ -72,6 +75,64 @@ type task struct { spinner *Spinner } +func newTask(e api.Resource) task { + t := task{ + ID: e.ID, + parents: utils.NewSet[string](), + startTime: time.Now(), + text: e.Text, + details: e.Details, + status: e.Status, + current: e.Current, + percent: e.Percent, + total: e.Total, + spinner: NewSpinner(), + } + if e.ParentID != "" { + t.parent = e.ParentID + t.parents.Add(e.ParentID) + } + if e.Status == api.Done || e.Status == api.Error { + t.stop() + } + return t +} + +// update adjusts task state based on last received event +func (t *task) update(e api.Resource) { + if e.ParentID != "" { + t.parents.Add(e.ParentID) + // we may receive same event from distinct parents (typically: images sharing layers) + // to avoid status to flicker, only accept updates from our first declared parent + if t.parent != e.ParentID { + return + } + } + + // update task based on received event + switch e.Status { + case api.Done, api.Error, api.Warning: + if t.status != e.Status { + t.stop() + } + case api.Working: + t.hasMore() + } + t.status = e.Status + t.text = e.Text + t.details = e.Details + // progress can only go up + if e.Total > t.total { + t.total = e.Total + } + if e.Current > t.current { + t.current = e.Current + } + if e.Percent > t.percent { + t.percent = e.Percent + } +} + func (t *task) stop() { t.endTime = time.Now() t.spinner.Stop() @@ -81,6 +142,15 @@ func (t *task) hasMore() { t.spinner.Restart() } +func (t *task) Completed() bool { + switch t.status { + case api.Done, api.Error, api.Warning: + return true + default: + return false + } +} + func (w *ttyWriter) Start(ctx context.Context, operation string) { w.ticker = time.NewTicker(100 * time.Millisecond) w.operation = operation @@ -137,48 +207,10 @@ func (w *ttyWriter) event(e api.Resource) { } if last, ok := w.tasks[e.ID]; ok { - switch e.Status { - case api.Done, api.Error, api.Warning: - if last.status != e.Status { - last.stop() - } - case api.Working: - last.hasMore() - } - last.status = e.Status - last.text = e.Text - last.details = e.Details - // progress can only go up - if e.Total > last.total { - last.total = e.Total - } - if e.Current > last.current { - last.current = e.Current - } - if e.Percent > last.percent { - last.percent = e.Percent - } - // allow set/unset of parent, but not swapping otherwise prompt is flickering - if last.parentID == "" || e.ParentID == "" { - last.parentID = e.ParentID - } + last.update(e) w.tasks[e.ID] = last } else { - t := task{ - ID: e.ID, - parentID: e.ParentID, - startTime: time.Now(), - text: e.Text, - details: e.Details, - status: e.Status, - current: e.Current, - percent: e.Percent, - total: e.Total, - spinner: NewSpinner(), - } - if e.Status == api.Done || e.Status == api.Error { - t.stop() - } + t := newTask(e) w.tasks[e.ID] = t w.ids = append(w.ids, e.ID) } @@ -205,6 +237,28 @@ func (w *ttyWriter) printEvent(e api.Resource) { _, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, color(e.Text), e.Details) } +func (w *ttyWriter) parentTasks() iter.Seq[task] { + return func(yield func(task) bool) { + for _, id := range w.ids { // iterate on ids to enforce a consistent order + t := w.tasks[id] + if len(t.parents) == 0 { + yield(t) + } + } + } +} + +func (w *ttyWriter) childrenTasks(parent string) iter.Seq[task] { + return func(yield func(task) bool) { + for _, id := range w.ids { // iterate on ids to enforce a consistent order + t := w.tasks[id] + if t.parents.Has(parent) { + yield(t) + } + } + } +} + func (w *ttyWriter) print() { w.mtx.Lock() defer w.mtx.Unlock() @@ -233,20 +287,25 @@ func (w *ttyWriter) print() { var statusPadding int for _, t := range w.tasks { l := len(t.ID) - if t.parentID == "" && statusPadding < l { + if len(t.parents) == 0 && statusPadding < l { statusPadding = l } } + skipChildEvents := len(w.tasks) > goterm.Height()-2 numLines := 0 - for _, id := range w.ids { // iterate on ids to enforce a consistent order - t := w.tasks[id] - if t.parentID != "" { - continue - } + for t := range w.parentTasks() { line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun) _, _ = fmt.Fprint(w.out, line) numLines++ + if skipChildEvents { + continue + } + for child := range w.childrenTasks(t.ID) { + line := w.lineText(child, " ", terminalWidth, statusPadding-2, w.dryRun) + _, _ = fmt.Fprint(w.out, line) + numLines++ + } } for i := numLines; i < w.numLines; i++ { if numLines < goterm.Height()-2 { @@ -281,18 +340,15 @@ func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding in // only show the aggregated progress while the root operation is in-progress if parent := t; parent.status == api.Working { - for _, id := range w.ids { - child := w.tasks[id] - if child.parentID == parent.ID { - if child.status == api.Working && child.total == 0 { - // we don't have totals available for all the child events - // so don't show the total progress yet - hideDetails = true - } - total += child.total - current += child.current - completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100]) + for child := range w.childrenTasks(parent.ID) { + if child.status == api.Working && child.total == 0 { + // we don't have totals available for all the child events + // so don't show the total progress yet + hideDetails = true } + total += child.total + current += child.current + completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100]) } } diff --git a/pkg/api/event.go b/pkg/api/event.go index 8a14fa3165..92f2b31446 100644 --- a/pkg/api/event.go +++ b/pkg/api/event.go @@ -38,33 +38,35 @@ const ( const ResourceCompose = "Compose" const ( - StatusError = "Error" - StatusCreating = "Creating" - StatusStarting = "Starting" - StatusStarted = "Started" - StatusWaiting = "Waiting" - StatusHealthy = "Healthy" - StatusExited = "Exited" - StatusRestarting = "Restarting" - StatusRestarted = "Restarted" - StatusRunning = "Running" - StatusCreated = "Created" - StatusStopping = "Stopping" - StatusStopped = "Stopped" - StatusKilling = "Killing" - StatusKilled = "Killed" - StatusRemoving = "Removing" - StatusRemoved = "Removed" - StatusBuilding = "Building" - StatusBuilt = "Built" - StatusPulling = "Pulling" - StatusPulled = "Pulled" - StatusCommitting = "Committing" - StatusCommitted = "Committed" - StatusCopying = "Copying" - StatusCopied = "Copied" - StatusExporting = "Exporting" - StatusExported = "Exported" + StatusError = "Error" + StatusCreating = "Creating" + StatusStarting = "Starting" + StatusStarted = "Started" + StatusWaiting = "Waiting" + StatusHealthy = "Healthy" + StatusExited = "Exited" + StatusRestarting = "Restarting" + StatusRestarted = "Restarted" + StatusRunning = "Running" + StatusCreated = "Created" + StatusStopping = "Stopping" + StatusStopped = "Stopped" + StatusKilling = "Killing" + StatusKilled = "Killed" + StatusRemoving = "Removing" + StatusRemoved = "Removed" + StatusBuilding = "Building" + StatusBuilt = "Built" + StatusPulling = "Pulling" + StatusPulled = "Pulled" + StatusCommitting = "Committing" + StatusCommitted = "Committed" + StatusCopying = "Copying" + StatusCopied = "Copied" + StatusExporting = "Exporting" + StatusExported = "Exported" + StatusDownloading = "Downloading" + StatusDownloadComplete = "Download complete" ) // Resource represents status change and progress for a compose resource. diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index 194500c313..276f2f6d70 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -398,14 +398,14 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E } var ( - text string - total int64 - percent int - current int64 - status = api.Working + progress string + total int64 + percent int + current int64 + status = api.Working ) - text = jm.Progress.String() + progress = jm.Progress.String() switch jm.Status { case PreparingPhase, WaitingPhase, PullingFsPhase: @@ -431,7 +431,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E if jm.Error != nil { status = api.Error - text = jm.Error.Message + progress = jm.Error.Message } events.On(api.Resource{ @@ -441,6 +441,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E Total: total, Percent: percent, Status: status, - Text: text, + Text: jm.Status, + Details: progress, }) } From ef481922a87d9a9e051dadad4cf6d57c6d230f73 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 12 Dec 2025 11:15:56 +0100 Subject: [PATCH 2/2] prefer *task for memory efficiency updating tasks Signed-off-by: Nicolas De Loof --- cmd/display/tty.go | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/cmd/display/tty.go b/cmd/display/tty.go index cc0cce3035..b6ba14d9bd 100644 --- a/cmd/display/tty.go +++ b/cmd/display/tty.go @@ -26,11 +26,11 @@ import ( "time" "github.com/buger/goterm" - "github.com/docker/compose/v5/pkg/utils" "github.com/docker/go-units" "github.com/morikuni/aec" "github.com/docker/compose/v5/pkg/api" + "github.com/docker/compose/v5/pkg/utils" ) // Full creates an EventProcessor that render advanced UI within a terminal. @@ -39,7 +39,7 @@ func Full(out io.Writer, info io.Writer) api.EventProcessor { return &ttyWriter{ out: out, info: info, - tasks: map[string]task{}, + tasks: map[string]*task{}, done: make(chan bool), mtx: &sync.Mutex{}, } @@ -48,7 +48,7 @@ func Full(out io.Writer, info io.Writer) api.EventProcessor { type ttyWriter struct { out io.Writer ids []string // tasks ids ordered as first event appeared - tasks map[string]task + tasks map[string]*task repeated bool numLines int done chan bool @@ -208,10 +208,9 @@ func (w *ttyWriter) event(e api.Resource) { if last, ok := w.tasks[e.ID]; ok { last.update(e) - w.tasks[e.ID] = last } else { t := newTask(e) - w.tasks[e.ID] = t + w.tasks[e.ID] = &t w.ids = append(w.ids, e.ID) } w.printEvent(e) @@ -237,8 +236,8 @@ func (w *ttyWriter) printEvent(e api.Resource) { _, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, color(e.Text), e.Details) } -func (w *ttyWriter) parentTasks() iter.Seq[task] { - return func(yield func(task) bool) { +func (w *ttyWriter) parentTasks() iter.Seq[*task] { + return func(yield func(*task) bool) { for _, id := range w.ids { // iterate on ids to enforce a consistent order t := w.tasks[id] if len(t.parents) == 0 { @@ -248,8 +247,8 @@ func (w *ttyWriter) parentTasks() iter.Seq[task] { } } -func (w *ttyWriter) childrenTasks(parent string) iter.Seq[task] { - return func(yield func(task) bool) { +func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] { + return func(yield func(*task) bool) { for _, id := range w.ids { // iterate on ids to enforce a consistent order t := w.tasks[id] if t.parents.Has(parent) { @@ -316,7 +315,7 @@ func (w *ttyWriter) print() { w.numLines = numLines } -func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding int, dryRun bool) string { +func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding int, dryRun bool) string { endTime := time.Now() if t.status != api.Working { endTime = t.startTime @@ -403,7 +402,7 @@ var ( spinnerError = "✘" ) -func spinner(t task) string { +func spinner(t *task) string { switch t.status { case api.Done: return SuccessColor(spinnerDone) @@ -429,7 +428,7 @@ func colorFn(s api.EventStatus) colorFunc { } } -func numDone(tasks map[string]task) int { +func numDone(tasks map[string]*task) int { i := 0 for _, t := range tasks { if t.status != api.Working {