mirror of
https://github.com/anchore/syft.git
synced 2026-11-01 15:06:36 +01:00
add and use event bus for UI progress
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"github.com/wagoodman/go-partybus"
|
||||
)
|
||||
|
||||
type UI func(<-chan error, *partybus.Subscription) int
|
||||
Reference in New Issue
Block a user