tty.go 10 KB

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