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:
@ -4,8 +4,8 @@ This directory contains pre-compiled binaries for the DB Backup Tool across mult
|
||||
|
||||
## Build Information
|
||||
- **Version**: 1.1.0
|
||||
- **Build Time**: 2025-11-06_08:16:58_UTC
|
||||
- **Git Commit**: 26ad1dc
|
||||
- **Build Time**: 2025-11-07_13:27:44_UTC
|
||||
- **Git Commit**: ebb77fb
|
||||
|
||||
## Recent Updates (v1.1.0)
|
||||
- ✅ 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.
@ -333,12 +333,19 @@ func (e *Engine) BackupCluster(ctx context.Context) error {
|
||||
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
|
||||
e.printf(" Backing up %d databases...\n", len(databases))
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
|
||||
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)
|
||||
quietProgress.Update(fmt.Sprintf("Backing up database %d/%d: %s", i+1, len(databases), dbName))
|
||||
|
||||
|
||||
145
internal/progress/estimator.go
Normal file
145
internal/progress/estimator.go
Normal 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))
|
||||
}
|
||||
@ -15,6 +15,7 @@ type Indicator interface {
|
||||
Complete(message string)
|
||||
Fail(message string)
|
||||
Stop()
|
||||
SetEstimator(estimator *ETAEstimator)
|
||||
}
|
||||
|
||||
// Spinner creates a spinning progress indicator
|
||||
@ -25,6 +26,7 @@ type Spinner struct {
|
||||
frames []string
|
||||
interval time.Duration
|
||||
stopCh chan bool
|
||||
estimator *ETAEstimator
|
||||
}
|
||||
|
||||
// NewSpinner creates a new spinner progress indicator
|
||||
@ -51,7 +53,14 @@ func (s *Spinner) Start(message string) {
|
||||
return
|
||||
default:
|
||||
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 {
|
||||
// Print new line for new messages
|
||||
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
|
||||
func (s *Spinner) Update(message string) {
|
||||
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
|
||||
type ProgressBar struct {
|
||||
writer io.Writer
|
||||
@ -232,6 +251,11 @@ func (p *ProgressBar) Stop() {
|
||||
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
|
||||
func (p *ProgressBar) render() {
|
||||
if !p.active {
|
||||
@ -283,10 +307,16 @@ func (s *Static) Stop() {
|
||||
// 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
|
||||
type LineByLine struct {
|
||||
writer io.Writer
|
||||
silent bool
|
||||
estimator *ETAEstimator
|
||||
}
|
||||
|
||||
// NewLineByLine creates a new line-by-line progress indicator
|
||||
@ -321,14 +351,27 @@ func NewQuietLineByLine() *LineByLine {
|
||||
|
||||
// Start shows the initial message
|
||||
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
|
||||
func (l *LineByLine) Update(message string) {
|
||||
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
|
||||
@ -375,6 +418,11 @@ func (l *Light) Stop() {
|
||||
// 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
|
||||
func NewIndicator(interactive bool, indicatorType string) Indicator {
|
||||
if !interactive {
|
||||
@ -410,3 +458,4 @@ func (n *NullIndicator) Update(message string) {}
|
||||
func (n *NullIndicator) Complete(message string) {}
|
||||
func (n *NullIndicator) Fail(message string) {}
|
||||
func (n *NullIndicator) Stop() {}
|
||||
func (n *NullIndicator) SetEstimator(estimator *ETAEstimator) {}
|
||||
|
||||
@ -364,18 +364,25 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Create ETA estimator for database restores
|
||||
estimator := progress.NewETAEstimator("Restoring cluster", totalDBs)
|
||||
e.progress.SetEstimator(estimator)
|
||||
|
||||
for i, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update estimator progress
|
||||
estimator.UpdateProgress(i)
|
||||
|
||||
dumpFile := filepath.Join(dumpsDir, entry.Name())
|
||||
dbName := strings.TrimSuffix(entry.Name(), ".dump")
|
||||
|
||||
// Calculate progress percentage for logging
|
||||
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.log.Info("Restoring database", "name", dbName, "file", dumpFile, "progress", dbProgress)
|
||||
|
||||
|
||||
@ -268,6 +268,7 @@ func (s *SilentProgressIndicator) Update(message string) {}
|
||||
func (s *SilentProgressIndicator) Complete(message string) {}
|
||||
func (s *SilentProgressIndicator) Fail(message string) {}
|
||||
func (s *SilentProgressIndicator) Stop() {}
|
||||
func (s *SilentProgressIndicator) SetEstimator(estimator *progress.ETAEstimator) {}
|
||||
|
||||
// RunBackupInTUI runs a backup operation with TUI-compatible progress reporting
|
||||
func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger,
|
||||
|
||||
Reference in New Issue
Block a user