diff --git a/bin/README.md b/bin/README.md index 752ce91..7a86911 100644 --- a/bin/README.md +++ b/bin/README.md @@ -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 diff --git a/bin/dbbackup_darwin_amd64 b/bin/dbbackup_darwin_amd64 deleted file mode 100755 index eea7502..0000000 Binary files a/bin/dbbackup_darwin_amd64 and /dev/null differ diff --git a/bin/dbbackup_darwin_arm64 b/bin/dbbackup_darwin_arm64 deleted file mode 100755 index 8275922..0000000 Binary files a/bin/dbbackup_darwin_arm64 and /dev/null differ diff --git a/bin/dbbackup_freebsd_amd64 b/bin/dbbackup_freebsd_amd64 deleted file mode 100755 index b3acd89..0000000 Binary files a/bin/dbbackup_freebsd_amd64 and /dev/null differ diff --git a/bin/dbbackup_linux_amd64 b/bin/dbbackup_linux_amd64 index 68363e5..b92ff7a 100755 Binary files a/bin/dbbackup_linux_amd64 and b/bin/dbbackup_linux_amd64 differ diff --git a/bin/dbbackup_linux_arm64 b/bin/dbbackup_linux_arm64 index f213ee9..b654e0a 100755 Binary files a/bin/dbbackup_linux_arm64 and b/bin/dbbackup_linux_arm64 differ diff --git a/bin/dbbackup_linux_arm_armv7 b/bin/dbbackup_linux_arm_armv7 deleted file mode 100755 index 158e8c5..0000000 Binary files a/bin/dbbackup_linux_arm_armv7 and /dev/null differ diff --git a/bin/dbbackup_netbsd_amd64 b/bin/dbbackup_netbsd_amd64 deleted file mode 100755 index 0671167..0000000 Binary files a/bin/dbbackup_netbsd_amd64 and /dev/null differ diff --git a/bin/dbbackup_openbsd_amd64 b/bin/dbbackup_openbsd_amd64 deleted file mode 100755 index 0dbbab7..0000000 Binary files a/bin/dbbackup_openbsd_amd64 and /dev/null differ diff --git a/bin/dbbackup_windows_amd64.exe b/bin/dbbackup_windows_amd64.exe deleted file mode 100755 index fdb6638..0000000 Binary files a/bin/dbbackup_windows_amd64.exe and /dev/null differ diff --git a/bin/dbbackup_windows_arm64.exe b/bin/dbbackup_windows_arm64.exe deleted file mode 100755 index 8587dbb..0000000 Binary files a/bin/dbbackup_windows_arm64.exe and /dev/null differ diff --git a/dbbackup b/dbbackup index 51541d2..59a1f77 100755 Binary files a/dbbackup and b/dbbackup differ diff --git a/internal/backup/engine.go b/internal/backup/engine.go index 2243294..27e8029 100644 --- a/internal/backup/engine.go +++ b/internal/backup/engine.go @@ -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)) diff --git a/internal/progress/estimator.go b/internal/progress/estimator.go new file mode 100644 index 0000000..b3ec789 --- /dev/null +++ b/internal/progress/estimator.go @@ -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)) +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go index b68b760..75d2e97 100644 --- a/internal/progress/progress.go +++ b/internal/progress/progress.go @@ -15,16 +15,18 @@ type Indicator interface { Complete(message string) Fail(message string) Stop() + SetEstimator(estimator *ETAEstimator) } // Spinner creates a spinning progress indicator type Spinner struct { - writer io.Writer - message string - active bool - frames []string - interval time.Duration - stopCh chan bool + writer io.Writer + message string + active bool + 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 + writer io.Writer + silent bool + estimator *ETAEstimator } // NewLineByLine creates a new line-by-line progress indicator @@ -321,16 +351,29 @@ 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 func (l *LineByLine) Complete(message string) { fmt.Fprintf(l.writer, "āœ… %s\n\n", 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 { @@ -405,8 +453,9 @@ func NewNullIndicator() *NullIndicator { return &NullIndicator{} } -func (n *NullIndicator) Start(message string) {} -func (n *NullIndicator) Update(message string) {} -func (n *NullIndicator) Complete(message string) {} -func (n *NullIndicator) Fail(message string) {} -func (n *NullIndicator) Stop() {} \ No newline at end of file +func (n *NullIndicator) Start(message string) {} +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) {} diff --git a/internal/restore/engine.go b/internal/restore/engine.go index 09e2d3d..c4c4ef5 100644 --- a/internal/restore/engine.go +++ b/internal/restore/engine.go @@ -363,11 +363,18 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error { totalDBs++ } } + + // 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") @@ -375,7 +382,7 @@ func (e *Engine) RestoreCluster(ctx context.Context, archivePath string) error { // 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) diff --git a/internal/tui/progress.go b/internal/tui/progress.go index c899ea8..fb38074 100644 --- a/internal/tui/progress.go +++ b/internal/tui/progress.go @@ -263,11 +263,12 @@ func (s *SilentOperation) Fail(message string, args ...any) {} // SilentProgressIndicator implements progress.Indicator but doesn't output anything type SilentProgressIndicator struct{} -func (s *SilentProgressIndicator) Start(message string) {} -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) Start(message string) {} +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,