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 }