From ed912b3798a703737be41745c8d638ed90c19450 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 9 Dec 2025 13:51:41 -0800 Subject: [PATCH 01/11] factor out WatchServiceState --- src/pkg/cli/client/errors.go | 7 ++++++- src/pkg/cli/subscribe.go | 40 +++++++++++++++++++++++++++--------- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/pkg/cli/client/errors.go b/src/pkg/cli/client/errors.go index 49c831a3b..c9a3b0a75 100644 --- a/src/pkg/cli/client/errors.go +++ b/src/pkg/cli/client/errors.go @@ -1,6 +1,11 @@ package client -import "fmt" +import ( + "errors" + "fmt" +) + +var ErrDeploymentSucceeded = errors.New("deployment succeeded") type ErrDeploymentFailed struct { Message string diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index be00a1e37..40029cae2 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -15,16 +15,14 @@ var ErrNothingToMonitor = errors.New("no services to monitor") type ServiceStates = map[string]defangv1.ServiceState -func WaitServiceState( +func WatchServiceState( ctx context.Context, provider client.Provider, - targetState defangv1.ServiceState, projectName string, etag types.ETag, services []string, + cb func(*defangv1.SubscribeResponse, *ServiceStates) error, ) (ServiceStates, error) { - term.Debugf("waiting for services %v to reach state %s\n", services, targetState) // TODO: don't print in Go-routine - if len(services) == 0 { return nil, ErrNothingToMonitor } @@ -83,20 +81,42 @@ func WaitServiceState( continue } - if serviceStates[msg.Name] != targetState { - serviceStates[msg.Name] = msg.State + serviceStates[msg.Name] = msg.State + err = cb(msg, &serviceStates) + if err != nil { + if errors.Is(err, client.ErrDeploymentSucceeded) { + return serviceStates, nil + } + return serviceStates, err + } + } +} + +func WaitServiceState( + ctx context.Context, + provider client.Provider, + targetState defangv1.ServiceState, + projectName string, + etag types.ETag, + services []string, +) (ServiceStates, error) { + term.Debugf("waiting for services %v to reach state %s\n", services, targetState) // TODO: don't print in Go-routine + return WatchServiceState(ctx, provider, projectName, etag, services, func(msg *defangv1.SubscribeResponse, serviceStates *ServiceStates) error { + if (*serviceStates)[msg.Name] != targetState { // exit early on detecting a FAILED state switch msg.State { case defangv1.ServiceState_BUILD_FAILED, defangv1.ServiceState_DEPLOYMENT_FAILED: - return serviceStates, client.ErrDeploymentFailed{Service: msg.Name, Message: msg.Status} + return client.ErrDeploymentFailed{Service: msg.Name, Message: msg.Status} } } - if allInState(targetState, serviceStates) { - return serviceStates, nil // all services are in the target state + if allInState(targetState, *serviceStates) { + return client.ErrDeploymentSucceeded // signal successful completion } - } + + return nil + }) } func allInState(targetState defangv1.ServiceState, serviceStates ServiceStates) bool { From 74759bc670da568f268b7c998a55876efd12f4a7 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 9 Dec 2025 13:52:19 -0800 Subject: [PATCH 02/11] factor out Monitor from TailAndMonitor --- src/pkg/cli/tailAndMonitor.go | 65 +++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index 1c922446f..c75dab50b 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -15,12 +15,9 @@ import ( defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" ) -const targetServiceState = defangv1.ServiceState_DEPLOYMENT_COMPLETED - -func TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, tailOptions TailOptions) (ServiceStates, error) { - tailOptions.Follow = true - if tailOptions.Deployment == "" { - panic("tailOptions.Deployment must be a valid deployment ID") +func Monitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string) (ServiceStates, error) { + if deploymentID == "" { + panic("deploymentID must be a valid deployment ID") } if waitTimeout > 0 { var cancelTimeout context.CancelFunc @@ -28,42 +25,66 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie defer cancelTimeout() } - tailCtx, cancelTail := context.WithCancelCause(context.Background()) - defer cancelTail(nil) // to cancel tail and clean-up context - svcStatusCtx, cancelSvcStatus := context.WithCancelCause(ctx) - defer cancelSvcStatus(nil) // to cancel WaitServiceState and clean-up context + defer cancelSvcStatus(nil) _, computeServices := splitManagedAndUnmanagedServices(project.Services) - var serviceStates ServiceStates - var cdErr, svcErr error + for _, svc := range computeServices { + term.Infof("[%s] %s\n", svc, "DEPLOYMENT_PENDING") + } + var ( + serviceStates ServiceStates + cdErr, svcErr error + ) wg := &sync.WaitGroup{} wg.Add(2) go func() { defer wg.Done() - // block on waiting for services to reach target state - serviceStates, svcErr = WaitServiceState(svcStatusCtx, provider, targetServiceState, project.Name, tailOptions.Deployment, computeServices) + serviceStates, svcErr = WatchServiceState(svcStatusCtx, provider, project.Name, deploymentID, computeServices, func(msg *defangv1.SubscribeResponse, states *ServiceStates) error { + // Print service status updates as they arrive + for name, state := range *states { + term.Infof("[%s] %s\n", name, state.String()) + } + return nil + }) }() go func() { defer wg.Done() - // block on waiting for cdTask to complete if err := WaitForCdTaskExit(ctx, provider); err != nil { cdErr = err - // When CD fails, stop WaitServiceState cancelSvcStatus(cdErr) } }() + wg.Wait() + pkg.SleepWithContext(ctx, 2*time.Second) + + return serviceStates, errors.Join(cdErr, svcErr) +} + +func TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, tailOptions TailOptions) (ServiceStates, error) { + tailOptions.Follow = true + if tailOptions.Deployment == "" { + panic("tailOptions.Deployment must be a valid deployment ID") + } + + tailCtx, cancelTail := context.WithCancelCause(context.Background()) + defer cancelTail(nil) // to cancel tail and clean-up context + errMonitoringDone := errors.New("monitoring done") // pseudo error to signal that monitoring is done + var serviceStates ServiceStates + var monitorErr error + + // Run Monitor in a goroutine go func() { - wg.Wait() + serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, tailOptions.Deployment) pkg.SleepWithContext(ctx, 2*time.Second) // a delay before cancelling tail to make sure we get last status messages - cancelTail(errMonitoringDone) // cancel the tail when both goroutines are done + cancelTail(errMonitoringDone) // cancel the tail when monitoring is done }() tailOptions.PrintBookends = false @@ -82,13 +103,13 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie switch { case errors.Is(err, io.EOF): - break // an end condition was detected; cdErr and/or svcErr might be nil + break // an end condition was detected; monitorErr might be nil case errors.Is(context.Cause(ctx), context.Canceled): term.Warn("Deployment is not finished. Service(s) might not be running.") case errors.Is(context.Cause(tailCtx), errMonitoringDone): - break // the monitoring stopped the tail; cdErr and/or svcErr will have been set + break // the monitoring stopped the tail; monitorErr will have been set case errors.Is(context.Cause(ctx), context.DeadlineExceeded): // Tail was canceled when wait-timeout is reached; show a warning and exit with an error @@ -96,11 +117,11 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie fallthrough default: - tailErr = err // report the error, in addition to the cdErr and svcErr + tailErr = err // report the error, in addition to the monitorErr } } - return serviceStates, errors.Join(cdErr, svcErr, tailErr) + return serviceStates, errors.Join(monitorErr, tailErr) } func CanMonitorService(service *compose.ServiceConfig) bool { From 1c1274e743c5153a7b588ee65f4bc8163bb7c83c Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 16 Mar 2026 16:07:55 -0700 Subject: [PATCH 03/11] monitor instead of tail by default after compose up --- src/cmd/cli/command/compose.go | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index f81bf21f6..4781b6a66 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -153,16 +153,21 @@ func makeComposeUpCmd() *cobra.Command { return nil } - // show users the current streaming logs - tailSource := "all services" - if deploy.Etag != "" { - tailSource = "deployment ID " + deploy.Etag + waitTimeoutDuration := time.Duration(waitTimeout) * time.Second + var serviceStates map[string]defangv1.ServiceState + if global.Verbose || global.NonInteractive { + tailOptions := newTailOptionsForDeploy(session.Stack.Name, deploy.Etag, since, global.Verbose) + tailOptions.Follow = true + term.Info("Tailing logs for deployment ID", deploy.Etag, "; press Ctrl+C to detach:") + serviceStates, err = cli.TailAndMonitor(ctx, project, session.Provider, waitTimeoutDuration, tailOptions) + if err != nil { + return err + } + } else { + term.Info("Live tail logs with `defang tail --deployment=" + deploy.Etag + "`") + serviceStates, err = cli.Monitor(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) } - term.Info("Tailing logs for", tailSource, "; press Ctrl+C to detach:") - - tailOptions := newTailOptionsForDeploy(session.Stack.Name, deploy.Etag, since, global.Verbose) - serviceStates, err := cli.TailAndMonitor(ctx, project, session.Provider, time.Duration(waitTimeout)*time.Second, tailOptions) - if err != nil { + if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err debugger, err := debug.NewDebugger(ctx, global.FabricAddr, session.Stack) if err != nil { From cdfab3919886fd6f2b6f8a5130bff49e73c546c0 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 9 Dec 2025 14:31:35 -0800 Subject: [PATCH 04/11] print logs on deployment failure print all cd logs, print build logs for failed services and runtime logs for unhealthy services --- src/cmd/cli/command/compose.go | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index 4781b6a66..d76b19125 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -169,6 +169,14 @@ func makeComposeUpCmd() *cobra.Command { } if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err + + options := tailOptionsForDeploymentFailure(deploy.Etag, since, serviceStates) + err := cli.Tail(ctx, session.Provider, project.Name, options) + if err != nil && !errors.Is(err, io.EOF) { + term.Warn("Failed to tail logs for deployment error", err) + return deploymentErr + } + debugger, err := debug.NewDebugger(ctx, global.FabricAddr, session.Stack) if err != nil { term.Warn("Failed to initialize debugger:", err) @@ -218,6 +226,37 @@ func makeComposeUpCmd() *cobra.Command { return composeUpCmd } +func tailOptionsForDeploymentFailure(etag types.ETag, since time.Time, serviceStates map[string]defangv1.ServiceState) cli.TailOptions { + options := cli.TailOptions{ + Deployment: etag, + LogType: logs.LogTypeCD, + Since: since, + Verbose: true, + Follow: false, + } + + // if any services failed to build, only show build logs for those services + var unbuiltServices = make([]string, 0, len(serviceStates)) + var unhealthyServices = make([]string, 0, len(serviceStates)) + for service, state := range serviceStates { + if state <= defangv1.ServiceState_BUILD_STOPPING { + unbuiltServices = append(unbuiltServices, service) + } else if state != defangv1.ServiceState_DEPLOYMENT_COMPLETED { + unhealthyServices = append(unhealthyServices, service) + } + } + + if len(unbuiltServices) > 0 { + options.LogType = logs.LogTypeBuild + options.Services = unbuiltServices + } else { + options.LogType = logs.LogTypeCD & logs.LogTypeRun + options.Services = unhealthyServices + } + + return options +} + func confirmDeployment(targetDirectory string, existingDeployments []*defangv1.Deployment, accountInfo *client.AccountInfo, stackName string) (bool, error) { samePlace := slices.ContainsFunc(existingDeployments, func(dep *defangv1.Deployment) bool { if dep.Provider != accountInfo.Provider.Value() { From 5232c414c67c04b2fccc1f11edec61b307c78a87 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Fri, 19 Dec 2025 12:45:46 -0800 Subject: [PATCH 05/11] Monitor accepts a callback --- src/cmd/cli/command/compose.go | 10 +++++++++- src/pkg/cli/tailAndMonitor.go | 19 ++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index d76b19125..e5a533232 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -165,7 +165,15 @@ func makeComposeUpCmd() *cobra.Command { } } else { term.Info("Live tail logs with `defang tail --deployment=" + deploy.Etag + "`") - serviceStates, err = cli.Monitor(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) + for _, svc := range project.Services { + term.Infof("[%s] %s\n", svc.Name, "DEPLOYMENT_PENDING") + } + serviceStates, err = cli.Monitor(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag, func(msg *defangv1.SubscribeResponse, serviceStates *map[string]defangv1.ServiceState) error { + for svc, state := range *serviceStates { + term.Infof("[%s] %s\n", svc, state) + } + return nil + }) } if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index c75dab50b..51888ce02 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -15,7 +15,7 @@ import ( defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" ) -func Monitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string) (ServiceStates, error) { +func Monitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string, watchCallback func(*defangv1.SubscribeResponse, *ServiceStates) error) (ServiceStates, error) { if deploymentID == "" { panic("deploymentID must be a valid deployment ID") } @@ -30,10 +30,6 @@ func Monitor(ctx context.Context, project *compose.Project, provider client.Prov _, computeServices := splitManagedAndUnmanagedServices(project.Services) - for _, svc := range computeServices { - term.Infof("[%s] %s\n", svc, "DEPLOYMENT_PENDING") - } - var ( serviceStates ServiceStates cdErr, svcErr error @@ -43,13 +39,7 @@ func Monitor(ctx context.Context, project *compose.Project, provider client.Prov go func() { defer wg.Done() - serviceStates, svcErr = WatchServiceState(svcStatusCtx, provider, project.Name, deploymentID, computeServices, func(msg *defangv1.SubscribeResponse, states *ServiceStates) error { - // Print service status updates as they arrive - for name, state := range *states { - term.Infof("[%s] %s\n", name, state.String()) - } - return nil - }) + serviceStates, svcErr = WatchServiceState(svcStatusCtx, provider, project.Name, deploymentID, computeServices, watchCallback) }() go func() { @@ -82,7 +72,10 @@ func TailAndMonitor(ctx context.Context, project *compose.Project, provider clie // Run Monitor in a goroutine go func() { - serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, tailOptions.Deployment) + // Pass a NOOP function for the callback since TailAndMonitor doesn't use UI + serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, tailOptions.Deployment, func(*defangv1.SubscribeResponse, *ServiceStates) error { + return nil // NOOP - no UI updates needed when tailing + }) pkg.SleepWithContext(ctx, 2*time.Second) // a delay before cancelling tail to make sure we get last status messages cancelTail(errMonitoringDone) // cancel the tail when monitoring is done }() From 900cd33e3ba4b7734a3468feabf66c527ca1d3b8 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 16 Mar 2026 16:44:52 -0700 Subject: [PATCH 06/11] move monitor into its own file --- src/pkg/cli/monitor.go | 54 +++++++++++++++++++++++++++++++++++ src/pkg/cli/tailAndMonitor.go | 42 --------------------------- 2 files changed, 54 insertions(+), 42 deletions(-) create mode 100644 src/pkg/cli/monitor.go diff --git a/src/pkg/cli/monitor.go b/src/pkg/cli/monitor.go new file mode 100644 index 000000000..05343dad8 --- /dev/null +++ b/src/pkg/cli/monitor.go @@ -0,0 +1,54 @@ +package cli + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/DefangLabs/defang/src/pkg" + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" +) + +func Monitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string, watchCallback func(*defangv1.SubscribeResponse, *ServiceStates) error) (ServiceStates, error) { + if deploymentID == "" { + panic("deploymentID must be a valid deployment ID") + } + if waitTimeout > 0 { + var cancelTimeout context.CancelFunc + ctx, cancelTimeout = context.WithTimeout(ctx, waitTimeout) + defer cancelTimeout() + } + + svcStatusCtx, cancelSvcStatus := context.WithCancelCause(ctx) + defer cancelSvcStatus(nil) + + _, computeServices := splitManagedAndUnmanagedServices(project.Services) + + var ( + serviceStates ServiceStates + cdErr, svcErr error + ) + wg := &sync.WaitGroup{} + wg.Add(2) + + go func() { + defer wg.Done() + serviceStates, svcErr = WatchServiceState(svcStatusCtx, provider, project.Name, deploymentID, computeServices, watchCallback) + }() + + go func() { + defer wg.Done() + if err := WaitForCdTaskExit(ctx, provider); err != nil { + cdErr = err + cancelSvcStatus(cdErr) + } + }() + + wg.Wait() + pkg.SleepWithContext(ctx, 2*time.Second) + + return serviceStates, errors.Join(cdErr, svcErr) +} diff --git a/src/pkg/cli/tailAndMonitor.go b/src/pkg/cli/tailAndMonitor.go index 51888ce02..e862ee308 100644 --- a/src/pkg/cli/tailAndMonitor.go +++ b/src/pkg/cli/tailAndMonitor.go @@ -4,7 +4,6 @@ import ( "context" "errors" "io" - "sync" "time" "connectrpc.com/connect" @@ -15,47 +14,6 @@ import ( defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" ) -func Monitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string, watchCallback func(*defangv1.SubscribeResponse, *ServiceStates) error) (ServiceStates, error) { - if deploymentID == "" { - panic("deploymentID must be a valid deployment ID") - } - if waitTimeout > 0 { - var cancelTimeout context.CancelFunc - ctx, cancelTimeout = context.WithTimeout(ctx, waitTimeout) - defer cancelTimeout() - } - - svcStatusCtx, cancelSvcStatus := context.WithCancelCause(ctx) - defer cancelSvcStatus(nil) - - _, computeServices := splitManagedAndUnmanagedServices(project.Services) - - var ( - serviceStates ServiceStates - cdErr, svcErr error - ) - wg := &sync.WaitGroup{} - wg.Add(2) - - go func() { - defer wg.Done() - serviceStates, svcErr = WatchServiceState(svcStatusCtx, provider, project.Name, deploymentID, computeServices, watchCallback) - }() - - go func() { - defer wg.Done() - if err := WaitForCdTaskExit(ctx, provider); err != nil { - cdErr = err - cancelSvcStatus(cdErr) - } - }() - - wg.Wait() - pkg.SleepWithContext(ctx, 2*time.Second) - - return serviceStates, errors.Join(cdErr, svcErr) -} - func TailAndMonitor(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, tailOptions TailOptions) (ServiceStates, error) { tailOptions.Follow = true if tailOptions.Deployment == "" { From b0b24a34399c28a96d8e1c1daf999284984db1c8 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 16 Mar 2026 16:35:27 -0700 Subject: [PATCH 07/11] monitor service status with bubbletea --- src/cmd/cli/command/compose.go | 10 +- src/go.mod | 18 ++- src/go.sum | 39 ++++++- src/pkg/cli/tui.go | 193 +++++++++++++++++++++++++++++++++ 4 files changed, 242 insertions(+), 18 deletions(-) create mode 100644 src/pkg/cli/tui.go diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index e5a533232..a531672a3 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -165,15 +165,7 @@ func makeComposeUpCmd() *cobra.Command { } } else { term.Info("Live tail logs with `defang tail --deployment=" + deploy.Etag + "`") - for _, svc := range project.Services { - term.Infof("[%s] %s\n", svc.Name, "DEPLOYMENT_PENDING") - } - serviceStates, err = cli.Monitor(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag, func(msg *defangv1.SubscribeResponse, serviceStates *map[string]defangv1.ServiceState) error { - for svc, state := range *serviceStates { - term.Infof("[%s] %s\n", svc, state) - } - return nil - }) + serviceStates, err = cli.MonitorWithTUI(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) } if err != nil && !errors.Is(err, context.Canceled) { deploymentErr := err diff --git a/src/go.mod b/src/go.mod index 86f1878b0..e4f95806c 100644 --- a/src/go.mod +++ b/src/go.mod @@ -34,6 +34,9 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 github.com/aws/smithy-go v1.24.0 github.com/awslabs/goformation/v7 v7.14.9 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/compose-spec/compose-go/v2 v2.10.1 github.com/digitalocean/godo v1.131.1 github.com/docker/cli v29.2.0+incompatible @@ -49,7 +52,7 @@ require ( github.com/miekg/dns v1.1.59 github.com/moby/buildkit v0.17.3 github.com/moby/patternmatcher v0.6.0 - github.com/muesli/termenv v0.15.2 + github.com/muesli/termenv v0.16.0 github.com/openai/openai-go v1.12.0 github.com/opencontainers/image-spec v1.1.0 github.com/pelletier/go-toml/v2 v2.2.2 @@ -88,6 +91,10 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f // indirect github.com/containerd/typeurl/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect @@ -96,6 +103,7 @@ require ( github.com/docker/docker-credential-helpers v0.8.2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-jose/go-jose/v4 v4.1.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-yaml v1.17.1 // indirect @@ -111,11 +119,14 @@ require ( github.com/invopop/jsonschema v0.13.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/rivo/uniseg v0.4.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect github.com/sergi/go-diff v1.3.1 // indirect @@ -132,6 +143,7 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect diff --git a/src/go.sum b/src/go.sum index da7d54791..f6aadcf4f 100644 --- a/src/go.sum +++ b/src/go.sum @@ -122,6 +122,20 @@ github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqy github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/compose-spec/compose-go/v2 v2.10.1 h1:mFbXobojGRFIVi1UknrvaDAZ+PkJfyjqkA1yseh+vAU= @@ -163,6 +177,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -262,8 +278,10 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A= @@ -283,8 +301,12 @@ github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE= @@ -307,8 +329,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= -github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/ross96D/cancelreader v0.2.6 h1:XLPWassoMWRTlHvEoVKS3z0N0a7jHcIupGU0U1gNArw= @@ -365,6 +387,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -408,6 +432,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -436,6 +462,7 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/src/pkg/cli/tui.go b/src/pkg/cli/tui.go new file mode 100644 index 000000000..cbaed7a7a --- /dev/null +++ b/src/pkg/cli/tui.go @@ -0,0 +1,193 @@ +package cli + +import ( + "context" + "sort" + "sync" + "time" + + "github.com/DefangLabs/defang/src/pkg/cli/client" + "github.com/DefangLabs/defang/src/pkg/cli/compose" + defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type deploymentModel struct { + services map[string]*serviceState + quitting bool + updateCh chan serviceUpdate +} + +type serviceState struct { + status defangv1.ServiceState + spinner spinner.Model +} + +type serviceUpdate struct { + name string + status defangv1.ServiceState +} + +var ( + spinnerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("206")) + statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("86")) + nameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) +) + +func newDeploymentModel(serviceNames []string) *deploymentModel { + services := make(map[string]*serviceState) + + for _, name := range serviceNames { + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = spinnerStyle + + services[name] = &serviceState{ + status: defangv1.ServiceState_DEPLOYMENT_PENDING, + spinner: s, + } + } + + return &deploymentModel{ + services: services, + updateCh: make(chan serviceUpdate, 100), + } +} + +func (m *deploymentModel) Init() tea.Cmd { + var cmds []tea.Cmd + for _, svc := range m.services { + cmds = append(cmds, svc.spinner.Tick) + } + return tea.Batch(cmds...) +} + +func (m *deploymentModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + m.quitting = true + return m, tea.Quit + } + case serviceUpdate: + m.services[msg.name].status = msg.status + return m, nil + case spinner.TickMsg: + var cmds []tea.Cmd + for _, svc := range m.services { + var cmd tea.Cmd + svc.spinner, cmd = svc.spinner.Update(msg) + cmds = append(cmds, cmd) + } + return m, tea.Batch(cmds...) + } + return m, nil +} + +func (m *deploymentModel) View() string { + if m.quitting { + return "" + } + + var lines []string + // Sort services by name for consistent ordering + var serviceNames []string + for name := range m.services { + serviceNames = append(serviceNames, name) + } + sort.Strings(serviceNames) + + for _, name := range serviceNames { + svc := m.services[name] + + // Stop spinner for completed services + spinnerOrCheck := svc.spinner.View() + if svc.status == defangv1.ServiceState_DEPLOYMENT_COMPLETED { + spinnerOrCheck = "✓ " + } else if svc.status == defangv1.ServiceState_DEPLOYMENT_FAILED { + spinnerOrCheck = "✗ " + } + + statusText := svc.status.String() + switch svc.status { + case defangv1.ServiceState_NOT_SPECIFIED: + statusText = "" + case defangv1.ServiceState_DEPLOYMENT_PENDING: + statusText = "DEPLOYING" + } + + line := lipgloss.JoinHorizontal( + lipgloss.Left, + " ", + spinnerOrCheck, + nameStyle.Render("["+name+"]"), + " ", + statusStyle.Render(statusText), + ) + lines = append(lines, line) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func MonitorWithTUI(ctx context.Context, project *compose.Project, provider client.Provider, waitTimeout time.Duration, deploymentID string) (map[string]defangv1.ServiceState, error) { + servicesNames := make([]string, 0, len(project.Services)) + for _, svc := range project.Services { + servicesNames = append(servicesNames, svc.Name) + } + + // Initialize the bubbletea model + model := newDeploymentModel(servicesNames) + + // Create the bubbletea program + p := tea.NewProgram(model) + + var ( + serviceStates map[string]defangv1.ServiceState + monitorErr error + wg sync.WaitGroup + ) + wg.Add(2) // One for UI, one for monitoring + + // Start the bubbletea UI in a goroutine + go func() { + defer wg.Done() + if _, err := p.Run(); err != nil { + // Handle UI errors if needed + } + }() + + // Start monitoring in a goroutine + go func() { + defer wg.Done() + serviceStates, monitorErr = Monitor(ctx, project, provider, waitTimeout, deploymentID, func(msg *defangv1.SubscribeResponse, states *ServiceStates) error { + // Send service status updates to the bubbletea model + for name, state := range *states { + p.Send(serviceUpdate{ + name: name, + status: state, + }) + } + return nil + }) + if monitorErr == nil { + // prevent leaving partial state and spinners on screen after successful completion + for _, serviceName := range servicesNames { + if serviceStates[serviceName] == defangv1.ServiceState_DEPLOYMENT_PENDING { + p.Send(serviceUpdate{ + name: serviceName, + status: defangv1.ServiceState_DEPLOYMENT_COMPLETED, + }) + } + } + } + // Quit the UI when monitoring is done + p.Quit() + }() + + wg.Wait() + + return serviceStates, monitorErr +} From 76e782ae8cbb5c2f9ce2faa2a3d4a36559fff752 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 16 Mar 2026 17:02:33 -0700 Subject: [PATCH 08/11] dont regress from terminal states --- src/pkg/cli/subscribe.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/pkg/cli/subscribe.go b/src/pkg/cli/subscribe.go index 40029cae2..366114ada 100644 --- a/src/pkg/cli/subscribe.go +++ b/src/pkg/cli/subscribe.go @@ -81,6 +81,11 @@ func WatchServiceState( continue } + // Don't regress from terminal states (e.g. a TASK_STOPPED after DEPLOYMENT_COMPLETED) + currentState := serviceStates[msg.Name] + if currentState == defangv1.ServiceState_DEPLOYMENT_COMPLETED || currentState == defangv1.ServiceState_DEPLOYMENT_FAILED { + continue + } serviceStates[msg.Name] = msg.State err = cb(msg, &serviceStates) if err != nil { From 220394d19d58ae6f197dbf8a386be883c1282264 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 16 Mar 2026 17:30:06 -0700 Subject: [PATCH 09/11] update nix vendor hash --- pkgs/defang/cli.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkgs/defang/cli.nix b/pkgs/defang/cli.nix index effae677b..5296283f6 100644 --- a/pkgs/defang/cli.nix +++ b/pkgs/defang/cli.nix @@ -7,7 +7,7 @@ buildGo124Module { pname = "defang-cli"; version = "git"; src = lib.cleanSource ../../src; - vendorHash = "sha256-v1CdVCvXnWJSZykHc7VeV08hRnCer0jxo+je/5+bJUo="; + vendorHash = "sha256-CiHbZ50QCrndlXXmCH2y3k7tbywb+ePFLKIfe2cK+3M="; subPackages = [ "cmd/cli" ]; From cc2802a39139f3b5879d1b4a772e4cfdff4791e2 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 16 Mar 2026 17:30:31 -0700 Subject: [PATCH 10/11] print --since with live tail hint --- .claude/settings.local.json | 8 ++++++++ src/cmd/cli/command/compose.go | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..728aaa746 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(go test:*)", + "Bash(go get:*)" + ] + } +} diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index a531672a3..d6a57041a 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -164,7 +164,7 @@ func makeComposeUpCmd() *cobra.Command { return err } } else { - term.Info("Live tail logs with `defang tail --deployment=" + deploy.Etag + "`") + printDefangHint("Live tail logs", fmt.Sprintf("tail --deployment=%s --since=%s", deploy.Etag, time.Since(since))) serviceStates, err = cli.MonitorWithTUI(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) } if err != nil && !errors.Is(err, context.Canceled) { From 27f3d5758689d088f1be556a793b5c50c9563146 Mon Sep 17 00:00:00 2001 From: jordanstephens Date: Mon, 16 Mar 2026 17:37:10 -0700 Subject: [PATCH 11/11] print hint on how to detach --- src/cmd/cli/command/compose.go | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cmd/cli/command/compose.go b/src/cmd/cli/command/compose.go index d6a57041a..f44511e57 100644 --- a/src/cmd/cli/command/compose.go +++ b/src/cmd/cli/command/compose.go @@ -164,6 +164,7 @@ func makeComposeUpCmd() *cobra.Command { return err } } else { + term.Info("Monitoring deployment ID", deploy.Etag, "; press Ctrl+C to detach:") printDefangHint("Live tail logs", fmt.Sprintf("tail --deployment=%s --since=%s", deploy.Etag, time.Since(since))) serviceStates, err = cli.MonitorWithTUI(ctx, project, session.Provider, waitTimeoutDuration, deploy.Etag) }