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:
2026-01-05 15:17:54 +01:00
parent e7f0a9f5eb
commit 4c171c0e44
16 changed files with 2271 additions and 26 deletions

View File

@@ -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()
}

View 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)
}

View File

@@ -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

View File

@@ -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"))