Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 120 additions & 65 deletions cmd/display/tty.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"fmt"
"io"
"iter"
"strings"
"sync"
"time"
Expand All @@ -29,6 +30,7 @@ import (
"github.com/morikuni/aec"

"github.com/docker/compose/v5/pkg/api"
"github.com/docker/compose/v5/pkg/utils"
)

// Full creates an EventProcessor that render advanced UI within a terminal.
Expand All @@ -37,7 +39,7 @@ func Full(out io.Writer, info io.Writer) api.EventProcessor {
return &ttyWriter{
out: out,
info: info,
tasks: map[string]task{},
tasks: map[string]*task{},
done: make(chan bool),
mtx: &sync.Mutex{},
}
Expand All @@ -46,7 +48,7 @@ func Full(out io.Writer, info io.Writer) api.EventProcessor {
type ttyWriter struct {
out io.Writer
ids []string // tasks ids ordered as first event appeared
tasks map[string]task
tasks map[string]*task
repeated bool
numLines int
done chan bool
Expand All @@ -60,7 +62,8 @@ type ttyWriter struct {

type task struct {
ID string
parentID string
parent string // the resource this task receives updates from - other parents will be ignored
parents utils.Set[string] // all resources to depend on this task
startTime time.Time
endTime time.Time
text string
Expand All @@ -72,6 +75,64 @@ type task struct {
spinner *Spinner
}

func newTask(e api.Resource) task {
t := task{
ID: e.ID,
parents: utils.NewSet[string](),
startTime: time.Now(),
text: e.Text,
details: e.Details,
status: e.Status,
current: e.Current,
percent: e.Percent,
total: e.Total,
spinner: NewSpinner(),
}
if e.ParentID != "" {
t.parent = e.ParentID
t.parents.Add(e.ParentID)
}
if e.Status == api.Done || e.Status == api.Error {
t.stop()
}
return t
}

// update adjusts task state based on last received event
func (t *task) update(e api.Resource) {
if e.ParentID != "" {
t.parents.Add(e.ParentID)
// we may receive same event from distinct parents (typically: images sharing layers)
// to avoid status to flicker, only accept updates from our first declared parent
if t.parent != e.ParentID {
return
}
}

// update task based on received event
switch e.Status {
case api.Done, api.Error, api.Warning:
if t.status != e.Status {
t.stop()
}
case api.Working:
t.hasMore()
}
t.status = e.Status
t.text = e.Text
t.details = e.Details
// progress can only go up
if e.Total > t.total {
t.total = e.Total
}
if e.Current > t.current {
t.current = e.Current
}
if e.Percent > t.percent {
t.percent = e.Percent
}
}

func (t *task) stop() {
t.endTime = time.Now()
t.spinner.Stop()
Expand All @@ -81,6 +142,15 @@ func (t *task) hasMore() {
t.spinner.Restart()
}

func (t *task) Completed() bool {
switch t.status {
case api.Done, api.Error, api.Warning:
return true
default:
return false
}
}

func (w *ttyWriter) Start(ctx context.Context, operation string) {
w.ticker = time.NewTicker(100 * time.Millisecond)
w.operation = operation
Expand Down Expand Up @@ -137,49 +207,10 @@ func (w *ttyWriter) event(e api.Resource) {
}

if last, ok := w.tasks[e.ID]; ok {
switch e.Status {
case api.Done, api.Error, api.Warning:
if last.status != e.Status {
last.stop()
}
case api.Working:
last.hasMore()
}
last.status = e.Status
last.text = e.Text
last.details = e.Details
// progress can only go up
if e.Total > last.total {
last.total = e.Total
}
if e.Current > last.current {
last.current = e.Current
}
if e.Percent > last.percent {
last.percent = e.Percent
}
// allow set/unset of parent, but not swapping otherwise prompt is flickering
if last.parentID == "" || e.ParentID == "" {
last.parentID = e.ParentID
}
w.tasks[e.ID] = last
last.update(e)
} else {
t := task{
ID: e.ID,
parentID: e.ParentID,
startTime: time.Now(),
text: e.Text,
details: e.Details,
status: e.Status,
current: e.Current,
percent: e.Percent,
total: e.Total,
spinner: NewSpinner(),
}
if e.Status == api.Done || e.Status == api.Error {
t.stop()
}
w.tasks[e.ID] = t
t := newTask(e)
w.tasks[e.ID] = &t
w.ids = append(w.ids, e.ID)
}
w.printEvent(e)
Expand All @@ -205,6 +236,28 @@ func (w *ttyWriter) printEvent(e api.Resource) {
_, _ = fmt.Fprintf(w.out, "%s %s %s\n", e.ID, color(e.Text), e.Details)
}

func (w *ttyWriter) parentTasks() iter.Seq[*task] {
return func(yield func(*task) bool) {
for _, id := range w.ids { // iterate on ids to enforce a consistent order
t := w.tasks[id]
if len(t.parents) == 0 {
yield(t)
}
}
}
}

func (w *ttyWriter) childrenTasks(parent string) iter.Seq[*task] {
return func(yield func(*task) bool) {
for _, id := range w.ids { // iterate on ids to enforce a consistent order
t := w.tasks[id]
if t.parents.Has(parent) {
yield(t)
}
}
}
}

func (w *ttyWriter) print() {
w.mtx.Lock()
defer w.mtx.Unlock()
Expand Down Expand Up @@ -233,20 +286,25 @@ func (w *ttyWriter) print() {
var statusPadding int
for _, t := range w.tasks {
l := len(t.ID)
if t.parentID == "" && statusPadding < l {
if len(t.parents) == 0 && statusPadding < l {
statusPadding = l
}
}

skipChildEvents := len(w.tasks) > goterm.Height()-2
numLines := 0
for _, id := range w.ids { // iterate on ids to enforce a consistent order
t := w.tasks[id]
if t.parentID != "" {
continue
}
for t := range w.parentTasks() {
line := w.lineText(t, "", terminalWidth, statusPadding, w.dryRun)
_, _ = fmt.Fprint(w.out, line)
numLines++
if skipChildEvents {
continue
}
for child := range w.childrenTasks(t.ID) {
line := w.lineText(child, " ", terminalWidth, statusPadding-2, w.dryRun)
_, _ = fmt.Fprint(w.out, line)
numLines++
}
}
for i := numLines; i < w.numLines; i++ {
if numLines < goterm.Height()-2 {
Expand All @@ -257,7 +315,7 @@ func (w *ttyWriter) print() {
w.numLines = numLines
}

func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding int, dryRun bool) string {
func (w *ttyWriter) lineText(t *task, pad string, terminalWidth, statusPadding int, dryRun bool) string {
endTime := time.Now()
if t.status != api.Working {
endTime = t.startTime
Expand All @@ -281,18 +339,15 @@ func (w *ttyWriter) lineText(t task, pad string, terminalWidth, statusPadding in

// only show the aggregated progress while the root operation is in-progress
if parent := t; parent.status == api.Working {
for _, id := range w.ids {
child := w.tasks[id]
if child.parentID == parent.ID {
if child.status == api.Working && child.total == 0 {
// we don't have totals available for all the child events
// so don't show the total progress yet
hideDetails = true
}
total += child.total
current += child.current
completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100])
for child := range w.childrenTasks(parent.ID) {
if child.status == api.Working && child.total == 0 {
// we don't have totals available for all the child events
// so don't show the total progress yet
hideDetails = true
}
total += child.total
current += child.current
completion = append(completion, percentChars[(len(percentChars)-1)*child.percent/100])
}
}

Expand Down Expand Up @@ -347,7 +402,7 @@ var (
spinnerError = "✘"
)

func spinner(t task) string {
func spinner(t *task) string {
switch t.status {
case api.Done:
return SuccessColor(spinnerDone)
Expand All @@ -373,7 +428,7 @@ func colorFn(s api.EventStatus) colorFunc {
}
}

func numDone(tasks map[string]task) int {
func numDone(tasks map[string]*task) int {
i := 0
for _, t := range tasks {
if t.status != api.Working {
Expand Down
56 changes: 29 additions & 27 deletions pkg/api/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,33 +38,35 @@ const (
const ResourceCompose = "Compose"

const (
StatusError = "Error"
StatusCreating = "Creating"
StatusStarting = "Starting"
StatusStarted = "Started"
StatusWaiting = "Waiting"
StatusHealthy = "Healthy"
StatusExited = "Exited"
StatusRestarting = "Restarting"
StatusRestarted = "Restarted"
StatusRunning = "Running"
StatusCreated = "Created"
StatusStopping = "Stopping"
StatusStopped = "Stopped"
StatusKilling = "Killing"
StatusKilled = "Killed"
StatusRemoving = "Removing"
StatusRemoved = "Removed"
StatusBuilding = "Building"
StatusBuilt = "Built"
StatusPulling = "Pulling"
StatusPulled = "Pulled"
StatusCommitting = "Committing"
StatusCommitted = "Committed"
StatusCopying = "Copying"
StatusCopied = "Copied"
StatusExporting = "Exporting"
StatusExported = "Exported"
StatusError = "Error"
StatusCreating = "Creating"
StatusStarting = "Starting"
StatusStarted = "Started"
StatusWaiting = "Waiting"
StatusHealthy = "Healthy"
StatusExited = "Exited"
StatusRestarting = "Restarting"
StatusRestarted = "Restarted"
StatusRunning = "Running"
StatusCreated = "Created"
StatusStopping = "Stopping"
StatusStopped = "Stopped"
StatusKilling = "Killing"
StatusKilled = "Killed"
StatusRemoving = "Removing"
StatusRemoved = "Removed"
StatusBuilding = "Building"
StatusBuilt = "Built"
StatusPulling = "Pulling"
StatusPulled = "Pulled"
StatusCommitting = "Committing"
StatusCommitted = "Committed"
StatusCopying = "Copying"
StatusCopied = "Copied"
StatusExporting = "Exporting"
StatusExported = "Exported"
StatusDownloading = "Downloading"
StatusDownloadComplete = "Download complete"
)

// Resource represents status change and progress for a compose resource.
Expand Down
17 changes: 9 additions & 8 deletions pkg/compose/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,14 +398,14 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E
}

var (
text string
total int64
percent int
current int64
status = api.Working
progress string
total int64
percent int
current int64
status = api.Working
)

text = jm.Progress.String()
progress = jm.Progress.String()

switch jm.Status {
case PreparingPhase, WaitingPhase, PullingFsPhase:
Expand All @@ -431,7 +431,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E

if jm.Error != nil {
status = api.Error
text = jm.Error.Message
progress = jm.Error.Message
}

events.On(api.Resource{
Expand All @@ -441,6 +441,7 @@ func toPullProgressEvent(parent string, jm jsonmessage.JSONMessage, events api.E
Total: total,
Percent: percent,
Status: status,
Text: text,
Text: jm.Status,
Details: progress,
})
}