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
|
## 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.
@ -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