Browse Source

compact TUI to monitor layers download progress

Signed-off-by: Nicolas De Loof <[email protected]>
Nicolas De Loof 2 years ago
parent
commit
24ff098252
7 changed files with 209 additions and 75 deletions
  1. 3 3
      go.mod
  2. 6 7
      go.sum
  3. 38 5
      pkg/compose/pull.go
  4. 18 3
      pkg/compose/push.go
  5. 42 3
      pkg/progress/event.go
  6. 82 41
      pkg/progress/tty.go
  7. 20 13
      pkg/progress/tty_test.go

+ 3 - 3
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

+ 6 - 7
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=

+ 38 - 5
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,

+ 18 - 3
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,
 	})
 }

+ 42 - 3
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()
+	}
+}

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

+ 20 - 13
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
+}