tty.go 15 KB

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