tty.go 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. /*
  2. Copyright 2020 Docker Compose CLI authors
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. http://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package progress
  14. import (
  15. "context"
  16. "fmt"
  17. "io"
  18. "strings"
  19. "sync"
  20. "time"
  21. "github.com/docker/compose/v2/pkg/api"
  22. "github.com/buger/goterm"
  23. "github.com/docker/go-units"
  24. "github.com/morikuni/aec"
  25. )
  26. // NewTTYWriter creates an EventProcessor that render advanced UI within a terminal.
  27. // On Start, TUI lists task with a progress timer
  28. func NewTTYWriter(out io.Writer) EventProcessor {
  29. return &ttyWriter{
  30. out: out,
  31. tasks: map[string]task{},
  32. done: make(chan bool),
  33. mtx: &sync.Mutex{},
  34. }
  35. }
  36. type ttyWriter struct {
  37. out io.Writer
  38. ids []string // tasks ids ordered as first event appeared
  39. tasks map[string]task
  40. repeated bool
  41. numLines int
  42. done chan bool
  43. mtx *sync.Mutex
  44. dryRun bool // FIXME(ndeloof) (re)implement support for dry-run
  45. skipChildEvents bool
  46. operation string
  47. ticker *time.Ticker
  48. suspended bool
  49. }
  50. type task struct {
  51. ID string
  52. parentID string
  53. startTime time.Time
  54. endTime time.Time
  55. text string
  56. details string
  57. status EventStatus
  58. current int64
  59. percent int
  60. total int64
  61. spinner *Spinner
  62. }
  63. func (t *task) stop() {
  64. t.endTime = time.Now()
  65. t.spinner.Stop()
  66. }
  67. func (t *task) hasMore() {
  68. t.spinner.Restart()
  69. }
  70. func (w *ttyWriter) Start(ctx context.Context, operation string) {
  71. w.ticker = time.NewTicker(100 * time.Millisecond)
  72. w.operation = operation
  73. go func() {
  74. for {
  75. select {
  76. case <-ctx.Done():
  77. // interrupted
  78. w.ticker.Stop()
  79. return
  80. case <-w.done:
  81. w.print()
  82. w.mtx.Lock()
  83. w.ticker.Stop()
  84. w.operation = ""
  85. w.mtx.Unlock()
  86. return
  87. case <-w.ticker.C:
  88. w.print()
  89. }
  90. }
  91. }()
  92. }
  93. func (w *ttyWriter) Done(operation string, success bool) {
  94. w.done <- true
  95. }
  96. func (w *ttyWriter) On(events ...Event) {
  97. w.mtx.Lock()
  98. defer w.mtx.Unlock()
  99. for _, e := range events {
  100. if w.operation != "start" && (e.Text == StatusStarted || e.Text == StatusStarting) {
  101. // skip those events to avoid mix with container logs
  102. continue
  103. }
  104. w.event(e)
  105. }
  106. }
  107. func (w *ttyWriter) event(e Event) {
  108. // Suspend print while a build is in progress, to avoid collision with buildkit Display
  109. if e.Text == StatusBuilding {
  110. w.ticker.Stop()
  111. w.suspended = true
  112. } else if w.suspended {
  113. w.ticker.Reset(100 * time.Millisecond)
  114. w.suspended = false
  115. }
  116. if last, ok := w.tasks[e.ID]; ok {
  117. switch e.Status {
  118. case Done, Error, Warning:
  119. if last.status != e.Status {
  120. last.stop()
  121. }
  122. case Working:
  123. last.hasMore()
  124. }
  125. last.status = e.Status
  126. last.text = e.Text
  127. last.details = e.Details
  128. // progress can only go up
  129. if e.Total > last.total {
  130. last.total = e.Total
  131. }
  132. if e.Current > last.current {
  133. last.current = e.Current
  134. }
  135. if e.Percent > last.percent {
  136. last.percent = e.Percent
  137. }
  138. // allow set/unset of parent, but not swapping otherwise prompt is flickering
  139. if last.parentID == "" || e.ParentID == "" {
  140. last.parentID = e.ParentID
  141. }
  142. w.tasks[e.ID] = last
  143. } else {
  144. t := task{
  145. ID: e.ID,
  146. parentID: e.ParentID,
  147. startTime: time.Now(),
  148. text: e.Text,
  149. details: e.Details,
  150. status: e.Status,
  151. current: e.Current,
  152. percent: e.Percent,
  153. total: e.Total,
  154. spinner: NewSpinner(),
  155. }
  156. if e.Status == Done || e.Status == Error {
  157. t.stop()
  158. }
  159. w.tasks[e.ID] = t
  160. w.ids = append(w.ids, e.ID)
  161. }
  162. w.printEvent(e)
  163. }
  164. func (w *ttyWriter) printEvent(e Event) {
  165. if w.operation != "" {
  166. // event will be displayed by progress UI on ticker's ticks
  167. return
  168. }
  169. var color colorFunc
  170. switch e.Status {
  171. case Working:
  172. color = SuccessColor
  173. case Done:
  174. color = SuccessColor
  175. case Warning:
  176. color = WarningColor
  177. case Error:
  178. color = ErrorColor
  179. }
  180. _, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, color(e.Text), e.Details)
  181. }
  182. func (w *ttyWriter) print() {
  183. w.mtx.Lock()
  184. defer w.mtx.Unlock()
  185. if len(w.tasks) == 0 {
  186. return
  187. }
  188. terminalWidth := goterm.Width()
  189. b := aec.EmptyBuilder
  190. for i := 0; i <= w.numLines; i++ {
  191. b = b.Up(1)
  192. }
  193. if !w.repeated {
  194. b = b.Down(1)
  195. }
  196. w.repeated = true
  197. _, _ = fmt.Fprint(w.out, b.Column(0).ANSI)
  198. // Hide the cursor while we are printing
  199. _, _ = fmt.Fprint(w.out, aec.Hide)
  200. defer func() {
  201. _, _ = fmt.Fprint(w.out, aec.Show)
  202. }()
  203. firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks))
  204. _, _ = fmt.Fprintln(w.out, firstLine)
  205. var statusPadding int
  206. for _, t := range w.tasks {
  207. l := len(t.ID)
  208. if statusPadding < l {
  209. statusPadding = l
  210. }
  211. if t.parentID != "" {
  212. statusPadding -= 2
  213. }
  214. }
  215. if len(w.tasks) > goterm.Height()-2 {
  216. w.skipChildEvents = true
  217. }
  218. numLines := 0
  219. for _, id := range w.ids { // iterate on ids to enforce a consistent order
  220. t := w.tasks[id]
  221. if t.parentID != "" {
  222. continue
  223. }
  224. line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun)
  225. _, _ = fmt.Fprint(w.out, line)
  226. numLines++
  227. for _, t := range w.tasks {
  228. if t.parentID == t.ID {
  229. if w.skipChildEvents {
  230. continue
  231. }
  232. line := w.lineText(t, " ", terminalWidth, statusPadding, w.dryRun)
  233. _, _ = fmt.Fprint(w.out, line)
  234. numLines++
  235. }
  236. }
  237. }
  238. for i := numLines; i < w.numLines; i++ {
  239. if numLines < goterm.Height()-2 {
  240. _, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
  241. numLines++
  242. }
  243. }
  244. w.numLines = numLines
  245. }
  246. func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding int, dryRun bool) string {
  247. endTime := time.Now()
  248. if t.status != Working {
  249. endTime = t.startTime
  250. if (t.endTime != time.Time{}) {
  251. endTime = t.endTime
  252. }
  253. }
  254. prefix := ""
  255. if dryRun {
  256. prefix = PrefixColor(api.DRYRUN_PREFIX)
  257. }
  258. elapsed := endTime.Sub(t.startTime).Seconds()
  259. var (
  260. hideDetails bool
  261. total int64
  262. current int64
  263. completion []string
  264. )
  265. // only show the aggregated progress while the root operation is in-progress
  266. if parent := t; parent.status == Working {
  267. for _, id := range w.ids {
  268. child := w.tasks[id]
  269. if child.parentID == parent.ID {
  270. if child.status == Working && child.total == 0 {
  271. // we don't have totals available for all the child events
  272. // so don't show the total progress yet
  273. hideDetails = true
  274. }
  275. total += child.total
  276. current += child.current
  277. completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100])
  278. }
  279. }
  280. }
  281. // don't try to show detailed progress if we don't have any idea
  282. if total == 0 {
  283. hideDetails = true
  284. }
  285. txt := t.ID
  286. if len(completion) > 0 {
  287. var progress string
  288. if !hideDetails {
  289. progress = fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
  290. }
  291. txt = fmt.Sprintf("%s [%s]%s",
  292. t.ID,
  293. SuccessColor(strings.Join(completion, "")),
  294. progress,
  295. )
  296. }
  297. textLen := len(txt)
  298. padding := statusPadding - textLen
  299. if padding < 0 {
  300. padding = 0
  301. }
  302. // calculate the max length for the status text, on errors it
  303. // is 2-3 lines long and breaks the line formatting
  304. maxDetailsLen := terminalWidth - textLen - statusPadding - 15
  305. details := t.details
  306. // in some cases (debugging under VS Code), terminalWidth is set to zero by goterm.Width() ; ensuring we don't tweak strings with negative char index
  307. if maxDetailsLen > 0 && len(details) > maxDetailsLen {
  308. details = details[:maxDetailsLen] + "..."
  309. }
  310. text := fmt.Sprintf("%s %s%s %s %s%s %s",
  311. pad,
  312. spinner(t),
  313. prefix,
  314. txt,
  315. strings.Repeat(" ", padding),
  316. colorFn(t.status)(t.text),
  317. details,
  318. )
  319. timer := fmt.Sprintf("%.1fs ", elapsed)
  320. o := align(text, TimerColor(timer), terminalWidth)
  321. return o
  322. }
  323. var (
  324. spinnerDone = "✔"
  325. spinnerWarning = "!"
  326. spinnerError = "✘"
  327. )
  328. func spinner(t task) string {
  329. switch t.status {
  330. case Done:
  331. return SuccessColor(spinnerDone)
  332. case Warning:
  333. return WarningColor(spinnerWarning)
  334. case Error:
  335. return ErrorColor(spinnerError)
  336. default:
  337. return CountColor(t.spinner.String())
  338. }
  339. }
  340. func colorFn(s EventStatus) colorFunc {
  341. switch s {
  342. case Done:
  343. return SuccessColor
  344. case Warning:
  345. return WarningColor
  346. case Error:
  347. return ErrorColor
  348. default:
  349. return nocolor
  350. }
  351. }
  352. func numDone(tasks map[string]task) int {
  353. i := 0
  354. for _, t := range tasks {
  355. if t.status != Working {
  356. i++
  357. }
  358. }
  359. return i
  360. }
  361. func align(l, r string, w int) string {
  362. ll := lenAnsi(l)
  363. lr := lenAnsi(r)
  364. pad := ""
  365. count := w - ll - lr
  366. if count > 0 {
  367. pad = strings.Repeat(" ", count)
  368. }
  369. return fmt.Sprintf("%s%s%s\n", l, pad, r)
  370. }
  371. // lenAnsi count of user-perceived characters in ANSI string.
  372. func lenAnsi(s string) int {
  373. length := 0
  374. ansiCode := false
  375. for _, r := range s {
  376. if r == '\x1b' {
  377. ansiCode = true
  378. continue
  379. }
  380. if ansiCode && r == 'm' {
  381. ansiCode = false
  382. continue
  383. }
  384. if !ansiCode {
  385. length++
  386. }
  387. }
  388. return length
  389. }
  390. var percentChars = strings.Split("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "")