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 }