github.com/jfrog/jfrog-cli-go@v1.22.1-0.20200318093948-4826ef344ffd/utils/progressbar/progressbar.go (about)

     1  package progressbar
     2  
     3  import (
     4  	"github.com/jfrog/jfrog-cli-go/utils/cliutils"
     5  	logUtils "github.com/jfrog/jfrog-cli-go/utils/log"
     6  	"github.com/jfrog/jfrog-client-go/utils"
     7  	ioUtils "github.com/jfrog/jfrog-client-go/utils/io"
     8  	"github.com/jfrog/jfrog-client-go/utils/log"
     9  	"github.com/vbauerster/mpb/v4"
    10  	"github.com/vbauerster/mpb/v4/decor"
    11  	"golang.org/x/crypto/ssh/terminal"
    12  	"io"
    13  	"io/ioutil"
    14  	"os"
    15  	"strings"
    16  	"sync"
    17  	"time"
    18  )
    19  
    20  var terminalWidth int
    21  
    22  const progressBarWidth = 20
    23  const minTerminalWidth = 70
    24  const progressRefreshRate = 200 * time.Millisecond
    25  
    26  type progressBarManager struct {
    27  	bars           []*progressBarUnit
    28  	barsWg         *sync.WaitGroup
    29  	container      *mpb.Progress
    30  	barsRWMutex    sync.RWMutex
    31  	headlineBar    *mpb.Bar
    32  	logFilePathBar *mpb.Bar
    33  }
    34  
    35  type progressBarUnit struct {
    36  	bar         *mpb.Bar
    37  	incrChannel chan int
    38  	replaceBar  *mpb.Bar
    39  }
    40  
    41  // Initializes a new progress bar
    42  func (p *progressBarManager) New(total int64, prefix, path string) (barId int) {
    43  	// Write Lock when appending a new bar to the slice
    44  	p.barsRWMutex.Lock()
    45  	p.barsWg.Add(1)
    46  
    47  	newBar := p.container.AddBar(int64(total),
    48  		mpb.BarStyle("⬜⬜⬜⬛⬛"),
    49  		mpb.BarRemoveOnComplete(),
    50  		mpb.AppendDecorators(
    51  			// Extra chars length is the max length of the KibiByteCounter
    52  			decor.Name(buildProgressDescription(prefix, path, 17)),
    53  			decor.CountersKibiByte("%3.1f/%3.1f"),
    54  		),
    55  	)
    56  
    57  	// Add bar to bars array
    58  	unit := initNewBarUnit(newBar)
    59  	p.bars = append(p.bars, unit)
    60  	barId = len(p.bars)
    61  	p.barsRWMutex.Unlock()
    62  	return barId
    63  }
    64  
    65  // Initializes a new progress bar, that replaces an existing bar when it is completed
    66  func (p *progressBarManager) NewReplacement(replaceBarId int, prefix, path string) (barId int) {
    67  	// Write Lock when appending a new bar to the slice
    68  	p.barsRWMutex.Lock()
    69  	p.barsWg.Add(1)
    70  
    71  	newBar := p.container.AddSpinner(1, mpb.SpinnerOnMiddle,
    72  		mpb.SpinnerStyle(createSpinnerFramesArray()),
    73  		mpb.BarParkTo(p.bars[replaceBarId-1].bar),
    74  		mpb.AppendDecorators(
    75  			decor.Name(buildProgressDescription(prefix, path, 0)),
    76  		),
    77  	)
    78  
    79  	// Bar replacement is a spinner and thus does not use a channel for incrementing
    80  	unit := &progressBarUnit{bar: newBar, incrChannel: nil, replaceBar: p.bars[replaceBarId-1].bar}
    81  	// Add bar to bars array
    82  	p.bars = append(p.bars, unit)
    83  	barId = len(p.bars)
    84  	p.barsRWMutex.Unlock()
    85  	return barId
    86  }
    87  
    88  func buildProgressDescription(prefix, path string, extraCharsLen int) string {
    89  	separator := " | "
    90  	// Max line length after decreasing bar width (*2 in case unicode chars with double width are used) and the extra chars
    91  	descMaxLength := terminalWidth - (progressBarWidth*2 + extraCharsLen)
    92  	return buildDescByLimits(descMaxLength, separator+prefix+separator, path, separator)
    93  }
    94  
    95  func buildDescByLimits(descMaxLength int, prefix, path, suffix string) string {
    96  	desc := prefix + path + suffix
    97  
    98  	// Verify that the whole description doesn't exceed the max length
    99  	if len(desc) <= descMaxLength {
   100  		return desc
   101  	}
   102  
   103  	// If it does exceed, check if shortening the path will help (+3 is for "...")
   104  	if len(desc)-len(path)+3 > descMaxLength {
   105  		// Still exceeds, do not display desc
   106  		return ""
   107  	}
   108  
   109  	// Shorten path from the beginning
   110  	path = "..." + path[len(desc)-descMaxLength+3:]
   111  	return prefix + path + suffix
   112  }
   113  
   114  func initNewBarUnit(bar *mpb.Bar) *progressBarUnit {
   115  	ch := make(chan int, 1000)
   116  	unit := &progressBarUnit{bar: bar, incrChannel: ch}
   117  	go incrBarFromChannel(unit)
   118  	return unit
   119  }
   120  
   121  func incrBarFromChannel(unit *progressBarUnit) {
   122  	// Increase bar while channel is open
   123  	for n := range unit.incrChannel {
   124  		unit.bar.IncrBy(n)
   125  	}
   126  }
   127  
   128  func createSpinnerFramesArray() []string {
   129  	black := "⬛"
   130  	white := "⬜"
   131  	spinnerFramesArray := make([]string, progressBarWidth)
   132  	for i := 0; i < progressBarWidth; i++ {
   133  		cur := strings.Repeat(black, i) + white + strings.Repeat(black, progressBarWidth-1-i)
   134  		spinnerFramesArray[i] = cur
   135  	}
   136  	return spinnerFramesArray
   137  }
   138  
   139  // Wraps a body of a response (io.Reader) and increments bar accordingly
   140  func (p *progressBarManager) ReadWithProgress(barId int, reader io.Reader) (wrappedReader io.Reader) {
   141  	p.barsRWMutex.RLock()
   142  	wrappedReader = initProxyReader(p.bars[barId-1], reader)
   143  	p.barsRWMutex.RUnlock()
   144  	return wrappedReader
   145  }
   146  
   147  func initProxyReader(unit *progressBarUnit, reader io.Reader) io.ReadCloser {
   148  	if reader == nil {
   149  		return nil
   150  	}
   151  	rc, ok := reader.(io.ReadCloser)
   152  	if !ok {
   153  		rc = ioutil.NopCloser(reader)
   154  	}
   155  	return &proxyReader{unit, rc}
   156  }
   157  
   158  // Wraps an io.Reader for bytes reading tracking
   159  type proxyReader struct {
   160  	unit *progressBarUnit
   161  	io.ReadCloser
   162  }
   163  
   164  // Overrides the Read method of the original io.Reader.
   165  func (pr *proxyReader) Read(p []byte) (n int, err error) {
   166  	n, err = pr.ReadCloser.Read(p)
   167  	if n > 0 && err == nil {
   168  		pr.incrChannel(n)
   169  	}
   170  	return
   171  }
   172  
   173  func (pr *proxyReader) incrChannel(n int) {
   174  	// When an upload / download error occurs (for example, a bad HTTP error code),
   175  	// The progress bar's Abort method is invoked and closes the channel.
   176  	// Therefore, the channel may be already closed at this stage, which leads to a panic.
   177  	// We therefore need to recover if that happens.
   178  	defer func() {
   179  		recover()
   180  	}()
   181  	pr.unit.incrChannel <- n
   182  }
   183  
   184  // Aborts a progress bar.
   185  // Should be called even if bar completed successfully.
   186  // The progress component's Abort method has no effect if bar has already completed, so can always be safely called anyway
   187  func (p *progressBarManager) Abort(barId int) {
   188  	p.barsRWMutex.RLock()
   189  	defer p.barsWg.Done()
   190  
   191  	// If a replacing bar
   192  	if p.bars[barId-1].replaceBar != nil {
   193  		// The replacing bar is displayed only if the replacedBar completed, so needs to be dropped only if so
   194  		if p.bars[barId-1].replaceBar.Completed() {
   195  			p.bars[barId-1].bar.Abort(true)
   196  		} else {
   197  			p.bars[barId-1].bar.Abort(false)
   198  		}
   199  	} else {
   200  		close(p.bars[barId-1].incrChannel)
   201  		p.bars[barId-1].bar.Abort(true)
   202  	}
   203  	p.barsRWMutex.RUnlock()
   204  }
   205  
   206  // Quits the progress bar while aborting the initial bars.
   207  func (p *progressBarManager) Quit() {
   208  	if p.headlineBar != nil {
   209  		p.headlineBar.Abort(true)
   210  		p.barsWg.Done()
   211  	}
   212  	if p.logFilePathBar != nil {
   213  		p.barsWg.Done()
   214  	}
   215  	// Wait a refresh rate to make sure all aborts have finished
   216  	time.Sleep(progressRefreshRate)
   217  	p.container.Wait()
   218  }
   219  
   220  // Initializes progress bar if possible (all conditions in 'shouldInitProgressBar' are met).
   221  // Creates a log file and sets the Logger to it. Caller responsible to close the file.
   222  // Returns nil, nil, err if failed.
   223  func InitProgressBarIfPossible() (ioUtils.Progress, *os.File, error) {
   224  	shouldInit, err := shouldInitProgressBar()
   225  	if !shouldInit || err != nil {
   226  		return nil, nil, err
   227  	}
   228  
   229  	logFile, err := logUtils.CreateLogFile()
   230  	if err != nil {
   231  		return nil, nil, err
   232  	}
   233  	log.SetLogger(log.NewLogger(logUtils.GetCliLogLevel(), logFile))
   234  
   235  	newProgressBar := &progressBarManager{}
   236  	newProgressBar.barsWg = new(sync.WaitGroup)
   237  
   238  	// Initialize the progressBar container with wg, to create a single joint point
   239  	newProgressBar.container = mpb.New(
   240  		mpb.WithOutput(os.Stderr),
   241  		mpb.WithWaitGroup(newProgressBar.barsWg),
   242  		mpb.WithWidth(progressBarWidth),
   243  		mpb.WithRefreshRate(progressRefreshRate))
   244  
   245  	// Add headline bar to the whole progress
   246  	newProgressBar.printLogFilePathAsBar(logFile.Name())
   247  	newProgressBar.newHeadlineBar(" Working... ")
   248  
   249  	return newProgressBar, logFile, nil
   250  }
   251  
   252  // Init progress bar if all required conditions are met:
   253  // CI == false (or unset), Stderr is a terminal, and terminal width is large enough
   254  func shouldInitProgressBar() (bool, error) {
   255  	ci, err := utils.GetBoolEnvValue(cliutils.CI, false)
   256  	if ci || err != nil {
   257  		return false, err
   258  	}
   259  	if !isTerminal() {
   260  		return false, err
   261  	}
   262  	err = setTerminalWidthVar()
   263  	if err != nil {
   264  		return false, err
   265  	}
   266  	return terminalWidth >= minTerminalWidth, nil
   267  }
   268  
   269  // Check if Stderr is a terminal
   270  func isTerminal() bool {
   271  	return terminal.IsTerminal(int(os.Stderr.Fd()))
   272  }
   273  
   274  // Get terminal dimensions
   275  func setTerminalWidthVar() error {
   276  	width, _, err := terminal.GetSize(int(os.Stderr.Fd()))
   277  	// -5 to avoid edges
   278  	terminalWidth = width - 5
   279  	return err
   280  }
   281  
   282  // Initializes a new progress bar for headline, with a spinner
   283  func (p *progressBarManager) newHeadlineBar(headline string) {
   284  	p.barsWg.Add(1)
   285  	p.headlineBar = p.container.AddSpinner(1, mpb.SpinnerOnLeft,
   286  		mpb.SpinnerStyle([]string{"-", "-", "\\", "\\", "|", "|", "/", "/"}),
   287  		mpb.BarRemoveOnComplete(),
   288  		mpb.PrependDecorators(
   289  			decor.Name(headline),
   290  		),
   291  	)
   292  }
   293  
   294  // Initializes a new progress bar that states the log file path. The bar's text remains after cli is done.
   295  func (p *progressBarManager) printLogFilePathAsBar(path string) {
   296  	p.barsWg.Add(1)
   297  	prefix := " Log path: "
   298  	p.logFilePathBar = p.container.AddBar(0,
   299  		mpb.BarClearOnComplete(),
   300  		mpb.PrependDecorators(
   301  			decor.Name(buildDescByLimits(terminalWidth, prefix, path, "")),
   302  		),
   303  	)
   304  	p.logFilePathBar.SetTotal(0, true)
   305  }