From 02008a00974b45631818cbcd49f37e79850f9702 Mon Sep 17 00:00:00 2001 From: Nicolas De Loof Date: Fri, 12 Dec 2025 09:22:44 +0100 Subject: [PATCH] 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 0eb47bf07..cc0cce303 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 8a14fa316..92f2b3144 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 194500c31..276f2f6d7 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, }) }