diff --git a/cmd/configure_projects.go b/cmd/configure_projects.go index cc39506..3d1ae8a 100644 --- a/cmd/configure_projects.go +++ b/cmd/configure_projects.go @@ -442,29 +442,43 @@ 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) + line := fmt.Sprintf("⚠️ Could not check status (%s elapsed)", elapsed) + fmt.Printf("\r%s%s", statusLinePrefix, truncatePad(line, progressLineWidth-statusLineIndent)) } 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) + line := fmt.Sprintf("%s %d/%d tasks — %s (%s elapsed)", bar, p.FinishedTasks, p.TotalTasks, p.Status, elapsed) + fmt.Printf("\r%s%s", statusLinePrefix, truncatePad(line, progressLineWidth-statusLineIndent)) 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..239e558 --- /dev/null +++ b/cmd/progress.go @@ -0,0 +1,251 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "strings" + "time" + "unicode" + + "golang.org/x/term" +) + +// ── Progress bar ───────────────────────────────────────────────── + +// progressLineWidth is the number of terminal display columns each in-place +// status line occupies. Every update() and spinner tick writes exactly this +// many display columns so that clear() (which overwrites the same width with +// spaces) always fully erases the previous line. +// Width is measured in display columns, not runes, so that wide/emoji +// characters (display width 2) are accounted for correctly. +const progressLineWidth = 72 + +// progressBarWidth is the number of block characters in every progress bar. +const progressBarWidth = 24 + +// statusLineIndent is the number of leading spaces printed before each +// in-place status line (e.g. " [bar] ..."). clear() must overwrite the +// full progressLineWidth columns; status content is truncated to +// progressLineWidth-statusLineIndent to fit within that budget. +const statusLineIndent = 3 + +// statusLinePrefix is the leading whitespace derived from statusLineIndent. +// It is used by update(), done(), and spinWhile() to ensure the prefix always +// matches the indent budget used for truncation. +var statusLinePrefix = strings.Repeat(" ", statusLineIndent) + +// truncatePad returns s padded with spaces to exactly width display columns, +// or truncated to (width-1) display columns followed by "…" if s is wider. +func truncatePad(s string, width int) string { + sw := stringDisplayWidth(s) + if sw > width { + s = truncateDisplayWidth(s, width) + sw = stringDisplayWidth(s) + } + return s + strings.Repeat(" ", width-sw) +} + +func stringDisplayWidth(s string) int { + width := 0 + for _, r := range s { + width += runeDisplayWidth(r) + } + return width +} + +func runeDisplayWidth(r rune) int { + switch { + case r == 0: + return 0 + case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r): + return 0 + // Approximate wcwidth-style "wide" runes (East Asian wide/fullwidth + emoji). + case r >= 0x1100 && (r <= 0x115F || + r == 0x2329 || r == 0x232A || + (0x2E80 <= r && r <= 0xA4CF && r != 0x303F) || + (0xAC00 <= r && r <= 0xD7A3) || + (0xF900 <= r && r <= 0xFAFF) || + (0xFE10 <= r && r <= 0xFE19) || + (0xFE30 <= r && r <= 0xFE6F) || + (0xFF00 <= r && r <= 0xFF60) || + (0xFFE0 <= r && r <= 0xFFE6) || + (0x1F300 <= r && r <= 0x1FAFF)): + return 2 + default: + return 1 + } +} + +func truncateDisplayWidth(s string, width int) string { + if width <= 0 { + return "" + } + if width == 1 { + return "…" + } + + limit := width - 1 + var b strings.Builder + used := 0 + for _, r := range s { + rw := runeDisplayWidth(r) + if used+rw > limit { + break + } + b.WriteRune(r) + used += rw + } + b.WriteRune('…') + return b.String() +} + +// 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 + out io.Writer + interactive bool +} + +var progressWriter = func() io.Writer { + if outputJSON { + return os.Stderr + } + return os.Stdout +} + +var progressWriterIsTerminal = func(w io.Writer) bool { + f, ok := w.(*os.File) + return ok && term.IsTerminal(int(f.Fd())) +} + +func newProgressBar(total int) *progressBar { + out := progressWriter() + return &progressBar{ + total: total, + width: progressBarWidth, + start: time.Now(), + out: out, + interactive: progressWriterIsTerminal(out), + } +} + +// 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) + "]" + } + clampedCurrent := current + if clampedCurrent < 0 { + clampedCurrent = 0 + } else if clampedCurrent > total { + clampedCurrent = total + } + // Use rounding division to avoid premature completion or invisible early progress. + filled := (width*clampedCurrent + 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. +// The line is always padded or truncated to exactly progressLineWidth +// display columns so clear() reliably erases it. +func (p *progressBar) update(current int, label string) { + bar := renderBar(current, p.total, p.width) + elapsed := time.Since(p.start).Truncate(time.Second) + line := fmt.Sprintf("%s%s %2d/%-2d %s (%s elapsed)", statusLinePrefix, bar, current, p.total, label, elapsed) + if p.interactive { + fmt.Fprintf(p.out, "\r%s", truncatePad(line, progressLineWidth)) + return + } + fmt.Fprintln(p.out, line) +} + +// clear erases the progress bar line and returns the cursor to column 0. +func (p *progressBar) clear() { + if !p.interactive { + return + } + fmt.Fprintf(p.out, "\r%s\r", strings.Repeat(" ", progressLineWidth)) +} + +// done clears the bar and prints a completion message. +func (p *progressBar) done(msg string) { + p.clear() + fmt.Fprintln(p.out, statusLinePrefix+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 { + out := progressWriter() + interactive := progressWriterIsTerminal(out) + done := make(chan error, 1) + go func() { + defer func() { + if r := recover(); r != nil { + done <- fmt.Errorf("panic in spinner task: %v", r) + } + }() + done <- fn() + }() + + start := time.Now() + if !interactive { + err := <-done + if err == nil { + elapsed := time.Since(start).Truncate(time.Second) + fmt.Fprintf(out, "%s✅ Done (%s)\n", statusLinePrefix, elapsed) + } + return err + } + + i := 0 + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case err := <-done: + elapsed := time.Since(start).Truncate(time.Second) + fmt.Fprintf(out, "\r%s\r", strings.Repeat(" ", progressLineWidth)) + if err == nil { + fmt.Fprintf(out, "%s✅ Done (%s)\n", statusLinePrefix, elapsed) + } + return err + case <-ticker.C: + elapsed := time.Since(start).Truncate(time.Second) + line := fmt.Sprintf("%s%s %s (%s elapsed)", statusLinePrefix, spinChars[i%len(spinChars)], label, elapsed) + fmt.Fprintf(out, "\r%s", truncatePad(line, progressLineWidth)) + i++ + } + } +} diff --git a/cmd/progress_test.go b/cmd/progress_test.go new file mode 100644 index 0000000..ee32cf5 --- /dev/null +++ b/cmd/progress_test.go @@ -0,0 +1,261 @@ +package cmd + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// TestTruncatePad checks the display-width-aware pad/truncate helper. +func TestTruncatePad(t *testing.T) { + tests := []struct { + name string + s string + width int + want string + }{ + { + name: "exact width — no change", + s: "hello", + width: 5, + want: "hello", + }, + { + name: "shorter — padded with spaces", + s: "hi", + width: 5, + want: "hi ", + }, + { + name: "empty string — all spaces", + s: "", + width: 4, + want: " ", + }, + { + name: "longer ASCII — truncated with ellipsis", + s: "hello world", + width: 7, + want: "hello …", // 6 ASCII + 1 ellipsis = 7 display cols + }, + { + name: "multi-byte rune — truncated at rune boundary", + s: "héllo", + width: 3, + want: "hé…", // 'h'(1) + 'é'(1) + '…'(1) = 3 display cols + }, + { + name: "wide CJK char — counts as 2 display columns", + s: "日本語", // each char is display width 2 → total 6 cols + width: 5, + want: "日本…", // "日"(2)+"本"(2) = 4 < 5, next would be 6 > 5, so truncate to 4 and add "…" → 5 cols + }, + { + name: "fullwidth latin char — counts as 2 display columns", + s: "ABC", // fullwidth A (2) + B (1) + C (1) = 4 + width: 3, + want: "A…", + }, + { + name: "width 1 — single ellipsis", + s: "hello", + width: 1, + want: "…", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := truncatePad(tc.s, tc.width) + if got != tc.want { + t.Errorf("truncatePad(%q, %d) = %q; want %q", tc.s, tc.width, got, tc.want) + } + }) + } +} + +// TestRenderBar checks the progress bar rendering logic. +func TestRenderBar(t *testing.T) { + tests := []struct { + name string + current int + total int + width int + want string + }{ + { + name: "total zero — all empty", + current: 0, + total: 0, + width: 4, + want: "[░░░░]", + }, + { + name: "total negative — all empty", + current: 5, + total: -1, + width: 4, + want: "[░░░░]", + }, + { + name: "current zero — all empty", + current: 0, + total: 10, + width: 4, + want: "[░░░░]", + }, + { + name: "current negative — clamped to empty", + current: -1, + total: 10, + width: 4, + want: "[░░░░]", + }, + { + name: "current equals total — all filled", + current: 10, + total: 10, + width: 4, + want: "[████]", + }, + { + name: "current exceeds total — clamped to full", + current: 15, + total: 10, + width: 4, + want: "[████]", + }, + { + name: "half progress — half filled", + current: 5, + total: 10, + width: 4, + want: "[██░░]", + }, + { + name: "rounding: 1/3 of width=3 rounds to 1 filled", + current: 1, + total: 3, + width: 3, + want: "[█░░]", + }, + { + name: "width 1 full", + current: 1, + total: 1, + width: 1, + want: "[█]", + }, + { + name: "width 1 empty", + current: 0, + total: 1, + width: 1, + want: "[░]", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := renderBar(tc.current, tc.total, tc.width) + if got != tc.want { + t.Errorf("renderBar(%d, %d, %d) = %q; want %q", + tc.current, tc.total, tc.width, got, tc.want) + } + }) + } +} + +// TestStatusLinePrefix verifies statusLinePrefix matches statusLineIndent. +func TestStatusLinePrefix(t *testing.T) { + if len(statusLinePrefix) != statusLineIndent { + t.Errorf("statusLinePrefix len = %d; want statusLineIndent = %d", + len(statusLinePrefix), statusLineIndent) + } + if statusLinePrefix != strings.Repeat(" ", statusLineIndent) { + t.Errorf("statusLinePrefix = %q; want %d spaces", statusLinePrefix, statusLineIndent) + } +} + +func TestSpinWhileRecover(t *testing.T) { + errCh := make(chan error, 1) + go func() { + captureStdout(func() { + errCh <- spinWhile("label", func() error { + panic("boom") + }) + }) + }() + + select { + case err := <-errCh: + if err == nil { + t.Fatal("spinWhile returned nil error after panic") + } + if !strings.Contains(err.Error(), "panic in spinner task: boom") { + t.Fatalf("spinWhile error = %q; want panic message", err) + } + case <-time.After(time.Second): + t.Fatal("spinWhile did not return after panic") + } +} + +func TestProgressBarNonInteractiveNoCarriageReturns(t *testing.T) { + origWriter := progressWriter + origIsTerminal := progressWriterIsTerminal + t.Cleanup(func() { + progressWriter = origWriter + progressWriterIsTerminal = origIsTerminal + }) + + var buf bytes.Buffer + progressWriter = func() io.Writer { return &buf } + progressWriterIsTerminal = func(io.Writer) bool { return false } + + bar := newProgressBar(3) + bar.update(1, "waiting") + bar.clear() + bar.done("done") + + out := buf.String() + if strings.Contains(out, "\r") { + t.Fatalf("non-interactive progress output should not contain carriage returns: %q", out) + } + if !strings.Contains(out, "done\n") { + t.Fatalf("expected completion line in output, got %q", out) + } +} + +func TestWaitForReadyJSONModeKeepsStdoutClean(t *testing.T) { + origJSON := outputJSON + outputJSON = true + t.Cleanup(func() { outputJSON = origJSON }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + + origStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + t.Cleanup(func() { os.Stdout = origStdout }) + t.Cleanup(func() { _ = r.Close() }) + + if err := waitForReady(srv.URL, 1, time.Millisecond); err != nil { + t.Fatalf("waitForReady returned error: %v", err) + } + + _ = w.Close() + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + if got := buf.String(); got != "" { + t.Fatalf("stdout should be empty in JSON mode, got %q", got) + } +}