Compare commits

..

7 Commits

Author SHA1 Message Date
e2cf9adc62 fix: improve cleanup toggle UX when database detection fails
All checks were successful
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m28s
CI/CD / Build & Release (push) Successful in 3m13s
- Allow cleanup toggle even when preview detection failed
- Show 'detection pending' message instead of blocking the toggle
- Will re-detect databases at restore execution time
- Always show cleanup toggle option for cluster restores
- Better messaging: 'enabled/disabled' instead of showing 0 count
2026-01-17 17:07:26 +01:00
29e089fe3b fix: re-detect databases at execution time for cluster cleanup
All checks were successful
CI/CD / Test (push) Successful in 1m16s
CI/CD / Lint (push) Successful in 1m25s
CI/CD / Build & Release (push) Successful in 3m10s
- Detection in preview may fail or return stale results
- Re-detect user databases when cleanup is enabled at execution time
- Fall back to preview list if re-detection fails
- Ensures actual databases are dropped, not just what was detected earlier
2026-01-17 17:00:28 +01:00
9396c8e605 fix: add debug logging for database detection
All checks were successful
CI/CD / Test (push) Successful in 1m19s
CI/CD / Lint (push) Successful in 1m34s
CI/CD / Build & Release (push) Successful in 3m24s
- Always set cmd.Env to preserve PGPASSWORD from environment
- Add debug logging for connection parameters and results
- Helps diagnose cluster restore database detection issues
2026-01-17 16:54:20 +01:00
e363e1937f fix: cluster restore database detection and TUI error display
All checks were successful
CI/CD / Test (push) Successful in 1m14s
CI/CD / Lint (push) Successful in 1m25s
CI/CD / Build & Release (push) Successful in 3m16s
- Fixed psql connection for database detection (always use -h flag)
- Use CombinedOutput() to capture stderr for better diagnostics
- Added existingDBError tracking in restore preview
- Show 'Unable to detect' instead of misleading 'None' when listing fails
- Disable cleanup toggle when database detection failed
2026-01-17 16:44:44 +01:00
df1ab2f55b feat: TUI improvements and consistency fixes
All checks were successful
CI/CD / Test (push) Successful in 1m14s
CI/CD / Lint (push) Successful in 1m23s
CI/CD / Build & Release (push) Successful in 3m10s
- Add product branding header to main menu (version + tagline)
- Fix backup success/error report formatting consistency
- Remove extra newline before error box in backup_exec
- Align backup and restore completion screens
2026-01-17 16:26:00 +01:00
0e050b2def fix: cluster backup TUI success report formatting consistency
All checks were successful
CI/CD / Test (push) Successful in 1m15s
CI/CD / Lint (push) Successful in 1m24s
CI/CD / Build & Release (push) Successful in 3m10s
- Aligned box width and indentation with restore success screen
- Removed inconsistent 2-space prefix from success/error boxes
- Standardized content indentation to 4 spaces
- Moved timing section outside else block (always shown)
- Updated footer style to match restore screen
2026-01-17 16:15:16 +01:00
62d58c77af feat(restore): add --parallel-dbs=-1 auto-detection based on CPU/RAM
All checks were successful
CI/CD / Test (push) Successful in 1m16s
CI/CD / Lint (push) Successful in 1m25s
CI/CD / Build & Release (push) Successful in 3m14s
- Add CalculateOptimalParallel() function to preflight.go
- Calculates optimal workers: min(RAM/3GB, CPU cores), capped at 16
- Reduces parallelism by 50% if memory pressure >80%
- Add -1 flag value for auto-detection mode
- Preflight summary now shows CPU cores and recommended parallel
2026-01-17 13:41:28 +01:00
8 changed files with 270 additions and 92 deletions

View File

@@ -4,8 +4,8 @@ This directory contains pre-compiled binaries for the DB Backup Tool across mult
## Build Information ## Build Information
- **Version**: 3.42.50 - **Version**: 3.42.50
- **Build Time**: 2026-01-17_06:48:35_UTC - **Build Time**: 2026-01-17_16:00:43_UTC
- **Git Commit**: dd1db84 - **Git Commit**: 29e089f
## 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

View File

@@ -290,7 +290,7 @@ func init() {
restoreClusterCmd.Flags().BoolVar(&restoreForce, "force", false, "Skip safety checks and confirmations") restoreClusterCmd.Flags().BoolVar(&restoreForce, "force", false, "Skip safety checks and confirmations")
restoreClusterCmd.Flags().BoolVar(&restoreCleanCluster, "clean-cluster", false, "Drop all existing user databases before restore (disaster recovery)") restoreClusterCmd.Flags().BoolVar(&restoreCleanCluster, "clean-cluster", false, "Drop all existing user databases before restore (disaster recovery)")
restoreClusterCmd.Flags().IntVar(&restoreJobs, "jobs", 0, "Number of parallel decompression jobs (0 = auto)") restoreClusterCmd.Flags().IntVar(&restoreJobs, "jobs", 0, "Number of parallel decompression jobs (0 = auto)")
restoreClusterCmd.Flags().IntVar(&restoreParallelDBs, "parallel-dbs", 0, "Number of databases to restore in parallel (0 = use config default, 1 = sequential)") restoreClusterCmd.Flags().IntVar(&restoreParallelDBs, "parallel-dbs", 0, "Number of databases to restore in parallel (0 = use config default, 1 = sequential, -1 = auto-detect based on CPU/RAM)")
restoreClusterCmd.Flags().StringVar(&restoreWorkdir, "workdir", "", "Working directory for extraction (use when system disk is small, e.g. /mnt/storage/restore_tmp)") restoreClusterCmd.Flags().StringVar(&restoreWorkdir, "workdir", "", "Working directory for extraction (use when system disk is small, e.g. /mnt/storage/restore_tmp)")
restoreClusterCmd.Flags().BoolVar(&restoreVerbose, "verbose", false, "Show detailed restore progress") restoreClusterCmd.Flags().BoolVar(&restoreVerbose, "verbose", false, "Show detailed restore progress")
restoreClusterCmd.Flags().BoolVar(&restoreNoProgress, "no-progress", false, "Disable progress indicators") restoreClusterCmd.Flags().BoolVar(&restoreNoProgress, "no-progress", false, "Disable progress indicators")
@@ -786,7 +786,12 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
} }
// Override cluster parallelism if --parallel-dbs is specified // Override cluster parallelism if --parallel-dbs is specified
if restoreParallelDBs > 0 { if restoreParallelDBs == -1 {
// Auto-detect optimal parallelism based on system resources
autoParallel := restore.CalculateOptimalParallel()
cfg.ClusterParallelism = autoParallel
log.Info("Auto-detected optimal parallelism for database restores", "parallel_dbs", autoParallel, "mode", "auto")
} else if restoreParallelDBs > 0 {
cfg.ClusterParallelism = restoreParallelDBs cfg.ClusterParallelism = restoreParallelDBs
log.Info("Using custom parallelism for database restores", "parallel_dbs", restoreParallelDBs) log.Info("Using custom parallelism for database restores", "parallel_dbs", restoreParallelDBs)
} }

View File

@@ -16,6 +16,57 @@ import (
"github.com/shirou/gopsutil/v3/mem" "github.com/shirou/gopsutil/v3/mem"
) )
// CalculateOptimalParallel returns the recommended number of parallel workers
// based on available system resources (CPU cores and RAM).
// This is a standalone function that can be called from anywhere.
// Returns 0 if resources cannot be detected.
func CalculateOptimalParallel() int {
cpuCores := runtime.NumCPU()
vmem, err := mem.VirtualMemory()
if err != nil {
// Fallback: use half of CPU cores if memory detection fails
if cpuCores > 1 {
return cpuCores / 2
}
return 1
}
memAvailableGB := float64(vmem.Available) / (1024 * 1024 * 1024)
// Each pg_restore worker needs approximately 2-4GB of RAM
// Use conservative 3GB per worker to avoid OOM
const memPerWorkerGB = 3.0
// Calculate limits
maxByMem := int(memAvailableGB / memPerWorkerGB)
maxByCPU := cpuCores
// Use the minimum of memory and CPU limits
recommended := maxByMem
if maxByCPU < recommended {
recommended = maxByCPU
}
// Apply sensible bounds
if recommended < 1 {
recommended = 1
}
if recommended > 16 {
recommended = 16 // Cap at 16 to avoid diminishing returns
}
// If memory pressure is high (>80%), reduce parallelism
if vmem.UsedPercent > 80 && recommended > 1 {
recommended = recommended / 2
if recommended < 1 {
recommended = 1
}
}
return recommended
}
// PreflightResult contains all preflight check results // PreflightResult contains all preflight check results
type PreflightResult struct { type PreflightResult struct {
// Linux system checks // Linux system checks
@@ -40,6 +91,8 @@ type LinuxChecks struct {
MemTotal uint64 // Total RAM in bytes MemTotal uint64 // Total RAM in bytes
MemAvailable uint64 // Available RAM in bytes MemAvailable uint64 // Available RAM in bytes
MemUsedPercent float64 // Memory usage percentage MemUsedPercent float64 // Memory usage percentage
CPUCores int // Number of CPU cores
RecommendedParallel int // Auto-calculated optimal parallel count
ShmMaxOK bool // Is shmmax sufficient? ShmMaxOK bool // Is shmmax sufficient?
ShmAllOK bool // Is shmall sufficient? ShmAllOK bool // Is shmall sufficient?
MemAvailableOK bool // Is available RAM sufficient? MemAvailableOK bool // Is available RAM sufficient?
@@ -100,6 +153,7 @@ func (e *Engine) RunPreflightChecks(ctx context.Context, dumpsDir string, entrie
// checkSystemResources uses gopsutil for cross-platform system checks // checkSystemResources uses gopsutil for cross-platform system checks
func (e *Engine) checkSystemResources(result *PreflightResult) { func (e *Engine) checkSystemResources(result *PreflightResult) {
result.Linux.IsLinux = runtime.GOOS == "linux" result.Linux.IsLinux = runtime.GOOS == "linux"
result.Linux.CPUCores = runtime.NumCPU()
// Get memory info (works on Linux, macOS, Windows, BSD) // Get memory info (works on Linux, macOS, Windows, BSD)
if vmem, err := mem.VirtualMemory(); err == nil { if vmem, err := mem.VirtualMemory(); err == nil {
@@ -118,6 +172,9 @@ func (e *Engine) checkSystemResources(result *PreflightResult) {
e.log.Warn("Could not detect system memory", "error", err) e.log.Warn("Could not detect system memory", "error", err)
} }
// Calculate recommended parallel based on resources
result.Linux.RecommendedParallel = e.calculateRecommendedParallel(result)
// Linux-specific kernel checks (shmmax, shmall) // Linux-specific kernel checks (shmmax, shmall)
if result.Linux.IsLinux { if result.Linux.IsLinux {
e.checkLinuxKernel(result) e.checkLinuxKernel(result)
@@ -434,6 +491,56 @@ func (e *Engine) calculateRecommendations(result *PreflightResult) {
"recommended_locks", lockBoost) "recommended_locks", lockBoost)
} }
// calculateRecommendedParallel determines optimal parallelism based on system resources
// Returns the recommended number of parallel workers for pg_restore
func (e *Engine) calculateRecommendedParallel(result *PreflightResult) int {
cpuCores := result.Linux.CPUCores
if cpuCores == 0 {
cpuCores = runtime.NumCPU()
}
memAvailableGB := float64(result.Linux.MemAvailable) / (1024 * 1024 * 1024)
// Each pg_restore worker needs approximately 2-4GB of RAM
// Use conservative 3GB per worker to avoid OOM
const memPerWorkerGB = 3.0
// Calculate limits
maxByMem := int(memAvailableGB / memPerWorkerGB)
maxByCPU := cpuCores
// Use the minimum of memory and CPU limits
recommended := maxByMem
if maxByCPU < recommended {
recommended = maxByCPU
}
// Apply sensible bounds
if recommended < 1 {
recommended = 1
}
if recommended > 16 {
recommended = 16 // Cap at 16 to avoid diminishing returns
}
// If memory pressure is high (>80%), reduce parallelism
if result.Linux.MemUsedPercent > 80 && recommended > 1 {
recommended = recommended / 2
if recommended < 1 {
recommended = 1
}
}
e.log.Info("Calculated recommended parallel",
"cpu_cores", cpuCores,
"mem_available_gb", fmt.Sprintf("%.1f", memAvailableGB),
"max_by_mem", maxByMem,
"max_by_cpu", maxByCPU,
"recommended", recommended)
return recommended
}
// printPreflightSummary prints a nice summary of all checks // printPreflightSummary prints a nice summary of all checks
func (e *Engine) printPreflightSummary(result *PreflightResult) { func (e *Engine) printPreflightSummary(result *PreflightResult) {
fmt.Println() fmt.Println()
@@ -446,6 +553,8 @@ func (e *Engine) printPreflightSummary(result *PreflightResult) {
printCheck("Total RAM", humanize.Bytes(result.Linux.MemTotal), true) printCheck("Total RAM", humanize.Bytes(result.Linux.MemTotal), true)
printCheck("Available RAM", humanize.Bytes(result.Linux.MemAvailable), result.Linux.MemAvailableOK || result.Linux.MemAvailable == 0) printCheck("Available RAM", humanize.Bytes(result.Linux.MemAvailable), result.Linux.MemAvailableOK || result.Linux.MemAvailable == 0)
printCheck("Memory Usage", fmt.Sprintf("%.1f%%", result.Linux.MemUsedPercent), result.Linux.MemUsedPercent < 85) printCheck("Memory Usage", fmt.Sprintf("%.1f%%", result.Linux.MemUsedPercent), result.Linux.MemUsedPercent < 85)
printCheck("CPU Cores", fmt.Sprintf("%d", result.Linux.CPUCores), true)
printCheck("Recommended Parallel", fmt.Sprintf("%d (auto-calculated)", result.Linux.RecommendedParallel), true)
// Linux-specific kernel checks // Linux-specific kernel checks
if result.Linux.IsLinux && result.Linux.ShmMax > 0 { if result.Linux.IsLinux && result.Linux.ShmMax > 0 {

View File

@@ -334,10 +334,12 @@ func (s *Safety) checkPostgresDatabaseExists(ctx context.Context, dbName string)
"-tAc", fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname='%s'", dbName), "-tAc", fmt.Sprintf("SELECT 1 FROM pg_database WHERE datname='%s'", dbName),
} }
// Only add -h flag if host is not localhost (to use Unix socket for peer auth) // Always add -h flag for explicit host connection (required for password auth)
if s.cfg.Host != "localhost" && s.cfg.Host != "127.0.0.1" && s.cfg.Host != "" { host := s.cfg.Host
args = append([]string{"-h", s.cfg.Host}, args...) if host == "" {
host = "localhost"
} }
args = append([]string{"-h", host}, args...)
cmd := exec.CommandContext(ctx, "psql", args...) cmd := exec.CommandContext(ctx, "psql", args...)
@@ -346,9 +348,9 @@ func (s *Safety) checkPostgresDatabaseExists(ctx context.Context, dbName string)
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", s.cfg.Password)) cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", s.cfg.Password))
} }
output, err := cmd.Output() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return false, fmt.Errorf("failed to check database existence: %w", err) return false, fmt.Errorf("failed to check database existence: %w (output: %s)", err, strings.TrimSpace(string(output)))
} }
return strings.TrimSpace(string(output)) == "1", nil return strings.TrimSpace(string(output)) == "1", nil
@@ -405,21 +407,29 @@ func (s *Safety) listPostgresUserDatabases(ctx context.Context) ([]string, error
"-c", query, "-c", query,
} }
// Only add -h flag if host is not localhost (to use Unix socket for peer auth) // Always add -h flag for explicit host connection (required for password auth)
if s.cfg.Host != "localhost" && s.cfg.Host != "127.0.0.1" && s.cfg.Host != "" { // Empty or unset host defaults to localhost
args = append([]string{"-h", s.cfg.Host}, args...) host := s.cfg.Host
if host == "" {
host = "localhost"
} }
args = append([]string{"-h", host}, args...)
cmd := exec.CommandContext(ctx, "psql", args...) cmd := exec.CommandContext(ctx, "psql", args...)
// Set password if provided // Set password - check config first, then environment
env := os.Environ()
if s.cfg.Password != "" { if s.cfg.Password != "" {
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", s.cfg.Password)) env = append(env, fmt.Sprintf("PGPASSWORD=%s", s.cfg.Password))
} }
cmd.Env = env
output, err := cmd.Output() s.log.Debug("Listing PostgreSQL databases", "host", host, "port", s.cfg.Port, "user", s.cfg.User)
output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list databases: %w", err) // Include psql output in error for debugging
return nil, fmt.Errorf("failed to list databases: %w (output: %s)", err, strings.TrimSpace(string(output)))
} }
// Parse output // Parse output
@@ -432,6 +442,8 @@ func (s *Safety) listPostgresUserDatabases(ctx context.Context) ([]string, error
} }
} }
s.log.Debug("Found user databases", "count", len(databases), "databases", databases, "raw_output", string(output))
return databases, nil return databases, nil
} }

View File

@@ -454,26 +454,24 @@ func (m BackupExecutionModel) View() string {
} else { } else {
// Show completion summary with detailed stats // Show completion summary with detailed stats
if m.err != nil { if m.err != nil {
s.WriteString(errorStyle.Render("╔══════════════════════════════════════════════════════════════╗"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(errorStyle.Render(" ╔══════════════════════════════════════════════════════════╗")) s.WriteString(errorStyle.Render(" [FAIL] BACKUP FAILED ║"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(errorStyle.Render(" ║ [FAIL] BACKUP FAILED ║")) s.WriteString(errorStyle.Render("╚══════════════════════════════════════════════════════════════╝"))
s.WriteString("\n")
s.WriteString(errorStyle.Render(" ╚══════════════════════════════════════════════════════════╝"))
s.WriteString("\n\n") s.WriteString("\n\n")
s.WriteString(errorStyle.Render(fmt.Sprintf(" Error: %v", m.err))) s.WriteString(errorStyle.Render(fmt.Sprintf(" Error: %v", m.err)))
s.WriteString("\n") s.WriteString("\n")
} else { } else {
s.WriteString(successStyle.Render("╔══════════════════════════════════════════════════════════════╗"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(successStyle.Render(" ╔══════════════════════════════════════════════════════════╗")) s.WriteString(successStyle.Render(" [OK] BACKUP COMPLETED SUCCESSFULLY ║"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(successStyle.Render(" ║ [OK] BACKUP COMPLETED SUCCESSFULLY ║")) s.WriteString(successStyle.Render("╚══════════════════════════════════════════════════════════════╝"))
s.WriteString("\n")
s.WriteString(successStyle.Render(" ╚══════════════════════════════════════════════════════════╝"))
s.WriteString("\n\n") s.WriteString("\n\n")
// Summary section // Summary section
s.WriteString(infoStyle.Render(" ─── Summary ─────────────────────────────────────────────")) s.WriteString(infoStyle.Render(" ─── Summary ───────────────────────────────────────────────"))
s.WriteString("\n\n") s.WriteString("\n\n")
// Backup type specific info // Backup type specific info
@@ -493,26 +491,24 @@ func (m BackupExecutionModel) View() string {
} }
s.WriteString("\n") s.WriteString("\n")
}
// Timing section // Timing section (always shown, consistent with restore)
s.WriteString(infoStyle.Render(" ─── Timing ──────────────────────────────────────────────")) s.WriteString(infoStyle.Render(" ─── Timing ────────────────────────────────────────────────"))
s.WriteString("\n\n") s.WriteString("\n\n")
elapsed := time.Since(m.startTime) elapsed := time.Since(m.startTime)
s.WriteString(fmt.Sprintf(" Total Time: %s\n", formatBackupDuration(elapsed))) s.WriteString(fmt.Sprintf(" Total Time: %s\n", formatBackupDuration(elapsed)))
if m.backupType == "cluster" && m.dbTotal > 0 { if m.backupType == "cluster" && m.dbTotal > 0 && m.err == nil {
avgPerDB := elapsed / time.Duration(m.dbTotal) avgPerDB := elapsed / time.Duration(m.dbTotal)
s.WriteString(fmt.Sprintf(" Avg per DB: %s\n", formatBackupDuration(avgPerDB))) s.WriteString(fmt.Sprintf(" Avg per DB: %s\n", formatBackupDuration(avgPerDB)))
} }
s.WriteString("\n") s.WriteString("\n")
s.WriteString(infoStyle.Render(" ─────────────────────────────────────────────────────────")) s.WriteString(infoStyle.Render(" ───────────────────────────────────────────────────────────"))
s.WriteString("\n") s.WriteString("\n\n")
} s.WriteString(infoStyle.Render(" [KEYS] Press Enter to continue"))
s.WriteString("\n")
s.WriteString(" [KEY] Press Enter or ESC to return to menu\n")
} }
return s.String() return s.String()

View File

@@ -299,9 +299,13 @@ func (m *MenuModel) View() string {
var s string var s string
// Product branding header
brandLine := fmt.Sprintf("dbbackup v%s • Enterprise Database Backup & Recovery", m.config.Version)
s += "\n" + infoStyle.Render(brandLine) + "\n"
// Header // Header
header := titleStyle.Render("Database Backup Tool - Interactive Menu") header := titleStyle.Render("Interactive Menu")
s += fmt.Sprintf("\n%s\n\n", header) s += fmt.Sprintf("%s\n\n", header)
if len(m.dbTypes) > 0 { if len(m.dbTypes) > 0 {
options := make([]string, len(m.dbTypes)) options := make([]string, len(m.dbTypes))

View File

@@ -273,7 +273,20 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
defer dbClient.Close() defer dbClient.Close()
// STEP 1: Clean cluster if requested (drop all existing user databases) // STEP 1: Clean cluster if requested (drop all existing user databases)
if restoreType == "restore-cluster" && cleanClusterFirst && len(existingDBs) > 0 { if restoreType == "restore-cluster" && cleanClusterFirst {
// Re-detect databases at execution time to get current state
// The preview list may be stale or detection may have failed earlier
safety := restore.NewSafety(cfg, log)
currentDBs, err := safety.ListUserDatabases(ctx)
if err != nil {
log.Warn("Failed to list databases for cleanup, using preview list", "error", err)
currentDBs = existingDBs // Fall back to preview list
} else if len(currentDBs) > 0 {
log.Info("Re-detected user databases for cleanup", "count", len(currentDBs), "databases", currentDBs)
existingDBs = currentDBs // Update with fresh list
}
if len(existingDBs) > 0 {
log.Info("Dropping existing user databases before cluster restore", "count", len(existingDBs)) log.Info("Dropping existing user databases before cluster restore", "count", len(existingDBs))
// Drop databases using command-line psql (no connection required) // Drop databases using command-line psql (no connection required)
@@ -293,6 +306,9 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
} }
log.Info("Cluster cleanup completed", "dropped", droppedCount, "total", len(existingDBs)) log.Info("Cluster cleanup completed", "dropped", droppedCount, "total", len(existingDBs))
} else {
log.Info("No user databases to clean up")
}
} }
// STEP 2: Create restore engine with silent progress (no stdout interference with TUI) // STEP 2: Create restore engine with silent progress (no stdout interference with TUI)

View File

@@ -55,6 +55,7 @@ type RestorePreviewModel struct {
cleanClusterFirst bool // For cluster restore: drop all user databases first cleanClusterFirst bool // For cluster restore: drop all user databases first
existingDBCount int // Number of existing user databases existingDBCount int // Number of existing user databases
existingDBs []string // List of existing user databases existingDBs []string // List of existing user databases
existingDBError string // Error message if database listing failed
safetyChecks []SafetyCheck safetyChecks []SafetyCheck
checking bool checking bool
canProceed bool canProceed bool
@@ -102,6 +103,7 @@ type safetyCheckCompleteMsg struct {
canProceed bool canProceed bool
existingDBCount int existingDBCount int
existingDBs []string existingDBs []string
existingDBError string
} }
func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string) tea.Cmd { func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string) tea.Cmd {
@@ -221,10 +223,12 @@ func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo,
check = SafetyCheck{Name: "Existing databases", Status: "checking", Critical: false} check = SafetyCheck{Name: "Existing databases", Status: "checking", Critical: false}
// Get list of existing user databases (exclude templates and system DBs) // Get list of existing user databases (exclude templates and system DBs)
var existingDBError string
dbList, err := safety.ListUserDatabases(ctx) dbList, err := safety.ListUserDatabases(ctx)
if err != nil { if err != nil {
check.Status = "warning" check.Status = "warning"
check.Message = fmt.Sprintf("Cannot list databases: %v", err) check.Message = fmt.Sprintf("Cannot list databases: %v", err)
existingDBError = err.Error()
} else { } else {
existingDBCount = len(dbList) existingDBCount = len(dbList)
existingDBs = dbList existingDBs = dbList
@@ -238,6 +242,14 @@ func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo,
} }
} }
checks = append(checks, check) checks = append(checks, check)
return safetyCheckCompleteMsg{
checks: checks,
canProceed: canProceed,
existingDBCount: existingDBCount,
existingDBs: existingDBs,
existingDBError: existingDBError,
}
} }
return safetyCheckCompleteMsg{ return safetyCheckCompleteMsg{
@@ -257,6 +269,7 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.canProceed = msg.canProceed m.canProceed = msg.canProceed
m.existingDBCount = msg.existingDBCount m.existingDBCount = msg.existingDBCount
m.existingDBs = msg.existingDBs m.existingDBs = msg.existingDBs
m.existingDBError = msg.existingDBError
// Auto-forward in auto-confirm mode // Auto-forward in auto-confirm mode
if m.config.TUIAutoConfirm { if m.config.TUIAutoConfirm {
return m.parent, tea.Quit return m.parent, tea.Quit
@@ -275,10 +288,17 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "c": case "c":
if m.mode == "restore-cluster" { if m.mode == "restore-cluster" {
// Toggle cluster cleanup // Toggle cluster cleanup - databases will be re-detected at execution time
m.cleanClusterFirst = !m.cleanClusterFirst m.cleanClusterFirst = !m.cleanClusterFirst
if m.cleanClusterFirst { if m.cleanClusterFirst {
if m.existingDBError != "" {
// Detection failed in preview - will re-detect at execution
m.message = checkWarningStyle.Render("[WARN] Will clean existing databases before restore (detection pending)")
} else if m.existingDBCount > 0 {
m.message = checkWarningStyle.Render(fmt.Sprintf("[WARN] Will drop %d existing database(s) before restore", m.existingDBCount)) m.message = checkWarningStyle.Render(fmt.Sprintf("[WARN] Will drop %d existing database(s) before restore", m.existingDBCount))
} else {
m.message = infoStyle.Render("[INFO] Cleanup enabled (no databases currently detected)")
}
} else { } else {
m.message = fmt.Sprintf("Clean cluster first: disabled") m.message = fmt.Sprintf("Clean cluster first: disabled")
} }
@@ -382,7 +402,12 @@ func (m RestorePreviewModel) View() string {
s.WriteString("\n") s.WriteString("\n")
s.WriteString(fmt.Sprintf(" Host: %s:%d\n", m.config.Host, m.config.Port)) s.WriteString(fmt.Sprintf(" Host: %s:%d\n", m.config.Host, m.config.Port))
if m.existingDBCount > 0 { if m.existingDBError != "" {
// Show warning when database listing failed - but still allow cleanup toggle
s.WriteString(checkWarningStyle.Render(" Existing Databases: Detection failed\n"))
s.WriteString(infoStyle.Render(fmt.Sprintf(" (%s)\n", m.existingDBError)))
s.WriteString(infoStyle.Render(" (Will re-detect at restore time)\n"))
} else if m.existingDBCount > 0 {
s.WriteString(fmt.Sprintf(" Existing Databases: %d found\n", m.existingDBCount)) s.WriteString(fmt.Sprintf(" Existing Databases: %d found\n", m.existingDBCount))
// Show first few database names // Show first few database names
@@ -395,16 +420,19 @@ func (m RestorePreviewModel) View() string {
} }
s.WriteString(fmt.Sprintf(" - %s\n", db)) s.WriteString(fmt.Sprintf(" - %s\n", db))
} }
} else {
s.WriteString(" Existing Databases: None (clean slate)\n")
}
// Always show cleanup toggle for cluster restore
cleanIcon := "[N]" cleanIcon := "[N]"
cleanStyle := infoStyle cleanStyle := infoStyle
if m.cleanClusterFirst { if m.cleanClusterFirst {
cleanIcon = "[Y]" cleanIcon := "[Y]"
cleanStyle = checkWarningStyle cleanStyle = checkWarningStyle
} s.WriteString(cleanStyle.Render(fmt.Sprintf(" Clean All First: %s enabled (press 'c' to toggle)\n", cleanIcon)))
s.WriteString(cleanStyle.Render(fmt.Sprintf(" Clean All First: %s %v (press 'c' to toggle)\n", cleanIcon, m.cleanClusterFirst)))
} else { } else {
s.WriteString(" Existing Databases: None (clean slate)\n") s.WriteString(cleanStyle.Render(fmt.Sprintf(" Clean All First: %s disabled (press 'c' to toggle)\n", cleanIcon)))
} }
s.WriteString("\n") s.WriteString("\n")
} }
@@ -453,10 +481,18 @@ func (m RestorePreviewModel) View() string {
s.WriteString(infoStyle.Render(" All existing data in target database will be dropped!")) s.WriteString(infoStyle.Render(" All existing data in target database will be dropped!"))
s.WriteString("\n\n") s.WriteString("\n\n")
} }
if m.cleanClusterFirst && m.existingDBCount > 0 { if m.cleanClusterFirst {
s.WriteString(checkWarningStyle.Render("[DANGER] WARNING: Cluster cleanup enabled")) s.WriteString(checkWarningStyle.Render("[DANGER] WARNING: Cluster cleanup enabled"))
s.WriteString("\n") s.WriteString("\n")
if m.existingDBError != "" {
s.WriteString(checkWarningStyle.Render(" Existing databases will be DROPPED before restore!"))
s.WriteString("\n")
s.WriteString(infoStyle.Render(" (Database count will be detected at restore time)"))
} else if m.existingDBCount > 0 {
s.WriteString(checkWarningStyle.Render(fmt.Sprintf(" %d existing database(s) will be DROPPED before restore!", m.existingDBCount))) s.WriteString(checkWarningStyle.Render(fmt.Sprintf(" %d existing database(s) will be DROPPED before restore!", m.existingDBCount)))
} else {
s.WriteString(infoStyle.Render(" No databases currently detected - cleanup will verify at restore time"))
}
s.WriteString("\n") s.WriteString("\n")
s.WriteString(infoStyle.Render(" This ensures a clean disaster recovery scenario")) s.WriteString(infoStyle.Render(" This ensures a clean disaster recovery scenario"))
s.WriteString("\n\n") s.WriteString("\n\n")