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:
@@ -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))
|
||||||
|
|
||||||
|
|||||||
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,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) {}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user