compact TUI to monitor layers download progress

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2023-02-14 16:13:38 +01:00 committed by Nicolas De loof
parent 593c4263f3
commit 24ff098252
7 changed files with 209 additions and 75 deletions

6
go.mod
View File

@ -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

13
go.sum
View File

@ -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=

View File

@ -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,

View File

@ -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,
})
}

View File

@ -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()
}
}

View File

@ -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("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "")
)

View File

@ -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
}