Skip to content
Draft
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
26 changes: 19 additions & 7 deletions cmd/configure_projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
11 changes: 7 additions & 4 deletions cmd/deploy_azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions cmd/deploy_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 12 additions & 6 deletions cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -198,41 +200,45 @@ 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)
}

// waitForMigration polls until DevLake finishes database migration.
// 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)
}

Expand Down
111 changes: 111 additions & 0 deletions cmd/progress.go
Original file line number Diff line number Diff line change
@@ -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++
}
}
}
Loading