tty.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  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.mtx.Lock()
  162. defer w.mtx.Unlock()
  163. if w.ticker != nil {
  164. w.ticker.Stop()
  165. }
  166. w.operation = ""
  167. w.done <- true
  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. // shorten details/taskID to fit terminal width
  307. w.adjustLineWidth(lines, timerLen, terminalWidth)
  308. // compute padding
  309. w.applyPadding(lines, terminalWidth, timerLen)
  310. // Render lines
  311. numLines := 0
  312. for _, l := range lines {
  313. _, _ = fmt.Fprint(w.out, lineText(l))
  314. numLines++
  315. }
  316. if showMore {
  317. moreCount := len(allTasks) - len(tasksToShow)
  318. moreText := fmt.Sprintf(" ... %d more", moreCount)
  319. pad := max(terminalWidth-len(moreText), 0)
  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 = max(terminalWidth-lineLen-timerLen, 1)
  351. lines[i] = l
  352. }
  353. }
  354. func (w *ttyWriter) adjustLineWidth(lines []lineData, timerLen int, terminalWidth int) {
  355. const minIDLen = 10
  356. maxStatusLen := maxStatusLength(lines)
  357. // Iteratively truncate until all lines fit
  358. for range 100 { // safety limit
  359. maxBeforeStatus := maxBeforeStatusWidth(lines)
  360. overflow := computeOverflow(lines, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth)
  361. if overflow <= 0 {
  362. break
  363. }
  364. // First try to truncate details, then taskID
  365. if !truncateDetails(lines, overflow) && !truncateLongestTaskID(lines, overflow, minIDLen) {
  366. break // Can't truncate further
  367. }
  368. }
  369. }
  370. // maxStatusLength returns the maximum status text length across all lines.
  371. func maxStatusLength(lines []lineData) int {
  372. var maxLen int
  373. for i := range lines {
  374. if len(lines[i].status) > maxLen {
  375. maxLen = len(lines[i].status)
  376. }
  377. }
  378. return maxLen
  379. }
  380. // maxBeforeStatusWidth computes the maximum width before statusPad across all lines.
  381. // This is: space(1) + spinner(1) + prefix + space(1) + taskID + progress
  382. func maxBeforeStatusWidth(lines []lineData) int {
  383. var maxWidth int
  384. for i := range lines {
  385. l := &lines[i]
  386. width := 3 + lenAnsi(l.prefix) + len(l.taskID) + lenAnsi(l.progress)
  387. if width > maxWidth {
  388. maxWidth = width
  389. }
  390. }
  391. return maxWidth
  392. }
  393. // computeOverflow calculates how many characters the widest line exceeds the terminal width.
  394. // Returns 0 or negative if all lines fit.
  395. func computeOverflow(lines []lineData, maxBeforeStatus, maxStatusLen, timerLen, terminalWidth int) int {
  396. var maxOverflow int
  397. for i := range lines {
  398. l := &lines[i]
  399. detailsLen := len(l.details)
  400. if detailsLen > 0 {
  401. detailsLen++ // space before details
  402. }
  403. // Line width: maxBeforeStatus + space(1) + status + details + minTimerPad(1) + timer
  404. lineWidth := maxBeforeStatus + 1 + maxStatusLen + detailsLen + 1 + timerLen
  405. overflow := lineWidth - terminalWidth
  406. if overflow > maxOverflow {
  407. maxOverflow = overflow
  408. }
  409. }
  410. return maxOverflow
  411. }
  412. // truncateDetails tries to truncate the first line's details to reduce overflow.
  413. // Returns true if any truncation was performed.
  414. func truncateDetails(lines []lineData, overflow int) bool {
  415. for i := range lines {
  416. l := &lines[i]
  417. if len(l.details) > 3 {
  418. reduction := min(overflow, len(l.details)-3)
  419. l.details = l.details[:len(l.details)-reduction-3] + "..."
  420. return true
  421. } else if l.details != "" {
  422. l.details = ""
  423. return true
  424. }
  425. }
  426. return false
  427. }
  428. // truncateLongestTaskID truncates the longest taskID to reduce overflow.
  429. // Returns true if truncation was performed.
  430. func truncateLongestTaskID(lines []lineData, overflow, minIDLen int) bool {
  431. longestIdx := -1
  432. longestLen := minIDLen
  433. for i := range lines {
  434. if len(lines[i].taskID) > longestLen {
  435. longestLen = len(lines[i].taskID)
  436. longestIdx = i
  437. }
  438. }
  439. if longestIdx < 0 {
  440. return false
  441. }
  442. l := &lines[longestIdx]
  443. reduction := overflow + 3 // account for "..."
  444. newLen := max(len(l.taskID)-reduction, minIDLen-3)
  445. if newLen > 0 {
  446. l.taskID = l.taskID[:newLen] + "..."
  447. }
  448. return true
  449. }
  450. func (w *ttyWriter) prepareLineData(t *task) lineData {
  451. endTime := time.Now()
  452. if t.status != api.Working {
  453. endTime = t.startTime
  454. if (t.endTime != time.Time{}) {
  455. endTime = t.endTime
  456. }
  457. }
  458. prefix := ""
  459. if w.dryRun {
  460. prefix = PrefixColor(DRYRUN_PREFIX)
  461. }
  462. elapsed := endTime.Sub(t.startTime).Seconds()
  463. var (
  464. hideDetails bool
  465. total int64
  466. current int64
  467. completion []string
  468. )
  469. // only show the aggregated progress while the root operation is in-progress
  470. if t.status == api.Working {
  471. for child := range w.childrenTasks(t.ID) {
  472. if child.status == api.Working && child.total == 0 {
  473. hideDetails = true
  474. }
  475. total += child.total
  476. current += child.current
  477. r := len(percentChars) - 1
  478. p := min(child.percent, 100)
  479. completion = append(completion, percentChars[r*p/100])
  480. }
  481. }
  482. if total == 0 {
  483. hideDetails = true
  484. }
  485. var progress string
  486. if len(completion) > 0 {
  487. progress = " [" + SuccessColor(strings.Join(completion, "")) + "]"
  488. if !hideDetails {
  489. progress += fmt.Sprintf(" %7s / %-7s", units.HumanSize(float64(current)), units.HumanSize(float64(total)))
  490. }
  491. }
  492. return lineData{
  493. spinner: spinner(t),
  494. prefix: prefix,
  495. taskID: t.ID,
  496. progress: progress,
  497. status: t.text,
  498. statusColor: colorFn(t.status),
  499. details: t.details,
  500. timer: fmt.Sprintf("%.1fs", elapsed),
  501. }
  502. }
  503. func lineText(l lineData) string {
  504. var sb strings.Builder
  505. sb.WriteString(" ")
  506. sb.WriteString(l.spinner)
  507. sb.WriteString(l.prefix)
  508. sb.WriteString(" ")
  509. sb.WriteString(l.taskID)
  510. sb.WriteString(l.progress)
  511. sb.WriteString(strings.Repeat(" ", l.statusPad))
  512. sb.WriteString(" ")
  513. sb.WriteString(l.statusColor(l.status))
  514. if l.details != "" {
  515. sb.WriteString(" ")
  516. sb.WriteString(l.details)
  517. }
  518. sb.WriteString(strings.Repeat(" ", l.timerPad))
  519. sb.WriteString(TimerColor(l.timer))
  520. sb.WriteString("\n")
  521. return sb.String()
  522. }
  523. var (
  524. spinnerDone = "✔"
  525. spinnerWarning = "!"
  526. spinnerError = "✘"
  527. )
  528. func spinner(t *task) string {
  529. switch t.status {
  530. case api.Done:
  531. return SuccessColor(spinnerDone)
  532. case api.Warning:
  533. return WarningColor(spinnerWarning)
  534. case api.Error:
  535. return ErrorColor(spinnerError)
  536. default:
  537. return CountColor(t.spinner.String())
  538. }
  539. }
  540. func colorFn(s api.EventStatus) colorFunc {
  541. switch s {
  542. case api.Done:
  543. return SuccessColor
  544. case api.Warning:
  545. return WarningColor
  546. case api.Error:
  547. return ErrorColor
  548. default:
  549. return nocolor
  550. }
  551. }
  552. func numDone(tasks map[string]*task) int {
  553. i := 0
  554. for _, t := range tasks {
  555. if t.status != api.Working {
  556. i++
  557. }
  558. }
  559. return i
  560. }
  561. // lenAnsi count of user-perceived characters in ANSI string.
  562. func lenAnsi(s string) int {
  563. length := 0
  564. ansiCode := false
  565. for _, r := range s {
  566. if r == '\x1b' {
  567. ansiCode = true
  568. continue
  569. }
  570. if ansiCode && r == 'm' {
  571. ansiCode = false
  572. continue
  573. }
  574. if !ansiCode {
  575. length++
  576. }
  577. }
  578. return length
  579. }
  580. var percentChars = strings.Split("⠀⡀⣀⣄⣤⣦⣶⣷⣿", "")