diff --git a/go.mod b/go.mod index e6895ce29..49c820c78 100644 --- a/go.mod +++ b/go.mod @@ -86,8 +86,8 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.15.12 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect @@ -132,7 +132,7 @@ require ( golang.org/x/crypto v0.2.0 // indirect golang.org/x/net v0.4.0 // indirect golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783 // indirect - golang.org/x/sys v0.4.0 // indirect + golang.org/x/sys v0.5.0 // indirect golang.org/x/term v0.3.0 // indirect golang.org/x/text v0.6.0 // indirect golang.org/x/time v0.1.0 // indirect diff --git a/go.sum b/go.sum index 45b7f8d22..598cfee99 100644 --- a/go.sum +++ b/go.sum @@ -516,12 +516,12 @@ github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPK github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= @@ -1001,7 +1001,6 @@ golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1021,8 +1020,8 @@ golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= -golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/pkg/compose/pull.go b/pkg/compose/pull.go index 05057c65c..21559a248 100644 --- a/pkg/compose/pull.go +++ b/pkg/compose/pull.go @@ -336,23 +336,53 @@ func isServiceImageToBuild(service types.ServiceConfig, services []types.Service return false } +const ( + PreparingPhase = "Preparing" + WaitingPhase = "Waiting" + PullingFsPhase = "Pulling fs layer" + DownloadingPhase = "Downloading" + DownloadCompletePhase = "Download complete" + ExtractingPhase = "Extracting" + VerifyingChecksumPhase = "Verifying Checksum" + AlreadyExistsPhase = "Already exists" + PullCompletePhase = "Pull complete" +) + func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, w progress.Writer) { if jm.ID == "" || jm.Progress == nil { return } var ( - text string - status = progress.Working + text string + total int64 + percent int + current int64 + status = progress.Working ) text = jm.Progress.String() - if jm.Status == "Pull complete" || - jm.Status == "Already exists" || - strings.Contains(jm.Status, "Image is up to date") || + switch jm.Status { + case PreparingPhase, WaitingPhase, PullingFsPhase: + percent = 0 + case DownloadingPhase, ExtractingPhase, VerifyingChecksumPhase: + if jm.Progress != nil { + current = jm.Progress.Current + total = jm.Progress.Total + if jm.Progress.Total > 0 { + percent = int(jm.Progress.Current * 100 / jm.Progress.Total) + } + } + case DownloadCompletePhase, AlreadyExistsPhase, PullCompletePhase: + status = progress.Done + percent = 100 + } + + if strings.Contains(jm.Status, "Image is up to date") || strings.Contains(jm.Status, "Downloaded newer image") { status = progress.Done + percent = 100 } if jm.Error != nil { @@ -363,6 +393,9 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, w progress.W w.Event(progress.Event{ ID: jm.ID, ParentID: parent, + Current: current, + Total: total, + Percent: percent, Text: jm.Status, Status: status, StatusText: text, diff --git a/pkg/compose/push.go b/pkg/compose/push.go index 08f9f5edc..a5a5bb75c 100644 --- a/pkg/compose/push.go +++ b/pkg/compose/push.go @@ -139,11 +139,15 @@ func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, w progress.W return } var ( - text string - status = progress.Working + text string + status = progress.Working + total int64 + current int64 + percent int ) - if jm.Status == "Pull complete" || jm.Status == "Already exists" { + if jm.Status == "Pushed" || jm.Status == "Already exists" { status = progress.Done + percent = 100 } if jm.Error != nil { status = progress.Error @@ -151,11 +155,22 @@ func toPushProgressEvent(prefix string, jm jsonmessage.JSONMessage, w progress.W } if jm.Progress != nil { text = jm.Progress.String() + if jm.Progress.Total != 0 { + current = jm.Progress.Current + total = jm.Progress.Total + if jm.Progress.Total > 0 { + percent = int(jm.Progress.Current * 100 / jm.Progress.Total) + } + } } + w.Event(progress.Event{ ID: fmt.Sprintf("Pushing %s: %s", prefix, jm.ID), Text: jm.Status, Status: status, + Current: current, + Total: total, + Percent: percent, StatusText: text, }) } diff --git a/pkg/progress/event.go b/pkg/progress/event.go index 8603410e9..1cd5846a5 100644 --- a/pkg/progress/event.go +++ b/pkg/progress/event.go @@ -16,20 +16,37 @@ package progress -import "time" +import ( + "time" + + "github.com/morikuni/aec" +) // EventStatus indicates the status of an action type EventStatus int +func (s EventStatus) color() aec.ANSI { + switch s { + case Done: + return aec.GreenF + case Warning: + return aec.YellowF.With(aec.Bold) + case Error: + return aec.RedF.With(aec.Bold) + default: + return aec.DefaultF + } +} + const ( // Working means that the current task is working Working EventStatus = iota // Done means that the current task is done Done - // Error means that the current task has errored - Error // Warning means that the current task has warning Warning + // Error means that the current task has errored + Error ) // Event represents a progress event. @@ -39,7 +56,10 @@ type Event struct { Text string Status EventStatus StatusText string + Current int64 + Percent int + Total int64 startTime time.Time endTime time.Time spinner *spinner @@ -148,3 +168,22 @@ func (e *Event) stop() { e.endTime = time.Now() e.spinner.Stop() } + +var ( + spinnerDone = aec.Apply("✔", aec.GreenF) + spinnerWarning = aec.Apply("!", aec.YellowF) + spinnerError = aec.Apply("✘", aec.RedF) +) + +func (e *Event) Spinner() any { + switch e.Status { + case Done: + return spinnerDone + case Warning: + return spinnerWarning + case Error: + return spinnerError + default: + return e.spinner.String() + } +} diff --git a/pkg/progress/tty.go b/pkg/progress/tty.go index f9b6ee50e..3490dd939 100644 --- a/pkg/progress/tty.go +++ b/pkg/progress/tty.go @@ -20,7 +20,6 @@ import ( "context" "fmt" "io" - "runtime" "strings" "sync" "time" @@ -29,19 +28,21 @@ import ( "github.com/docker/compose/v2/pkg/utils" "github.com/buger/goterm" + "github.com/docker/go-units" "github.com/morikuni/aec" ) type ttyWriter struct { - out io.Writer - events map[string]Event - eventIDs []string - repeated bool - numLines int - done chan bool - mtx *sync.Mutex - tailEvents []string - dryRun bool + out io.Writer + events map[string]Event + eventIDs []string + repeated bool + numLines int + done chan bool + mtx *sync.Mutex + tailEvents []string + dryRun bool + skipChildEvents bool } func (w *ttyWriter) Start(ctx context.Context) error { @@ -85,6 +86,9 @@ func (w *ttyWriter) Event(e Event) { last.Status = e.Status last.Text = e.Text last.StatusText = e.StatusText + last.Total = e.Total + last.Current = e.Current + 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 @@ -163,9 +167,8 @@ func (w *ttyWriter) print() { //nolint:gocyclo } } - skipChildEvents := false if len(w.eventIDs) > goterm.Height()-2 { - skipChildEvents = true + w.skipChildEvents = true } numLines := 0 for _, v := range w.eventIDs { @@ -173,16 +176,16 @@ func (w *ttyWriter) print() { //nolint:gocyclo if event.ParentID != "" { continue } - line := lineText(event, "", terminalWidth, statusPadding, runtime.GOOS != "windows", w.dryRun) + line := w.lineText(event, "", terminalWidth, statusPadding, w.dryRun) fmt.Fprint(w.out, line) numLines++ for _, v := range w.eventIDs { ev := w.events[v] if ev.ParentID == event.ID { - if skipChildEvents { + if w.skipChildEvents { continue } - line := lineText(ev, " ", terminalWidth, statusPadding, runtime.GOOS != "windows", w.dryRun) + line := w.lineText(ev, " ", terminalWidth, statusPadding, w.dryRun) fmt.Fprint(w.out, line) numLines++ } @@ -197,7 +200,7 @@ func (w *ttyWriter) print() { //nolint:gocyclo w.numLines = numLines } -func lineText(event Event, pad string, terminalWidth, statusPadding int, color bool, dryRun bool) string { +func (w *ttyWriter) lineText(event Event, pad string, terminalWidth, statusPadding int, dryRun bool) string { endTime := time.Now() if event.Status != Working { endTime = event.startTime @@ -207,12 +210,38 @@ func lineText(event Event, pad string, terminalWidth, statusPadding int, color b } prefix := "" if dryRun { - prefix = api.DRYRUN_PREFIX + prefix = aec.Apply(api.DRYRUN_PREFIX, aec.CyanF) } elapsed := endTime.Sub(event.startTime).Seconds() - textLen := len(fmt.Sprintf("%s %s", event.ID, event.Text)) + var ( + total int64 + current int64 + completion []string + ) + + for _, v := range w.eventIDs { + ev := w.events[v] + if ev.ParentID == event.ID { + total += ev.Total + current += ev.Current + completion = append(completion, percentChars[(len(percentChars)-1)*ev.Percent/100]) + } + } + + var txt string + if len(completion) > 0 { + txt = fmt.Sprintf("%s %s [%s] %7s/%-7s %s", + event.ID, + aec.Apply(fmt.Sprintf("%d layers", len(completion)), aec.YellowF), + aec.Apply(strings.Join(completion, ""), aec.GreenF, aec.Bold), + units.HumanSize(float64(current)), units.HumanSize(float64(total)), + event.Text) + } else { + txt = fmt.Sprintf("%s %s", event.ID, event.Text) + } + textLen := len(txt) padding := statusPadding - textLen if padding < 0 { padding = 0 @@ -225,31 +254,16 @@ func lineText(event Event, pad string, terminalWidth, statusPadding int, color b if maxStatusLen > 0 && len(status) > maxStatusLen { status = status[:maxStatusLen] + "..." } - text := fmt.Sprintf("%s %s%s %s %s%s %s", + text := fmt.Sprintf("%s %s%s %s%s %s", pad, - event.spinner.String(), + event.Spinner(), prefix, - event.ID, - event.Text, + txt, strings.Repeat(" ", padding), - status, + aec.Apply(status, event.Status.color()), ) - timer := fmt.Sprintf("%.1fs\n", elapsed) - o := align(text, timer, terminalWidth) - - if color { - color := aec.WhiteF - if event.Status == Done { - color = aec.BlueF - } - if event.Status == Error { - color = aec.RedF - } - if event.Status == Warning { - color = aec.YellowF - } - return aec.Apply(o, color) - } + timer := fmt.Sprintf("%.1fs ", elapsed) + o := align(text, aec.Apply(timer, aec.BlueF), terminalWidth) return o } @@ -257,7 +271,7 @@ func lineText(event Event, pad string, terminalWidth, statusPadding int, color b func numDone(events map[string]Event) int { i := 0 for _, e := range events { - if e.Status == Done { + if e.Status != Working { i++ } } @@ -265,5 +279,32 @@ func numDone(events map[string]Event) int { } func align(l, r string, w int) string { - return fmt.Sprintf("%-[2]*[1]s %[3]s", l, w-len(r)-1, r) + ll := lenAnsi(l) + lr := lenAnsi(r) + pad := strings.Repeat(" ", w-ll-lr) + return fmt.Sprintf("%s%s%s\n", l, pad, r) } + +// lenAnsi count of user-perceived characters in ANSI string. +func lenAnsi(s string) int { + length := 0 + ansiCode := false + for _, r := range s { + if r == '\x1b' { + ansiCode = true + continue + } + if ansiCode && r == 'm' { + ansiCode = false + continue + } + if !ansiCode { + length++ + } + } + return length +} + +var ( + percentChars = strings.Split("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "") +) diff --git a/pkg/progress/tty_test.go b/pkg/progress/tty_test.go index 53f9d2233..bbc311f9d 100644 --- a/pkg/progress/tty_test.go +++ b/pkg/progress/tty_test.go @@ -41,23 +41,20 @@ func TestLineText(t *testing.T) { lineWidth := len(fmt.Sprintf("%s %s", ev.ID, ev.Text)) - out := lineText(ev, "", 50, lineWidth, true, false) - assert.Equal(t, out, "\x1b[37m . id Text Status 0.0s\n\x1b[0m") - - out = lineText(ev, "", 50, lineWidth, false, false) - assert.Equal(t, out, " . id Text Status 0.0s\n") + out := tty().lineText(ev, "", 50, lineWidth, false) + assert.Equal(t, out, " . id Text \x1b[39mStatus\x1b[0m \x1b[34m0.0s \x1b[0m\n") ev.Status = Done - out = lineText(ev, "", 50, lineWidth, true, false) - assert.Equal(t, out, "\x1b[34m . id Text Status 0.0s\n\x1b[0m") + out = tty().lineText(ev, "", 50, lineWidth, false) + assert.Equal(t, out, " \x1b[32m✔\x1b[0m id Text \x1b[32mStatus\x1b[0m \x1b[34m0.0s \x1b[0m\n") ev.Status = Error - out = lineText(ev, "", 50, lineWidth, true, false) - assert.Equal(t, out, "\x1b[31m . id Text Status 0.0s\n\x1b[0m") + out = tty().lineText(ev, "", 50, lineWidth, false) + assert.Equal(t, out, " \x1b[31m✘\x1b[0m id Text \x1b[31m\x1b[1mStatus\x1b[0m \x1b[34m0.0s \x1b[0m\n") ev.Status = Warning - out = lineText(ev, "", 50, lineWidth, true, false) - assert.Equal(t, out, "\x1b[33m . id Text Status 0.0s\n\x1b[0m") + out = tty().lineText(ev, "", 50, lineWidth, false) + assert.Equal(t, out, " \x1b[33m!\x1b[0m id Text \x1b[33m\x1b[1mStatus\x1b[0m \x1b[34m0.0s \x1b[0m\n") } func TestLineTextSingleEvent(t *testing.T) { @@ -75,8 +72,8 @@ func TestLineTextSingleEvent(t *testing.T) { lineWidth := len(fmt.Sprintf("%s %s", ev.ID, ev.Text)) - out := lineText(ev, "", 50, lineWidth, true, false) - assert.Equal(t, out, "\x1b[34m . id Text Status 0.0s\n\x1b[0m") + out := tty().lineText(ev, "", 50, lineWidth, false) + assert.Equal(t, out, " \x1b[32m✔\x1b[0m id Text \x1b[32mStatus\x1b[0m \x1b[34m0.0s \x1b[0m\n") } func TestErrorEvent(t *testing.T) { @@ -136,3 +133,13 @@ func TestWarningEvent(t *testing.T) { assert.Assert(t, ok) assert.Assert(t, event.endTime.After(time.Now().Add(-10*time.Second))) } + +func tty() *ttyWriter { + tty := &ttyWriter{ + eventIDs: []string{}, + events: map[string]Event{}, + done: make(chan bool), + mtx: &sync.Mutex{}, + } + return tty +}