diff --git a/experimental/ssh/internal/client/client.go b/experimental/ssh/internal/client/client.go index 07e6dd3af5..0e4777b9c6 100644 --- a/experimental/ssh/internal/client/client.go +++ b/experimental/ssh/internal/client/client.go @@ -279,7 +279,7 @@ func Run(ctx context.Context, client *databricks.WorkspaceClient, opts ClientOpt if opts.ServerMetadata == "" { cmdio.LogString(ctx, "Uploading binaries...") - sp := cmdio.NewSpinner(ctx) + sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime()) sp.Update("Uploading binaries...") err := UploadTunnelReleases(ctx, client, version, opts.ReleasesDir) sp.Close() @@ -581,7 +581,7 @@ func runSSHProxy(ctx context.Context, client *databricks.WorkspaceClient, server } func checkClusterState(ctx context.Context, client *databricks.WorkspaceClient, clusterID string, autoStart bool) error { - sp := cmdio.NewSpinner(ctx) + sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime()) defer sp.Close() if autoStart { sp.Update("Ensuring the cluster is running...") @@ -605,7 +605,7 @@ func checkClusterState(ctx context.Context, client *databricks.WorkspaceClient, // waitForJobToStart polls the task status until the SSH server task is in RUNNING state or terminates. // Returns an error if the task fails to start or if polling times out. func waitForJobToStart(ctx context.Context, client *databricks.WorkspaceClient, runID int64, taskStartupTimeout time.Duration) error { - sp := cmdio.NewSpinner(ctx) + sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime()) defer sp.Close() sp.Update("Starting SSH server...") var prevState jobs.RunLifecycleStateV2State @@ -674,7 +674,7 @@ func ensureSSHServerIsRunning(ctx context.Context, client *databricks.WorkspaceC return "", 0, "", fmt.Errorf("failed to submit and start ssh server job: %w", err) } - sp := cmdio.NewSpinner(ctx) + sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime()) defer sp.Close() sp.Update("Waiting for the SSH server to start...") maxRetries := 30 @@ -684,6 +684,7 @@ func ensureSSHServerIsRunning(ctx context.Context, client *databricks.WorkspaceC } serverPort, userName, effectiveClusterID, err = getServerMetadata(ctx, client, sessionID, clusterID, version, opts.Liteswap) if err == nil { + cmdio.LogString(ctx, "Health check successful, starting ssh WebSocket connection...") break } else if retries < maxRetries-1 { time.Sleep(2 * time.Second) diff --git a/libs/cmdio/io.go b/libs/cmdio/io.go index da22c3b492..d54840d0f0 100644 --- a/libs/cmdio/io.go +++ b/libs/cmdio/io.go @@ -195,9 +195,9 @@ func RunSelect(ctx context.Context, prompt *promptui.Select) (int, string, error // // The spinner automatically degrades in non-interactive terminals (no output). // Context cancellation will automatically close the spinner. -func NewSpinner(ctx context.Context) *spinner { +func NewSpinner(ctx context.Context, opts ...SpinnerOption) *spinner { c := fromContext(ctx) - return c.NewSpinner(ctx) + return c.NewSpinner(ctx, opts...) } type cmdIOType int diff --git a/libs/cmdio/spinner.go b/libs/cmdio/spinner.go index 9557710524..503a03ad64 100644 --- a/libs/cmdio/spinner.go +++ b/libs/cmdio/spinner.go @@ -2,6 +2,7 @@ package cmdio import ( "context" + "fmt" "sync" "time" @@ -12,9 +13,10 @@ import ( // spinnerModel is the Bubble Tea model for the spinner. type spinnerModel struct { - spinner bubblespinner.Model - suffix string - quitting bool + spinner bubblespinner.Model + suffix string + quitting bool + startTime time.Time // non-zero when elapsed time display is enabled } // Message types for spinner updates. @@ -23,8 +25,18 @@ type ( quitMsg struct{} ) +// SpinnerOption configures spinner behavior. +type SpinnerOption func(*spinnerModel) + +// WithElapsedTime enables an elapsed time prefix (MM:SS) on the spinner. +func WithElapsedTime() SpinnerOption { + return func(m *spinnerModel) { + m.startTime = time.Now() + } +} + // newSpinnerModel creates a new spinner model. -func newSpinnerModel() spinnerModel { +func newSpinnerModel(opts ...SpinnerOption) spinnerModel { s := bubblespinner.New() // Braille spinner frames with 200ms timing s.Spinner = bubblespinner.Spinner{ @@ -33,11 +45,13 @@ func newSpinnerModel() spinnerModel { } s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // Green - return spinnerModel{ - spinner: s, - suffix: "", - quitting: false, + m := spinnerModel{ + spinner: s, + } + for _, opt := range opts { + opt(&m) } + return m } func (m spinnerModel) Init() tea.Cmd { @@ -69,11 +83,16 @@ func (m spinnerModel) View() string { return "" } + var result string + if !m.startTime.IsZero() { + elapsed := time.Since(m.startTime) + result += fmt.Sprintf("%02d:%02d ", int(elapsed.Minutes()), int(elapsed.Seconds())%60) + } + result += m.spinner.View() if m.suffix != "" { - return m.spinner.View() + " " + m.suffix + result += " " + m.suffix } - - return m.spinner.View() + return result } // spinner provides a structured interface for displaying progress indicators. @@ -121,14 +140,18 @@ func (sp *spinner) Close() { // sp := cmdio.NewSpinner(ctx) // defer sp.Close() // sp.Update("processing files") -func (c *cmdIO) NewSpinner(ctx context.Context) *spinner { +// +// Use WithElapsedTime() to show a running MM:SS prefix: +// +// sp := cmdio.NewSpinner(ctx, cmdio.WithElapsedTime()) +func (c *cmdIO) NewSpinner(ctx context.Context, opts ...SpinnerOption) *spinner { // Don't show spinner if not interactive if !c.capabilities.SupportsInteractive() { return &spinner{p: nil, c: c, ctx: ctx} } // Create model and program - m := newSpinnerModel() + m := newSpinnerModel(opts...) p := tea.NewProgram( m, tea.WithInput(nil),