diff --git a/cli/progress/multiprogress.go b/cli/progress/multiprogress.go deleted file mode 100644 index 22a6556f29..0000000000 --- a/cli/progress/multiprogress.go +++ /dev/null @@ -1,592 +0,0 @@ -// Copyright (c) Mondoo, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -package progress - -import ( - "fmt" - "math" - "os" - "strings" - "sync" - - "github.com/charmbracelet/bubbles/progress" - tea "github.com/charmbracelet/bubbletea" - "github.com/muesli/reflow/ansi" - "go.mondoo.com/mql/v13/cli/components" - "go.mondoo.com/mql/v13/cli/theme" - "go.mondoo.com/mql/v13/logger" -) - -type ProgressOption = func(*modelMultiProgress) - -func WithScore() ProgressOption { - return func(p *modelMultiProgress) { - p.includeScore = true - } -} - -type MultiProgress interface { - Open() error - OnProgress(index string, percent float64) - Score(index string, score string) - Errored(index string) - NotApplicable(index string) - Completed(index string) - Close() -} - -type NoopMultiProgressBars struct{} - -func (n NoopMultiProgressBars) Open() error { return nil } -func (n NoopMultiProgressBars) OnProgress(string, float64) {} -func (n NoopMultiProgressBars) Score(string, string) {} -func (n NoopMultiProgressBars) Errored(string) {} -func (n NoopMultiProgressBars) NotApplicable(string) {} -func (n NoopMultiProgressBars) Completed(string) {} -func (n NoopMultiProgressBars) Close() {} - -const ( - padding = 0 - defaultWidth = 40 - defaultProgressNumAssets = 1 - overallProgressIndexName = "overall" -) - -type MultiProgressAdapter struct { - Multi MultiProgress - Key string -} - -func (m *MultiProgressAdapter) Open() error { return m.Multi.Open() } -func (m *MultiProgressAdapter) OnProgress(current int, total int) { - percent := 0.0 - if total > 0 { - percent = float64(current) / float64(total) - } - m.Multi.OnProgress(m.Key, percent) -} -func (m *MultiProgressAdapter) Score(score string) { m.Multi.Score(m.Key, score) } -func (m *MultiProgressAdapter) Errored() { m.Multi.Errored(m.Key) } -func (m *MultiProgressAdapter) NotApplicable() { m.Multi.NotApplicable(m.Key) } -func (m *MultiProgressAdapter) Completed() { m.Multi.Completed(m.Key) } -func (m *MultiProgressAdapter) Close() { m.Multi.Close() } - -type MsgProgress struct { - Index string - Percent float64 -} - -// For mql the progressbar is completed, when percent is 1.0 -// But for cnspec we also need the score, which is displayed after the progressbar -// So we need a second message to indicate when the progressbar is completed -type MsgCompleted struct { - Index string -} - -type MsgErrored struct { - Index string -} - -type MsgNotApplicable struct { - Index string -} - -type MsgScore struct { - Index string - Score string -} - -type ProgressState int - -const ( - ProgressStateUnknownProgressState = iota - ProgressStateNotApplicable - ProgressStateCompleted - ProgressStateErrored -) - -type modelProgress struct { - model *progress.Model - percent float64 - Name string - Score string - ProgressState ProgressState -} - -type modelMultiProgress struct { - Progress map[string]*modelProgress - maxNameWidth int - maxItemsToShow int - orderedKeys []string - lock sync.Mutex - maxProgressBarWith int - includeScore bool -} - -type multiProgressBars struct { - program *tea.Program - Progress map[string]*modelProgress - maxNameWidth int //nolint:unused - maxItemsToShow int //nolint:unused - orderedKeys []string //nolint:unused -} - -func newProgressBar() progress.Model { - progressbar := progress.New(progress.WithScaledGradient("#5A56E0", "#EE6FF8")) - progressbar.Width = defaultWidth - progressbar.Full = '━' - progressbar.FullColor = "#7571F9" - progressbar.Empty = '─' - progressbar.EmptyColor = "#606060" - progressbar.ShowPercentage = true - progressbar.PercentFormat = " %3.0f%%" - return progressbar -} - -// Creates a new progress bars for the given elements. -// This is a wrapper around a tea.Programm. -// The key of the map is used to identify the progress bar. -// The value of the map is used as the name displayed for the progress bar. -// orderedKeys is used to define the order of the progress bars. -// includeScore indicates if the score should be displayed after the progress bar. This will only be used for spacing -func NewMultiProgressBars(elements map[string]string, orderedKeys []string, opts ...ProgressOption) (*multiProgressBars, error) { - program, err := newMultiProgressProgram(elements, orderedKeys, opts...) - if err != nil { - return nil, err - } - return &multiProgressBars{program: program}, nil -} - -// Start the progress bars -// Form now on the progress bars can be updated -func (m *multiProgressBars) Open() error { - (logger.LogOutputWriter.(*logger.BufferedWriter)).Pause() - defer (logger.LogOutputWriter.(*logger.BufferedWriter)).Resume() - if _, err := m.program.Run(); err != nil { - fmt.Println(err.Error()) - panic(err) - } - return nil -} - -// Set the current progress of a progress bar -func (m *multiProgressBars) OnProgress(index string, percent float64) { - m.program.Send(MsgProgress{ - Index: index, - Percent: percent, - }) -} - -// Add a score to the progress bar -// This should be called before Completed is called -func (m *multiProgressBars) Score(index string, score string) { - m.program.Send(MsgScore{ - Index: index, - Score: score, - }) -} - -// This is called when an error occurs during the progress -func (m *multiProgressBars) Errored(index string) { - m.program.Send(MsgErrored{ - Index: index, - }) -} - -// This is called when an error occurs during the progress -func (m *multiProgressBars) NotApplicable(index string) { - m.program.Send(MsgNotApplicable{ - Index: index, - }) -} - -// Set a single bar to completed -// For mql this should be called after the progress is 100% -// For cnspec this should be called after the score is set -func (m *multiProgressBars) Completed(index string) { - m.program.Send(MsgCompleted{ - Index: index, - }) -} - -// This ends the multiprogrssbar no matter the current progress -func (m *multiProgressBars) Close() { - m.program.Quit() -} - -// create the actual tea.Program -func newMultiProgressProgram(elements map[string]string, orderedKeys []string, opts ...ProgressOption) (*tea.Program, error) { - if len(elements) != len(orderedKeys) { - return nil, fmt.Errorf("number of elements and orderedKeys must be equal") - } - m := newMultiProgress(elements, opts...) - m.maxItemsToShow = defaultProgressNumAssets - m.orderedKeys = orderedKeys - return tea.NewProgram(m), nil -} - -func newMultiProgress(elements map[string]string, opts ...ProgressOption) *modelMultiProgress { - numBars := len(elements) - if numBars > 1 { - numBars++ - } - multiprogress := make(map[string]*modelProgress, numBars) - - m := &modelMultiProgress{ - Progress: multiprogress, - maxNameWidth: 0, - maxProgressBarWith: defaultWidth, - } - for _, opt := range opts { - opt(m) - } - - if numBars > 1 { - // add overall with max possible length, so we do not have to move progress bars later on - overallName := fmt.Sprintf("%d/%d scanned %d/%d errored %d/%d n/a", numBars, numBars, numBars, numBars, numBars, numBars) - m.add(overallProgressIndexName, overallName, m.maxProgressBarWith) - } - - w := m.calculateMaxProgressBarWidth() - if w > 10 { - m.maxProgressBarWith = w - } - - for k, v := range elements { - m.add(k, v, m.maxProgressBarWith) - } - - maxNameWidth := 0 - for k := range m.Progress { - if len(m.Progress[k].Name) > maxNameWidth { - maxNameWidth = ansi.PrintableRuneWidth(m.Progress[k].Name) - } - } - m.maxNameWidth = maxNameWidth - - return m -} - -func (m *modelMultiProgress) Init() tea.Cmd { - return nil -} - -func (m *modelMultiProgress) calculateMaxProgressBarWidth() int { - w := 0 - terminalWidth, err := components.TerminalWidth(os.Stdout) - if err == nil { - w = terminalWidth - m.maxNameWidth - 8 // 5 for percentage + space - // space for " score: F" - if m.includeScore { - w -= 9 - } - } - return w -} - -func (m *modelMultiProgress) add(key string, name string, width int) { - progressbar := newProgressBar() - progressbar.Width = width - m.Progress[key] = &modelProgress{ - model: &progressbar, - Name: name, - Score: "", - } -} - -func (m *modelMultiProgress) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - default: - return m, nil - } - - case tea.WindowSizeMsg: - w := m.calculateMaxProgressBarWidth() - if w > 10 { - m.maxProgressBarWith = w - } - for k := range m.Progress { - m.Progress[k].model.Width = m.maxProgressBarWith - } - return m, nil - - case MsgCompleted: - if _, ok := m.Progress[msg.Index]; !ok { - return m, nil - } - m.lock.Lock() - m.Progress[msg.Index].ProgressState = ProgressStateCompleted - m.lock.Unlock() - - if m.allDone() { - return m, tea.Quit - } - return m, nil - - case MsgProgress: - if _, ok := m.Progress[msg.Index]; !ok { - return m, nil - } - - if msg.Percent != 0 { - m.lock.Lock() - m.Progress[msg.Index].percent = msg.Percent - m.lock.Unlock() - } - - m.updateOverallProgress() - - return m, nil - - case MsgNotApplicable: - if _, ok := m.Progress[msg.Index]; !ok { - return m, nil - } - - m.lock.Lock() - m.Progress[msg.Index].ProgressState = ProgressStateNotApplicable - m.Progress[msg.Index].model.ShowPercentage = false - // settings ShowPercentage to false, expanse the progress bar to match the others - // we need to manually reduce the width to match the others without the percentage - m.Progress[msg.Index].model.Width -= 5 - m.lock.Unlock() - - m.updateOverallProgress() - - if m.allDone() { - return m, tea.Quit - } - - return m, nil - case MsgErrored: - if _, ok := m.Progress[msg.Index]; !ok { - return m, nil - } - - m.lock.Lock() - m.Progress[msg.Index].ProgressState = ProgressStateErrored - m.Progress[msg.Index].model.ShowPercentage = false - // settings ShowPercentage to false, expanse the progress bar to match the others - // we need to manually reduce the width to match the others without the percentage - m.Progress[msg.Index].model.Width -= 5 - m.lock.Unlock() - - m.updateOverallProgress() - - if m.allDone() { - return m, tea.Quit - } - - return m, nil - - case MsgScore: - if _, ok := m.Progress[msg.Index]; !ok { - return m, nil - } - - if msg.Score != "" { - m.lock.Lock() - m.Progress[msg.Index].Score = msg.Score - m.lock.Unlock() - } - return m, nil - - // FrameMsg is sent when the progress bar wants to animate itself - case progress.FrameMsg: - var cmds []tea.Cmd - for k := range m.Progress { - progressModel, cmd := m.Progress[k].model.Update(msg) - cmds = append(cmds, cmd) - if pModel, ok := progressModel.(progress.Model); ok { - m.Progress[k].model = &pModel - } - } - return m, tea.Batch(cmds...) - - default: - return m, nil - } -} - -func (m *modelMultiProgress) allDone() bool { - finished := 0 - m.lock.Lock() - defer m.lock.Unlock() - for k := range m.Progress { - if k == overallProgressIndexName { - continue - } - if m.Progress[k].ProgressState == ProgressStateErrored || - m.Progress[k].ProgressState == ProgressStateNotApplicable || - m.Progress[k].ProgressState == ProgressStateCompleted { - finished++ - } - } - allDone := false - if _, ok := m.Progress[overallProgressIndexName]; ok { - if finished == len(m.Progress)-1 { - m.Progress[overallProgressIndexName].ProgressState = ProgressStateCompleted - } - allDone = m.Progress[overallProgressIndexName].ProgressState == ProgressStateCompleted - } else { - allDone = finished == len(m.Progress) - } - - return allDone -} - -func (m *modelMultiProgress) updateOverallProgress() { - if _, ok := m.Progress[overallProgressIndexName]; !ok { - return - } - overallPercent := 0.0 - m.lock.Lock() - defer m.lock.Unlock() - sumPercent := 0.0 - validAssets := 0 - erroredAssets := 0 - notApplicableAssets := 0 - for k := range m.Progress { - if k == overallProgressIndexName { - continue - } - - switch m.Progress[k].ProgressState { - case ProgressStateErrored: - erroredAssets++ - continue - case ProgressStateNotApplicable: - notApplicableAssets++ - continue - } - - sumPercent += m.Progress[k].percent - validAssets++ - } - if validAssets > 0 { - overallPercent = math.Floor((sumPercent/float64(validAssets))*100) / 100 - } - _, ok := m.Progress[overallProgressIndexName] - if ok && erroredAssets+notApplicableAssets == len(m.Progress)-1 { - overallPercent = 1.0 - } - m.Progress[overallProgressIndexName].percent = overallPercent -} - -func (m *modelMultiProgress) View() string { - pad := strings.Repeat(" ", padding) - output := "" - - m.lock.Lock() - defer m.lock.Unlock() - completedAssets := 0 - erroredAssets := 0 - notApplicableAssets := 0 - for _, k := range m.orderedKeys { - switch m.Progress[k].ProgressState { - case ProgressStateErrored: - erroredAssets++ - case ProgressStateNotApplicable: - notApplicableAssets++ - case ProgressStateCompleted: - completedAssets++ - } - } - outputFinished := "" - numItemsFinished := 0 - for _, k := range m.orderedKeys { - progressState := m.Progress[k].ProgressState - if progressState != ProgressStateErrored && progressState != ProgressStateCompleted && progressState != ProgressStateNotApplicable { - continue - } - name := m.Progress[k].Name - - repeat := m.maxNameWidth - ansi.PrintableRuneWidth(name) - if repeat < 0 { - repeat = 0 - } - pad := strings.Repeat(" ", repeat) - switch progressState { - case ProgressStateErrored: - outputFinished += " " + theme.DefaultTheme.Error(name) + pad + " " + m.Progress[k].model.View() + theme.DefaultTheme.Error(" X") - case ProgressStateNotApplicable: - outputFinished += " " + name + pad + " " + m.Progress[k].model.View() + " n/a" - case ProgressStateCompleted: - percent := m.Progress[k].percent - outputFinished += " " + name + pad + " " + m.Progress[k].model.ViewAs(percent) - } - - score := m.Progress[k].Score - if score != "" { - switch progressState { - case ProgressStateErrored: - outputFinished += theme.DefaultTheme.Error(" score: " + score) - case ProgressStateNotApplicable: - outputFinished += " score: " + score - default: - outputFinished += " score: " + score - } - } - outputFinished += "\n" - numItemsFinished++ - } - - itemsInProgress := 0 - outputNotDone := "" - for _, k := range m.orderedKeys { - progressState := m.Progress[k].ProgressState - if progressState == ProgressStateErrored || progressState == ProgressStateNotApplicable || progressState == ProgressStateCompleted { - continue - } - name := m.Progress[k].Name - - repeat := m.maxNameWidth - ansi.PrintableRuneWidth(name) - if repeat < 0 { - repeat = 0 - } - pad := strings.Repeat(" ", repeat) - percent := m.Progress[k].percent - outputNotDone += " " + name + pad + " " + m.Progress[k].model.ViewAs(percent) + "\n" - itemsInProgress++ - if itemsInProgress == m.maxItemsToShow { - break - } - } - itemsUnfinished := len(m.orderedKeys) - itemsInProgress - numItemsFinished - if m.maxItemsToShow > 0 && itemsUnfinished > 0 { - label := "asset" - if itemsUnfinished > 1 { - label = "assets" - } - outputNotDone += fmt.Sprintf("... %d more %s ...\n", itemsUnfinished, label) - } - - output += outputFinished + outputNotDone - if _, ok := m.Progress[overallProgressIndexName]; ok { - percent := m.Progress[overallProgressIndexName].percent - stats := fmt.Sprintf("%d/%d scanned", completedAssets, len(m.Progress)-1) - - if erroredAssets > 0 { - stats += fmt.Sprintf(" %d/%d errored", erroredAssets, len(m.Progress)-1) - } - - if notApplicableAssets > 0 { - stats += fmt.Sprintf(" %d/%d n/a", notApplicableAssets, len(m.Progress)-1) - } - - repeat := m.maxNameWidth - ansi.PrintableRuneWidth(stats) - if repeat < 0 { - repeat = 0 - } - pad := strings.Repeat(" ", repeat) - output += "\n" - output += " " + stats + pad + " " + m.Progress[overallProgressIndexName].model.ViewAs(percent) - } - - return "\n" + pad + output + "\n\n" -} diff --git a/cli/progress/multiprogress_mock.go b/cli/progress/multiprogress_mock.go deleted file mode 100644 index b2d9d01f9f..0000000000 --- a/cli/progress/multiprogress_mock.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Mondoo, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -package progress - -import ( - "fmt" - "io" - - tea "github.com/charmbracelet/bubbletea" -) - -// This way we get the output without having a tty. -func newMultiProgressMockProgram(elements map[string]string, orderedKeys []string, input io.Reader, output io.Writer) (*tea.Program, error) { - if len(elements) != len(orderedKeys) { - return nil, fmt.Errorf("number of elements and orderedKeys must be equal") - } - m := newMultiProgress(elements) - m.orderedKeys = orderedKeys - m.maxItemsToShow = defaultProgressNumAssets - return tea.NewProgram(m, tea.WithInput(input), tea.WithOutput(output)), nil -} - -func newMultiProgressBarsMock(elements map[string]string, orderedKeys []string, input io.Reader, output io.Writer) (*multiProgressBars, error) { - program, err := newMultiProgressMockProgram(elements, orderedKeys, input, output) - if err != nil { - return nil, err - } - return &multiProgressBars{program: program}, nil -} diff --git a/cli/progress/multiprogress_test.go b/cli/progress/multiprogress_test.go deleted file mode 100644 index c50ce16c13..0000000000 --- a/cli/progress/multiprogress_test.go +++ /dev/null @@ -1,298 +0,0 @@ -// Copyright (c) Mondoo, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -package progress - -import ( - "bytes" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMultiProgressBar(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - progressBarElements := map[string]string{"1": "test1", "2": "test2", "3": "test3"} - multiprogress, err := newMultiProgressBarsMock(progressBarElements, []string{"1", "2", "3"}, &in, &buf) - require.NoError(t, err) - - go func() { - // we need to wait for tea to start the Program, otherwise these would be no-ops - time.Sleep(1 * time.Millisecond) - multiprogress.OnProgress("1", 0.5) - multiprogress.OnProgress("2", 0.5) - multiprogress.OnProgress("1", 1.0) - multiprogress.Score("1", "F") - multiprogress.Completed("1") - multiprogress.Close() - }() - err = multiprogress.Open() - require.NoError(t, err) - - assert.Contains(t, buf.String(), "test1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "test2 ━━━━━━━━━━━━━━━━━━───────────────── 50%") - assert.Contains(t, buf.String(), "1/3 scanned ━━━━━━━━━━━━━━━━━━───────────────── 50%") - assert.Contains(t, buf.String(), "... 1 more asset ...") -} - -func TestProgressBarLongAssetName(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - data := []byte{77, 97, 110, 97, 103, 101, 100, 226, 128, 153, 115, 32, 86, 105, 114, 116, 117, 97, 108, 32, 77, 97, 99, 104, 105, 110, 101} - str := string(data) - - progressBarElements := map[string]string{"1": str} - multiprogress, err := newMultiProgressBarsMock(progressBarElements, []string{"1"}, &in, &buf) - require.NoError(t, err) - - go func() { - // we need to wait for tea to start the Program, otherwise these would be no-ops - time.Sleep(1 * time.Millisecond) - multiprogress.OnProgress("1", 0.5) - multiprogress.OnProgress("1", 1.0) - multiprogress.Score("1", "F") - multiprogress.Completed("1") - multiprogress.Close() - }() - err = multiprogress.Open() - require.NoError(t, err) - - assert.Contains(t, buf.String(), "Managed’s Virtual Machine ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") -} - -func TestMultiProgressBarSingleAsset(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - progressBarElements := map[string]string{"1": "test1"} - multiprogress, err := newMultiProgressBarsMock(progressBarElements, []string{"1"}, &in, &buf) - require.NoError(t, err) - - go func() { - // we need to wait for tea to start the Program, otherwise these would be no-ops - time.Sleep(1 * time.Millisecond) - multiprogress.OnProgress("1", 0.5) - multiprogress.OnProgress("2", 0.5) - multiprogress.OnProgress("1", 1.0) - multiprogress.Score("1", "F") - multiprogress.Completed("1") - multiprogress.Close() - }() - err = multiprogress.Open() - require.NoError(t, err) - assert.Contains(t, buf.String(), "test1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.NotContains(t, buf.String(), "test2") - assert.NotContains(t, buf.String(), "scanned") -} - -func TestMultiProgressBarFinished(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - progressBarElements := map[string]string{"1": "test1", "2": "test2", "3": "test3"} - multiprogress, err := newMultiProgressBarsMock(progressBarElements, []string{"1", "2", "3"}, &in, &buf) - require.NoError(t, err) - - go func() { - // we need to wait for tea to start the Program, otherwise these would be no-ops - time.Sleep(1 * time.Millisecond) - multiprogress.OnProgress("1", 1.0) - multiprogress.OnProgress("2", 1.0) - multiprogress.OnProgress("3", 1.0) - multiprogress.Score("1", "F") - multiprogress.Completed("1") - multiprogress.Score("2", "F") - multiprogress.Completed("2") - multiprogress.Score("3", "F") - multiprogress.Completed("3") - }() - err = multiprogress.Open() - require.NoError(t, err) - - assert.Contains(t, buf.String(), "test1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "test2 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "test3 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "3/3 scanned ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%") - assert.NotContains(t, buf.String(), "errored") -} - -func TestMultiProgressBarErrored(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - progressBarElements := map[string]string{"1": "test1", "2": "test2", "3": "test3"} - multiprogress, err := newMultiProgressBarsMock(progressBarElements, []string{"1", "2", "3"}, &in, &buf) - require.NoError(t, err) - - go func() { - // we need to wait for tea to start the Program, otherwise these would be no-ops - time.Sleep(1 * time.Millisecond) - multiprogress.OnProgress("1", 1.0) - multiprogress.Score("1", "F") - multiprogress.Completed("1") - multiprogress.Score("2", "X") - multiprogress.Errored("2") - multiprogress.OnProgress("3", 1.0) - multiprogress.Score("3", "F") - multiprogress.Completed("3") - }() - err = multiprogress.Open() - require.NoError(t, err) - assert.Contains(t, buf.String(), "test1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "test2 ─────────────────────────────────── X") - assert.Contains(t, buf.String(), "test3 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "2/3 scanned 1/3 errored ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%") -} - -func TestMultiProgressBarLastErrored(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - progressBarElements := map[string]string{"1": "test1", "2": "test2", "3": "test3"} - multiprogress, err := newMultiProgressBarsMock(progressBarElements, []string{"1", "2", "3"}, &in, &buf) - require.NoError(t, err) - - go func() { - // we need to wait for tea to start the Program, otherwise these would be no-ops - time.Sleep(1 * time.Millisecond) - multiprogress.OnProgress("1", 1.0) - multiprogress.OnProgress("2", 1.0) - multiprogress.Score("1", "F") - multiprogress.Completed("1") - multiprogress.Score("2", "F") - multiprogress.Completed("2") - multiprogress.Score("3", "X") - multiprogress.Errored("3") - }() - err = multiprogress.Open() - require.NoError(t, err) - assert.Contains(t, buf.String(), "test1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "test2 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "test3 ─────────────────────────────────── X") - assert.Contains(t, buf.String(), "2/3 scanned 1/3 errored ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%") -} - -func TestMultiProgressBarOnlyOneErrored(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - progressBarElements := map[string]string{"1": "test1"} - multiprogress, err := newMultiProgressBarsMock(progressBarElements, []string{"1"}, &in, &buf) - require.NoError(t, err) - - go func() { - // we need to wait for tea to start the Program, otherwise these would be no-ops - time.Sleep(1 * time.Millisecond) - // this should also end the tea program - multiprogress.Errored("1") - multiprogress.Close() - }() - err = multiprogress.Open() - require.NoError(t, err) - - assert.Contains(t, buf.String(), "test1 ─────────────────────────────────── X") - assert.NotContains(t, buf.String(), "0/1 scanned 1/1 errored") -} - -func TestMultiProgressBarLimitedOneMore(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - progressBarElements := map[string]string{"1": "test1", "2": "test2", "3": "test3", "4": "test4"} - multiprogress, err := newMultiProgressBarsMock(progressBarElements, []string{"1", "2", "3", "4"}, &in, &buf) - require.NoError(t, err) - - go func() { - // we need to wait for tea to start the Program, otherwise these would be no-ops - time.Sleep(1 * time.Millisecond) - multiprogress.OnProgress("1", 1.0) - multiprogress.OnProgress("2", 0.1) - multiprogress.Score("1", "F") - multiprogress.Completed("1") - multiprogress.Close() - }() - err = multiprogress.Open() - require.NoError(t, err) - assert.Contains(t, buf.String(), "test1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "test2 ━━━━─────────────────────────────── 10%") - assert.Contains(t, buf.String(), "1/4 scanned ━━━━━━━━━────────────────────────── 27%") - assert.Contains(t, buf.String(), "2 more assets") -} - -func TestMultiProgressBarError(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - progressBarElements := map[string]string{"1": "test1", "2": "test2", "3": "test3"} - _, err := newMultiProgressBarsMock(progressBarElements, []string{"1", "3"}, &in, &buf) - require.Error(t, err) -} - -func TestMultiProgressBarOrdering(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - progressBarElements := map[string]string{"1": "test1", "2": "test2", "3": "test3"} - multiprogress, err := newMultiProgressBarsMock(progressBarElements, []string{"1", "3", "2"}, &in, &buf) - require.NoError(t, err) - - go func() { - // we need to wait for tea to start the Program, otherwise these would be no-ops - time.Sleep(1 * time.Millisecond) - multiprogress.OnProgress("1", 1.0) - multiprogress.OnProgress("2", 1.0) - multiprogress.OnProgress("3", 1.0) - multiprogress.Score("1", "F") - multiprogress.Completed("1") - multiprogress.Score("2", "F") - multiprogress.Completed("2") - multiprogress.Score("3", "F") - multiprogress.Completed("3") - multiprogress.Close() - }() - err = multiprogress.Open() - require.NoError(t, err) - assert.Contains(t, buf.String(), "test1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "test2 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "test3 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "3/3 scanned ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%") - // regexp is not working, perhaps because of ansi escape characters??? - // ordering := regexp.MustCompile(`^.*test1.*test3.*test2.*$`) - // m := ordering.FindString(buf.String()) - assert.Contains(t, buf.String(), "test1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F\r\n test3 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F\r\n test2") -} - -func TestMultiProgressBarNotApplicable(t *testing.T) { - var in bytes.Buffer - var buf bytes.Buffer - - progressBarElements := map[string]string{"1": "test1", "2": "test2", "3": "test3"} - multiprogress, err := newMultiProgressBarsMock(progressBarElements, []string{"1", "2", "3"}, &in, &buf) - require.NoError(t, err) - - go func() { - // we need to wait for tea to start the Program, otherwise these would be no-ops - time.Sleep(1 * time.Millisecond) - multiprogress.OnProgress("1", 1.0) - multiprogress.Score("1", "F") - multiprogress.Completed("1") - - multiprogress.Score("2", "X") - multiprogress.Errored("2") - - multiprogress.Score("3", "U") - multiprogress.NotApplicable("3") - }() - err = multiprogress.Open() - require.NoError(t, err) - - assert.Contains(t, buf.String(), "test1 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% score: F") - assert.Contains(t, buf.String(), "test2 ─────────────────────────────────── X score: X") - assert.Contains(t, buf.String(), "test3 ─────────────────────────────────── n/a score: U") - assert.Contains(t, buf.String(), "1/3 scanned 1/3 errored 1/3 n/a ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100%") -} diff --git a/cli/progress/progress.go b/cli/progress/progress.go deleted file mode 100644 index fec0e97660..0000000000 --- a/cli/progress/progress.go +++ /dev/null @@ -1,202 +0,0 @@ -// Copyright (c) Mondoo, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -package progress - -import ( - "fmt" - "os" - "strings" - "sync" - "time" - - tea "github.com/charmbracelet/bubbletea" - "github.com/mattn/go-isatty" - "github.com/muesli/termenv" - "go.mondoo.com/mql/v13/logger" - "go.mondoo.com/mql/v13/utils/multierr" -) - -type Progress interface { - Open() error - OnProgress(current int, total int) - Score(score string) - Errored() - NotApplicable() - Completed() - Close() -} - -type Noop struct{} - -func (n Noop) Open() error { return nil } -func (n Noop) OnProgress(int, int) {} -func (n Noop) Score(score string) {} -func (n Noop) Errored() {} -func (n Noop) NotApplicable() {} -func (n Noop) Completed() {} -func (n Noop) Close() {} - -type progressbar struct { - id string - maxNameWidth int - padding int - Data progressData - lock sync.Mutex - bar *renderer - isTTY bool - wg sync.WaitGroup -} - -type progressData struct { - Names []string - Completion []float32 - complete bool -} - -func New(id string, name string) *progressbar { - return NewMultiBar(id, progressData{ - Names: []string{name}, - Completion: []float32{0}, - complete: false, - }) -} - -func NewMultiBar(id string, data progressData) *progressbar { - maxNameWidth := 0 - for _, v := range data.Names { - l := len(v) - if l > maxNameWidth { - maxNameWidth = l - } - } - - return &progressbar{ - id: id, - maxNameWidth: maxNameWidth, - Data: data, - isTTY: isatty.IsTerminal(os.Stdout.Fd()), - } -} - -func (p *progressbar) Errored() {} -func (p *progressbar) NotApplicable() {} -func (p *progressbar) Score(string) {} -func (p *progressbar) Completed() {} - -func (p *progressbar) Open() error { - var err error - p.bar, err = newRenderer() - if err != nil { - return multierr.Wrap(err, "failed to initialize progressbar renderer") - } - - p.wg.Add(1) - if p.isTTY { - go func() { - defer p.wg.Done() - (logger.LogOutputWriter.(*logger.BufferedWriter)).Pause() - defer (logger.LogOutputWriter.(*logger.BufferedWriter)).Resume() - if _, err := tea.NewProgram(p).Run(); err != nil { - fmt.Println(err.Error()) - panic(err) - } - }() - } else { - go func() { - defer p.wg.Done() - o := termenv.NewOutput(os.Stdout) - for { - time.Sleep(time.Second / progressPipedFps) - o.ClearLines(2) - _, _ = o.WriteString(p.View()) - p.lock.Lock() - complete := p.Data.complete - p.lock.Unlock() - if complete { - break - } - } - }() - } - - return nil -} - -func (p *progressbar) OnProgress(current int, total int) { - p.lock.Lock() - p.Data.Completion[0] = float32(current) / float32(total) - p.lock.Unlock() -} - -func (p *progressbar) Close() { - p.lock.Lock() - p.Data.complete = true - p.lock.Unlock() - p.wg.Wait() -} - -const ( - progressDefaultFps = 60 - progressDefaultWidth = 80 - progressPipedFps = 1 -) - -type tickMsg time.Time - -// Init is a required interface method for the underlying renderer -func (p *progressbar) Init() tea.Cmd { - return tickCmd() -} - -// Update is a required interface method for the underlying renderer -func (p *progressbar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - return p, tea.Quit - default: - return p, nil - } - - case tea.WindowSizeMsg: - p.bar.Width = msg.Width - p.padding*2 - 4 - p.maxNameWidth - if p.bar.Width > progressDefaultWidth { - p.bar.Width = progressDefaultWidth - } - return p, nil - - case tickMsg: - p.lock.Lock() - complete := p.Data.complete - p.lock.Unlock() - if complete { - return p, tea.Quit - } - return p, tickCmd() - - default: - return p, nil - } -} - -// View is a required interface method for the underlying renderer -func (p *progressbar) View() string { - pad := strings.Repeat(" ", p.padding) - out := "" - for i := range p.Data.Names { - name := p.Data.Names[i] - value := p.Data.Completion[i] - out += "\n" + pad + p.bar.View(value) + " " + name - } - - out += "\n" - return out -} - -func tickCmd() tea.Cmd { - return tea.Tick(time.Second/progressDefaultFps, func(t time.Time) tea.Msg { - return tickMsg(t) - }) -} diff --git a/cli/progress/renderer.go b/cli/progress/renderer.go deleted file mode 100644 index 044379acfc..0000000000 --- a/cli/progress/renderer.go +++ /dev/null @@ -1,131 +0,0 @@ -// Copyright (c) Mondoo, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -package progress - -import ( - "errors" - "fmt" - "strings" - - "github.com/lucasb-eyer/go-colorful" - "github.com/muesli/ansi" - "github.com/muesli/termenv" - "go.mondoo.com/mql/v13/cli/theme/colors" -) - -var color func(string) termenv.Color = colors.Profile.Color - -// renderer stores values we'll use when rendering the progress bar. -type renderer struct { - // Total width of the progress bar, including percentage, if set. - Width int - - // "Filled" sections of the progress bar - Full rune - FullColor string - - // "Empty" sections of progress bar - Empty rune - EmptyColor string - - // Settings for rendering the numeric percentage - ShowPercentage bool - PercentFormat string // a fmt string for a float - PercentageStyle *termenv.Style - - useRamp bool - rampColorA colorful.Color - rampColorB colorful.Color - - // When true, we scale the gradient to fit the width of the filled section - // of the progress bar. When false, the width of the gradient will be set - // to the full width of the progress bar. - scaleRamp bool -} - -// newRenderer returns a model with default values. -func newRenderer() (*renderer, error) { - m := &renderer{ - Width: 40, - Full: '█', - FullColor: "#7571F9", - Empty: '░', - EmptyColor: "#606060", - ShowPercentage: true, - PercentFormat: " %3.0f%%", - } - - if err := m.setRamp("#5A56E0", "#EE6FF8", true); err != nil { - return nil, errors.New("default color setup failed, please report this issue") - } - - return m, nil -} - -// View renders the progress bar as a given percentage. -func (m *renderer) View(percent float32) string { - b := strings.Builder{} - if m.ShowPercentage { - percentage := fmt.Sprintf(m.PercentFormat, percent*100) - if m.PercentageStyle != nil { - percentage = m.PercentageStyle.Styled(percentage) - } - m.bar(&b, percent, ansi.PrintableRuneWidth(percentage)) - b.WriteString(percentage) - } else { - m.bar(&b, percent, 0) - } - return b.String() -} - -func (m *renderer) bar(b *strings.Builder, percent float32, textWidth int) { - var ( - tw = m.Width - textWidth // total width - fw = int(float32(tw) * percent) // filled width - p float64 - ) - - if m.useRamp { - // Gradient fill - for i := 0; i < fw; i++ { - if m.scaleRamp { - p = float64(i) / float64(fw) - } else { - p = float64(i) / float64(tw) - } - c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex() - b.WriteString(termenv. - String(string(m.Full)). - Foreground(color(c)). - String(), - ) - } - } else { - // Solid fill - s := termenv.String(string(m.Full)).Foreground(color(m.FullColor)).String() - b.WriteString(strings.Repeat(s, fw)) - } - - // Empty fill - e := termenv.String(string(m.Empty)).Foreground(color(m.EmptyColor)).String() - b.WriteString(strings.Repeat(e, tw-fw)) -} - -func (m *renderer) setRamp(colorA, colorB string, scaled bool) error { - a, err := colorful.Hex(colorA) - if err != nil { - return err - } - - b, err := colorful.Hex(colorB) - if err != nil { - return err - } - - m.useRamp = true - m.scaleRamp = scaled - m.rampColorA = a - m.rampColorB = b - return nil -} diff --git a/go.mod b/go.mod index 8f7af6661f..4cda9e2fba 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f github.com/knqyf263/go-rpmdb v0.1.1 github.com/lithammer/fuzzysearch v1.1.8 - github.com/lucasb-eyer/go-colorful v1.3.0 + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 github.com/mattn/go-isatty v0.0.20 github.com/miekg/dns v1.1.72 @@ -68,7 +68,7 @@ require ( // pin v0.19.0 github.com/moby/buildkit v0.16.0 github.com/moby/sys/mount v0.3.4 - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.4 @@ -161,7 +161,6 @@ require ( github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/go.sum b/go.sum index f51ed03d24..bb5c1a0c1f 100644 --- a/go.sum +++ b/go.sum @@ -217,8 +217,6 @@ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlv github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= -github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=