package tui import ( "context" "fmt" "strings" "sync" "time" tea "github.com/charmbracelet/bubbletea" "dbbackup/internal/backup" "dbbackup/internal/config" "dbbackup/internal/database" "dbbackup/internal/logger" "path/filepath" ) // Backup phase constants for consistency const ( backupPhaseGlobals = 1 backupPhaseDatabases = 2 backupPhaseCompressing = 3 ) // BackupExecutionModel handles backup execution with progress type BackupExecutionModel struct { config *config.Config logger logger.Logger parent tea.Model ctx context.Context cancel context.CancelFunc // Cancel function to stop the operation backupType string databaseName string ratio int status string progress int done bool cancelling bool // True when user has requested cancellation err error result string archivePath string // Path to created archive (for summary) archiveSize int64 // Size of created archive (for summary) startTime time.Time elapsed time.Duration // Final elapsed time details []string spinnerFrame int // Database count progress (for cluster backup) dbTotal int dbDone int dbName string // Current database being backed up overallPhase int // 1=globals, 2=databases, 3=compressing phaseDesc string // Description of current phase phase2StartTime time.Time // When phase 2 (databases) started (for realtime ETA) dbPhaseElapsed time.Duration // Elapsed time since database backup phase started dbAvgPerDB time.Duration // Average time per database backup } // sharedBackupProgressState holds progress state that can be safely accessed from callbacks type sharedBackupProgressState struct { mu sync.Mutex dbTotal int dbDone int dbName string overallPhase int // 1=globals, 2=databases, 3=compressing phaseDesc string // Description of current phase hasUpdate bool phase2StartTime time.Time // When phase 2 started (for realtime ETA calculation) dbPhaseElapsed time.Duration // Elapsed time since database backup phase started dbAvgPerDB time.Duration // Average time per database backup } // Package-level shared progress state for backup operations var ( currentBackupProgressMu sync.Mutex currentBackupProgressState *sharedBackupProgressState ) func setCurrentBackupProgress(state *sharedBackupProgressState) { currentBackupProgressMu.Lock() defer currentBackupProgressMu.Unlock() currentBackupProgressState = state } func clearCurrentBackupProgress() { currentBackupProgressMu.Lock() defer currentBackupProgressMu.Unlock() currentBackupProgressState = nil } func getCurrentBackupProgress() (dbTotal, dbDone int, dbName string, overallPhase int, phaseDesc string, hasUpdate bool, dbPhaseElapsed, dbAvgPerDB time.Duration, phase2StartTime time.Time) { currentBackupProgressMu.Lock() defer currentBackupProgressMu.Unlock() if currentBackupProgressState == nil { return 0, 0, "", 0, "", false, 0, 0, time.Time{} } currentBackupProgressState.mu.Lock() defer currentBackupProgressState.mu.Unlock() hasUpdate = currentBackupProgressState.hasUpdate currentBackupProgressState.hasUpdate = false // Calculate realtime phase elapsed if we have a phase 2 start time dbPhaseElapsed = currentBackupProgressState.dbPhaseElapsed if !currentBackupProgressState.phase2StartTime.IsZero() { dbPhaseElapsed = time.Since(currentBackupProgressState.phase2StartTime) } return currentBackupProgressState.dbTotal, currentBackupProgressState.dbDone, currentBackupProgressState.dbName, currentBackupProgressState.overallPhase, currentBackupProgressState.phaseDesc, hasUpdate, dbPhaseElapsed, currentBackupProgressState.dbAvgPerDB, currentBackupProgressState.phase2StartTime } func NewBackupExecution(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, backupType, dbName string, ratio int) BackupExecutionModel { // Create a cancellable context derived from parent childCtx, cancel := context.WithCancel(ctx) return BackupExecutionModel{ config: cfg, logger: log, parent: parent, ctx: childCtx, cancel: cancel, backupType: backupType, databaseName: dbName, ratio: ratio, status: "Initializing...", startTime: time.Now(), details: []string{}, spinnerFrame: 0, } } func (m BackupExecutionModel) Init() tea.Cmd { return tea.Batch( executeBackupWithTUIProgress(m.ctx, m.config, m.logger, m.backupType, m.databaseName, m.ratio), backupTickCmd(), ) } type backupTickMsg time.Time func backupTickCmd() tea.Cmd { return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg { return backupTickMsg(t) }) } type backupProgressMsg struct { status string progress int detail string } type backupCompleteMsg struct { result string err error archivePath string archiveSize int64 elapsed time.Duration } func executeBackupWithTUIProgress(parentCtx context.Context, cfg *config.Config, log logger.Logger, backupType, dbName string, ratio int) tea.Cmd { return func() tea.Msg { // NO TIMEOUT for backup operations - a backup takes as long as it takes // Large databases can take many hours // Only manual cancellation (Ctrl+C) should stop the backup ctx, cancel := context.WithCancel(parentCtx) defer cancel() start := time.Now() // Setup shared progress state for TUI polling progressState := &sharedBackupProgressState{} setCurrentBackupProgress(progressState) defer clearCurrentBackupProgress() dbClient, err := database.New(cfg, log) if err != nil { return backupCompleteMsg{ result: "", err: fmt.Errorf("failed to create database client: %w", err), } } defer dbClient.Close() if err := dbClient.Connect(ctx); err != nil { return backupCompleteMsg{ result: "", err: fmt.Errorf("database connection failed: %w", err), } } // Pass nil as indicator - TUI itself handles all display, no stdout printing engine := backup.NewSilent(cfg, log, dbClient, nil) // Set database progress callback for cluster backups engine.SetDatabaseProgressCallback(func(done, total int, currentDB string) { progressState.mu.Lock() progressState.dbDone = done progressState.dbTotal = total progressState.dbName = currentDB progressState.overallPhase = backupPhaseDatabases progressState.phaseDesc = fmt.Sprintf("Phase 2/3: Backing up Databases (%d/%d)", done, total) progressState.hasUpdate = true // Set phase 2 start time on first callback (for realtime ETA calculation) if progressState.phase2StartTime.IsZero() { progressState.phase2StartTime = time.Now() } progressState.mu.Unlock() }) var backupErr error switch backupType { case "single": backupErr = engine.BackupSingle(ctx, dbName) case "sample": cfg.SampleStrategy = "ratio" cfg.SampleValue = ratio backupErr = engine.BackupSample(ctx, dbName) case "cluster": backupErr = engine.BackupCluster(ctx) default: return backupCompleteMsg{err: fmt.Errorf("unknown backup type: %s", backupType)} } if backupErr != nil { return backupCompleteMsg{ result: "", err: fmt.Errorf("backup failed: %w", backupErr), } } elapsed := time.Since(start).Round(time.Second) var result string switch backupType { case "single": result = fmt.Sprintf("[+] Single database backup of '%s' completed successfully in %v", dbName, elapsed) case "sample": result = fmt.Sprintf("[+] Sample backup of '%s' (ratio: %d) completed successfully in %v", dbName, ratio, elapsed) case "cluster": result = fmt.Sprintf("[+] Cluster backup completed successfully in %v", elapsed) } return backupCompleteMsg{ result: result, err: nil, elapsed: elapsed, } } } func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case backupTickMsg: if !m.done { // Increment spinner frame for smooth animation m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames) // Poll for database progress updates from callbacks dbTotal, dbDone, dbName, overallPhase, phaseDesc, hasUpdate, dbPhaseElapsed, dbAvgPerDB, _ := getCurrentBackupProgress() if hasUpdate { m.dbTotal = dbTotal m.dbDone = dbDone m.dbName = dbName m.overallPhase = overallPhase m.phaseDesc = phaseDesc m.dbPhaseElapsed = dbPhaseElapsed m.dbAvgPerDB = dbAvgPerDB } // Update status based on progress and elapsed time elapsedSec := int(time.Since(m.startTime).Seconds()) if m.dbTotal > 0 && m.dbDone > 0 { // We have real progress from cluster backup m.status = fmt.Sprintf("Backing up database: %s", m.dbName) } else if elapsedSec < 2 { m.status = "Initializing backup..." } else if elapsedSec < 5 { if m.backupType == "cluster" { m.status = "Connecting to database cluster..." } else { m.status = fmt.Sprintf("Connecting to database '%s'...", m.databaseName) } } else if elapsedSec < 10 { if m.backupType == "cluster" { m.status = "Backing up global objects (roles, tablespaces)..." } else if m.backupType == "sample" { m.status = fmt.Sprintf("Analyzing tables for sampling (ratio: %d)...", m.ratio) } else { m.status = fmt.Sprintf("Dumping database '%s'...", m.databaseName) } } else { if m.backupType == "cluster" { m.status = "Backing up cluster databases..." } else if m.backupType == "sample" { m.status = fmt.Sprintf("Creating sample backup of '%s'...", m.databaseName) } else { m.status = fmt.Sprintf("Backing up database '%s'...", m.databaseName) } } return m, backupTickCmd() } return m, nil case backupProgressMsg: m.status = msg.status m.progress = msg.progress return m, nil case backupCompleteMsg: m.done = true m.err = msg.err m.result = msg.result m.elapsed = msg.elapsed if m.err == nil { m.status = "[OK] Backup completed successfully!" } else { m.status = fmt.Sprintf("[FAIL] Backup failed: %v", m.err) } // Auto-forward in debug/auto-confirm mode if m.config.TUIAutoConfirm { return m.parent, tea.Quit } return m, nil case tea.InterruptMsg: // Handle Ctrl+C signal (SIGINT) - Bubbletea v1.3+ sends this instead of KeyMsg for ctrl+c if !m.done && !m.cancelling { m.cancelling = true m.status = "[STOP] Cancelling backup... (please wait)" if m.cancel != nil { m.cancel() } return m, nil } else if m.done { return m.parent, tea.Quit } return m, nil case tea.KeyMsg: switch msg.String() { case "ctrl+c", "esc": if !m.done && !m.cancelling { // User requested cancellation - cancel the context m.cancelling = true m.status = "[STOP] Cancelling backup... (please wait)" if m.cancel != nil { m.cancel() } return m, nil } else if m.done { return m.parent, nil } case "enter", "q": if m.done { return m.parent, nil } } } return m, nil } // renderDatabaseProgressBar renders a progress bar for database count progress func renderBackupDatabaseProgressBar(done, total int, dbName string, width int) string { if total == 0 { return "" } // Calculate progress percentage percent := float64(done) / float64(total) if percent > 1.0 { percent = 1.0 } // Calculate filled width barWidth := width - 20 // Leave room for label and percentage if barWidth < 10 { barWidth = 10 } filled := int(float64(barWidth) * percent) if filled > barWidth { filled = barWidth } // Build progress bar bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) return fmt.Sprintf(" Database: [%s] %d/%d", bar, done, total) } // renderBackupDatabaseProgressBarWithTiming renders database backup progress with ETA func renderBackupDatabaseProgressBarWithTiming(done, total int, dbPhaseElapsed, dbAvgPerDB time.Duration) string { if total == 0 { return "" } // Calculate progress percentage percent := float64(done) / float64(total) if percent > 1.0 { percent = 1.0 } // Build progress bar barWidth := 50 filled := int(float64(barWidth) * percent) if filled > barWidth { filled = barWidth } bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) // Calculate ETA similar to restore var etaStr string if done > 0 && done < total { avgPerDB := dbPhaseElapsed / time.Duration(done) remaining := total - done eta := avgPerDB * time.Duration(remaining) etaStr = fmt.Sprintf(" | ETA: %s", formatDuration(eta)) } else if done == total { etaStr = " | Complete" } return fmt.Sprintf(" Databases: [%s] %d/%d | Elapsed: %s%s\n", bar, done, total, formatDuration(dbPhaseElapsed), etaStr) } func (m BackupExecutionModel) View() string { var s strings.Builder s.Grow(512) // Pre-allocate estimated capacity for better performance // Clear screen with newlines and render header s.WriteString("\n\n") header := "[EXEC] Backing up Database" if m.backupType == "cluster" { header = "[EXEC] Cluster Backup" } s.WriteString(titleStyle.Render(header)) s.WriteString("\n\n") // Backup details - properly aligned s.WriteString(fmt.Sprintf(" %-10s %s\n", "Type:", m.backupType)) if m.databaseName != "" { s.WriteString(fmt.Sprintf(" %-10s %s\n", "Database:", m.databaseName)) } if m.ratio > 0 { s.WriteString(fmt.Sprintf(" %-10s %d\n", "Sample:", m.ratio)) } s.WriteString("\n") // Status display if !m.done { // Unified progress display for cluster backup if m.backupType == "cluster" { // Calculate overall progress across all phases // Phase 1: Globals (0-15%) // Phase 2: Databases (15-90%) // Phase 3: Compressing (90-100%) overallProgress := 0 phaseLabel := "Starting..." elapsedSec := int(time.Since(m.startTime).Seconds()) if m.overallPhase == backupPhaseDatabases && m.dbTotal > 0 { // Phase 2: Database backups - contributes 15-90% dbPct := int((int64(m.dbDone) * 100) / int64(m.dbTotal)) overallProgress = 15 + (dbPct * 75 / 100) phaseLabel = m.phaseDesc } else if m.overallPhase == backupPhaseCompressing { // Phase 3: Compressing archive overallProgress = 92 phaseLabel = "Phase 3/3: Compressing Archive" } else if elapsedSec < 5 { // Initial setup overallProgress = 2 phaseLabel = "Phase 1/3: Initializing..." } else if m.dbTotal == 0 { // Phase 1: Globals backup (before databases start) overallProgress = 10 phaseLabel = "Phase 1/3: Backing up Globals" } // Header with phase and overall progress s.WriteString(infoStyle.Render(" ─── Cluster Backup Progress ──────────────────────────────")) s.WriteString("\n\n") s.WriteString(fmt.Sprintf(" %s\n\n", phaseLabel)) // Overall progress bar s.WriteString(" Overall: ") s.WriteString(renderProgressBar(overallProgress)) s.WriteString(fmt.Sprintf(" %d%%\n", overallProgress)) // Phase-specific details if m.dbTotal > 0 && m.dbDone > 0 { // Show current database being backed up s.WriteString("\n") spinner := spinnerFrames[m.spinnerFrame] if m.dbName != "" && m.dbDone <= m.dbTotal { s.WriteString(fmt.Sprintf(" Current: %s %s\n", spinner, m.dbName)) } s.WriteString("\n") // Database progress bar with timing s.WriteString(renderBackupDatabaseProgressBarWithTiming(m.dbDone, m.dbTotal, m.dbPhaseElapsed, m.dbAvgPerDB)) s.WriteString("\n") } else { // Intermediate phase (globals) spinner := spinnerFrames[m.spinnerFrame] s.WriteString(fmt.Sprintf("\n %s %s\n\n", spinner, m.status)) } s.WriteString("\n") s.WriteString(infoStyle.Render(" ───────────────────────────────────────────────────────────")) s.WriteString("\n\n") } else { // Single/sample database backup - simpler display spinner := spinnerFrames[m.spinnerFrame] s.WriteString(fmt.Sprintf(" %s %s\n", spinner, m.status)) } if !m.cancelling { // Elapsed time s.WriteString(fmt.Sprintf("Elapsed: %s\n", formatDuration(time.Since(m.startTime)))) s.WriteString("\n") s.WriteString(infoStyle.Render("[KEYS] Press Ctrl+C or ESC to cancel")) } } else { // Show completion summary with detailed stats if m.err != nil { s.WriteString(errorStyle.Render("╔══════════════════════════════════════════════════════════════╗")) s.WriteString("\n") s.WriteString(errorStyle.Render("║ [FAIL] BACKUP FAILED ║")) s.WriteString("\n") s.WriteString(errorStyle.Render("╚══════════════════════════════════════════════════════════════╝")) s.WriteString("\n\n") s.WriteString(errorStyle.Render(fmt.Sprintf(" Error: %v", m.err))) s.WriteString("\n") } else { s.WriteString(successStyle.Render("╔══════════════════════════════════════════════════════════════╗")) s.WriteString("\n") s.WriteString(successStyle.Render("║ [OK] BACKUP COMPLETED SUCCESSFULLY ║")) s.WriteString("\n") s.WriteString(successStyle.Render("╚══════════════════════════════════════════════════════════════╝")) s.WriteString("\n\n") // Summary section s.WriteString(infoStyle.Render(" ─── Summary ───────────────────────────────────────────────")) s.WriteString("\n\n") // Archive info (if available) if m.archivePath != "" { s.WriteString(fmt.Sprintf(" Archive: %s\n", filepath.Base(m.archivePath))) } if m.archiveSize > 0 { s.WriteString(fmt.Sprintf(" Archive Size: %s\n", FormatBytes(m.archiveSize))) } // Backup type specific info switch m.backupType { case "cluster": s.WriteString(" Type: Cluster Backup\n") if m.dbTotal > 0 { s.WriteString(fmt.Sprintf(" Databases: %d backed up\n", m.dbTotal)) } case "single": s.WriteString(" Type: Single Database Backup\n") s.WriteString(fmt.Sprintf(" Database: %s\n", m.databaseName)) case "sample": s.WriteString(" Type: Sample Backup\n") s.WriteString(fmt.Sprintf(" Database: %s\n", m.databaseName)) s.WriteString(fmt.Sprintf(" Sample Ratio: %d\n", m.ratio)) } s.WriteString("\n") } // Timing section (always shown, consistent with restore) s.WriteString(infoStyle.Render(" ─── Timing ────────────────────────────────────────────────")) s.WriteString("\n\n") elapsed := m.elapsed if elapsed == 0 { elapsed = time.Since(m.startTime) } s.WriteString(fmt.Sprintf(" Total Time: %s\n", formatDuration(elapsed))) // Calculate and show throughput if we have size info if m.archiveSize > 0 && elapsed.Seconds() > 0 { throughput := float64(m.archiveSize) / elapsed.Seconds() s.WriteString(fmt.Sprintf(" Throughput: %s/s (average)\n", FormatBytes(int64(throughput)))) } if m.backupType == "cluster" && m.dbTotal > 0 && m.err == nil { avgPerDB := elapsed / time.Duration(m.dbTotal) s.WriteString(fmt.Sprintf(" Avg per DB: %s\n", formatDuration(avgPerDB))) } s.WriteString("\n") s.WriteString(infoStyle.Render(" ───────────────────────────────────────────────────────────")) s.WriteString("\n\n") s.WriteString(infoStyle.Render(" [KEYS] Press Enter to continue")) } return s.String() }