Add ETA estimation to cluster backup/restore operations

- Created internal/progress/estimator.go with ETAEstimator component
- Tracks elapsed time and estimates remaining time based on progress
- Enhanced Spinner and LineByLine indicators to display ETA info
- Integrated into BackupCluster and RestoreCluster functions
- Display format: 'Operation | X/Y (Z%) | Elapsed: Xm | ETA: ~Ym remaining'
- Preserves spinner animation while showing progress/time estimates
- Quick Win approach: no historical data storage, just current operation tracking
This commit is contained in:
2025-11-07 13:28:11 +00:00
parent ebb77fb960
commit b3ac5a18df
17 changed files with 233 additions and 24 deletions

View File

@ -4,8 +4,8 @@ This directory contains pre-compiled binaries for the DB Backup Tool across mult
## Build Information ## Build Information
- **Version**: 1.1.0 - **Version**: 1.1.0
- **Build Time**: 2025-11-06_08:16:58_UTC - **Build Time**: 2025-11-07_13:27:44_UTC
- **Git Commit**: 26ad1dc - **Git Commit**: ebb77fb
## Recent Updates (v1.1.0) ## Recent Updates (v1.1.0)
- ✅ Fixed TUI progress display with line-by-line output - ✅ Fixed TUI progress display with line-by-line output

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
dbbackup

Binary file not shown.

View File

@ -333,12 +333,19 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
return fmt.Errorf("failed to list databases: %w", err) return fmt.Errorf("failed to list databases: %w", err)
} }
// Create ETA estimator for database backups
estimator := progress.NewETAEstimator("Backing up cluster", len(databases))
quietProgress.SetEstimator(estimator)
// Backup each database // Backup each database
e.printf(" Backing up %d databases...\n", len(databases)) e.printf(" Backing up %d databases...\n", len(databases))
successCount := 0 successCount := 0
failCount := 0 failCount := 0
for i, dbName := range databases { for i, dbName := range databases {
// Update estimator progress
estimator.UpdateProgress(i)
e.printf(" [%d/%d] Backing up database: %s\n", i+1, len(databases), dbName) e.printf(" [%d/%d] Backing up database: %s\n", i+1, len(databases), dbName)
quietProgress.Update(fmt.Sprintf("Backing up database %d/%d: %s", i+1, len(databases), dbName)) quietProgress.Update(fmt.Sprintf("Backing up database %d/%d: %s", i+1, len(databases), dbName))

View File

@ -0,0 +1,145 @@
package progress
import (
"fmt"
"time"
)
// ETAEstimator calculates estimated time remaining for operations
type ETAEstimator struct {
startTime time.Time
operation string
totalItems int
itemsComplete int
lastUpdate time.Time
}
// NewETAEstimator creates a new ETA estimator
func NewETAEstimator(operation string, totalItems int) *ETAEstimator {
now := time.Now()
return &ETAEstimator{
startTime: now,
operation: operation,
totalItems: totalItems,
itemsComplete: 0,
lastUpdate: now,
}
}
// UpdateProgress updates the completed item count
func (e *ETAEstimator) UpdateProgress(itemsComplete int) {
e.itemsComplete = itemsComplete
e.lastUpdate = time.Now()
}
// GetElapsed returns elapsed time since start
func (e *ETAEstimator) GetElapsed() time.Duration {
return time.Since(e.startTime)
}
// GetETA calculates estimated time remaining
func (e *ETAEstimator) GetETA() time.Duration {
if e.itemsComplete == 0 || e.totalItems == 0 {
return 0
}
elapsed := e.GetElapsed()
avgTimePerItem := elapsed / time.Duration(e.itemsComplete)
remainingItems := e.totalItems - e.itemsComplete
return avgTimePerItem * time.Duration(remainingItems)
}
// GetProgress returns current progress as percentage
func (e *ETAEstimator) GetProgress() float64 {
if e.totalItems == 0 {
return 0
}
return float64(e.itemsComplete) / float64(e.totalItems) * 100
}
// FormatElapsed returns formatted elapsed time (e.g., "25m 30s")
func (e *ETAEstimator) FormatElapsed() string {
return FormatDuration(e.GetElapsed())
}
// FormatETA returns formatted ETA (e.g., "~40m remaining")
func (e *ETAEstimator) FormatETA() string {
eta := e.GetETA()
if eta == 0 {
return "calculating..."
}
return "~" + FormatDuration(eta) + " remaining"
}
// FormatProgress returns formatted progress string (e.g., "5/13 (38%)")
func (e *ETAEstimator) FormatProgress() string {
return fmt.Sprintf("%d/%d (%.0f%%)", e.itemsComplete, e.totalItems, e.GetProgress())
}
// GetFullStatus returns complete status line with all info
func (e *ETAEstimator) GetFullStatus(baseMessage string) string {
if e.totalItems == 0 {
// No items to track, just show elapsed
return fmt.Sprintf("%s | Elapsed: %s", baseMessage, e.FormatElapsed())
}
if e.itemsComplete == 0 {
// Just started
return fmt.Sprintf("%s | 0/%d | Starting...", baseMessage, e.totalItems)
}
// Full status with progress and ETA
return fmt.Sprintf("%s | %s | Elapsed: %s | ETA: %s",
baseMessage,
e.FormatProgress(),
e.FormatElapsed(),
e.FormatETA())
}
// FormatDuration formats a duration in human-readable format
func FormatDuration(d time.Duration) string {
if d < time.Second {
return "< 1s"
}
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60
if hours > 0 {
if minutes > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dh", hours)
}
if minutes > 0 {
if seconds > 5 { // Only show seconds if > 5
return fmt.Sprintf("%dm %ds", minutes, seconds)
}
return fmt.Sprintf("%dm", minutes)
}
return fmt.Sprintf("%ds", seconds)
}
// EstimateSizeBasedDuration estimates duration based on size (fallback when no progress tracking)
func EstimateSizeBasedDuration(sizeBytes int64, cores int) time.Duration {
sizeMB := float64(sizeBytes) / (1024 * 1024)
// Base estimate: ~100MB per minute on average hardware
baseMinutes := sizeMB / 100.0
// Adjust for CPU cores (more cores = faster, but not linear)
// Use square root to represent diminishing returns
if cores > 1 {
speedup := 1.0 + (0.3 * (float64(cores) - 1)) // 30% improvement per core
baseMinutes = baseMinutes / speedup
}
// Add 20% buffer for safety
baseMinutes = baseMinutes * 1.2
return time.Duration(baseMinutes * float64(time.Minute))
}

View File

@ -15,16 +15,18 @@ type Indicator interface {
Complete(message string) Complete(message string)
Fail(message string) Fail(message string)
Stop() Stop()
SetEstimator(estimator *ETAEstimator)
} }
// Spinner creates a spinning progress indicator // Spinner creates a spinning progress indicator
type Spinner struct { type Spinner struct {
writer io.Writer writer io.Writer
message string message string
active bool active bool
frames []string frames []string
interval time.Duration interval time.Duration
stopCh chan bool stopCh chan bool
estimator *ETAEstimator
} }
// NewSpinner creates a new spinner progress indicator // NewSpinner creates a new spinner progress indicator
@ -51,7 +53,14 @@ func (s *Spinner) Start(message string) {
return return
default: default:
if s.active { if s.active {
currentFrame := fmt.Sprintf("%s %s", s.frames[i%len(s.frames)], s.message) displayMsg := s.message
// Add ETA info if estimator is available
if s.estimator != nil {
displayMsg = s.estimator.GetFullStatus(s.message)
}
currentFrame := fmt.Sprintf("%s %s", s.frames[i%len(s.frames)], displayMsg)
if s.message != lastMessage { if s.message != lastMessage {
// Print new line for new messages // Print new line for new messages
fmt.Fprintf(s.writer, "\n%s", currentFrame) fmt.Fprintf(s.writer, "\n%s", currentFrame)
@ -68,6 +77,11 @@ func (s *Spinner) Start(message string) {
}() }()
} }
// SetEstimator sets an ETA estimator for this spinner
func (s *Spinner) SetEstimator(estimator *ETAEstimator) {
s.estimator = estimator
}
// Update changes the spinner message // Update changes the spinner message
func (s *Spinner) Update(message string) { func (s *Spinner) Update(message string) {
s.message = message s.message = message
@ -166,6 +180,11 @@ func (d *Dots) Stop() {
} }
} }
// SetEstimator is a no-op for dots (doesn't support ETA display)
func (d *Dots) SetEstimator(estimator *ETAEstimator) {
// Dots indicator doesn't support ETA display
}
// ProgressBar creates a visual progress bar // ProgressBar creates a visual progress bar
type ProgressBar struct { type ProgressBar struct {
writer io.Writer writer io.Writer
@ -232,6 +251,11 @@ func (p *ProgressBar) Stop() {
p.active = false p.active = false
} }
// SetEstimator is a no-op for progress bar (has its own progress tracking)
func (p *ProgressBar) SetEstimator(estimator *ETAEstimator) {
// Progress bar has its own progress tracking
}
// render draws the progress bar // render draws the progress bar
func (p *ProgressBar) render() { func (p *ProgressBar) render() {
if !p.active { if !p.active {
@ -283,10 +307,16 @@ func (s *Static) Stop() {
// No-op for static indicator // No-op for static indicator
} }
// SetEstimator is a no-op for static indicator
func (s *Static) SetEstimator(estimator *ETAEstimator) {
// Static indicator doesn't support ETA display
}
// LineByLine creates a line-by-line progress indicator // LineByLine creates a line-by-line progress indicator
type LineByLine struct { type LineByLine struct {
writer io.Writer writer io.Writer
silent bool silent bool
estimator *ETAEstimator
} }
// NewLineByLine creates a new line-by-line progress indicator // NewLineByLine creates a new line-by-line progress indicator
@ -321,16 +351,29 @@ func NewQuietLineByLine() *LineByLine {
// Start shows the initial message // Start shows the initial message
func (l *LineByLine) Start(message string) { func (l *LineByLine) Start(message string) {
fmt.Fprintf(l.writer, "\n🔄 %s\n", message) displayMsg := message
if l.estimator != nil {
displayMsg = l.estimator.GetFullStatus(message)
}
fmt.Fprintf(l.writer, "\n🔄 %s\n", displayMsg)
} }
// Update shows an update message // Update shows an update message
func (l *LineByLine) Update(message string) { func (l *LineByLine) Update(message string) {
if !l.silent { if !l.silent {
fmt.Fprintf(l.writer, " %s\n", message) displayMsg := message
if l.estimator != nil {
displayMsg = l.estimator.GetFullStatus(message)
}
fmt.Fprintf(l.writer, " %s\n", displayMsg)
} }
} }
// SetEstimator sets an ETA estimator for this indicator
func (l *LineByLine) SetEstimator(estimator *ETAEstimator) {
l.estimator = estimator
}
// Complete shows completion message // Complete shows completion message
func (l *LineByLine) Complete(message string) { func (l *LineByLine) Complete(message string) {
fmt.Fprintf(l.writer, "✅ %s\n\n", message) fmt.Fprintf(l.writer, "✅ %s\n\n", message)
@ -375,6 +418,11 @@ func (l *Light) Stop() {
// No cleanup needed for light indicator // No cleanup needed for light indicator
} }
// SetEstimator is a no-op for light indicator
func (l *Light) SetEstimator(estimator *ETAEstimator) {
// Light indicator doesn't support ETA display
}
// NewIndicator creates an appropriate progress indicator based on environment // NewIndicator creates an appropriate progress indicator based on environment
func NewIndicator(interactive bool, indicatorType string) Indicator { func NewIndicator(interactive bool, indicatorType string) Indicator {
if !interactive { if !interactive {
@ -405,8 +453,9 @@ func NewNullIndicator() *NullIndicator {
return &NullIndicator{} return &NullIndicator{}
} }
func (n *NullIndicator) Start(message string) {} func (n *NullIndicator) Start(message string) {}
func (n *NullIndicator) Update(message string) {} func (n *NullIndicator) Update(message string) {}
func (n *NullIndicator) Complete(message string) {} func (n *NullIndicator) Complete(message string) {}
func (n *NullIndicator) Fail(message string) {} func (n *NullIndicator) Fail(message string) {}
func (n *NullIndicator) Stop() {} func (n *NullIndicator) Stop() {}
func (n *NullIndicator) SetEstimator(estimator *ETAEstimator) {}

View File

@ -363,11 +363,18 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
totalDBs++ totalDBs++
} }
} }
// Create ETA estimator for database restores
estimator := progress.NewETAEstimator("Restoring cluster", totalDBs)
e.progress.SetEstimator(estimator)
for i, entry := range entries { for i, entry := range entries {
if entry.IsDir() { if entry.IsDir() {
continue continue
} }
// Update estimator progress
estimator.UpdateProgress(i)
dumpFile := filepath.Join(dumpsDir, entry.Name()) dumpFile := filepath.Join(dumpsDir, entry.Name())
dbName := strings.TrimSuffix(entry.Name(), ".dump") dbName := strings.TrimSuffix(entry.Name(), ".dump")
@ -375,7 +382,7 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
// Calculate progress percentage for logging // Calculate progress percentage for logging
dbProgress := 15 + int(float64(i)/float64(totalDBs)*85.0) dbProgress := 15 + int(float64(i)/float64(totalDBs)*85.0)
statusMsg := fmt.Sprintf("⠋ [%d/%d] Restoring: %s", i+1, totalDBs, dbName) statusMsg := fmt.Sprintf("Restoring database %s", dbName)
e.progress.Update(statusMsg) e.progress.Update(statusMsg)
e.log.Info("Restoring database", "name", dbName, "file", dumpFile, "progress", dbProgress) e.log.Info("Restoring database", "name", dbName, "file", dumpFile, "progress", dbProgress)

View File

@ -263,11 +263,12 @@ func (s *SilentOperation) Fail(message string, args ...any) {}
// SilentProgressIndicator implements progress.Indicator but doesn't output anything // SilentProgressIndicator implements progress.Indicator but doesn't output anything
type SilentProgressIndicator struct{} type SilentProgressIndicator struct{}
func (s *SilentProgressIndicator) Start(message string) {} func (s *SilentProgressIndicator) Start(message string) {}
func (s *SilentProgressIndicator) Update(message string) {} func (s *SilentProgressIndicator) Update(message string) {}
func (s *SilentProgressIndicator) Complete(message string) {} func (s *SilentProgressIndicator) Complete(message string) {}
func (s *SilentProgressIndicator) Fail(message string) {} func (s *SilentProgressIndicator) Fail(message string) {}
func (s *SilentProgressIndicator) Stop() {} func (s *SilentProgressIndicator) Stop() {}
func (s *SilentProgressIndicator) SetEstimator(estimator *progress.ETAEstimator) {}
// RunBackupInTUI runs a backup operation with TUI-compatible progress reporting // RunBackupInTUI runs a backup operation with TUI-compatible progress reporting
func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger, func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger,