tty.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  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. "slices"
  20. "strings"
  21. "sync"
  22. "time"
  23. "unicode/utf8"
  24. "github.com/buger/goterm"
  25. "github.com/docker/go-units"
  26. "github.com/morikuni/aec"
  27. "github.com/docker/compose/v5/pkg/api"
  28. "github.com/docker/compose/v5/pkg/utils"
  29. )
  30. // Full creates an EventProcessor that render advanced UI within a terminal.
  31. // On Start, TUI lists task with a progress timer
  32. func Full(out io.Writer, info io.Writer, detached bool) api.EventProcessor {
  33. return &ttyWriter{
  34. out: out,
  35. info: info,
  36. tasks: map[string]*task{},
  37. done: make(chan bool),
  38. mtx: &sync.Mutex{},
  39. detached: detached,
  40. }
  41. }
  42. type ttyWriter struct {
  43. out io.Writer
  44. ids []string // tasks ids ordered as first event appeared
  45. tasks map[string]*task
  46. repeated bool
  47. numLines int
  48. done chan bool
  49. mtx *sync.Mutex
  50. dryRun bool // FIXME(ndeloof) (re)implement support for dry-run
  51. operation string
  52. ticker *time.Ticker
  53. suspended bool
  54. info io.Writer
  55. detached bool
  56. }
  57. type task struct {
  58. ID string
  59. parent string // the resource this task receives updates from - other parents will be ignored
  60. parents utils.Set[string] // all resources to depend on this task
  61. startTime time.Time
  62. endTime time.Time
  63. text string
  64. details string
  65. status api.EventStatus
  66. current int64
  67. percent int
  68. total int64
  69. spinner *Spinner
  70. }
  71. func newTask(e api.Resource) task {
  72. t := task{
  73. ID: e.ID,
  74. parents: utils.NewSet[string](),
  75. startTime: time.Now(),
  76. text: e.Text,
  77. details: e.Details,
  78. status: e.Status,
  79. current: e.Current,
  80. percent: e.Percent,
  81. total: e.Total,
  82. spinner: NewSpinner(),
  83. }
  84. if e.ParentID != "" {
  85. t.parent = e.ParentID
  86. t.parents.Add(e.ParentID)
  87. }
  88. if e.Status == api.Done || e.Status == api.Error {
  89. t.stop()
  90. }
  91. return t
  92. }
  93. // update adjusts task state based on last received event
  94. func (t *task) update(e api.Resource) {
  95. if e.ParentID != "" {
  96. t.parents.Add(e.ParentID)
  97. // we may receive same event from distinct parents (typically: images sharing layers)
  98. // to avoid status to flicker, only accept updates from our first declared parent
  99. if t.parent != e.ParentID {
  100. return
  101. }
  102. }
  103. // update task based on received event
  104. switch e.Status {
  105. case api.Done, api.Error, api.Warning:
  106. if t.status != e.Status {
  107. t.stop()
  108. }
  109. case api.Working:
  110. t.hasMore()
  111. }
  112. t.status = e.Status
  113. t.text = e.Text
  114. t.details = e.Details
  115. // progress can only go up
  116. if e.Total > t.total {
  117. t.total = e.Total
  118. }
  119. if e.Current > t.current {
  120. t.current = e.Current
  121. }
  122. if e.Percent > t.percent {
  123. t.percent = e.Percent
  124. }
  125. }
  126. func (t *task) stop() {
  127. t.endTime = time.Now()
  128. t.spinner.Stop()
  129. }
  130. func (t *task) hasMore() {
  131. t.spinner.Restart()
  132. }
  133. func (t *task) Completed() bool {
  134. switch t.status {
  135. case api.Done, api.Error, api.Warning:
  136. return true
  137. default:
  138. return false
  139. }
  140. }
  141. func (w *ttyWriter) Start(ctx context.Context, operation string) {
  142. w.ticker = time.NewTicker(100 * time.Millisecond)
  143. w.operation = operation
  144. go func() {
  145. for {
  146. select {
  147. case <-ctx.Done():
  148. // interrupted
  149. w.ticker.Stop()
  150. return
  151. case <-w.done:
  152. return
  153. case <-w.ticker.C:
  154. w.print()
  155. }
  156. }
  157. }()
  158. }
  159. func (w *ttyWriter) Done(operation string, success bool) {
  160. w.print()
  161. w.done <- true
  162. w.mtx.Lock()
  163. defer w.mtx.Unlock()
  164. if w.ticker != nil {
  165. w.ticker.Stop()
  166. }
  167. w.operation = ""
  168. }
  169. func (w *ttyWriter) On(events ...api.Resource) {
  170. w.mtx.Lock()
  171. defer w.mtx.Unlock()
  172. for _, e := range events {
  173. if e.ID == "Compose" {
  174. _, _ = fmt.Fprintln(w.info, ErrorColor(e.Details))
  175. continue
  176. }
  177. if w.operation != "start" && (e.Text == api.StatusStarted || e.Text == api.StatusStarting) && !w.detached {
  178. // skip those events to avoid mix with container logs
  179. continue
  180. }
  181. w.event(e)
  182. }
  183. }
  184. func (w *ttyWriter) event(e api.Resource) {
  185. // Suspend print while a build is in progress, to avoid collision with buildkit Display
  186. if w.ticker != nil {
  187. if e.Text == api.StatusBuilding {
  188. w.ticker.Stop()
  189. w.suspended = true
  190. } else if w.suspended {
  191. w.ticker.Reset(100 * time.Millisecond)
  192. w.suspended = false
  193. }
  194. }
  195. if last, ok := w.tasks[e.ID]; ok {
  196. last.update(e)
  197. } else {
  198. t := newTask(e)
  199. w.tasks[e.ID] = &t
  200. w.ids = append(w.ids, e.ID)
  201. }
  202. w.printEvent(e)
  203. }
  204. func (w *ttyWriter) printEvent(e api.Resource) {
  205. if w.operation != "" {
  206. // event will be displayed by progress UI on ticker's ticks
  207. return
  208. }
  209. var color colorFunc
  210. switch e.Status {
  211. case api.Working:
  212. color = SuccessColor
  213. case api.Done:
  214. color = SuccessColor
  215. case api.Warning:
  216. color = WarningColor
  217. case api.Error:
  218. color = ErrorColor
  219. }
  220. _, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, color(e.Text), e.Details)
  221. }
  222. func (w *ttyWriter) parentTasks() iter.Seq[*task] {
  223. return func(yield func(*task) bool) {
  224. for _, id := range w.ids { // iterate on ids to enforce a consistent order
  225. t := w.tasks[id]
  226. if len(t.parents) == 0 {
  227. yield(t)
  228. }
  229. }
  230. }
  231. }
  232. func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] {
  233. return func(yield func(*task) bool) {
  234. for _, id := range w.ids { // iterate on ids to enforce a consistent order
  235. t := w.tasks[id]
  236. if t.parents.Has(parent) {
  237. yield(t)
  238. }
  239. }
  240. }
  241. }
  242. // lineData holds pre-computed formatting for a task line
  243. type lineData struct {
  244. spinner string // rendered spinner with color
  245. prefix string // dry-run prefix if any
  246. taskID string // possibly abbreviated
  247. progress string // progress bar and size info
  248. status string // rendered status with color
  249. details string // possibly abbreviated
  250. timer string // rendered timer with color
  251. statusPad int // padding before status to align
  252. timerPad int // padding before timer to align
  253. statusColor colorFunc
  254. }
  255. func (w *ttyWriter) print() {
  256. terminalWidth := goterm.Width()
  257. terminalHeight := goterm.Height()
  258. if terminalWidth <= 0 {
  259. terminalWidth = 80
  260. }
  261. if terminalHeight <= 0 {
  262. terminalHeight = 24
  263. }
  264. w.printWithDimensions(terminalWidth, terminalHeight)
  265. }
  266. func (w *ttyWriter) printWithDimensions(terminalWidth, terminalHeight int) {
  267. w.mtx.Lock()
  268. defer w.mtx.Unlock()
  269. if len(w.tasks) == 0 {
  270. return
  271. }
  272. up := w.numLines + 1
  273. if !w.repeated {
  274. up--
  275. w.repeated = true
  276. }
  277. b := aec.NewBuilder(
  278. aec.Hide, // Hide the cursor while we are printing
  279. aec.Up(uint(up)),
  280. aec.Column(0),
  281. )
  282. _, _ = fmt.Fprint(w.out, b.ANSI)
  283. defer func() {
  284. _, _ = fmt.Fprint(w.out, aec.Show)
  285. }()
  286. firstLine := fmt.Sprintf("[+] %s %d/%d", w.operation, numDone(w.tasks), len(w.tasks))
  287. _, _ = fmt.Fprintln(w.out, firstLine)
  288. // Collect parent tasks in original order
  289. allTasks := slices.Collect(w.parentTasks())
  290. // Available lines: terminal height - 2 (header line + potential "more" line)
  291. maxLines := max(terminalHeight-2, 1)
  292. showMore := len(allTasks) > maxLines
  293. tasksToShow := allTasks
  294. if showMore {
  295. tasksToShow = allTasks[:maxLines-1] // Reserve one line for "more" message
  296. }
  297. // collect line data and compute timerLen
  298. lines := make([]lineData, len(tasksToShow))
  299. var timerLen int
  300. for i, t := range tasksToShow {
  301. lines[i] = w.prepareLineData(t)
  302. if len(lines[i].timer) > timerLen {
  303. timerLen = len(lines[i].timer)
  304. }
  305. }
  306. // pad timers so they all have the same visible width
  307. for i := range lines {
  308. l := &lines[i]
  309. if l.timer == "" {
  310. continue
  311. }
  312. timerWidth := utf8.RuneCountInString(l.timer)
  313. if timerWidth < timerLen {
  314. // Left-pad so the timer's right edge stays aligned on the terminal.
  315. // This also prevents stale suffix characters from visually “sticking”
  316. // when a previously-rendered timer was wider (e.g. "10.6s" -> "0.0s").
  317. l.timer = strings.Repeat(" ", timerLen-timerWidth) + l.timer
  318. }
  319. }
  320. // shorten details/taskID to fit terminal width
  321. w.adjustLineWidth(lines, timerLen, terminalWidth)
  322. // compute padding
  323. w.applyPadding(lines, terminalWidth, timerLen)
  324. // Render lines
  325. numLines := 0
  326. for _, l := range lines {
  327. _, _ = fmt.Fprint(w.out, lineText(l))
  328. numLines++
  329. }
  330. if showMore {
  331. moreCount := len(allTasks) - len(tasksToShow)
  332. moreText := fmt.Sprintf(" ... %d more", moreCount)
  333. pad := max(terminalWidth-len(moreText), 0)
  334. _, _ = fmt.Fprintf(w.out, "%s%s\n", moreText, strings.Repeat(" ", pad))
  335. numLines++
  336. }
  337. // Clear any remaining lines from previous render
  338. for i := numLines; i < w.numLines; i++ {
  339. _, _ = fmt.Fprintln(w.out, strings.Repeat(" ", terminalWidth))
  340. numLines++
  341. }
  342. w.numLines = numLines
  343. }
  344. func (w *ttyWriter) applyPadding(lines []lineData, terminalWidth int, timerLen int) {
  345. var maxBeforeStatus int
  346. for i := range lines {
  347. l := &lines[i]
  348. // Width before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress
  349. beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
  350. if beforeStatus > maxBeforeStatus {
  351. maxBeforeStatus = beforeStatus
  352. }
  353. }
  354. for i, l := range lines {
  355. // Position before statusPad: space(1) + spinner(1) + prefix + space(1) + taskID + progress
  356. beforeStatus := 3 + lenAnsi(l.prefix) + utf8.RuneCountInString(l.taskID) + lenAnsi(l.progress)
  357. // statusPad aligns status; lineText adds 1 more space after statusPad
  358. l.statusPad = maxBeforeStatus - beforeStatus
  359. // Format: beforeStatus + statusPad + space(1) + status
  360. lineLen := beforeStatus + l.statusPad + 1 + utf8.RuneCountInString(l.status)
  361. if l.details != "" {
  362. lineLen += 1 + utf8.RuneCountInString(l.details)
  363. }
  364. l.timerPad = max(terminalWidth-lineLen-timerLen, 1)
  365. lines[i] = l
  366. }
  367. }
  368. func (w *ttyWriter) adjustLineWidth(lines []lineData, timerLen int, terminalWidth int) {
  369. const minIDLen = 10
  370. maxStatusLen := maxStatusLength(lines)
  371. // Iteratively truncate until all lines fit
  372. for range 100 { // safety limit
  373. maxBeforeStatus := maxBeforeStatusWidth(lines)
  374. overflow := computeOverflow(lines, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth)
  375. if overflow <= 0 {
  376. break
  377. }
  378. // First try to truncate details, then taskID
  379. if !truncateDetails(lines, overflow) && !truncateLongestTaskID(lines, overflow, minIDLen) {
  380. break // Can't truncate further
  381. }
  382. }
  383. }
  384. // maxStatusLength returns the maximum status text length across all lines.
  385. func maxStatusLength(lines []lineData) int {
  386. var maxLen int
  387. for i := range lines {
  388. if len(lines[i].status) > maxLen {
  389. maxLen = len(lines[i].status)
  390. }
  391. }
  392. return maxLen
  393. }
  394. // maxBeforeStatusWidth computes the maximum width before statusPad across all lines.
  395. // This is: space(1) + spinner(1) + prefix + space(1) + taskID + progress
  396. func maxBeforeStatusWidth(lines []lineData) int {
  397. var maxWidth int
  398. for i := range lines {
  399. l := &lines[i]
  400. width := 3 + lenAnsi(l.prefix) + len(l.taskID) + lenAnsi(l.progress)
  401. if width > maxWidth {
  402. maxWidth = width
  403. }
  404. }
  405. return maxWidth
  406. }
  407. // computeOverflow calculates how many characters the widest line exceeds the terminal width.
  408. // Returns 0 or negative if all lines fit.
  409. func computeOverflow(lines []lineData, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth int) int {
  410. var maxOverflow int
  411. for i := range lines {
  412. l := &lines[i]
  413. detailsLen := len(l.details)
  414. if detailsLen > 0 {
  415. detailsLen++ // space before details
  416. }
  417. // Line width: maxBeforeStatus + space(1) + status + details + minTimerPad(1) + timer
  418. lineWidth := maxBeforeStatus + 1 + maxStatusLen + detailsLen + 1 + timerLen
  419. overflow := lineWidth - terminalWidth
  420. if overflow > maxOverflow {
  421. maxOverflow = overflow
  422. }
  423. }
  424. return maxOverflow
  425. }
  426. // truncateDetails tries to truncate the first line's details to reduce overflow.
  427. // Returns true if any truncation was performed.
  428. func truncateDetails(lines []lineData, overflow int) bool {
  429. for i := range lines {
  430. l := &lines[i]
  431. if len(l.details) > 3 {
  432. reduction := min(overflow, len(l.details)-3)
  433. l.details = l.details[:len(l.details)-reduction-3] + "..."
  434. return true
  435. } else if l.details != "" {
  436. l.details = ""
  437. return true
  438. }
  439. }
  440. return false
  441. }
  442. // truncateLongestTaskID truncates the longest taskID to reduce overflow.
  443. // Returns true if truncation was performed.
  444. func truncateLongestTaskID(lines []lineData, overflow, minIDLen int) bool {
  445. longestIdx := -1
  446. longestLen := minIDLen
  447. for i := range lines {
  448. if len(lines[i].taskID) > longestLen {
  449. longestLen = len(lines[i].taskID)
  450. longestIdx = i
  451. }
  452. }
  453. if longestIdx < 0 {
  454. return false
  455. }
  456. l := &lines[longestIdx]
  457. reduction := overflow + 3 // account for "..."
  458. newLen := max(len(l.taskID)-reduction, minIDLen-3)
  459. if newLen > 0 {
  460. l.taskID = l.taskID[:newLen] + "..."
  461. }
  462. return true
  463. }
  464. func (w *ttyWriter) prepareLineData(t *task) lineData {
  465. endTime := time.Now()
  466. if t.status != api.Working {
  467. endTime = t.startTime
  468. if (t.endTime != time.Time{}) {
  469. endTime = t.endTime
  470. }
  471. }
  472. prefix := ""
  473. if w.dryRun {
  474. prefix = PrefixColor(DRYRUN_PREFIX)
  475. }
  476. elapsed := endTime.Sub(t.startTime).Seconds()
  477. var (
  478. hideDetails bool
  479. total int64
  480. current int64
  481. completion []string
  482. )
  483. // only show the aggregated progress while the root operation is in-progress
  484. if t.status == api.Working {
  485. for child := range w.childrenTasks(t.ID) {
  486. if child.status == api.Working && child.total == 0 {
  487. hideDetails = true
  488. }
  489. total += child.total
  490. current += child.current
  491. r := len(percentChars) - 1
  492. p := min(child.percent, 100)
  493. completion = append(completion, percentChars[r*p/100])
  494. }
  495. }
  496. if total == 0 {
  497. hideDetails = true
  498. }
  499. var progress string
  500. if len(completion) > 0 {
  501. progress = " [" + SuccessColor(strings.Join(completion, "")) + "]"
  502. if !hideDetails {
  503. progress += fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
  504. }
  505. }
  506. return lineData{
  507. spinner: spinner(t),
  508. prefix: prefix,
  509. taskID: t.ID,
  510. progress: progress,
  511. status: t.text,
  512. statusColor: colorFn(t.status),
  513. details: t.details,
  514. timer: fmt.Sprintf("%.1fs", elapsed),
  515. }
  516. }
  517. func lineText(l lineData) string {
  518. var sb strings.Builder
  519. sb.WriteString(" ")
  520. sb.WriteString(l.spinner)
  521. sb.WriteString(l.prefix)
  522. sb.WriteString(" ")
  523. sb.WriteString(l.taskID)
  524. sb.WriteString(l.progress)
  525. sb.WriteString(strings.Repeat(" ", l.statusPad))
  526. sb.WriteString(" ")
  527. sb.WriteString(l.statusColor(l.status))
  528. if l.details != "" {
  529. sb.WriteString(" ")
  530. sb.WriteString(l.details)
  531. }
  532. sb.WriteString(strings.Repeat(" ", l.timerPad))
  533. sb.WriteString(TimerColor(l.timer))
  534. sb.WriteString("\n")
  535. return sb.String()
  536. }
  537. var (
  538. spinnerDone = "✔"
  539. spinnerWarning = "!"
  540. spinnerError = "✘"
  541. )
  542. func spinner(t *task) string {
  543. switch t.status {
  544. case api.Done:
  545. return SuccessColor(spinnerDone)
  546. case api.Warning:
  547. return WarningColor(spinnerWarning)
  548. case api.Error:
  549. return ErrorColor(spinnerError)
  550. default:
  551. return CountColor(t.spinner.String())
  552. }
  553. }
  554. func colorFn(s api.EventStatus) colorFunc {
  555. switch s {
  556. case api.Done:
  557. return SuccessColor
  558. case api.Warning:
  559. return WarningColor
  560. case api.Error:
  561. return ErrorColor
  562. default:
  563. return nocolor
  564. }
  565. }
  566. func numDone(tasks map[string]*task) int {
  567. i := 0
  568. for _, t := range tasks {
  569. if t.status != api.Working {
  570. i++
  571. }
  572. }
  573. return i
  574. }
  575. // lenAnsi count of user-perceived characters in ANSI string.
  576. func lenAnsi(s string) int {
  577. length := 0
  578. ansiCode := false
  579. for _, r := range s {
  580. if r == '\x1b' {
  581. ansiCode = true
  582. continue
  583. }
  584. if ansiCode && r == 'm' {
  585. ansiCode = false
  586. continue
  587. }
  588. if !ansiCode {
  589. length++
  590. }
  591. }
  592. return length
  593. }
  594. var percentChars = strings.Split("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "")