package tui import ( "context" "fmt" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "dbbackup/internal/config" "dbbackup/internal/logger" "dbbackup/internal/restore" ) var ( previewBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("240")). Padding(1, 2) checkPassedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("2")) checkFailedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("1")) checkWarningStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("3")) checkPendingStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("244")) ) // SafetyCheck represents a pre-restore safety check type SafetyCheck struct { Name string Status string // "pending", "checking", "passed", "failed", "warning" Message string Critical bool } // RestorePreviewModel shows restore preview and safety checks type RestorePreviewModel struct { config *config.Config logger logger.Logger parent tea.Model ctx context.Context archive ArchiveInfo mode string targetDB string cleanFirst bool createIfMissing bool cleanClusterFirst bool // For cluster restore: drop all user databases first existingDBCount int // Number of existing user databases existingDBs []string // List of existing user databases safetyChecks []SafetyCheck checking bool canProceed bool message string saveDebugLog bool // Save detailed error report on failure workDir string // Custom work directory for extraction } // NewRestorePreview creates a new restore preview func NewRestorePreview(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, archive ArchiveInfo, mode string) RestorePreviewModel { // Default target database name from archive targetDB := archive.DatabaseName if targetDB == "" { targetDB = cfg.Database } return RestorePreviewModel{ config: cfg, logger: log, parent: parent, ctx: ctx, archive: archive, mode: mode, targetDB: targetDB, cleanFirst: false, createIfMissing: true, checking: true, workDir: cfg.WorkDir, // Use configured work directory safetyChecks: []SafetyCheck{ {Name: "Archive integrity", Status: "pending", Critical: true}, {Name: "Dump validity", Status: "pending", Critical: true}, {Name: "Disk space", Status: "pending", Critical: true}, {Name: "Required tools", Status: "pending", Critical: true}, {Name: "Target database", Status: "pending", Critical: false}, }, } } func (m RestorePreviewModel) Init() tea.Cmd { return runSafetyChecks(m.config, m.logger, m.archive, m.targetDB) } type safetyCheckCompleteMsg struct { checks []SafetyCheck canProceed bool existingDBCount int existingDBs []string } func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string) tea.Cmd { return func() tea.Msg { // 10 minutes for safety checks - large archives can take a long time to diagnose ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() safety := restore.NewSafety(cfg, log) checks := []SafetyCheck{} canProceed := true // 1. Archive integrity check := SafetyCheck{Name: "Archive integrity", Status: "checking", Critical: true} if err := safety.ValidateArchive(archive.Path); err != nil { check.Status = "failed" check.Message = err.Error() canProceed = false } else { check.Status = "passed" check.Message = "Valid backup archive" } checks = append(checks, check) // 2. Dump validity (deep diagnosis) check = SafetyCheck{Name: "Dump validity", Status: "checking", Critical: true} diagnoser := restore.NewDiagnoser(log, false) diagResult, diagErr := diagnoser.DiagnoseFile(archive.Path) if diagErr != nil { check.Status = "warning" check.Message = fmt.Sprintf("Cannot diagnose: %v", diagErr) } else if !diagResult.IsValid { check.Status = "failed" check.Critical = true if diagResult.IsTruncated { check.Message = "Dump is TRUNCATED - restore will fail" } else if diagResult.IsCorrupted { check.Message = "Dump is CORRUPTED - restore will fail" } else if len(diagResult.Errors) > 0 { check.Message = diagResult.Errors[0] } else { check.Message = "Dump has validation errors" } canProceed = false } else { check.Status = "passed" check.Message = "Dump structure verified" } checks = append(checks, check) // 3. Disk space check = SafetyCheck{Name: "Disk space", Status: "checking", Critical: true} multiplier := 3.0 if archive.Format.IsClusterBackup() { multiplier = 4.0 } if err := safety.CheckDiskSpace(archive.Path, multiplier); err != nil { check.Status = "warning" check.Message = err.Error() // Not critical - just warning } else { check.Status = "passed" check.Message = "Sufficient space available" } checks = append(checks, check) // 4. Required tools check = SafetyCheck{Name: "Required tools", Status: "checking", Critical: true} dbType := "postgres" if archive.Format.IsMySQL() { dbType = "mysql" } if err := safety.VerifyTools(dbType); err != nil { check.Status = "failed" check.Message = err.Error() canProceed = false } else { check.Status = "passed" check.Message = "All required tools available" } checks = append(checks, check) // 5. Target database check (skip for cluster restores) existingDBCount := 0 existingDBs := []string{} if !archive.Format.IsClusterBackup() { check = SafetyCheck{Name: "Target database", Status: "checking", Critical: false} exists, err := safety.CheckDatabaseExists(ctx, targetDB) if err != nil { check.Status = "warning" check.Message = fmt.Sprintf("Cannot check: %v", err) } else if exists { check.Status = "warning" check.Message = fmt.Sprintf("Database '%s' exists - will be overwritten if clean-first enabled", targetDB) } else { check.Status = "passed" check.Message = fmt.Sprintf("Database '%s' does not exist - will be created", targetDB) } checks = append(checks, check) } else { // For cluster restores, detect existing user databases check = SafetyCheck{Name: "Existing databases", Status: "checking", Critical: false} // Get list of existing user databases (exclude templates and system DBs) dbList, err := safety.ListUserDatabases(ctx) if err != nil { check.Status = "warning" check.Message = fmt.Sprintf("Cannot list databases: %v", err) } else { existingDBCount = len(dbList) existingDBs = dbList if existingDBCount > 0 { check.Status = "warning" check.Message = fmt.Sprintf("Found %d existing user database(s) - can be cleaned before restore", existingDBCount) } else { check.Status = "passed" check.Message = "No existing user databases - clean slate" } } checks = append(checks, check) } return safetyCheckCompleteMsg{ checks: checks, canProceed: canProceed, existingDBCount: existingDBCount, existingDBs: existingDBs, } } } func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case safetyCheckCompleteMsg: m.checking = false m.safetyChecks = msg.checks m.canProceed = msg.canProceed m.existingDBCount = msg.existingDBCount m.existingDBs = msg.existingDBs // Auto-forward in auto-confirm mode if m.config.TUIAutoConfirm { return m.parent, tea.Quit } return m, nil case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q", "esc": return m.parent, nil case "t": // Toggle clean-first m.cleanFirst = !m.cleanFirst m.message = fmt.Sprintf("Clean-first: %v", m.cleanFirst) case "c": if m.mode == "restore-cluster" { // Toggle cluster cleanup m.cleanClusterFirst = !m.cleanClusterFirst if m.cleanClusterFirst { m.message = checkWarningStyle.Render(fmt.Sprintf("[WARN] Will drop %d existing database(s) before restore", m.existingDBCount)) } else { m.message = fmt.Sprintf("Clean cluster first: disabled") } } else { // Toggle create if missing m.createIfMissing = !m.createIfMissing m.message = fmt.Sprintf("Create if missing: %v", m.createIfMissing) } case "d": // Toggle debug log saving m.saveDebugLog = !m.saveDebugLog if m.saveDebugLog { m.message = infoStyle.Render("[DEBUG] Debug log: enabled (will save detailed report on failure)") } else { m.message = "Debug log: disabled" } case "w": // Toggle/set work directory if m.workDir == "" { // Set to backup directory as default alternative m.workDir = m.config.BackupDir m.message = infoStyle.Render(fmt.Sprintf("[DIR] Work directory set to: %s", m.workDir)) } else { // Clear work directory (use system temp) m.workDir = "" m.message = "Work directory: using system temp" } case "enter", " ": if m.checking { m.message = "Please wait for safety checks to complete..." return m, nil } if !m.canProceed { m.message = errorStyle.Render("[FAIL] Cannot proceed - critical safety checks failed") return m, nil } // Proceed to restore execution exec := NewRestoreExecution(m.config, m.logger, m.parent, m.ctx, m.archive, m.targetDB, m.cleanFirst, m.createIfMissing, m.mode, m.cleanClusterFirst, m.existingDBs, m.saveDebugLog, m.workDir) return exec, exec.Init() } } return m, nil } func (m RestorePreviewModel) View() string { var s strings.Builder // Title title := "Restore Preview" if m.mode == "restore-cluster" { title = "Cluster Restore Preview" } s.WriteString(titleStyle.Render(title)) s.WriteString("\n\n") // Archive Information s.WriteString(archiveHeaderStyle.Render("[ARCHIVE] Information")) s.WriteString("\n") s.WriteString(fmt.Sprintf(" File: %s\n", m.archive.Name)) s.WriteString(fmt.Sprintf(" Format: %s\n", m.archive.Format.String())) s.WriteString(fmt.Sprintf(" Size: %s\n", formatSize(m.archive.Size))) s.WriteString(fmt.Sprintf(" Created: %s\n", m.archive.Modified.Format("2006-01-02 15:04:05"))) if m.archive.DatabaseName != "" { s.WriteString(fmt.Sprintf(" Database: %s\n", m.archive.DatabaseName)) } s.WriteString("\n") // Target Information if m.mode == "restore-single" { s.WriteString(archiveHeaderStyle.Render("[TARGET] Information")) s.WriteString("\n") s.WriteString(fmt.Sprintf(" Database: %s\n", m.targetDB)) s.WriteString(fmt.Sprintf(" Host: %s:%d\n", m.config.Host, m.config.Port)) cleanIcon := "[N]" if m.cleanFirst { cleanIcon = "[Y]" } s.WriteString(fmt.Sprintf(" Clean First: %s %v\n", cleanIcon, m.cleanFirst)) createIcon := "[N]" if m.createIfMissing { createIcon = "[Y]" } s.WriteString(fmt.Sprintf(" Create If Missing: %s %v\n", createIcon, m.createIfMissing)) s.WriteString("\n") } else if m.mode == "restore-cluster" { s.WriteString(archiveHeaderStyle.Render("[CLUSTER] Restore Options")) s.WriteString("\n") s.WriteString(fmt.Sprintf(" Host: %s:%d\n", m.config.Host, m.config.Port)) if m.existingDBCount > 0 { s.WriteString(fmt.Sprintf(" Existing Databases: %d found\n", m.existingDBCount)) // Show first few database names maxShow := 5 for i, db := range m.existingDBs { if i >= maxShow { remaining := len(m.existingDBs) - maxShow s.WriteString(fmt.Sprintf(" ... and %d more\n", remaining)) break } s.WriteString(fmt.Sprintf(" - %s\n", db)) } cleanIcon := "[N]" cleanStyle := infoStyle if m.cleanClusterFirst { cleanIcon = "[Y]" cleanStyle = checkWarningStyle } s.WriteString(cleanStyle.Render(fmt.Sprintf(" Clean All First: %s %v (press 'c' to toggle)\n", cleanIcon, m.cleanClusterFirst))) } else { s.WriteString(" Existing Databases: None (clean slate)\n") } s.WriteString("\n") } // Safety Checks s.WriteString(archiveHeaderStyle.Render("[SAFETY] Checks")) s.WriteString("\n") if m.checking { s.WriteString(infoStyle.Render(" Running safety checks...")) s.WriteString("\n") } else { for _, check := range m.safetyChecks { icon := "[ ]" style := checkPendingStyle switch check.Status { case "passed": icon = "[+]" style = checkPassedStyle case "failed": icon = "[-]" style = checkFailedStyle case "warning": icon = "[!]" style = checkWarningStyle case "checking": icon = "[~]" style = checkPendingStyle } line := fmt.Sprintf(" %s %s", icon, check.Name) if check.Message != "" { line += fmt.Sprintf(" ... %s", check.Message) } s.WriteString(style.Render(line)) s.WriteString("\n") } } s.WriteString("\n") // Warnings if m.cleanFirst { s.WriteString(checkWarningStyle.Render("[WARN] Warning: Clean-first enabled")) s.WriteString("\n") s.WriteString(infoStyle.Render(" All existing data in target database will be dropped!")) s.WriteString("\n\n") } if m.cleanClusterFirst && m.existingDBCount > 0 { s.WriteString(checkWarningStyle.Render("[DANGER] WARNING: Cluster cleanup enabled")) s.WriteString("\n") s.WriteString(checkWarningStyle.Render(fmt.Sprintf(" %d existing database(s) will be DROPPED before restore!", m.existingDBCount))) s.WriteString("\n") s.WriteString(infoStyle.Render(" This ensures a clean disaster recovery scenario")) s.WriteString("\n\n") } // Advanced Options s.WriteString(archiveHeaderStyle.Render("[OPTIONS] Advanced")) s.WriteString("\n") // Work directory option workDirIcon := "[-]" workDirStyle := infoStyle workDirValue := "(system temp)" if m.workDir != "" { workDirIcon = "[+]" workDirStyle = checkPassedStyle workDirValue = m.workDir } s.WriteString(workDirStyle.Render(fmt.Sprintf(" %s Work Dir: %s (press 'w' to toggle)", workDirIcon, workDirValue))) s.WriteString("\n") if m.workDir == "" { s.WriteString(infoStyle.Render(" [WARN] Large archives need more space than /tmp may have")) s.WriteString("\n") } // Debug log option debugIcon := "[-]" debugStyle := infoStyle if m.saveDebugLog { debugIcon = "[+]" debugStyle = checkPassedStyle } s.WriteString(debugStyle.Render(fmt.Sprintf(" %s Debug Log: %v (press 'd' to toggle)", debugIcon, m.saveDebugLog))) s.WriteString("\n") if m.saveDebugLog { s.WriteString(infoStyle.Render(fmt.Sprintf(" Saves detailed error report to %s on failure", m.config.GetEffectiveWorkDir()))) s.WriteString("\n") } s.WriteString("\n") // Message if m.message != "" { s.WriteString(m.message) s.WriteString("\n\n") } // Footer if m.checking { s.WriteString(infoStyle.Render("Please wait...")) } else if m.canProceed { s.WriteString(successStyle.Render("[OK] Ready to restore")) s.WriteString("\n") if m.mode == "restore-single" { s.WriteString(infoStyle.Render("t: Clean-first | c: Create | w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel")) } else if m.mode == "restore-cluster" { if m.existingDBCount > 0 { s.WriteString(infoStyle.Render("c: Cleanup | w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel")) } else { s.WriteString(infoStyle.Render("w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel")) } } else { s.WriteString(infoStyle.Render("w: WorkDir | d: Debug | Enter: Proceed | Esc: Cancel")) } } else { s.WriteString(errorStyle.Render("[FAIL] Cannot proceed - please fix errors above")) s.WriteString("\n") s.WriteString(infoStyle.Render("Esc: Go back")) } return s.String() }