Files
dbbackup/internal/tui/archive_browser.go
Renz 87e0ca3b39 feat: implement full restore functionality with TUI integration
- Add complete restore engine (internal/restore/)
  - RestoreSingle() for single database restore
  - RestoreCluster() for full cluster restore
  - Archive format detection (7 formats supported)
  - Safety validation (integrity, disk space, tools)
  - Streaming decompression with pigz support

- Add CLI restore commands (cmd/restore.go)
  - restore single: restore single database backup
  - restore cluster: restore full cluster backup
  - restore list: list available backup archives
  - Safety-first design: dry-run by default, --confirm required

- Add TUI restore integration (internal/tui/)
  - Archive browser: browse and select backups
  - Restore preview: safety checks and confirmation
  - Restore execution: real-time progress tracking
  - Backup manager: comprehensive archive management

- Features:
  - Format auto-detection (.dump, .dump.gz, .sql, .sql.gz, .tar.gz)
  - Archive validation before restore
  - Disk space verification
  - Tool availability checks
  - Target database configuration
  - Clean-first and create-if-missing options
  - Parallel decompression support
  - Progress tracking with phases

Phase 1 (Core Functionality) complete and tested
2025-11-07 09:41:44 +00:00

412 lines
9.7 KiB
Go

package tui
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"dbbackup/internal/config"
"dbbackup/internal/logger"
"dbbackup/internal/restore"
)
var (
archiveHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4"))
archiveSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF75B7")).
Bold(true)
archiveNormalStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262"))
archiveValidStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575"))
archiveInvalidStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF6B6B"))
archiveOldStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFA500"))
)
// ArchiveInfo holds information about a backup archive
type ArchiveInfo struct {
Name string
Path string
Format restore.ArchiveFormat
Size int64
Modified time.Time
DatabaseName string
Valid bool
ValidationMsg string
}
// ArchiveBrowserModel for browsing and selecting backup archives
type ArchiveBrowserModel struct {
config *config.Config
logger logger.Logger
parent tea.Model
archives []ArchiveInfo
cursor int
loading bool
err error
mode string // "restore-single", "restore-cluster", "manage"
filterType string // "all", "postgres", "mysql", "cluster"
message string
}
// NewArchiveBrowser creates a new archive browser
func NewArchiveBrowser(cfg *config.Config, log logger.Logger, parent tea.Model, mode string) ArchiveBrowserModel {
return ArchiveBrowserModel{
config: cfg,
logger: log,
parent: parent,
loading: true,
mode: mode,
filterType: "all",
}
}
func (m ArchiveBrowserModel) Init() tea.Cmd {
return loadArchives(m.config, m.logger)
}
type archiveListMsg struct {
archives []ArchiveInfo
err error
}
func loadArchives(cfg *config.Config, log logger.Logger) tea.Cmd {
return func() tea.Msg {
backupDir := cfg.BackupDir
// Check if backup directory exists
if _, err := os.Stat(backupDir); err != nil {
return archiveListMsg{archives: nil, err: fmt.Errorf("backup directory not found: %s", backupDir)}
}
// List all files
files, err := os.ReadDir(backupDir)
if err != nil {
return archiveListMsg{archives: nil, err: fmt.Errorf("cannot read backup directory: %w", err)}
}
var archives []ArchiveInfo
for _, file := range files {
if file.IsDir() {
continue
}
name := file.Name()
format := restore.DetectArchiveFormat(name)
if format == restore.FormatUnknown {
continue // Skip non-backup files
}
info, _ := file.Info()
fullPath := filepath.Join(backupDir, name)
// Extract database name
dbName := extractDBNameFromFilename(name)
// Basic validation (just check if file is readable)
valid := true
validationMsg := "Valid"
if info.Size() == 0 {
valid = false
validationMsg = "Empty file"
}
archives = append(archives, ArchiveInfo{
Name: name,
Path: fullPath,
Format: format,
Size: info.Size(),
Modified: info.ModTime(),
DatabaseName: dbName,
Valid: valid,
ValidationMsg: validationMsg,
})
}
// Sort by modification time (newest first)
sort.Slice(archives, func(i, j int) bool {
return archives[i].Modified.After(archives[j].Modified)
})
return archiveListMsg{archives: archives, err: nil}
}
}
func (m ArchiveBrowserModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case archiveListMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.archives = m.filterArchives(msg.archives)
if len(m.archives) == 0 {
m.message = "No backup archives found"
}
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "q", "esc":
return m.parent, nil
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.archives)-1 {
m.cursor++
}
case "f":
// Toggle filter
filters := []string{"all", "postgres", "mysql", "cluster"}
for i, f := range filters {
if f == m.filterType {
m.filterType = filters[(i+1)%len(filters)]
break
}
}
m.cursor = 0
return m, loadArchives(m.config, m.logger)
case "enter", " ":
if len(m.archives) > 0 && m.cursor < len(m.archives) {
selected := m.archives[m.cursor]
// Validate selection based on mode
if m.mode == "restore-cluster" && !selected.Format.IsClusterBackup() {
m.message = errorStyle.Render("❌ Please select a cluster backup (.tar.gz)")
return m, nil
}
if m.mode == "restore-single" && selected.Format.IsClusterBackup() {
m.message = errorStyle.Render("❌ Please select a single database backup")
return m, nil
}
// Open restore preview
preview := NewRestorePreview(m.config, m.logger, m.parent, selected, m.mode)
return preview, preview.Init()
}
case "i":
// Show detailed info
if len(m.archives) > 0 && m.cursor < len(m.archives) {
selected := m.archives[m.cursor]
m.message = fmt.Sprintf("📦 %s | Format: %s | Size: %s | Modified: %s",
selected.Name,
selected.Format.String(),
formatSize(selected.Size),
selected.Modified.Format("2006-01-02 15:04:05"))
}
}
}
return m, nil
}
func (m ArchiveBrowserModel) View() string {
var s strings.Builder
// Header
title := "📦 Backup Archives"
if m.mode == "restore-single" {
title = "📦 Select Archive to Restore (Single Database)"
} else if m.mode == "restore-cluster" {
title = "📦 Select Archive to Restore (Cluster)"
}
s.WriteString(titleStyle.Render(title))
s.WriteString("\n\n")
if m.loading {
s.WriteString(infoStyle.Render("Loading archives..."))
return s.String()
}
if m.err != nil {
s.WriteString(errorStyle.Render(fmt.Sprintf("❌ Error: %v", m.err)))
s.WriteString("\n\n")
s.WriteString(infoStyle.Render("Press Esc to go back"))
return s.String()
}
// Filter info
filterLabel := "Filter: " + m.filterType
s.WriteString(infoStyle.Render(filterLabel))
s.WriteString(infoStyle.Render(" (Press 'f' to change filter)"))
s.WriteString("\n\n")
// Archives list
if len(m.archives) == 0 {
s.WriteString(infoStyle.Render(m.message))
s.WriteString("\n\n")
s.WriteString(infoStyle.Render("Press Esc to go back"))
return s.String()
}
// Column headers
s.WriteString(archiveHeaderStyle.Render(fmt.Sprintf("%-40s %-25s %-12s %-20s",
"FILENAME", "FORMAT", "SIZE", "MODIFIED")))
s.WriteString("\n")
s.WriteString(strings.Repeat("─", 100))
s.WriteString("\n")
// Show archives (limit to visible area)
start := m.cursor - 5
if start < 0 {
start = 0
}
end := start + 10
if end > len(m.archives) {
end = len(m.archives)
}
for i := start; i < end; i++ {
archive := m.archives[i]
cursor := " "
style := archiveNormalStyle
if i == m.cursor {
cursor = ">"
style = archiveSelectedStyle
}
// Color code based on validity and age
statusIcon := "✓"
if !archive.Valid {
statusIcon = "✗"
style = archiveInvalidStyle
} else if time.Since(archive.Modified) > 30*24*time.Hour {
style = archiveOldStyle
statusIcon = "⚠"
}
filename := truncate(archive.Name, 38)
format := truncate(archive.Format.String(), 23)
line := fmt.Sprintf("%s %s %-38s %-23s %-10s %-19s",
cursor,
statusIcon,
filename,
format,
formatSize(archive.Size),
archive.Modified.Format("2006-01-02 15:04"))
s.WriteString(style.Render(line))
s.WriteString("\n")
}
// Footer
s.WriteString("\n")
if m.message != "" {
s.WriteString(m.message)
s.WriteString("\n")
}
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"))
return s.String()
}
// filterArchives filters archives based on current filter setting
func (m ArchiveBrowserModel) filterArchives(archives []ArchiveInfo) []ArchiveInfo {
if m.filterType == "all" {
return archives
}
var filtered []ArchiveInfo
for _, archive := range archives {
switch m.filterType {
case "postgres":
if archive.Format.IsPostgreSQL() && !archive.Format.IsClusterBackup() {
filtered = append(filtered, archive)
}
case "mysql":
if archive.Format.IsMySQL() {
filtered = append(filtered, archive)
}
case "cluster":
if archive.Format.IsClusterBackup() {
filtered = append(filtered, archive)
}
}
}
return filtered
}
// extractDBNameFromFilename extracts database name from archive filename
func extractDBNameFromFilename(filename string) string {
base := filepath.Base(filename)
// Remove extensions
base = strings.TrimSuffix(base, ".tar.gz")
base = strings.TrimSuffix(base, ".dump.gz")
base = strings.TrimSuffix(base, ".sql.gz")
base = strings.TrimSuffix(base, ".dump")
base = strings.TrimSuffix(base, ".sql")
// Remove timestamp patterns (YYYYMMDD_HHMMSS)
parts := strings.Split(base, "_")
for i := len(parts) - 1; i >= 0; i-- {
// Check if part looks like a date or time
if len(parts[i]) == 8 || len(parts[i]) == 6 {
parts = parts[:i]
} else {
break
}
}
if len(parts) > 0 {
return parts[0]
}
return base
}
// formatSize formats file size
func formatSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// truncate truncates string to max length
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}