浏览代码

Merge pull request #233 from docker/feat-progress

Feat progress
Guillaume Tardif 5 年之前
父节点
当前提交
26273b3aa3
共有 10 个文件被更改,包括 434 次插入10 次删除
  1. 29 0
      azure/aci.go
  2. 4 1
      cli/cmd/compose/up.go
  3. 7 5
      cli/cmd/run/run.go
  4. 3 2
      go.mod
  5. 5 2
      go.sum
  6. 29 0
      progress/plain.go
  7. 39 0
      progress/spinner.go
  8. 169 0
      progress/tty.go
  9. 37 0
      progress/tty_test.go
  10. 112 0
      progress/writer.go

+ 29 - 0
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

+ 4 - 1
cli/cmd/compose/up.go

@@ -35,6 +35,7 @@ import (
 
 	"github.com/docker/api/client"
 	"github.com/docker/api/compose"
+	"github.com/docker/api/progress"
 )
 
 func upCommand() *cobra.Command {
@@ -64,5 +65,7 @@ func runUp(ctx context.Context, opts compose.ProjectOptions) error {
 		return errors.New("compose not implemented in current context")
 	}
 
-	return composeService.Up(ctx, opts)
+	return progress.Run(ctx, func(ctx context.Context) error {
+		return composeService.Up(ctx, opts)
+	})
 }

+ 7 - 5
cli/cmd/run/run.go

@@ -35,6 +35,7 @@ import (
 
 	"github.com/docker/api/cli/options/run"
 	"github.com/docker/api/client"
+	"github.com/docker/api/progress"
 )
 
 // Command runs a container
@@ -68,10 +69,11 @@ func runRun(ctx context.Context, image string, opts run.Opts) error {
 		return err
 	}
 
-	if err = c.ContainerService().Run(ctx, containerConfig); err != nil {
-		return err
+	err = progress.Run(ctx, func(ctx context.Context) error {
+		return c.ContainerService().Run(ctx, containerConfig)
+	})
+	if err == nil {
+		fmt.Println(opts.Name)
 	}
-	fmt.Println(opts.Name)
-
-	return nil
+	return err
 }

+ 3 - 2
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,7 +31,8 @@ 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/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
 	github.com/opencontainers/image-spec v1.0.1 // indirect
@@ -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

+ 5 - 2
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=
@@ -80,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=
@@ -204,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=
@@ -330,6 +332,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=

+ 29 - 0
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
+}

+ 39 - 0
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
+}

+ 169 - 0
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
+}

+ 37 - 0
progress/tty_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)
+}

+ 112 - 0
progress/writer.go

@@ -0,0 +1,112 @@
+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
+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 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
+}
+
+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)
+
+	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
+	}
+
+	return &plainWriter{
+		out:  out,
+		done: make(chan bool),
+	}, nil
+}