tty.go 9.1 KB

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