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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user