- 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
412 lines
9.7 KiB
Go
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] + "..."
|
|
}
|