From d0e48a25aa6f66c2d5f9ff731575b99bd601616b Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Tue, 16 Jun 2020 09:42:07 +0200 Subject: [PATCH 1/4] Implement a progress writer --- go.mod | 2 +- go.sum | 3 +- progress/spinner.go | 39 ++++++++ progress/writer.go | 214 ++++++++++++++++++++++++++++++++++++++++ progress/writer_test.go | 37 +++++++ 5 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 progress/spinner.go create mode 100644 progress/writer.go create mode 100644 progress/writer_test.go diff --git a/go.mod b/go.mod index c601eb703..6d46b57f9 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/google/uuid v1.1.1 github.com/gorilla/mux v1.7.4 // indirect github.com/hashicorp/go-multierror v1.1.0 - github.com/morikuni/aec v1.0.0 // indirect + github.com/morikuni/aec v1.0.0 github.com/onsi/gomega v1.10.1 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.0.1 // indirect diff --git a/go.sum b/go.sum index 1b979ca2b..a2893b679 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/AlecAivazis/survey/v2 v2.0.7 h1:+f825XHLse/hWd2tE/V5df04WFGimk34Eyg/z github.com/AlecAivazis/survey/v2 v2.0.7/go.mod h1:mlizQTaPjnR4jcpwRSaSlkbsRfYFEyKgLQvYTzxxiHA= github.com/Azure/azure-pipeline-go v0.2.1 h1:OLBdZJ3yvOn2MezlWvbrBMTEUQC72zAftRZOMdj5HYo= github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= -github.com/Azure/azure-sdk-for-go v43.1.0+incompatible h1:m6EAp2Dmb8/t+ToZ2jtmvdp+JBwsdfSlZuBV31WGLGQ= -github.com/Azure/azure-sdk-for-go v43.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v43.2.0+incompatible h1:H8jfb+wuVlLqyP1Nr6zqapNxqhgwshD5OETJsBO74iY= github.com/Azure/azure-sdk-for-go v43.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-storage-file-go v0.7.0 h1:yWoV0MYwzmoSgWACcVkdPolvAULFPNamcQLpIvS/Et4= @@ -330,6 +328,7 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/progress/spinner.go b/progress/spinner.go new file mode 100644 index 000000000..8abb9821b --- /dev/null +++ b/progress/spinner.go @@ -0,0 +1,39 @@ +package progress + +import "time" + +type spinner struct { + time time.Time + index int + chars []string + stop bool + done string +} + +func newSpinner() *spinner { + return &spinner{ + index: 0, + time: time.Now(), + chars: []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", + }, + done: "⠿", + } +} + +func (s *spinner) String() string { + if s.stop { + return s.done + } + + d := time.Since(s.time) + if d.Milliseconds() > 100 { + s.index = (s.index + 1) % len(s.chars) + } + + return s.chars[s.index] +} + +func (s *spinner) Stop() { + s.stop = true +} diff --git a/progress/writer.go b/progress/writer.go new file mode 100644 index 000000000..9803f3822 --- /dev/null +++ b/progress/writer.go @@ -0,0 +1,214 @@ +package progress + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/buger/goterm" + "github.com/morikuni/aec" +) + +// EventStatus indicates the status of an action +type EventStatus int + +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 +) + +// Event reprensents a progress event +type Event struct { + ID string + Text string + Status EventStatus + StatusText string + Done bool + + startTime time.Time + endTime time.Time + spinner *spinner +} + +func (e *Event) stop() { + e.endTime = time.Now() + e.spinner.Stop() +} + +// Writer can write multiple progress events +type Writer interface { + Start(context.Context) error + Stop() + Event(Event) +} + +type writer struct { + out io.Writer + events map[string]Event + eventIDs []string + repeated bool + numLines int + done chan bool + mtx *sync.RWMutex +} + +// NewWriter returns a new multi-progress writer +func NewWriter(out io.Writer) Writer { + return &writer{ + out: out, + eventIDs: []string{}, + events: map[string]Event{}, + repeated: false, + done: make(chan bool), + mtx: &sync.RWMutex{}, + } +} + +func (w *writer) Start(ctx context.Context) error { + ticker := time.NewTicker(100 * time.Millisecond) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-w.done: + w.print() + return nil + case <-ticker.C: + w.print() + } + } +} + +func (w *writer) Stop() { + w.done <- true +} + +func (w *writer) Event(e Event) { + w.mtx.Lock() + defer w.mtx.Unlock() + if !contains(w.eventIDs, e.ID) { + w.eventIDs = append(w.eventIDs, e.ID) + } + if _, ok := w.events[e.ID]; ok { + event := w.events[e.ID] + if event.Status != Done && e.Status == Done { + event.stop() + } + event.Status = e.Status + event.Text = e.Text + event.StatusText = e.StatusText + w.events[e.ID] = event + } else { + e.startTime = time.Now() + e.spinner = newSpinner() + w.events[e.ID] = e + } +} + +func (w *writer) print() { + w.mtx.Lock() + defer w.mtx.Unlock() + terminalWidth := goterm.Width() + b := aec.EmptyBuilder + for i := 0; i <= w.numLines; i++ { + b = b.Up(1) + } + if !w.repeated { + b = b.Down(1) + } + w.repeated = true + fmt.Fprint(w.out, b.Column(0).ANSI) + + // Hide the cursor while we are printing + fmt.Fprint(w.out, aec.Hide) + defer fmt.Fprint(w.out, aec.Show) + + firstLine := fmt.Sprintf("[+] Running %d/%d", numDone(w.events), w.numLines) + if w.numLines != 0 && numDone(w.events) == w.numLines { + firstLine = aec.Apply(firstLine, aec.BlueF) + } + fmt.Fprintln(w.out, firstLine) + + var statusPadding int + for _, v := range w.eventIDs { + l := len(fmt.Sprintf("%s %s", w.events[v].ID, w.events[v].Text)) + if statusPadding < l { + statusPadding = l + } + } + + numLines := 0 + for _, v := range w.eventIDs { + line := lineText(w.events[v], terminalWidth, statusPadding) + // nolint: errcheck + fmt.Fprint(w.out, line) + numLines++ + } + + w.numLines = numLines +} + +func lineText(event Event, terminalWidth, statusPadding int) string { + endTime := time.Now() + if event.Status != Working { + endTime = event.endTime + } + + elapsed := endTime.Sub(event.startTime).Seconds() + + textLen := len(fmt.Sprintf("%s %s", event.ID, event.Text)) + padding := statusPadding - textLen + if padding < 0 { + padding = 0 + } + text := fmt.Sprintf(" %s %s %s%s %s", + event.spinner.String(), + event.ID, + event.Text, + strings.Repeat(" ", padding), + event.StatusText, + ) + timer := fmt.Sprintf("%.1fs\n", elapsed) + o := align(text, timer, terminalWidth) + + color := aec.WhiteF + if event.Status == Done { + color = aec.BlueF + } + if event.Status == Error { + color = aec.RedF + } + + return aec.Apply(o, color) +} + +func numDone(events map[string]Event) int { + i := 0 + for _, e := range events { + if e.Status == Done { + i++ + } + } + return i +} + +func align(l, r string, w int) string { + return fmt.Sprintf("%-[2]*[1]s %[3]s", l, w-len(r)-1, r) +} + +func contains(ar []string, needle string) bool { + for _, v := range ar { + if needle == v { + return true + } + } + return false +} diff --git a/progress/writer_test.go b/progress/writer_test.go new file mode 100644 index 000000000..bb7d31b9f --- /dev/null +++ b/progress/writer_test.go @@ -0,0 +1,37 @@ +package progress + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLineText(t *testing.T) { + now := time.Now() + ev := Event{ + ID: "id", + Text: "Text", + Status: Working, + StatusText: "Status", + endTime: now, + startTime: now, + spinner: &spinner{ + chars: []string{"."}, + }, + } + + lineWidth := len(fmt.Sprintf("%s %s", ev.ID, ev.Text)) + + out := lineText(ev, 50, lineWidth) + assert.Equal(t, "\x1b[37m . id Text Status 0.0s\n\x1b[0m", out) + + ev.Status = Done + out = lineText(ev, 50, lineWidth) + assert.Equal(t, "\x1b[34m . id Text Status 0.0s\n\x1b[0m", out) + + ev.Status = Error + out = lineText(ev, 50, lineWidth) + assert.Equal(t, "\x1b[31m . id Text Status 0.0s\n\x1b[0m", out) +} From fe472377671106d712b7d808c509e66c69db4696 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Wed, 17 Jun 2020 17:24:50 +0200 Subject: [PATCH 2/4] Add functions to add the writer in the context --- progress/writer.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/progress/writer.go b/progress/writer.go index 9803f3822..9f386d195 100644 --- a/progress/writer.go +++ b/progress/writer.go @@ -59,6 +59,19 @@ type writer struct { mtx *sync.RWMutex } +type writerKey struct{} + +// WithContextWriter adds the writer to the context +func WithContextWriter(ctx context.Context, writer Writer) context.Context { + return context.WithValue(ctx, writerKey{}, writer) +} + +// ContextWriter returns the writer from the context +func ContextWriter(ctx context.Context) Writer { + s, _ := ctx.Value(writerKey{}).(Writer) + return s +} + // NewWriter returns a new multi-progress writer func NewWriter(out io.Writer) Writer { return &writer{ From b55267739ac597d638f9001e10d2ced25ca14c6e Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Wed, 17 Jun 2020 22:19:08 +0200 Subject: [PATCH 3/4] Azure backend reports progress --- azure/aci.go | 29 ++++ cli/cmd/compose/up.go | 22 ++- cli/cmd/run/run.go | 22 ++- go.mod | 3 +- go.sum | 4 + progress/plain.go | 29 ++++ progress/tty.go | 169 +++++++++++++++++++++ progress/{writer_test.go => tty_test.go} | 0 progress/writer.go | 185 +++-------------------- 9 files changed, 295 insertions(+), 168 deletions(-) create mode 100644 progress/plain.go create mode 100644 progress/tty.go rename progress/{writer_test.go => tty_test.go} (100%) diff --git a/azure/aci.go b/azure/aci.go index bf7a51091..9d566e2d5 100644 --- a/azure/aci.go +++ b/azure/aci.go @@ -19,6 +19,7 @@ import ( "github.com/docker/api/azure/login" "github.com/docker/api/context/store" + "github.com/docker/api/progress" ) const aciDockerUserAgent = "docker-cli" @@ -47,10 +48,17 @@ func createACIContainers(ctx context.Context, aciContext store.AciContext, group } func createOrUpdateACIContainers(ctx context.Context, aciContext store.AciContext, groupDefinition containerinstance.ContainerGroup) error { + w := progress.ContextWriter(ctx) containerGroupsClient, err := getContainerGroupsClient(aciContext.SubscriptionID) if err != nil { return errors.Wrapf(err, "cannot get container group client") } + w.Event(progress.Event{ + ID: *groupDefinition.Name, + Status: progress.Working, + StatusText: "Waiting", + }) + future, err := containerGroupsClient.CreateOrUpdate( ctx, aciContext.ResourceGroup, @@ -61,14 +69,35 @@ func createOrUpdateACIContainers(ctx context.Context, aciContext store.AciContex return err } + w.Event(progress.Event{ + ID: *groupDefinition.Name, + Status: progress.Done, + StatusText: "Created", + }) + for _, c := range *groupDefinition.Containers { + w.Event(progress.Event{ + ID: *c.Name, + Status: progress.Working, + StatusText: "Waiting", + }) + } + err = future.WaitForCompletionRef(ctx, containerGroupsClient.Client) if err != nil { return err } + containerGroup, err := future.Result(containerGroupsClient) if err != nil { return err } + for _, c := range *groupDefinition.Containers { + w.Event(progress.Event{ + ID: *c.Name, + Status: progress.Done, + StatusText: "Done", + }) + } if len(*containerGroup.Containers) > 1 { var commands []string diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index 88f45fd6a..5f58da97f 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -30,11 +30,14 @@ package compose import ( "context" "errors" + "os" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" "github.com/docker/api/client" "github.com/docker/api/compose" + "github.com/docker/api/progress" ) func upCommand() *cobra.Command { @@ -64,5 +67,22 @@ func runUp(ctx context.Context, opts compose.ProjectOptions) error { return errors.New("compose not implemented in current context") } - return composeService.Up(ctx, opts) + eg, _ := errgroup.WithContext(ctx) + w, err := progress.NewWriter(os.Stderr) + if err != nil { + return err + } + eg.Go(func() error { + return w.Start(context.Background()) + }) + + ctx = progress.WithContextWriter(ctx, w) + + eg.Go(func() error { + defer w.Stop() + err := composeService.Up(ctx, opts) + return err + }) + + return eg.Wait() } diff --git a/cli/cmd/run/run.go b/cli/cmd/run/run.go index 6c5e864e8..49032c04a 100644 --- a/cli/cmd/run/run.go +++ b/cli/cmd/run/run.go @@ -30,11 +30,14 @@ package run import ( "context" "fmt" + "os" "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" "github.com/docker/api/cli/options/run" "github.com/docker/api/client" + "github.com/docker/api/progress" ) // Command runs a container @@ -68,10 +71,23 @@ func runRun(ctx context.Context, image string, opts run.Opts) error { return err } - if err = c.ContainerService().Run(ctx, containerConfig); err != nil { + eg, _ := errgroup.WithContext(ctx) + w, err := progress.NewWriter(os.Stderr) + if err != nil { return err } - fmt.Println(opts.Name) + eg.Go(func() error { + return w.Start(context.Background()) + }) - return nil + ctx = progress.WithContextWriter(ctx, w) + + eg.Go(func() error { + defer w.Stop() + return c.ContainerService().Run(ctx, containerConfig) + }) + + err = eg.Wait() + fmt.Println(opts.Name) + return err } diff --git a/go.mod b/go.mod index 6d46b57f9..e1b3ebfa8 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/AlecAivazis/survey/v2 v2.0.7 github.com/Azure/azure-sdk-for-go v43.2.0+incompatible github.com/Azure/azure-storage-file-go v0.7.0 - github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect github.com/Azure/go-autorest/autorest v0.10.2 github.com/Azure/go-autorest/autorest/adal v0.8.3 github.com/Azure/go-autorest/autorest/azure/cli v0.3.1 @@ -32,6 +31,7 @@ require ( github.com/google/uuid v1.1.1 github.com/gorilla/mux v1.7.4 // indirect github.com/hashicorp/go-multierror v1.1.0 + github.com/moby/term v0.0.0-20200611042045-63b9a826fb74 github.com/morikuni/aec v1.0.0 github.com/onsi/gomega v1.10.1 github.com/opencontainers/go-digest v1.0.0 @@ -46,6 +46,7 @@ require ( github.com/tj/survey v2.0.6+incompatible golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be + golang.org/x/sync v0.0.0-20190423024810-112230192c58 golang.org/x/sys v0.0.0-20200519105757-fe76b779f299 // indirect google.golang.org/grpc v1.29.1 google.golang.org/protobuf v1.24.0 diff --git a/go.sum b/go.sum index a2893b679..f796aed0a 100644 --- a/go.sum +++ b/go.sum @@ -78,6 +78,8 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -202,6 +204,8 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4= github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/term v0.0.0-20200611042045-63b9a826fb74 h1:kvRIeqJNICemq2UFLx8q/Pj+1IRNZS0XPTaMFkuNsvg= +github.com/moby/term v0.0.0-20200611042045-63b9a826fb74/go.mod h1:pJ0Ot5YGdTcMdxnPMyGCfAr6fKXe0g9cDlz16MuFEBE= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= diff --git a/progress/plain.go b/progress/plain.go new file mode 100644 index 000000000..8e476807d --- /dev/null +++ b/progress/plain.go @@ -0,0 +1,29 @@ +package progress + +import ( + "context" + "fmt" + "io" +) + +type plainWriter struct { + out io.Writer + done chan bool +} + +func (p *plainWriter) Start(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-p.done: + return nil + } +} + +func (p *plainWriter) Event(e Event) { + fmt.Println(e.ID, e.Text, e.StatusText) +} + +func (p *plainWriter) Stop() { + p.done <- true +} diff --git a/progress/tty.go b/progress/tty.go new file mode 100644 index 000000000..591c2a11d --- /dev/null +++ b/progress/tty.go @@ -0,0 +1,169 @@ +package progress + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/buger/goterm" + "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.RWMutex +} + +func (w *ttyWriter) Start(ctx context.Context) error { + ticker := time.NewTicker(100 * time.Millisecond) + + for { + select { + case <-ctx.Done(): + w.print() + return ctx.Err() + case <-w.done: + w.print() + return nil + case <-ticker.C: + w.print() + } + } +} + +func (w *ttyWriter) Stop() { + w.done <- true +} + +func (w *ttyWriter) Event(e Event) { + w.mtx.Lock() + defer w.mtx.Unlock() + if !contains(w.eventIDs, e.ID) { + w.eventIDs = append(w.eventIDs, e.ID) + } + if _, ok := w.events[e.ID]; ok { + event := w.events[e.ID] + if event.Status != Done && e.Status == Done { + event.stop() + } + event.Status = e.Status + event.Text = e.Text + event.StatusText = e.StatusText + w.events[e.ID] = event + } else { + e.startTime = time.Now() + e.spinner = newSpinner() + w.events[e.ID] = e + } +} + +func (w *ttyWriter) print() { + w.mtx.Lock() + defer w.mtx.Unlock() + if len(w.eventIDs) == 0 { + return + } + terminalWidth := goterm.Width() + b := aec.EmptyBuilder + for i := 0; i <= w.numLines; i++ { + b = b.Up(1) + } + if !w.repeated { + b = b.Down(1) + } + w.repeated = true + fmt.Fprint(w.out, b.Column(0).ANSI) + + // Hide the cursor while we are printing + fmt.Fprint(w.out, aec.Hide) + defer fmt.Fprint(w.out, aec.Show) + + firstLine := fmt.Sprintf("[+] Running %d/%d", numDone(w.events), w.numLines) + if w.numLines != 0 && numDone(w.events) == w.numLines { + firstLine = aec.Apply(firstLine, aec.BlueF) + } + fmt.Fprintln(w.out, firstLine) + + var statusPadding int + for _, v := range w.eventIDs { + l := len(fmt.Sprintf("%s %s", w.events[v].ID, w.events[v].Text)) + if statusPadding < l { + statusPadding = l + } + } + + numLines := 0 + for _, v := range w.eventIDs { + line := lineText(w.events[v], terminalWidth, statusPadding) + // nolint: errcheck + fmt.Fprint(w.out, line) + numLines++ + } + + w.numLines = numLines +} + +func lineText(event Event, terminalWidth, statusPadding int) string { + endTime := time.Now() + if event.Status != Working { + endTime = event.endTime + } + + elapsed := endTime.Sub(event.startTime).Seconds() + + textLen := len(fmt.Sprintf("%s %s", event.ID, event.Text)) + padding := statusPadding - textLen + if padding < 0 { + padding = 0 + } + text := fmt.Sprintf(" %s %s %s%s %s", + event.spinner.String(), + event.ID, + event.Text, + strings.Repeat(" ", padding), + event.StatusText, + ) + timer := fmt.Sprintf("%.1fs\n", elapsed) + o := align(text, timer, terminalWidth) + + color := aec.WhiteF + if event.Status == Done { + color = aec.BlueF + } + if event.Status == Error { + color = aec.RedF + } + + return aec.Apply(o, color) +} + +func numDone(events map[string]Event) int { + i := 0 + for _, e := range events { + if e.Status == Done { + i++ + } + } + return i +} + +func align(l, r string, w int) string { + return fmt.Sprintf("%-[2]*[1]s %[3]s", l, w-len(r)-1, r) +} + +func contains(ar []string, needle string) bool { + for _, v := range ar { + if needle == v { + return true + } + } + return false +} diff --git a/progress/writer_test.go b/progress/tty_test.go similarity index 100% rename from progress/writer_test.go rename to progress/tty_test.go diff --git a/progress/writer.go b/progress/writer.go index 9f386d195..5d0fec78b 100644 --- a/progress/writer.go +++ b/progress/writer.go @@ -2,14 +2,11 @@ package progress import ( "context" - "fmt" - "io" - "strings" "sync" "time" - "github.com/buger/goterm" - "github.com/morikuni/aec" + "github.com/containerd/console" + "github.com/moby/term" ) // EventStatus indicates the status of an action @@ -49,16 +46,6 @@ type Writer interface { Event(Event) } -type writer struct { - out io.Writer - events map[string]Event - eventIDs []string - repeated bool - numLines int - done chan bool - mtx *sync.RWMutex -} - type writerKey struct{} // WithContextWriter adds the writer to the context @@ -73,155 +60,27 @@ func ContextWriter(ctx context.Context) Writer { } // NewWriter returns a new multi-progress writer -func NewWriter(out io.Writer) Writer { - return &writer{ - out: out, - eventIDs: []string{}, - events: map[string]Event{}, - repeated: false, - done: make(chan bool), - mtx: &sync.RWMutex{}, - } -} +func NewWriter(out console.File) (Writer, error) { + _, isTerminal := term.GetFdInfo(out) -func (w *writer) Start(ctx context.Context) error { - ticker := time.NewTicker(100 * time.Millisecond) - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case <-w.done: - w.print() - return nil - case <-ticker.C: - w.print() + if isTerminal { + con, err := console.ConsoleFromFile(out) + if err != nil { + return nil, err } + + return &ttyWriter{ + out: con, + eventIDs: []string{}, + events: map[string]Event{}, + repeated: false, + done: make(chan bool), + mtx: &sync.RWMutex{}, + }, nil } -} - -func (w *writer) Stop() { - w.done <- true -} - -func (w *writer) Event(e Event) { - w.mtx.Lock() - defer w.mtx.Unlock() - if !contains(w.eventIDs, e.ID) { - w.eventIDs = append(w.eventIDs, e.ID) - } - if _, ok := w.events[e.ID]; ok { - event := w.events[e.ID] - if event.Status != Done && e.Status == Done { - event.stop() - } - event.Status = e.Status - event.Text = e.Text - event.StatusText = e.StatusText - w.events[e.ID] = event - } else { - e.startTime = time.Now() - e.spinner = newSpinner() - w.events[e.ID] = e - } -} - -func (w *writer) print() { - w.mtx.Lock() - defer w.mtx.Unlock() - terminalWidth := goterm.Width() - b := aec.EmptyBuilder - for i := 0; i <= w.numLines; i++ { - b = b.Up(1) - } - if !w.repeated { - b = b.Down(1) - } - w.repeated = true - fmt.Fprint(w.out, b.Column(0).ANSI) - - // Hide the cursor while we are printing - fmt.Fprint(w.out, aec.Hide) - defer fmt.Fprint(w.out, aec.Show) - - firstLine := fmt.Sprintf("[+] Running %d/%d", numDone(w.events), w.numLines) - if w.numLines != 0 && numDone(w.events) == w.numLines { - firstLine = aec.Apply(firstLine, aec.BlueF) - } - fmt.Fprintln(w.out, firstLine) - - var statusPadding int - for _, v := range w.eventIDs { - l := len(fmt.Sprintf("%s %s", w.events[v].ID, w.events[v].Text)) - if statusPadding < l { - statusPadding = l - } - } - - numLines := 0 - for _, v := range w.eventIDs { - line := lineText(w.events[v], terminalWidth, statusPadding) - // nolint: errcheck - fmt.Fprint(w.out, line) - numLines++ - } - - w.numLines = numLines -} - -func lineText(event Event, terminalWidth, statusPadding int) string { - endTime := time.Now() - if event.Status != Working { - endTime = event.endTime - } - - elapsed := endTime.Sub(event.startTime).Seconds() - - textLen := len(fmt.Sprintf("%s %s", event.ID, event.Text)) - padding := statusPadding - textLen - if padding < 0 { - padding = 0 - } - text := fmt.Sprintf(" %s %s %s%s %s", - event.spinner.String(), - event.ID, - event.Text, - strings.Repeat(" ", padding), - event.StatusText, - ) - timer := fmt.Sprintf("%.1fs\n", elapsed) - o := align(text, timer, terminalWidth) - - color := aec.WhiteF - if event.Status == Done { - color = aec.BlueF - } - if event.Status == Error { - color = aec.RedF - } - - return aec.Apply(o, color) -} - -func numDone(events map[string]Event) int { - i := 0 - for _, e := range events { - if e.Status == Done { - i++ - } - } - return i -} - -func align(l, r string, w int) string { - return fmt.Sprintf("%-[2]*[1]s %[3]s", l, w-len(r)-1, r) -} - -func contains(ar []string, needle string) bool { - for _, v := range ar { - if needle == v { - return true - } - } - return false + + return &plainWriter{ + out: out, + done: make(chan bool), + }, nil } From a1bb04ebe186c0b99570b9edc4dcd5de9bdc82e5 Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Thu, 18 Jun 2020 12:10:36 +0200 Subject: [PATCH 4/4] Refactor calling the progress writer to make it easier --- cli/cmd/compose/up.go | 21 ++------------------- cli/cmd/run/run.go | 22 ++++------------------ progress/writer.go | 26 ++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 37 deletions(-) diff --git a/cli/cmd/compose/up.go b/cli/cmd/compose/up.go index 5f58da97f..18d8af0e7 100644 --- a/cli/cmd/compose/up.go +++ b/cli/cmd/compose/up.go @@ -30,10 +30,8 @@ package compose import ( "context" "errors" - "os" "github.com/spf13/cobra" - "golang.org/x/sync/errgroup" "github.com/docker/api/client" "github.com/docker/api/compose" @@ -67,22 +65,7 @@ func runUp(ctx context.Context, opts compose.ProjectOptions) error { return errors.New("compose not implemented in current context") } - eg, _ := errgroup.WithContext(ctx) - w, err := progress.NewWriter(os.Stderr) - if err != nil { - return err - } - eg.Go(func() error { - return w.Start(context.Background()) + return progress.Run(ctx, func(ctx context.Context) error { + return composeService.Up(ctx, opts) }) - - ctx = progress.WithContextWriter(ctx, w) - - eg.Go(func() error { - defer w.Stop() - err := composeService.Up(ctx, opts) - return err - }) - - return eg.Wait() } diff --git a/cli/cmd/run/run.go b/cli/cmd/run/run.go index 49032c04a..cad343d08 100644 --- a/cli/cmd/run/run.go +++ b/cli/cmd/run/run.go @@ -30,10 +30,8 @@ package run import ( "context" "fmt" - "os" "github.com/spf13/cobra" - "golang.org/x/sync/errgroup" "github.com/docker/api/cli/options/run" "github.com/docker/api/client" @@ -71,23 +69,11 @@ func runRun(ctx context.Context, image string, opts run.Opts) error { return err } - eg, _ := errgroup.WithContext(ctx) - w, err := progress.NewWriter(os.Stderr) - if err != nil { - return err - } - eg.Go(func() error { - return w.Start(context.Background()) - }) - - ctx = progress.WithContextWriter(ctx, w) - - eg.Go(func() error { - defer w.Stop() + err = progress.Run(ctx, func(ctx context.Context) error { return c.ContainerService().Run(ctx, containerConfig) }) - - err = eg.Wait() - fmt.Println(opts.Name) + if err == nil { + fmt.Println(opts.Name) + } return err } diff --git a/progress/writer.go b/progress/writer.go index 5d0fec78b..a5b8e1cdf 100644 --- a/progress/writer.go +++ b/progress/writer.go @@ -2,11 +2,13 @@ package progress import ( "context" + "os" "sync" "time" "github.com/containerd/console" "github.com/moby/term" + "golang.org/x/sync/errgroup" ) // EventStatus indicates the status of an action @@ -59,6 +61,30 @@ func ContextWriter(ctx context.Context) Writer { return s } +type progressFunc func(context.Context) error + +// Run will run a writer and the progress function +// in parallel +func Run(ctx context.Context, pf progressFunc) error { + eg, _ := errgroup.WithContext(ctx) + w, err := NewWriter(os.Stderr) + if err != nil { + return err + } + eg.Go(func() error { + return w.Start(context.Background()) + }) + + ctx = WithContextWriter(ctx, w) + + eg.Go(func() error { + defer w.Stop() + return pf(ctx) + }) + + return eg.Wait() +} + // NewWriter returns a new multi-progress writer func NewWriter(out console.File) (Writer, error) { _, isTerminal := term.GetFdInfo(out)