diff --git a/cmd/configure_projects.go b/cmd/configure_projects.go index cc39506..560f2cb 100644 --- a/cmd/configure_projects.go +++ b/cmd/configure_projects.go @@ -442,29 +442,41 @@ func triggerAndPoll(client *devlake.Client, blueprintID int, wait bool, timeout fmt.Println(" Monitoring progress...") deadline := time.Now().Add(timeout) + start := time.Now() + + // clearLine erases the current in-place status line so subsequent output + // (completion banners, error messages) appears on a clean line. + clearLine := func() { + fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) + } + ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for range ticker.C { p, err := client.GetPipeline(pipeline.ID) + elapsed := time.Since(start).Truncate(time.Second) + if err != nil { - elapsed := time.Since(deadline.Add(-timeout)).Truncate(time.Second) - fmt.Printf(" [%s] Could not check status...\n", elapsed) + fmt.Printf("\r %-*s", progressLineWidth-3, fmt.Sprintf("⚠️ Could not check status (%s elapsed)", elapsed)) } else { - elapsed := time.Since(deadline.Add(-timeout)).Truncate(time.Second) - fmt.Printf(" [%s] Status: %s | Tasks: %d/%d\n", elapsed, p.Status, p.FinishedTasks, p.TotalTasks) + bar := renderBar(p.FinishedTasks, p.TotalTasks, progressBarWidth) + fmt.Printf("\r %-*s", progressLineWidth-3, fmt.Sprintf("%s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed)) switch p.Status { case "TASK_COMPLETED": - fmt.Println(" \u2705 Data sync completed!") + clearLine() + fmt.Println(" ✅ Data sync completed!") return nil case "TASK_FAILED": - return fmt.Errorf("pipeline failed \u2014 check DevLake logs") + clearLine() + return fmt.Errorf("pipeline failed — check DevLake logs") } } if time.Now().After(deadline) { - fmt.Println(" \u26a0\ufe0f Monitoring timed out. Pipeline is still running.") + clearLine() + fmt.Println(" ⚠️ Monitoring timed out. Pipeline is still running.") fmt.Printf(" Check status: GET /pipelines/%d\n", pipeline.ID) return nil } diff --git a/cmd/deploy_azure.go b/cmd/deploy_azure.go index 14e9a12..28cac4a 100644 --- a/cmd/deploy_azure.go +++ b/cmd/deploy_azure.go @@ -244,8 +244,7 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { if err := azure.MySQLStart(mysqlName, azureRG); err != nil { fmt.Printf(" ⚠️ Could not start MySQL: %v\n", err) } else { - fmt.Println(" Waiting 30s for MySQL...") - time.Sleep(30 * time.Second) + countdown(30, "waiting for MySQL") fmt.Println(" ✅ MySQL started") } } else if state != "" { @@ -287,7 +286,12 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { params["acrName"] = acrName } - deployment, err := azure.DeployBicep(azureRG, templatePath, params) + var deployment *azure.DeploymentOutput + err = spinWhile("Deploying Azure resources via Bicep (this takes several minutes)", func() error { + var innerErr error + deployment, innerErr = azure.DeployBicep(azureRG, templatePath, params) + return innerErr + }) if err != nil { return fmt.Errorf("Bicep deployment failed: %w", err) } @@ -303,7 +307,6 @@ func runDeployAzure(cmd *cobra.Command, args []string) error { backendReady := waitForReady(deployment.BackendEndpoint, 30, 10*time.Second) == nil if backendReady { - fmt.Println(" ✅ Backend is responding!") fmt.Println("\n🔄 Triggering database migration...") httpClient := &http.Client{Timeout: 5 * time.Second} resp, err := httpClient.Get(deployment.BackendEndpoint + "/proceed-db-migration") diff --git a/cmd/deploy_local.go b/cmd/deploy_local.go index 917282c..8e513a1 100644 --- a/cmd/deploy_local.go +++ b/cmd/deploy_local.go @@ -531,8 +531,8 @@ func startLocalContainers(dir string, build bool, services ...string) (string, e backendURLCandidates := []string{"http://localhost:8080", "http://localhost:8085"} fmt.Println("\n⏳ Waiting for DevLake to be ready...") - fmt.Println(" Giving MySQL time to initialize (this takes ~30s on first run)...") - time.Sleep(30 * time.Second) + fmt.Println(" Giving MySQL time to initialize...") + countdown(30, "MySQL initializing") backendURL, err := waitForReadyAny(backendURLCandidates, 36, 10*time.Second) if err != nil { diff --git a/cmd/helpers.go b/cmd/helpers.go index edfe91a..dc82638 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -178,18 +178,20 @@ func deduplicateResults(results []ConnSetupResult) []ConnSetupResult { // maxAttempts is exhausted. interval is the pause between attempts. func waitForReady(baseURL string, maxAttempts int, interval time.Duration) error { httpClient := &http.Client{Timeout: 5 * time.Second} + bar := newProgressBar(maxAttempts) for attempt := 1; attempt <= maxAttempts; attempt++ { + bar.update(attempt, "waiting for DevLake") resp, err := httpClient.Get(baseURL + "/ping") if err == nil { resp.Body.Close() if resp.StatusCode == http.StatusOK { - fmt.Println(" ✅ DevLake is responding!") + bar.done("✅ DevLake is responding!") return nil } } - fmt.Printf(" Attempt %d/%d — waiting...\n", attempt, maxAttempts) time.Sleep(interval) } + bar.clear() return fmt.Errorf("DevLake not ready after %d attempts — check logs", maxAttempts) } @@ -198,22 +200,24 @@ func waitForReady(baseURL string, maxAttempts int, interval time.Duration) error // internal/devlake/discovery.go which checks both 8080 and 8085. func waitForReadyAny(baseURLs []string, maxAttempts int, interval time.Duration) (string, error) { httpClient := &http.Client{Timeout: 5 * time.Second} + bar := newProgressBar(maxAttempts) for attempt := 1; attempt <= maxAttempts; attempt++ { + bar.update(attempt, "waiting for DevLake") for _, baseURL := range baseURLs { resp, err := httpClient.Get(baseURL + "/ping") if err == nil { resp.Body.Close() if resp.StatusCode == http.StatusOK { - fmt.Println(" ✅ DevLake is responding!") + bar.done("✅ DevLake is responding!") return baseURL, nil } } } if attempt < maxAttempts { - fmt.Printf(" Attempt %d/%d — waiting...\n", attempt, maxAttempts) time.Sleep(interval) } } + bar.clear() return "", fmt.Errorf("timed out after %d attempts", maxAttempts) } @@ -221,18 +225,20 @@ func waitForReadyAny(baseURLs []string, maxAttempts int, interval time.Duration) // During migration the API returns 428 (Precondition Required). func waitForMigration(baseURL string, maxAttempts int, interval time.Duration) error { httpClient := &http.Client{Timeout: 5 * time.Second} + bar := newProgressBar(maxAttempts) for attempt := 1; attempt <= maxAttempts; attempt++ { + bar.update(attempt, "migrating database") resp, err := httpClient.Get(baseURL + "/ping") if err == nil { resp.Body.Close() if resp.StatusCode == http.StatusOK { - fmt.Println(" ✅ Migration complete!") + bar.done("✅ Migration complete!") return nil } } - fmt.Printf(" Migrating... (%d/%d)\n", attempt, maxAttempts) time.Sleep(interval) } + bar.clear() return fmt.Errorf("migration did not complete after %d attempts", maxAttempts) } diff --git a/cmd/progress.go b/cmd/progress.go new file mode 100644 index 0000000..981245f --- /dev/null +++ b/cmd/progress.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "fmt" + "strings" + "time" +) + +// ── Progress bar ───────────────────────────────────────────────── + +// progressLineWidth is the terminal column count used to clear in-place status +// lines written with \r. Must be wide enough to overwrite the longest possible +// progress or spinner line. +const progressLineWidth = 72 + +// progressBarWidth is the number of block characters in every progress bar. +const progressBarWidth = 24 + +// progressBar renders an in-place terminal progress bar using \r. +// Create with newProgressBar, call update to redraw, and done to finish. +type progressBar struct { + total int + width int + start time.Time +} + +func newProgressBar(total int) *progressBar { + return &progressBar{total: total, width: progressBarWidth, start: time.Now()} +} + +// renderBar returns a [████░░░░] string representing current/total progress. +// Rounding is applied so early progress is visible even at low percentages. +func renderBar(current, total, width int) string { + if total <= 0 { + return "[" + strings.Repeat("░", width) + "]" + } + // Use rounding division to avoid premature completion or invisible early progress. + filled := (width*current + total/2) / total + if filled > width { + filled = width + } + return "[" + strings.Repeat("█", filled) + strings.Repeat("░", width-filled) + "]" +} + +// update redraws the progress bar at position current. +// It uses \r to overwrite the current line in the terminal. +func (p *progressBar) update(current int, label string) { + bar := renderBar(current, p.total, p.width) + elapsed := time.Since(p.start).Truncate(time.Second) + fmt.Printf("\r %s %2d/%-2d %s (%s elapsed) ", bar, current, p.total, label, elapsed) +} + +// clear erases the progress bar line and returns the cursor to column 0. +func (p *progressBar) clear() { + fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) +} + +// done clears the bar and prints a completion message. +func (p *progressBar) done(msg string) { + p.clear() + fmt.Println(" " + msg) +} + +// countdown shows a progress bar that ticks every second for n seconds, +// then clears the bar. Used for deterministic sleeps where the duration +// is known upfront (e.g. "Giving MySQL time to initialize"). +func countdown(n int, label string) { + bar := newProgressBar(n) + for i := 0; i <= n; i++ { + bar.update(i, label) + if i < n { + time.Sleep(time.Second) + } + } + bar.clear() +} + +// ── Spinner ─────────────────────────────────────────────────────── + +// spinChars is a rotating set of characters for indeterminate waits. +var spinChars = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +// spinWhile runs fn in a goroutine and displays a spinner until it completes. +// On success it prints the elapsed time; on failure it clears the spinner line +// so the caller's error message lands on a clean line. +// Returns fn's error. +func spinWhile(label string, fn func() error) error { + done := make(chan error, 1) + go func() { done <- fn() }() + + start := time.Now() + i := 0 + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case err := <-done: + elapsed := time.Since(start).Truncate(time.Second) + fmt.Printf("\r%s\r", strings.Repeat(" ", progressLineWidth)) + if err == nil { + fmt.Printf(" ✅ Done (%s)\n", elapsed) + } + return err + case <-ticker.C: + elapsed := time.Since(start).Truncate(time.Second) + fmt.Printf("\r %s %s (%s elapsed) ", spinChars[i%len(spinChars)], label, elapsed) + i++ + } + } +}