Implement a progress writer

This commit is contained in:
Djordje Lukic 2020-06-16 09:42:07 +02:00
parent 6dfd22cb1f
commit d0e48a25aa
5 changed files with 292 additions and 3 deletions

2
go.mod
View File

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

3
go.sum
View File

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

39
progress/spinner.go Normal file
View File

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

214
progress/writer.go Normal file
View File

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

37
progress/writer_test.go Normal file
View File

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