tty.go 9.4 KB

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