add and use event bus for UI progress

This commit is contained in:
Alex Goodman
2020-06-25 10:39:11 -04:00
parent 2e9e969c5f
commit 926b5f2a50
24 changed files with 725 additions and 96 deletions
+19
View File
@@ -0,0 +1,19 @@
package bus
import "github.com/wagoodman/go-partybus"
var publisher partybus.Publisher
var active bool
func SetPublisher(p partybus.Publisher) {
publisher = p
if p != nil {
active = true
}
}
func Publish(event partybus.Event) {
if active {
publisher.Publish(event)
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ func (l *ZapLogger) getConsoleEncoder(config LogConfig) zapcore.Encoder {
encoderConfig := zap.NewProductionEncoderConfig()
if config.Structured {
encoderConfig.EncodeName = zapcore.FullNameEncoder
encoderConfig.EncodeCaller = zapcore.FullCallerEncoder
encoderConfig.EncodeCaller = zapcore.ShortCallerEncoder
return zapcore.NewJSONEncoder(encoderConfig)
}
encoderConfig.EncodeTime = nil
+22
View File
@@ -0,0 +1,22 @@
package common
import (
"fmt"
"os"
imgbomEventParsers "github.com/anchore/imgbom/imgbom/event/parsers"
"github.com/wagoodman/go-partybus"
)
func CatalogerFinishedHandler(event partybus.Event) error {
// show the report to stdout
pres, err := imgbomEventParsers.ParseCatalogerFinished(event)
if err != nil {
return fmt.Errorf("bad CatalogerFinished event: %w", err)
}
if err := pres.Present(os.Stdout); err != nil {
return fmt.Errorf("unable to show package catalog report: %w", err)
}
return nil
}
+102
View File
@@ -0,0 +1,102 @@
package etui
import (
"context"
"fmt"
"os"
"sync"
imgbomEvent "github.com/anchore/imgbom/imgbom/event"
"github.com/anchore/imgbom/internal/log"
"github.com/anchore/imgbom/internal/ui/common"
stereoscopeEvent "github.com/anchore/stereoscope/pkg/event"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/jotframe/pkg/frame"
)
// TODO: specify per-platform implementations with build tags
func setupScreen(output *os.File) *frame.Frame {
config := frame.Config{
PositionPolicy: frame.PolicyFloatForward,
// only report output to stderr, reserve report output for stdout
Output: output,
}
fr, err := frame.New(config)
if err != nil {
log.Errorf("failed to create screen object: %+v", err)
return nil
}
return fr
}
func OutputToEphemeralTUI(workerErrs <-chan error, subscription *partybus.Subscription) int {
output := os.Stderr
// hide cursor
_, _ = fmt.Fprint(output, "\x1b[?25l")
// show cursor
defer fmt.Fprint(output, "\x1b[?25h")
fr := setupScreen(output)
if fr == nil {
return 1
}
var err error
var wg = &sync.WaitGroup{}
events := subscription.Events()
ctx := context.Background()
eventLoop:
for {
select {
case e, ok := <-events:
if !ok {
// is this unexpected? if so should we indicate this?
break eventLoop
}
switch e.Type {
case stereoscopeEvent.ReadImage:
err = imageReadHandler(ctx, fr, e, wg)
if err != nil {
log.Errorf("unable to show read image event: %+v", err)
}
case stereoscopeEvent.FetchImage:
err = imageFetchHandler(ctx, fr, e, wg)
if err != nil {
log.Errorf("unable to show fetch image event: %+v", err)
}
case imgbomEvent.CatalogerStarted:
err = catalogerStartedHandler(ctx, fr, e, wg)
if err != nil {
log.Errorf("unable to show catalog image start event: %+v", err)
}
case imgbomEvent.CatalogerFinished:
// we may have other background processes still displaying progress, wait for them to
// finish before discontinuing dynamic content and showing the final report
wg.Wait()
frame.Close()
fmt.Println()
err := common.CatalogerFinishedHandler(e)
if err != nil {
log.Errorf("unable to show catalog image finished event: %+v", err)
}
// this is the last expected event
break eventLoop
}
case <-ctx.Done():
if ctx.Err() != nil {
log.Errorf("cancelled (%+v)", err)
}
break eventLoop
}
}
return 0
}
+145
View File
@@ -0,0 +1,145 @@
package etui
import (
"context"
"fmt"
"io"
"sync"
"time"
imgbomEventParsers "github.com/anchore/imgbom/imgbom/event/parsers"
stereoEventParsers "github.com/anchore/stereoscope/pkg/event/parsers"
"github.com/gookit/color"
"github.com/wagoodman/go-partybus"
"github.com/wagoodman/go-progress"
"github.com/wagoodman/go-progress/format"
"github.com/wagoodman/jotframe/pkg/frame"
)
const maxBarWidth = 50
const statusSet = SpinnerDotSet // SpinnerCircleOutlineSet
const completedStatus = "✔" //"●"
const tileFormat = color.Bold
const statusTitleTemplate = " %s %-28s "
var auxInfoFormat = color.HEX("#777777")
func startProcess() (format.Simple, *Spinner) {
width, _ := frame.GetTerminalSize()
barWidth := int(0.25 * float64(width))
if barWidth > maxBarWidth {
barWidth = maxBarWidth
}
formatter := format.NewSimpleWithTheme(barWidth, format.HeavyNoBarTheme, format.ColorCompleted, format.ColorTodo)
spinner := NewSpinner(statusSet)
return formatter, &spinner
}
func imageFetchHandler(ctx context.Context, fr *frame.Frame, event partybus.Event, wg *sync.WaitGroup) error {
_, prog, err := stereoEventParsers.ParseFetchImage(event)
if err != nil {
return fmt.Errorf("bad FetchImage event: %w", err)
}
line, err := fr.Append()
if err != nil {
return err
}
wg.Add(1)
go func() {
defer wg.Done()
formatter, spinner := startProcess()
stream := progress.Stream(ctx, prog, 150*time.Millisecond)
title := tileFormat.Sprint("Fetching image...")
for p := range stream {
progStr, err := formatter.Format(p)
spin := color.Magenta.Sprint(spinner.Next())
if err != nil {
_, _ = io.WriteString(line, fmt.Sprintf("Error: %+v", err))
} else {
auxInfo := auxInfoFormat.Sprintf("[%s]", prog.Stage())
_, _ = io.WriteString(line, fmt.Sprintf(statusTitleTemplate+"%s %s", spin, title, progStr, auxInfo))
}
}
spin := color.Green.Sprint(completedStatus)
title = tileFormat.Sprint("Fetched image")
_, _ = io.WriteString(line, fmt.Sprintf(statusTitleTemplate, spin, title))
}()
return err
}
func imageReadHandler(ctx context.Context, fr *frame.Frame, event partybus.Event, wg *sync.WaitGroup) error {
_, prog, err := stereoEventParsers.ParseReadImage(event)
if err != nil {
return fmt.Errorf("bad ReadImage event: %w", err)
}
line, err := fr.Append()
if err != nil {
return err
}
wg.Add(1)
go func() {
defer wg.Done()
formatter, spinner := startProcess()
stream := progress.Stream(ctx, prog, 150*time.Millisecond)
title := tileFormat.Sprint("Reading image...")
for p := range stream {
progStr, err := formatter.Format(p)
spin := color.Magenta.Sprint(spinner.Next())
if err != nil {
_, _ = io.WriteString(line, fmt.Sprintf("Error: %+v", err))
} else {
_, _ = io.WriteString(line, fmt.Sprintf(statusTitleTemplate+"%s", spin, title, progStr))
}
}
spin := color.Green.Sprint(completedStatus)
title = tileFormat.Sprint("Read image")
_, _ = io.WriteString(line, fmt.Sprintf(statusTitleTemplate, spin, title))
}()
return nil
}
func catalogerStartedHandler(ctx context.Context, fr *frame.Frame, event partybus.Event, wg *sync.WaitGroup) error {
monitor, err := imgbomEventParsers.ParseCatalogerStarted(event)
if err != nil {
return fmt.Errorf("bad CatalogerStarted event: %w", err)
}
line, err := fr.Append()
if err != nil {
return err
}
wg.Add(1)
go func() {
defer wg.Done()
_, spinner := startProcess()
stream := progress.StreamMonitors(ctx, []progress.Monitorable{monitor.FilesProcessed, monitor.PackagesDiscovered}, 50*time.Millisecond)
title := tileFormat.Sprint("Cataloging image...")
for p := range stream {
spin := color.Magenta.Sprint(spinner.Next())
auxInfo := auxInfoFormat.Sprintf("[packages %d]", p[1])
_, _ = io.WriteString(line, fmt.Sprintf(statusTitleTemplate+"%s", spin, title, auxInfo))
}
spin := color.Green.Sprint(completedStatus)
title = tileFormat.Sprint("Cataloged image")
auxInfo := auxInfoFormat.Sprintf("[%d packages]", monitor.PackagesDiscovered.Current())
_, _ = io.WriteString(line, fmt.Sprintf(statusTitleTemplate+"%s", spin, title, auxInfo))
}()
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package etui
import (
"strings"
"sync"
)
// TODO: move me to a common module (used in multiple repos)
const (
SpinnerCircleOutlineSet = "◜◠◯◎◉●◉◎◯◡◞"
SpinnerCircleSet = "◌◯◎◉●◉◎◯"
SpinnerDotSet = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
SpinnerHorizontalBarSet = "▉▊▋▌▍▎▏▎▍▌▋▊▉"
SpinnerVerticalBarSet = "▁▃▄▅▆▇█▇▆▅▄▃▁"
SpinnerDoubleBarSet = "▁▂▃▄▅▆▇█▉▊▋▌▍▎▏▏▎▍▌▋▊▉█▇▆▅▄▃▂▁"
SpinnerArrowSet = "←↖↑↗→↘↓↙"
)
var SpinnerCircleDotSet = []string{
"⠈⠁",
"⠈⠑",
"⠈⠱",
"⠈⡱",
"⢀⡱",
"⢄⡱",
"⢄⡱",
"⢆⡱",
"⢎⡱",
"⢎⡰",
"⢎⡠",
"⢎⡀",
"⢎⠁",
"⠎⠁",
"⠊⠁",
}
type Spinner struct {
index int
charset []string
lock sync.Mutex
}
func NewSpinner(charset string) Spinner {
return Spinner{
charset: strings.Split(charset, ""),
}
}
func NewSpinnerFromSlice(charset []string) Spinner {
return Spinner{
charset: charset,
}
}
func (s *Spinner) Current() string {
s.lock.Lock()
defer s.lock.Unlock()
return s.charset[s.index]
}
func (s *Spinner) Next() string {
s.lock.Lock()
defer s.lock.Unlock()
c := s.charset[s.index]
s.index++
if s.index >= len(s.charset) {
s.index = 0
}
return c
}
+42
View File
@@ -0,0 +1,42 @@
package ui
import (
imgbomEvent "github.com/anchore/imgbom/imgbom/event"
"github.com/anchore/imgbom/internal/log"
"github.com/anchore/imgbom/internal/ui/common"
"github.com/wagoodman/go-partybus"
)
func LoggerUI(workerErrs <-chan error, subscription *partybus.Subscription) int {
var returnCode int
events := subscription.Events()
eventLoop:
for {
select {
case err := <-workerErrs:
if err != nil {
log.Errorf(err.Error())
returnCode = 1
}
case e, ok := <-events:
if !ok {
// event bus closed...
break eventLoop
}
// ignore all events except for the final event
if e.Type == imgbomEvent.CatalogerFinished {
err := common.CatalogerFinishedHandler(e)
if err != nil {
log.Errorf("unable to show catalog image finished event: %+v", err)
}
// this is the last expected event
break eventLoop
}
}
}
return returnCode
}
+28
View File
@@ -0,0 +1,28 @@
package ui
import (
"os"
"runtime"
"github.com/anchore/imgbom/internal/ui/etui"
"golang.org/x/crypto/ssh/terminal"
)
// TODO: build tags to exclude options from windows
func Select(verbose, quiet bool) UI {
var ui UI
isStdoutATty := terminal.IsTerminal(int(os.Stdout.Fd()))
isStderrATty := terminal.IsTerminal(int(os.Stderr.Fd()))
notATerminal := !isStderrATty && !isStdoutATty
switch {
case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty:
ui = LoggerUI
default:
ui = etui.OutputToEphemeralTUI
}
return ui
}
+7
View File
@@ -0,0 +1,7 @@
package ui
import (
"github.com/wagoodman/go-partybus"
)
type UI func(<-chan error, *partybus.Subscription) int