Restored image layer download progress details on pull.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2025-12-12 09:22:44 +01:00 committed by Guillaume Lours
parent 4f419e5098
commit 02008a0097
3 changed files with 152 additions and 93 deletions

View File

@ -20,11 +20,13 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"iter"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/buger/goterm" "github.com/buger/goterm"
"github.com/docker/compose/v5/pkg/utils"
"github.com/docker/go-units" "github.com/docker/go-units"
"github.com/morikuni/aec" "github.com/morikuni/aec"
@ -60,7 +62,8 @@ type ttyWriter struct {
type task struct { type task struct {
ID string 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 startTime time.Time
endTime time.Time endTime time.Time
text string text string
@ -72,6 +75,64 @@ type task struct {
spinner *Spinner 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() { func (t *task) stop() {
t.endTime = time.Now() t.endTime = time.Now()
t.spinner.Stop() t.spinner.Stop()
@ -81,6 +142,15 @@ func (t *task) hasMore() {
t.spinner.Restart() 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) { func (w *ttyWriter) Start(ctx context.Context, operation string) {
w.ticker = time.NewTicker(100 * time.Millisecond) w.ticker = time.NewTicker(100 * time.Millisecond)
w.operation = operation w.operation = operation
@ -137,48 +207,10 @@ func (w *ttyWriter) event(e api.Resource) {
} }
if last, ok := w.tasks[e.ID]; ok { if last, ok := w.tasks[e.ID]; ok {
switch e.Status { last.update(e)
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
}
w.tasks[e.ID] = last w.tasks[e.ID] = last
} else { } else {
t := task{ t := newTask(e)
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()
}
w.tasks[e.ID] = t w.tasks[e.ID] = t
w.ids = append(w.ids, e.ID) 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) _, _ = 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() { func (w *ttyWriter) print() {
w.mtx.Lock() w.mtx.Lock()
defer w.mtx.Unlock() defer w.mtx.Unlock()
@ -233,20 +287,25 @@ func (w *ttyWriter) print() {
var statusPadding int var statusPadding int
for _, t := range w.tasks { for _, t := range w.tasks {
l := len(t.ID) l := len(t.ID)
if t.parentID == "" && statusPadding < l { if len(t.parents) == 0 && statusPadding < l {
statusPadding = l statusPadding = l
} }
} }
skipChildEvents := len(w.tasks) > goterm.Height()-2
numLines := 0 numLines := 0
for _, id := range w.ids { // iterate on ids to enforce a consistent order for t := range w.parentTasks() {
t := w.tasks[id]
if t.parentID != "" {
continue
}
line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun) line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun)
_, _ = fmt.Fprint(w.out, line) _, _ = fmt.Fprint(w.out, line)
numLines++ 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++ { for i := numLines; i < w.numLines; i++ {
if numLines < goterm.Height()-2 { if numLines < goterm.Height()-2 {
@ -281,9 +340,7 @@ func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding in
// only show the aggregated progress while the root operation is in-progress // only show the aggregated progress while the root operation is in-progress
if parent := t; parent.status == api.Working { if parent := t; parent.status == api.Working {
for _, id := range w.ids { for child := range w.childrenTasks(parent.ID) {
child := w.tasks[id]
if child.parentID == parent.ID {
if child.status == api.Working && child.total == 0 { if child.status == api.Working && child.total == 0 {
// we don't have totals available for all the child events // we don't have totals available for all the child events
// so don't show the total progress yet // so don't show the total progress yet
@ -294,7 +351,6 @@ func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding in
completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100]) completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100])
} }
} }
}
// don't try to show detailed progress if we don't have any idea // don't try to show detailed progress if we don't have any idea
if total == 0 { if total == 0 {

View File

@ -65,6 +65,8 @@ const (
StatusCopied = "Copied" StatusCopied = "Copied"
StatusExporting = "Exporting" StatusExporting = "Exporting"
StatusExported = "Exported" StatusExported = "Exported"
StatusDownloading = "Downloading"
StatusDownloadComplete = "Download complete"
) )
// Resource represents status change and progress for a compose resource. // Resource represents status change and progress for a compose resource.

View File

@ -398,14 +398,14 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E
} }
var ( var (
text string progress string
total int64 total int64
percent int percent int
current int64 current int64
status = api.Working status = api.Working
) )
text = jm.Progress.String() progress = jm.Progress.String()
switch jm.Status { switch jm.Status {
case PreparingPhase, WaitingPhase, PullingFsPhase: case PreparingPhase, WaitingPhase, PullingFsPhase:
@ -431,7 +431,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E
if jm.Error != nil { if jm.Error != nil {
status = api.Error status = api.Error
text = jm.Error.Message progress = jm.Error.Message
} }
events.On(api.Resource{ events.On(api.Resource{
@ -441,6 +441,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E
Total: total, Total: total,
Percent: percent, Percent: percent,
Status: status, Status: status,
Text: text, Text: jm.Status,
Details: progress,
}) })
} }