v3.40.0: Restore diagnostics and error reporting
Features: - restore diagnose command for backup file analysis - Deep COPY block verification for truncated dump detection - PGDMP signature and gzip integrity validation - Detailed error reports with --save-debug-log flag - Ring buffer stderr capture (prevents OOM on 2M+ errors) - Error classification with actionable recommendations TUI Enhancements: - Automatic dump validity safety check before restore - Press 'd' in archive browser to diagnose backups - Press 'd' in restore preview for debug log toggle - Debug logs saved to /tmp on failure when enabled Documentation: - Updated README with diagnose command and examples - Updated CHANGELOG with full feature list - Updated restore preview screenshots
This commit is contained in:
@@ -227,6 +227,14 @@ func (m ArchiveBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
formatSize(selected.Size),
|
||||
selected.Modified.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
case "d":
|
||||
// Run diagnosis on selected archive
|
||||
if len(m.archives) > 0 && m.cursor < len(m.archives) {
|
||||
selected := m.archives[m.cursor]
|
||||
diagnoseView := NewDiagnoseView(m.config, m.logger, m, m.ctx, selected)
|
||||
return diagnoseView, diagnoseView.Init()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +343,7 @@ func (m ArchiveBrowserModel) View() string {
|
||||
s.WriteString(infoStyle.Render(fmt.Sprintf("Total: %d archive(s) | Selected: %d/%d",
|
||||
len(m.archives), m.cursor+1, len(m.archives))))
|
||||
s.WriteString("\n")
|
||||
s.WriteString(infoStyle.Render("⌨️ ↑/↓: Navigate | Enter: Select | f: Filter | i: Info | Esc: Back"))
|
||||
s.WriteString(infoStyle.Render("⌨️ ↑/↓: Navigate | Enter: Select | d: Diagnose | f: Filter | i: Info | Esc: Back"))
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
450
internal/tui/diagnose_view.go
Normal file
450
internal/tui/diagnose_view.go
Normal file
@@ -0,0 +1,450 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/logger"
|
||||
"dbbackup/internal/restore"
|
||||
)
|
||||
|
||||
var (
|
||||
diagnoseBoxStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("63")).
|
||||
Padding(1, 2)
|
||||
|
||||
diagnosePassStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("2")).
|
||||
Bold(true)
|
||||
|
||||
diagnoseFailStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("1")).
|
||||
Bold(true)
|
||||
|
||||
diagnoseWarnStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("3"))
|
||||
|
||||
diagnoseInfoStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("244"))
|
||||
|
||||
diagnoseHeaderStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("63")).
|
||||
Bold(true)
|
||||
)
|
||||
|
||||
// DiagnoseViewModel shows backup file diagnosis results
|
||||
type DiagnoseViewModel struct {
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
parent tea.Model
|
||||
ctx context.Context
|
||||
archive ArchiveInfo
|
||||
result *restore.DiagnoseResult
|
||||
results []*restore.DiagnoseResult // For cluster archives
|
||||
running bool
|
||||
completed bool
|
||||
progress string
|
||||
cursor int // For scrolling through cluster results
|
||||
err error
|
||||
}
|
||||
|
||||
// NewDiagnoseView creates a new diagnose view
|
||||
func NewDiagnoseView(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, archive ArchiveInfo) DiagnoseViewModel {
|
||||
return DiagnoseViewModel{
|
||||
config: cfg,
|
||||
logger: log,
|
||||
parent: parent,
|
||||
ctx: ctx,
|
||||
archive: archive,
|
||||
running: true,
|
||||
progress: "Starting diagnosis...",
|
||||
}
|
||||
}
|
||||
|
||||
func (m DiagnoseViewModel) Init() tea.Cmd {
|
||||
return runDiagnosis(m.config, m.logger, m.archive)
|
||||
}
|
||||
|
||||
type diagnoseCompleteMsg struct {
|
||||
result *restore.DiagnoseResult
|
||||
results []*restore.DiagnoseResult
|
||||
err error
|
||||
}
|
||||
|
||||
type diagnoseProgressMsg struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func runDiagnosis(cfg *config.Config, log logger.Logger, archive ArchiveInfo) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
diagnoser := restore.NewDiagnoser(log, true)
|
||||
|
||||
// For cluster archives, we can do deep analysis
|
||||
if archive.Format.IsClusterBackup() {
|
||||
// Create temp directory
|
||||
tempDir, err := createTempDir("dbbackup-diagnose-*")
|
||||
if err != nil {
|
||||
return diagnoseCompleteMsg{err: fmt.Errorf("failed to create temp dir: %w", err)}
|
||||
}
|
||||
defer removeTempDir(tempDir)
|
||||
|
||||
// Diagnose all dumps in the cluster
|
||||
results, err := diagnoser.DiagnoseClusterDumps(archive.Path, tempDir)
|
||||
if err != nil {
|
||||
return diagnoseCompleteMsg{err: err}
|
||||
}
|
||||
|
||||
return diagnoseCompleteMsg{results: results}
|
||||
}
|
||||
|
||||
// Single file diagnosis
|
||||
result, err := diagnoser.DiagnoseFile(archive.Path)
|
||||
if err != nil {
|
||||
return diagnoseCompleteMsg{err: err}
|
||||
}
|
||||
|
||||
return diagnoseCompleteMsg{result: result}
|
||||
}
|
||||
}
|
||||
|
||||
func (m DiagnoseViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case diagnoseCompleteMsg:
|
||||
m.running = false
|
||||
m.completed = true
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.result = msg.result
|
||||
m.results = msg.results
|
||||
return m, nil
|
||||
|
||||
case diagnoseProgressMsg:
|
||||
m.progress = msg.message
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q", "esc":
|
||||
return m.parent, nil
|
||||
|
||||
case "up", "k":
|
||||
if len(m.results) > 0 && m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
if len(m.results) > 0 && m.cursor < len(m.results)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
|
||||
case "enter", " ":
|
||||
return m.parent, nil
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m DiagnoseViewModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
// Header
|
||||
s.WriteString(titleStyle.Render("🔍 Backup Diagnosis"))
|
||||
s.WriteString("\n\n")
|
||||
|
||||
// Archive info
|
||||
s.WriteString(diagnoseHeaderStyle.Render("Archive: "))
|
||||
s.WriteString(m.archive.Name)
|
||||
s.WriteString("\n")
|
||||
s.WriteString(diagnoseHeaderStyle.Render("Format: "))
|
||||
s.WriteString(m.archive.Format.String())
|
||||
s.WriteString("\n")
|
||||
s.WriteString(diagnoseHeaderStyle.Render("Size: "))
|
||||
s.WriteString(formatSize(m.archive.Size))
|
||||
s.WriteString("\n\n")
|
||||
|
||||
if m.running {
|
||||
s.WriteString(infoStyle.Render("⏳ " + m.progress))
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString(diagnoseInfoStyle.Render("This may take a while for large archives..."))
|
||||
return s.String()
|
||||
}
|
||||
|
||||
if m.err != nil {
|
||||
s.WriteString(errorStyle.Render(fmt.Sprintf("❌ Diagnosis failed: %v", m.err)))
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString(infoStyle.Render("Press Enter or Esc to go back"))
|
||||
return s.String()
|
||||
}
|
||||
|
||||
// For cluster archives, show summary + details
|
||||
if len(m.results) > 0 {
|
||||
s.WriteString(m.renderClusterResults())
|
||||
} else if m.result != nil {
|
||||
s.WriteString(m.renderSingleResult(m.result))
|
||||
}
|
||||
|
||||
s.WriteString("\n")
|
||||
s.WriteString(infoStyle.Render("Press Enter or Esc to go back"))
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (m DiagnoseViewModel) renderSingleResult(result *restore.DiagnoseResult) string {
|
||||
var s strings.Builder
|
||||
|
||||
// Status
|
||||
s.WriteString(strings.Repeat("─", 60))
|
||||
s.WriteString("\n")
|
||||
|
||||
if result.IsValid {
|
||||
s.WriteString(diagnosePassStyle.Render("✅ STATUS: VALID"))
|
||||
} else {
|
||||
s.WriteString(diagnoseFailStyle.Render("❌ STATUS: INVALID"))
|
||||
}
|
||||
s.WriteString("\n")
|
||||
|
||||
if result.IsTruncated {
|
||||
s.WriteString(diagnoseFailStyle.Render("⚠️ TRUNCATED: File appears incomplete"))
|
||||
s.WriteString("\n")
|
||||
}
|
||||
|
||||
if result.IsCorrupted {
|
||||
s.WriteString(diagnoseFailStyle.Render("⚠️ CORRUPTED: File structure is damaged"))
|
||||
s.WriteString("\n")
|
||||
}
|
||||
|
||||
s.WriteString(strings.Repeat("─", 60))
|
||||
s.WriteString("\n\n")
|
||||
|
||||
// Details
|
||||
if result.Details != nil {
|
||||
s.WriteString(diagnoseHeaderStyle.Render("📊 DETAILS:"))
|
||||
s.WriteString("\n")
|
||||
|
||||
if result.Details.HasPGDMPSignature {
|
||||
s.WriteString(diagnosePassStyle.Render(" ✓ "))
|
||||
s.WriteString("Has PGDMP signature (custom format)\n")
|
||||
}
|
||||
|
||||
if result.Details.HasSQLHeader {
|
||||
s.WriteString(diagnosePassStyle.Render(" ✓ "))
|
||||
s.WriteString("Has PostgreSQL SQL header\n")
|
||||
}
|
||||
|
||||
if result.Details.GzipValid {
|
||||
s.WriteString(diagnosePassStyle.Render(" ✓ "))
|
||||
s.WriteString("Gzip compression valid\n")
|
||||
}
|
||||
|
||||
if result.Details.PgRestoreListable {
|
||||
s.WriteString(diagnosePassStyle.Render(" ✓ "))
|
||||
s.WriteString(fmt.Sprintf("pg_restore can list contents (%d tables)\n", result.Details.TableCount))
|
||||
}
|
||||
|
||||
if result.Details.CopyBlockCount > 0 {
|
||||
s.WriteString(diagnoseInfoStyle.Render(" • "))
|
||||
s.WriteString(fmt.Sprintf("Contains %d COPY blocks\n", result.Details.CopyBlockCount))
|
||||
}
|
||||
|
||||
if result.Details.UnterminatedCopy {
|
||||
s.WriteString(diagnoseFailStyle.Render(" ✗ "))
|
||||
s.WriteString(fmt.Sprintf("Unterminated COPY block: %s (line %d)\n",
|
||||
result.Details.LastCopyTable, result.Details.LastCopyLineNumber))
|
||||
}
|
||||
|
||||
if result.Details.ProperlyTerminated {
|
||||
s.WriteString(diagnosePassStyle.Render(" ✓ "))
|
||||
s.WriteString("All COPY blocks properly terminated\n")
|
||||
}
|
||||
|
||||
if result.Details.ExpandedSize > 0 {
|
||||
s.WriteString(diagnoseInfoStyle.Render(" • "))
|
||||
s.WriteString(fmt.Sprintf("Expanded size: %s (ratio: %.1fx)\n",
|
||||
formatSize(result.Details.ExpandedSize), result.Details.CompressionRatio))
|
||||
}
|
||||
}
|
||||
|
||||
// Errors
|
||||
if len(result.Errors) > 0 {
|
||||
s.WriteString("\n")
|
||||
s.WriteString(diagnoseFailStyle.Render("❌ ERRORS:"))
|
||||
s.WriteString("\n")
|
||||
for i, e := range result.Errors {
|
||||
if i >= 5 {
|
||||
s.WriteString(diagnoseInfoStyle.Render(fmt.Sprintf(" ... and %d more\n", len(result.Errors)-5)))
|
||||
break
|
||||
}
|
||||
s.WriteString(diagnoseFailStyle.Render(" • "))
|
||||
s.WriteString(truncate(e, 70))
|
||||
s.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Warnings
|
||||
if len(result.Warnings) > 0 {
|
||||
s.WriteString("\n")
|
||||
s.WriteString(diagnoseWarnStyle.Render("⚠️ WARNINGS:"))
|
||||
s.WriteString("\n")
|
||||
for i, w := range result.Warnings {
|
||||
if i >= 3 {
|
||||
s.WriteString(diagnoseInfoStyle.Render(fmt.Sprintf(" ... and %d more\n", len(result.Warnings)-3)))
|
||||
break
|
||||
}
|
||||
s.WriteString(diagnoseWarnStyle.Render(" • "))
|
||||
s.WriteString(truncate(w, 70))
|
||||
s.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
if !result.IsValid {
|
||||
s.WriteString("\n")
|
||||
s.WriteString(diagnoseHeaderStyle.Render("💡 RECOMMENDATIONS:"))
|
||||
s.WriteString("\n")
|
||||
if result.IsTruncated {
|
||||
s.WriteString(" 1. Re-run the backup process for this database\n")
|
||||
s.WriteString(" 2. Check disk space on backup server\n")
|
||||
s.WriteString(" 3. Verify network stability for remote backups\n")
|
||||
}
|
||||
if result.IsCorrupted {
|
||||
s.WriteString(" 1. Verify backup was transferred completely\n")
|
||||
s.WriteString(" 2. Try restoring from a previous backup\n")
|
||||
}
|
||||
}
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (m DiagnoseViewModel) renderClusterResults() string {
|
||||
var s strings.Builder
|
||||
|
||||
// Summary
|
||||
validCount := 0
|
||||
invalidCount := 0
|
||||
for _, r := range m.results {
|
||||
if r.IsValid {
|
||||
validCount++
|
||||
} else {
|
||||
invalidCount++
|
||||
}
|
||||
}
|
||||
|
||||
s.WriteString(strings.Repeat("─", 60))
|
||||
s.WriteString("\n")
|
||||
s.WriteString(diagnoseHeaderStyle.Render(fmt.Sprintf("📊 CLUSTER SUMMARY: %d databases\n", len(m.results))))
|
||||
s.WriteString(strings.Repeat("─", 60))
|
||||
s.WriteString("\n\n")
|
||||
|
||||
if invalidCount == 0 {
|
||||
s.WriteString(diagnosePassStyle.Render("✅ All dumps are valid"))
|
||||
s.WriteString("\n\n")
|
||||
} else {
|
||||
s.WriteString(diagnoseFailStyle.Render(fmt.Sprintf("❌ %d/%d dumps have issues", invalidCount, len(m.results))))
|
||||
s.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// List all dumps with status
|
||||
s.WriteString(diagnoseHeaderStyle.Render("Database Dumps:"))
|
||||
s.WriteString("\n")
|
||||
|
||||
// Show visible range based on cursor
|
||||
start := m.cursor - 5
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := start + 12
|
||||
if end > len(m.results) {
|
||||
end = len(m.results)
|
||||
}
|
||||
|
||||
for i := start; i < end; i++ {
|
||||
r := m.results[i]
|
||||
cursor := " "
|
||||
if i == m.cursor {
|
||||
cursor = ">"
|
||||
}
|
||||
|
||||
var status string
|
||||
if r.IsValid {
|
||||
status = diagnosePassStyle.Render("✓")
|
||||
} else if r.IsTruncated {
|
||||
status = diagnoseFailStyle.Render("✗ TRUNCATED")
|
||||
} else if r.IsCorrupted {
|
||||
status = diagnoseFailStyle.Render("✗ CORRUPTED")
|
||||
} else {
|
||||
status = diagnoseFailStyle.Render("✗ INVALID")
|
||||
}
|
||||
|
||||
line := fmt.Sprintf("%s %s %-35s %s",
|
||||
cursor,
|
||||
status,
|
||||
truncate(r.FileName, 35),
|
||||
formatSize(r.FileSize))
|
||||
|
||||
if i == m.cursor {
|
||||
s.WriteString(archiveSelectedStyle.Render(line))
|
||||
} else {
|
||||
s.WriteString(line)
|
||||
}
|
||||
s.WriteString("\n")
|
||||
}
|
||||
|
||||
// Show selected dump details
|
||||
if m.cursor < len(m.results) {
|
||||
selected := m.results[m.cursor]
|
||||
s.WriteString("\n")
|
||||
s.WriteString(strings.Repeat("─", 60))
|
||||
s.WriteString("\n")
|
||||
s.WriteString(diagnoseHeaderStyle.Render("Selected: " + selected.FileName))
|
||||
s.WriteString("\n\n")
|
||||
|
||||
// Show condensed details for selected
|
||||
if selected.Details != nil {
|
||||
if selected.Details.UnterminatedCopy {
|
||||
s.WriteString(diagnoseFailStyle.Render(" ✗ Unterminated COPY: "))
|
||||
s.WriteString(selected.Details.LastCopyTable)
|
||||
s.WriteString(fmt.Sprintf(" (line %d)\n", selected.Details.LastCopyLineNumber))
|
||||
}
|
||||
if len(selected.Details.SampleCopyData) > 0 {
|
||||
s.WriteString(diagnoseInfoStyle.Render(" Sample orphaned data: "))
|
||||
s.WriteString(truncate(selected.Details.SampleCopyData[0], 50))
|
||||
s.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(selected.Errors) > 0 {
|
||||
for i, e := range selected.Errors {
|
||||
if i >= 2 {
|
||||
break
|
||||
}
|
||||
s.WriteString(diagnoseFailStyle.Render(" • "))
|
||||
s.WriteString(truncate(e, 55))
|
||||
s.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.WriteString("\n")
|
||||
s.WriteString(infoStyle.Render("Use ↑/↓ to browse, Enter/Esc to go back"))
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
// Helper functions for temp directory management
|
||||
func createTempDir(pattern string) (string, error) {
|
||||
return os.MkdirTemp("", pattern)
|
||||
}
|
||||
|
||||
func removeTempDir(path string) error {
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ type RestoreExecutionModel struct {
|
||||
restoreType string
|
||||
cleanClusterFirst bool // Drop all user databases before cluster restore
|
||||
existingDBs []string // List of databases to drop
|
||||
saveDebugLog bool // Save detailed error report on failure
|
||||
|
||||
// Progress tracking
|
||||
status string
|
||||
@@ -49,7 +50,7 @@ type RestoreExecutionModel struct {
|
||||
}
|
||||
|
||||
// NewRestoreExecution creates a new restore execution model
|
||||
func NewRestoreExecution(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, archive ArchiveInfo, targetDB string, cleanFirst, createIfMissing bool, restoreType string, cleanClusterFirst bool, existingDBs []string) RestoreExecutionModel {
|
||||
func NewRestoreExecution(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, archive ArchiveInfo, targetDB string, cleanFirst, createIfMissing bool, restoreType string, cleanClusterFirst bool, existingDBs []string, saveDebugLog bool) RestoreExecutionModel {
|
||||
return RestoreExecutionModel{
|
||||
config: cfg,
|
||||
logger: log,
|
||||
@@ -62,6 +63,7 @@ func NewRestoreExecution(cfg *config.Config, log logger.Logger, parent tea.Model
|
||||
restoreType: restoreType,
|
||||
cleanClusterFirst: cleanClusterFirst,
|
||||
existingDBs: existingDBs,
|
||||
saveDebugLog: saveDebugLog,
|
||||
status: "Initializing...",
|
||||
phase: "Starting",
|
||||
startTime: time.Now(),
|
||||
@@ -73,7 +75,7 @@ func NewRestoreExecution(cfg *config.Config, log logger.Logger, parent tea.Model
|
||||
|
||||
func (m RestoreExecutionModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
executeRestoreWithTUIProgress(m.ctx, m.config, m.logger, m.archive, m.targetDB, m.cleanFirst, m.createIfMissing, m.restoreType, m.cleanClusterFirst, m.existingDBs),
|
||||
executeRestoreWithTUIProgress(m.ctx, m.config, m.logger, m.archive, m.targetDB, m.cleanFirst, m.createIfMissing, m.restoreType, m.cleanClusterFirst, m.existingDBs, m.saveDebugLog),
|
||||
restoreTickCmd(),
|
||||
)
|
||||
}
|
||||
@@ -99,7 +101,7 @@ type restoreCompleteMsg struct {
|
||||
elapsed time.Duration
|
||||
}
|
||||
|
||||
func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string, cleanFirst, createIfMissing bool, restoreType string, cleanClusterFirst bool, existingDBs []string) tea.Cmd {
|
||||
func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string, cleanFirst, createIfMissing bool, restoreType string, cleanClusterFirst bool, existingDBs []string, saveDebugLog bool) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Use configurable cluster timeout (minutes) from config; default set in config.New()
|
||||
// Use parent context to inherit cancellation from TUI
|
||||
@@ -146,6 +148,14 @@ func executeRestoreWithTUIProgress(parentCtx context.Context, cfg *config.Config
|
||||
// STEP 2: Create restore engine with silent progress (no stdout interference with TUI)
|
||||
engine := restore.NewSilent(cfg, log, dbClient)
|
||||
|
||||
// Enable debug logging if requested
|
||||
if saveDebugLog {
|
||||
// Generate debug log path based on archive name and timestamp
|
||||
debugLogPath := fmt.Sprintf("/tmp/dbbackup-restore-debug-%s.json", time.Now().Format("20060102-150405"))
|
||||
engine.SetDebugLogPath(debugLogPath)
|
||||
log.Info("Debug logging enabled", "path", debugLogPath)
|
||||
}
|
||||
|
||||
// Set up progress callback (but it won't work in goroutine - progress is already sent via logs)
|
||||
// The TUI will just use spinner animation to show activity
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ type RestorePreviewModel struct {
|
||||
checking bool
|
||||
canProceed bool
|
||||
message string
|
||||
saveDebugLog bool // Save detailed error report on failure
|
||||
}
|
||||
|
||||
// NewRestorePreview creates a new restore preview
|
||||
@@ -82,6 +83,7 @@ func NewRestorePreview(cfg *config.Config, log logger.Logger, parent tea.Model,
|
||||
checking: true,
|
||||
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},
|
||||
@@ -102,7 +104,7 @@ type safetyCheckCompleteMsg struct {
|
||||
|
||||
func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo, targetDB string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
safety := restore.NewSafety(cfg, log)
|
||||
@@ -121,7 +123,33 @@ func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo,
|
||||
}
|
||||
checks = append(checks, check)
|
||||
|
||||
// 2. Disk space
|
||||
// 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() {
|
||||
@@ -137,7 +165,7 @@ func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo,
|
||||
}
|
||||
checks = append(checks, check)
|
||||
|
||||
// 3. Required tools
|
||||
// 4. Required tools
|
||||
check = SafetyCheck{Name: "Required tools", Status: "checking", Critical: true}
|
||||
dbType := "postgres"
|
||||
if archive.Format.IsMySQL() {
|
||||
@@ -153,7 +181,7 @@ func runSafetyChecks(cfg *config.Config, log logger.Logger, archive ArchiveInfo,
|
||||
}
|
||||
checks = append(checks, check)
|
||||
|
||||
// 4. Target database check (skip for cluster restores)
|
||||
// 5. Target database check (skip for cluster restores)
|
||||
existingDBCount := 0
|
||||
existingDBs := []string{}
|
||||
|
||||
@@ -243,6 +271,15 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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 log: enabled (will save detailed report on failure)")
|
||||
} else {
|
||||
m.message = "Debug log: disabled"
|
||||
}
|
||||
|
||||
case "enter", " ":
|
||||
if m.checking {
|
||||
m.message = "Please wait for safety checks to complete..."
|
||||
@@ -255,7 +292,7 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
return exec, exec.Init()
|
||||
}
|
||||
}
|
||||
@@ -390,6 +427,23 @@ func (m RestorePreviewModel) View() string {
|
||||
s.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Advanced Options
|
||||
s.WriteString(archiveHeaderStyle.Render("⚙️ Advanced Options"))
|
||||
s.WriteString("\n")
|
||||
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(" Saves detailed error report to /tmp on failure"))
|
||||
s.WriteString("\n")
|
||||
}
|
||||
s.WriteString("\n")
|
||||
|
||||
// Message
|
||||
if m.message != "" {
|
||||
s.WriteString(m.message)
|
||||
@@ -403,15 +457,15 @@ func (m RestorePreviewModel) View() string {
|
||||
s.WriteString(successStyle.Render("✅ Ready to restore"))
|
||||
s.WriteString("\n")
|
||||
if m.mode == "restore-single" {
|
||||
s.WriteString(infoStyle.Render("⌨️ t: Toggle clean-first | c: Toggle create | Enter: Proceed | Esc: Cancel"))
|
||||
s.WriteString(infoStyle.Render("⌨️ t: Clean-first | c: Create | d: Debug log | Enter: Proceed | Esc: Cancel"))
|
||||
} else if m.mode == "restore-cluster" {
|
||||
if m.existingDBCount > 0 {
|
||||
s.WriteString(infoStyle.Render("⌨️ c: Toggle cleanup | Enter: Proceed | Esc: Cancel"))
|
||||
s.WriteString(infoStyle.Render("⌨️ c: Cleanup | d: Debug log | Enter: Proceed | Esc: Cancel"))
|
||||
} else {
|
||||
s.WriteString(infoStyle.Render("⌨️ Enter: Proceed | Esc: Cancel"))
|
||||
s.WriteString(infoStyle.Render("⌨️ d: Debug log | Enter: Proceed | Esc: Cancel"))
|
||||
}
|
||||
} else {
|
||||
s.WriteString(infoStyle.Render("⌨️ Enter: Proceed | Esc: Cancel"))
|
||||
s.WriteString(infoStyle.Render("⌨️ d: Debug log | Enter: Proceed | Esc: Cancel"))
|
||||
}
|
||||
} else {
|
||||
s.WriteString(errorStyle.Render("❌ Cannot proceed - please fix errors above"))
|
||||
|
||||
Reference in New Issue
Block a user