go.mondoo.com/cnquery@v0.0.0-20231005093811-59568235f6ea/cli/progress/multiprogress.go (about)

     1  // Copyright (c) Mondoo, Inc.
     2  // SPDX-License-Identifier: BUSL-1.1
     3  
     4  package progress
     5  
     6  import (
     7  	"fmt"
     8  	"math"
     9  	"os"
    10  	"strings"
    11  	"sync"
    12  
    13  	"github.com/charmbracelet/bubbles/progress"
    14  	tea "github.com/charmbracelet/bubbletea"
    15  	"github.com/muesli/reflow/ansi"
    16  	"go.mondoo.com/cnquery/cli/components"
    17  	"go.mondoo.com/cnquery/cli/theme"
    18  	"go.mondoo.com/cnquery/logger"
    19  )
    20  
    21  type ProgressOption = func(*modelMultiProgress)
    22  
    23  func WithScore() ProgressOption {
    24  	return func(p *modelMultiProgress) {
    25  		p.includeScore = true
    26  	}
    27  }
    28  
    29  type MultiProgress interface {
    30  	Open() error
    31  	OnProgress(index string, percent float64)
    32  	Score(index string, score string)
    33  	Errored(index string)
    34  	NotApplicable(index string)
    35  	Completed(index string)
    36  	Close()
    37  }
    38  
    39  type NoopMultiProgressBars struct{}
    40  
    41  func (n NoopMultiProgressBars) Open() error                { return nil }
    42  func (n NoopMultiProgressBars) OnProgress(string, float64) {}
    43  func (n NoopMultiProgressBars) Score(string, string)       {}
    44  func (n NoopMultiProgressBars) Errored(string)             {}
    45  func (n NoopMultiProgressBars) NotApplicable(string)       {}
    46  func (n NoopMultiProgressBars) Completed(string)           {}
    47  func (n NoopMultiProgressBars) Close()                     {}
    48  
    49  const (
    50  	padding                  = 0
    51  	defaultWidth             = 40
    52  	defaultProgressNumAssets = 1
    53  	overallProgressIndexName = "overall"
    54  )
    55  
    56  type MultiProgressAdapter struct {
    57  	Multi MultiProgress
    58  	Key   string
    59  }
    60  
    61  func (m *MultiProgressAdapter) Open() error { return m.Multi.Open() }
    62  func (m *MultiProgressAdapter) OnProgress(current int, total int) {
    63  	percent := 0.0
    64  	if total > 0 {
    65  		percent = float64(current) / float64(total)
    66  	}
    67  	m.Multi.OnProgress(m.Key, percent)
    68  }
    69  func (m *MultiProgressAdapter) Score(score string) { m.Multi.Score(m.Key, score) }
    70  func (m *MultiProgressAdapter) Errored()           { m.Multi.Errored(m.Key) }
    71  func (m *MultiProgressAdapter) NotApplicable()     { m.Multi.NotApplicable(m.Key) }
    72  func (m *MultiProgressAdapter) Completed()         { m.Multi.Completed(m.Key) }
    73  func (m *MultiProgressAdapter) Close()             { m.Multi.Close() }
    74  
    75  type MsgProgress struct {
    76  	Index   string
    77  	Percent float64
    78  }
    79  
    80  // For cnquery the progressbar is completed, when percent is 1.0
    81  // But for cnspec we also need the score, which is displayed after the progressbar
    82  // So we need a second message to indicate when the progressbar is completed
    83  type MsgCompleted struct {
    84  	Index string
    85  }
    86  
    87  type MsgErrored struct {
    88  	Index string
    89  }
    90  
    91  type MsgNotApplicable struct {
    92  	Index string
    93  }
    94  
    95  type MsgScore struct {
    96  	Index string
    97  	Score string
    98  }
    99  
   100  type ProgressState int
   101  
   102  const (
   103  	ProgressStateUnknownProgressState = iota
   104  	ProgressStateNotApplicable
   105  	ProgressStateCompleted
   106  	ProgressStateErrored
   107  )
   108  
   109  type modelProgress struct {
   110  	model         *progress.Model
   111  	percent       float64
   112  	Name          string
   113  	Score         string
   114  	ProgressState ProgressState
   115  }
   116  
   117  type modelMultiProgress struct {
   118  	Progress           map[string]*modelProgress
   119  	maxNameWidth       int
   120  	maxItemsToShow     int
   121  	orderedKeys        []string
   122  	lock               sync.Mutex
   123  	maxProgressBarWith int
   124  	includeScore       bool
   125  }
   126  
   127  type multiProgressBars struct {
   128  	program        *tea.Program
   129  	Progress       map[string]*modelProgress
   130  	maxNameWidth   int
   131  	maxItemsToShow int
   132  	orderedKeys    []string
   133  }
   134  
   135  func newProgressBar() progress.Model {
   136  	progressbar := progress.New(progress.WithScaledGradient("#5A56E0", "#EE6FF8"))
   137  	progressbar.Width = defaultWidth
   138  	progressbar.Full = '━'
   139  	progressbar.FullColor = "#7571F9"
   140  	progressbar.Empty = '─'
   141  	progressbar.EmptyColor = "#606060"
   142  	progressbar.ShowPercentage = true
   143  	progressbar.PercentFormat = " %3.0f%%"
   144  	return progressbar
   145  }
   146  
   147  // Creates a new progress bars for the given elements.
   148  // This is a wrapper around a tea.Programm.
   149  // The key of the map is used to identify the progress bar.
   150  // The value of the map is used as the name displayed for the progress bar.
   151  // orderedKeys is used to define the order of the progress bars.
   152  // includeScore indicates if the score should be displayed after the progress bar. This will only be used for spacing
   153  func NewMultiProgressBars(elements map[string]string, orderedKeys []string, opts ...ProgressOption) (*multiProgressBars, error) {
   154  	program, err := newMultiProgressProgram(elements, orderedKeys, opts...)
   155  	if err != nil {
   156  		return nil, err
   157  	}
   158  	return &multiProgressBars{program: program}, nil
   159  }
   160  
   161  // Start the progress bars
   162  // Form now on the progress bars can be updated
   163  func (m *multiProgressBars) Open() error {
   164  	(logger.LogOutputWriter.(*logger.BufferedWriter)).Pause()
   165  	defer (logger.LogOutputWriter.(*logger.BufferedWriter)).Resume()
   166  	if _, err := m.program.Run(); err != nil {
   167  		fmt.Println(err.Error())
   168  		panic(err)
   169  	}
   170  	return nil
   171  }
   172  
   173  // Set the current progress of a progress bar
   174  func (m *multiProgressBars) OnProgress(index string, percent float64) {
   175  	m.program.Send(MsgProgress{
   176  		Index:   index,
   177  		Percent: percent,
   178  	})
   179  }
   180  
   181  // Add a score to the progress bar
   182  // This should be called before Completed is called
   183  func (m *multiProgressBars) Score(index string, score string) {
   184  	m.program.Send(MsgScore{
   185  		Index: index,
   186  		Score: score,
   187  	})
   188  }
   189  
   190  // This is called when an error occurs during the progress
   191  func (m *multiProgressBars) Errored(index string) {
   192  	m.program.Send(MsgErrored{
   193  		Index: index,
   194  	})
   195  }
   196  
   197  // This is called when an error occurs during the progress
   198  func (m *multiProgressBars) NotApplicable(index string) {
   199  	m.program.Send(MsgNotApplicable{
   200  		Index: index,
   201  	})
   202  }
   203  
   204  // Set a single bar to completed
   205  // For cnquery this should be called after the progress is 100%
   206  // For cnspec this should be called after the score is set
   207  func (m *multiProgressBars) Completed(index string) {
   208  	m.program.Send(MsgCompleted{
   209  		Index: index,
   210  	})
   211  }
   212  
   213  // This ends the multiprogrssbar no matter the current progress
   214  func (m *multiProgressBars) Close() {
   215  	m.program.Quit()
   216  }
   217  
   218  // create the actual tea.Program
   219  func newMultiProgressProgram(elements map[string]string, orderedKeys []string, opts ...ProgressOption) (*tea.Program, error) {
   220  	if len(elements) != len(orderedKeys) {
   221  		return nil, fmt.Errorf("number of elements and orderedKeys must be equal")
   222  	}
   223  	m := newMultiProgress(elements, opts...)
   224  	m.maxItemsToShow = defaultProgressNumAssets
   225  	m.orderedKeys = orderedKeys
   226  	return tea.NewProgram(m), nil
   227  }
   228  
   229  func newMultiProgress(elements map[string]string, opts ...ProgressOption) *modelMultiProgress {
   230  	numBars := len(elements)
   231  	if numBars > 1 {
   232  		numBars++
   233  	}
   234  	multiprogress := make(map[string]*modelProgress, numBars)
   235  
   236  	m := &modelMultiProgress{
   237  		Progress:           multiprogress,
   238  		maxNameWidth:       0,
   239  		maxProgressBarWith: defaultWidth,
   240  	}
   241  	for _, opt := range opts {
   242  		opt(m)
   243  	}
   244  
   245  	if numBars > 1 {
   246  		// add overall with max possible length, so we do not have to move progress bars later on
   247  		overallName := fmt.Sprintf("%d/%d scanned %d/%d errored %d/%d n/a", numBars, numBars, numBars, numBars, numBars, numBars)
   248  		m.add(overallProgressIndexName, overallName, m.maxProgressBarWith)
   249  	}
   250  
   251  	w := m.calculateMaxProgressBarWidth()
   252  	if w > 10 {
   253  		m.maxProgressBarWith = w
   254  	}
   255  
   256  	for k, v := range elements {
   257  		m.add(k, v, m.maxProgressBarWith)
   258  	}
   259  
   260  	maxNameWidth := 0
   261  	for k := range m.Progress {
   262  		if len(m.Progress[k].Name) > maxNameWidth {
   263  			maxNameWidth = ansi.PrintableRuneWidth(m.Progress[k].Name)
   264  		}
   265  	}
   266  	m.maxNameWidth = maxNameWidth
   267  
   268  	return m
   269  }
   270  
   271  func (m *modelMultiProgress) Init() tea.Cmd {
   272  	return nil
   273  }
   274  
   275  func (m *modelMultiProgress) calculateMaxProgressBarWidth() int {
   276  	w := 0
   277  	terminalWidth, err := components.TerminalWidth(os.Stdout)
   278  	if err == nil {
   279  		w = terminalWidth - m.maxNameWidth - 8 // 5 for percentage + space
   280  		// space for " score: F"
   281  		if m.includeScore {
   282  			w -= 9
   283  		}
   284  	}
   285  	return w
   286  }
   287  
   288  func (m *modelMultiProgress) add(key string, name string, width int) {
   289  	progressbar := newProgressBar()
   290  	progressbar.Width = width
   291  	m.Progress[key] = &modelProgress{
   292  		model: &progressbar,
   293  		Name:  name,
   294  		Score: "",
   295  	}
   296  }
   297  
   298  func (m *modelMultiProgress) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   299  	switch msg := msg.(type) {
   300  	case tea.KeyMsg:
   301  		switch msg.String() {
   302  		case "q", "ctrl+c":
   303  			return m, tea.Quit
   304  		default:
   305  			return m, nil
   306  		}
   307  
   308  	case tea.WindowSizeMsg:
   309  		w := m.calculateMaxProgressBarWidth()
   310  		if w > 10 {
   311  			m.maxProgressBarWith = w
   312  		}
   313  		for k := range m.Progress {
   314  			m.Progress[k].model.Width = m.maxProgressBarWith
   315  		}
   316  		return m, nil
   317  
   318  	case MsgCompleted:
   319  		if _, ok := m.Progress[msg.Index]; !ok {
   320  			return m, nil
   321  		}
   322  		m.lock.Lock()
   323  		m.Progress[msg.Index].ProgressState = ProgressStateCompleted
   324  		m.lock.Unlock()
   325  
   326  		if m.allDone() {
   327  			return m, tea.Quit
   328  		}
   329  		return m, nil
   330  
   331  	case MsgProgress:
   332  		if _, ok := m.Progress[msg.Index]; !ok {
   333  			return m, nil
   334  		}
   335  
   336  		if msg.Percent != 0 {
   337  			m.lock.Lock()
   338  			m.Progress[msg.Index].percent = msg.Percent
   339  			m.lock.Unlock()
   340  		}
   341  
   342  		m.updateOverallProgress()
   343  
   344  		return m, nil
   345  
   346  	case MsgNotApplicable:
   347  		if _, ok := m.Progress[msg.Index]; !ok {
   348  			return m, nil
   349  		}
   350  
   351  		m.lock.Lock()
   352  		m.Progress[msg.Index].ProgressState = ProgressStateNotApplicable
   353  		m.Progress[msg.Index].model.ShowPercentage = false
   354  		// settings ShowPercentage to false, expanse the progress bar to match the others
   355  		// we need to manually reduce the width to match the others without the percentage
   356  		m.Progress[msg.Index].model.Width -= 5
   357  		m.lock.Unlock()
   358  
   359  		m.updateOverallProgress()
   360  
   361  		if m.allDone() {
   362  			return m, tea.Quit
   363  		}
   364  
   365  		return m, nil
   366  	case MsgErrored:
   367  		if _, ok := m.Progress[msg.Index]; !ok {
   368  			return m, nil
   369  		}
   370  
   371  		m.lock.Lock()
   372  		m.Progress[msg.Index].ProgressState = ProgressStateErrored
   373  		m.Progress[msg.Index].model.ShowPercentage = false
   374  		// settings ShowPercentage to false, expanse the progress bar to match the others
   375  		// we need to manually reduce the width to match the others without the percentage
   376  		m.Progress[msg.Index].model.Width -= 5
   377  		m.lock.Unlock()
   378  
   379  		m.updateOverallProgress()
   380  
   381  		if m.allDone() {
   382  			return m, tea.Quit
   383  		}
   384  
   385  		return m, nil
   386  
   387  	case MsgScore:
   388  		if _, ok := m.Progress[msg.Index]; !ok {
   389  			return m, nil
   390  		}
   391  
   392  		if msg.Score != "" {
   393  			m.lock.Lock()
   394  			m.Progress[msg.Index].Score = msg.Score
   395  			m.lock.Unlock()
   396  		}
   397  		return m, nil
   398  
   399  	// FrameMsg is sent when the progress bar wants to animate itself
   400  	case progress.FrameMsg:
   401  		var cmds []tea.Cmd
   402  		for k := range m.Progress {
   403  			progressModel, cmd := m.Progress[k].model.Update(msg)
   404  			cmds = append(cmds, cmd)
   405  			if pModel, ok := progressModel.(progress.Model); ok {
   406  				m.Progress[k].model = &pModel
   407  			}
   408  		}
   409  		return m, tea.Batch(cmds...)
   410  
   411  	default:
   412  		return m, nil
   413  	}
   414  }
   415  
   416  func (m *modelMultiProgress) allDone() bool {
   417  	finished := 0
   418  	m.lock.Lock()
   419  	defer m.lock.Unlock()
   420  	for k := range m.Progress {
   421  		if k == overallProgressIndexName {
   422  			continue
   423  		}
   424  		if m.Progress[k].ProgressState == ProgressStateErrored ||
   425  			m.Progress[k].ProgressState == ProgressStateNotApplicable ||
   426  			m.Progress[k].ProgressState == ProgressStateCompleted {
   427  			finished++
   428  		}
   429  	}
   430  	allDone := false
   431  	if _, ok := m.Progress[overallProgressIndexName]; ok {
   432  		if finished == len(m.Progress)-1 {
   433  			m.Progress[overallProgressIndexName].ProgressState = ProgressStateCompleted
   434  		}
   435  		allDone = m.Progress[overallProgressIndexName].ProgressState == ProgressStateCompleted
   436  	} else {
   437  		allDone = finished == len(m.Progress)
   438  	}
   439  
   440  	return allDone
   441  }
   442  
   443  func (m *modelMultiProgress) updateOverallProgress() {
   444  	if _, ok := m.Progress[overallProgressIndexName]; !ok {
   445  		return
   446  	}
   447  	overallPercent := 0.0
   448  	m.lock.Lock()
   449  	defer m.lock.Unlock()
   450  	sumPercent := 0.0
   451  	validAssets := 0
   452  	erroredAssets := 0
   453  	notApplicableAssets := 0
   454  	for k := range m.Progress {
   455  		if k == overallProgressIndexName {
   456  			continue
   457  		}
   458  
   459  		switch m.Progress[k].ProgressState {
   460  		case ProgressStateErrored:
   461  			erroredAssets++
   462  			continue
   463  		case ProgressStateNotApplicable:
   464  			notApplicableAssets++
   465  			continue
   466  		}
   467  
   468  		sumPercent += m.Progress[k].percent
   469  		validAssets++
   470  	}
   471  	if validAssets > 0 {
   472  		overallPercent = math.Floor((sumPercent/float64(validAssets))*100) / 100
   473  	}
   474  	_, ok := m.Progress[overallProgressIndexName]
   475  	if ok && erroredAssets+notApplicableAssets == len(m.Progress)-1 {
   476  		overallPercent = 1.0
   477  	}
   478  	m.Progress[overallProgressIndexName].percent = overallPercent
   479  
   480  	return
   481  }
   482  
   483  func (m *modelMultiProgress) View() string {
   484  	pad := strings.Repeat(" ", padding)
   485  	output := ""
   486  
   487  	m.lock.Lock()
   488  	defer m.lock.Unlock()
   489  	completedAssets := 0
   490  	erroredAssets := 0
   491  	notApplicableAssets := 0
   492  	for _, k := range m.orderedKeys {
   493  		switch m.Progress[k].ProgressState {
   494  		case ProgressStateErrored:
   495  			erroredAssets++
   496  		case ProgressStateNotApplicable:
   497  			notApplicableAssets++
   498  		case ProgressStateCompleted:
   499  			completedAssets++
   500  		}
   501  	}
   502  	outputFinished := ""
   503  	numItemsFinished := 0
   504  	for _, k := range m.orderedKeys {
   505  		progressState := m.Progress[k].ProgressState
   506  		if progressState != ProgressStateErrored && progressState != ProgressStateCompleted && progressState != ProgressStateNotApplicable {
   507  			continue
   508  		}
   509  		name := m.Progress[k].Name
   510  		pad := strings.Repeat(" ", m.maxNameWidth-len(name))
   511  		switch progressState {
   512  		case ProgressStateErrored:
   513  			outputFinished += " " + theme.DefaultTheme.Error(name) + pad + " " + m.Progress[k].model.View() + theme.DefaultTheme.Error("    X")
   514  		case ProgressStateNotApplicable:
   515  			outputFinished += " " + name + pad + " " + m.Progress[k].model.View() + "  n/a"
   516  		case ProgressStateCompleted:
   517  			percent := m.Progress[k].percent
   518  			outputFinished += " " + name + pad + " " + m.Progress[k].model.ViewAs(percent)
   519  		}
   520  
   521  		score := m.Progress[k].Score
   522  		if score != "" {
   523  			switch progressState {
   524  			case ProgressStateErrored:
   525  				outputFinished += theme.DefaultTheme.Error(" score: " + score)
   526  			case ProgressStateNotApplicable:
   527  				outputFinished += " score: " + score
   528  			default:
   529  				outputFinished += " score: " + score
   530  			}
   531  		}
   532  		outputFinished += "\n"
   533  		numItemsFinished++
   534  	}
   535  
   536  	itemsInProgress := 0
   537  	outputNotDone := ""
   538  	for _, k := range m.orderedKeys {
   539  		progressState := m.Progress[k].ProgressState
   540  		if progressState == ProgressStateErrored || progressState == ProgressStateNotApplicable || progressState == ProgressStateCompleted {
   541  			continue
   542  		}
   543  		name := m.Progress[k].Name
   544  		pad := strings.Repeat(" ", m.maxNameWidth-len(name))
   545  		percent := m.Progress[k].percent
   546  		outputNotDone += " " + name + pad + " " + m.Progress[k].model.ViewAs(percent) + "\n"
   547  		itemsInProgress++
   548  		if itemsInProgress == m.maxItemsToShow {
   549  			break
   550  		}
   551  	}
   552  	itemsUnfinished := len(m.orderedKeys) - itemsInProgress - numItemsFinished
   553  	if m.maxItemsToShow > 0 && itemsUnfinished > 0 {
   554  		label := "asset"
   555  		if itemsUnfinished > 1 {
   556  			label = "assets"
   557  		}
   558  		outputNotDone += fmt.Sprintf("... %d more %s ...\n", itemsUnfinished, label)
   559  	}
   560  
   561  	output += outputFinished + outputNotDone
   562  	if _, ok := m.Progress[overallProgressIndexName]; ok {
   563  		percent := m.Progress[overallProgressIndexName].percent
   564  		stats := fmt.Sprintf("%d/%d scanned", completedAssets, len(m.Progress)-1)
   565  
   566  		if erroredAssets > 0 {
   567  			stats += fmt.Sprintf(" %d/%d errored", erroredAssets, len(m.Progress)-1)
   568  		}
   569  
   570  		if notApplicableAssets > 0 {
   571  			stats += fmt.Sprintf(" %d/%d n/a", notApplicableAssets, len(m.Progress)-1)
   572  		}
   573  
   574  		repeat := m.maxNameWidth - len(stats)
   575  		if repeat < 0 {
   576  			repeat = 0
   577  		}
   578  		pad := strings.Repeat(" ", repeat)
   579  		output += "\n"
   580  		output += " " + stats + pad + " " + m.Progress[overallProgressIndexName].model.ViewAs(percent)
   581  	}
   582  
   583  	return "\n" + pad + output + "\n\n"
   584  }