Files
dbbackup/internal/tui/backup_manager.go
Alexander Renz e24d7ab49f
All checks were successful
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m27s
CI/CD / Build & Release (push) Successful in 3m17s
v3.42.35: Standardize TUI title prefixes for consistency
- [CHECK] for diagnosis, previews, validations
- [STATS] for status, history, metrics views
- [SELECT] for selection/browsing screens
- [EXEC] for execution screens (backup/restore)
- [CONFIG] for settings/configuration

Fixed 8 files with inconsistent prefixes:
- diagnose_view.go: [SEARCH] → [CHECK]
- settings.go: [CFG] → [CONFIG]
- menu.go: [DB] → clean title
- history.go: [HISTORY] → [STATS]
- backup_manager.go: [DB] → [SELECT]
- archive_browser.go: [PKG]/[SEARCH] → [SELECT]
- restore_preview.go: added [CHECK]
- restore_exec.go: [RESTORE] → [EXEC]
2026-01-14 16:36:35 +01:00

423 lines
11 KiB
Go
Executable File

package tui
import (
"context"
"fmt"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"dbbackup/internal/config"
"dbbackup/internal/logger"
"dbbackup/internal/restore"
)
// OperationState represents the current operation state
type OperationState int
const (
OpIdle OperationState = iota
OpVerifying
OpDeleting
)
// BackupManagerModel manages backup archives
type BackupManagerModel struct {
config *config.Config
logger logger.Logger
parent tea.Model
ctx context.Context
archives []ArchiveInfo
cursor int
loading bool
err error
message string
totalSize int64
freeSpace int64
opState OperationState
opTarget string // Name of archive being operated on
spinnerFrame int
}
// NewBackupManager creates a new backup manager
func NewBackupManager(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context) BackupManagerModel {
return BackupManagerModel{
config: cfg,
logger: log,
parent: parent,
ctx: ctx,
loading: true,
opState: OpIdle,
spinnerFrame: 0,
}
}
func (m BackupManagerModel) Init() tea.Cmd {
return tea.Batch(loadArchives(m.config, m.logger), managerTickCmd())
}
// Tick for spinner animation
type managerTickMsg time.Time
func managerTickCmd() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return managerTickMsg(t)
})
}
// Verify result message
type verifyResultMsg struct {
archive string
valid bool
err error
details string
}
func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case managerTickMsg:
// Update spinner frame
m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames)
return m, managerTickCmd()
case verifyResultMsg:
m.opState = OpIdle
m.opTarget = ""
if msg.err != nil {
m.message = fmt.Sprintf("[-] Verify failed: %v", msg.err)
} else if msg.valid {
m.message = fmt.Sprintf("[+] %s: Valid - %s", msg.archive, msg.details)
// Update archive validity in list
for i := range m.archives {
if m.archives[i].Name == msg.archive {
m.archives[i].Valid = true
break
}
}
} else {
m.message = fmt.Sprintf("[-] %s: Invalid - %s", msg.archive, msg.details)
for i := range m.archives {
if m.archives[i].Name == msg.archive {
m.archives[i].Valid = false
break
}
}
}
return m, nil
case archiveListMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err
return m, nil
}
m.archives = msg.archives
// Calculate total size
m.totalSize = 0
for _, archive := range m.archives {
m.totalSize += archive.Size
}
// Get free space (simplified - just show message)
m.message = fmt.Sprintf("Loaded %d archive(s)", len(m.archives))
// Auto-forward in auto-confirm mode
if m.config.TUIAutoConfirm {
return m.parent, tea.Quit
}
return m, nil
case tea.KeyMsg:
// Allow escape/cancel even during operations
if msg.String() == "ctrl+c" || msg.String() == "esc" || msg.String() == "q" {
if m.opState != OpIdle {
// Cancel current operation
m.opState = OpIdle
m.opTarget = ""
m.message = "Operation cancelled"
return m, nil
}
return m.parent, nil
}
// Block other input during operations
if m.opState != OpIdle {
return m, nil
}
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.archives)-1 {
m.cursor++
}
case "v":
// Verify archive with real verification
if len(m.archives) > 0 && m.cursor < len(m.archives) {
selected := m.archives[m.cursor]
m.opState = OpVerifying
m.opTarget = selected.Name
m.message = ""
return m, verifyArchiveCmd(selected)
}
case "d":
// Delete archive (with confirmation)
if len(m.archives) > 0 && m.cursor < len(m.archives) {
selected := m.archives[m.cursor]
archivePath := selected.Path
confirm := NewConfirmationModelWithAction(m.config, m.logger, m,
"[DELETE] Delete Archive",
fmt.Sprintf("Delete archive '%s'? This cannot be undone.", selected.Name),
func() (tea.Model, tea.Cmd) {
// Delete the archive
err := deleteArchive(archivePath)
if err != nil {
m.err = fmt.Errorf("failed to delete archive: %v", err)
m.message = fmt.Sprintf("[FAIL] Failed to delete: %v", err)
} else {
m.message = fmt.Sprintf("[OK] Deleted: %s", selected.Name)
}
// Refresh the archive list
m.loading = true
return m, loadArchives(m.config, m.logger)
})
return confirm, nil
}
case "i":
// Show info
if len(m.archives) > 0 && m.cursor < len(m.archives) {
selected := m.archives[m.cursor]
m.message = fmt.Sprintf("[PKG] %s | %s | %s | Modified: %s",
selected.Name,
selected.Format.String(),
formatSize(selected.Size),
selected.Modified.Format("2006-01-02 15:04:05"))
}
case "r":
// Restore selected archive
if len(m.archives) > 0 && m.cursor < len(m.archives) {
selected := m.archives[m.cursor]
mode := "restore-single"
if selected.Format.IsClusterBackup() {
mode = "restore-cluster"
}
preview := NewRestorePreview(m.config, m.logger, m.parent, m.ctx, selected, mode)
return preview, preview.Init()
}
case "R":
// Refresh list
m.loading = true
m.message = "Refreshing..."
return m, loadArchives(m.config, m.logger)
}
}
return m, nil
}
func (m BackupManagerModel) View() string {
var s strings.Builder
// Title
s.WriteString(TitleStyle.Render("[SELECT] Backup Archive Manager"))
s.WriteString("\n\n")
// Status line (no box, bold+color accents)
switch m.opState {
case OpVerifying:
spinner := spinnerFrames[m.spinnerFrame]
s.WriteString(StatusActiveStyle.Render(fmt.Sprintf("%s Verifying: %s", spinner, m.opTarget)))
s.WriteString("\n\n")
case OpDeleting:
spinner := spinnerFrames[m.spinnerFrame]
s.WriteString(StatusActiveStyle.Render(fmt.Sprintf("%s Deleting: %s", spinner, m.opTarget)))
s.WriteString("\n\n")
default:
if m.loading {
spinner := spinnerFrames[m.spinnerFrame]
s.WriteString(StatusActiveStyle.Render(fmt.Sprintf("%s Loading archives...", spinner)))
s.WriteString("\n\n")
} else if m.message != "" {
// Color based on message content
if strings.HasPrefix(m.message, "[+]") || strings.HasPrefix(m.message, "Valid") {
s.WriteString(StatusSuccessStyle.Render(m.message))
} else if strings.HasPrefix(m.message, "[-]") || strings.HasPrefix(m.message, "Error") {
s.WriteString(StatusErrorStyle.Render(m.message))
} else {
s.WriteString(StatusActiveStyle.Render(m.message))
}
s.WriteString("\n\n")
}
// No "Ready" message when idle - cleaner UI
}
if m.loading {
return s.String()
}
if m.err != nil {
s.WriteString(StatusErrorStyle.Render(fmt.Sprintf("[FAIL] Error: %v", m.err)))
s.WriteString("\n\n")
s.WriteString(ShortcutStyle.Render("Press Esc to go back"))
return s.String()
}
// Summary
s.WriteString(LabelStyle.Render(fmt.Sprintf("Total Archives: %d | Total Size: %s",
len(m.archives), formatSize(m.totalSize))))
s.WriteString("\n\n")
// Archives list
if len(m.archives) == 0 {
s.WriteString(StatusReadyStyle.Render("No backup archives found"))
s.WriteString("\n\n")
s.WriteString(ShortcutStyle.Render("Press Esc to go back"))
return s.String()
}
// Column headers with better alignment
s.WriteString(ListHeaderStyle.Render(fmt.Sprintf(" %-32s %-22s %10s %-16s",
"FILENAME", "FORMAT", "SIZE", "MODIFIED")))
s.WriteString("\n")
s.WriteString(strings.Repeat("-", 90))
s.WriteString("\n")
// Show archives (limit to visible area)
start := m.cursor - 5
if start < 0 {
start = 0
}
end := start + 12
if end > len(m.archives) {
end = len(m.archives)
}
for i := start; i < end; i++ {
archive := m.archives[i]
cursor := " "
style := ListNormalStyle
if i == m.cursor {
cursor = "> "
style = ListSelectedStyle
}
// Status icon - consistent 4-char width
statusIcon := " [+]"
if !archive.Valid {
statusIcon = " [-]"
style = ItemInvalidStyle
} else if time.Since(archive.Modified) > 30*24*time.Hour {
statusIcon = " [!]"
}
filename := truncate(archive.Name, 32)
format := truncate(archive.Format.String(), 22)
line := fmt.Sprintf("%s%s %-32s %-22s %10s %-16s",
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")
s.WriteString(StatusReadyStyle.Render(fmt.Sprintf("Selected: %d/%d", m.cursor+1, len(m.archives))))
s.WriteString("\n\n")
// Grouped keyboard shortcuts
s.WriteString(ShortcutStyle.Render("SHORTCUTS: Up/Down=Move | r=Restore | v=Verify | d=Delete | i=Info | R=Refresh | Esc=Back | q=Quit"))
return s.String()
}
// verifyArchiveCmd runs the SAME verification as restore safety checks
// This ensures consistency between backup manager verify and restore preview
func verifyArchiveCmd(archive ArchiveInfo) tea.Cmd {
return func() tea.Msg {
var issues []string
// 1. Run the same archive integrity check as restore
safety := restore.NewSafety(nil, nil) // Doesn't need config/log for validation
if err := safety.ValidateArchive(archive.Path); err != nil {
return verifyResultMsg{
archive: archive.Name,
valid: false,
err: nil,
details: fmt.Sprintf("Archive integrity: %v", err),
}
}
// 2. Run the same deep diagnosis as restore
diagnoser := restore.NewDiagnoser(nil, false)
diagResult, diagErr := diagnoser.DiagnoseFile(archive.Path)
if diagErr != nil {
return verifyResultMsg{
archive: archive.Name,
valid: false,
err: diagErr,
details: "Cannot diagnose archive",
}
}
if !diagResult.IsValid {
// Collect error details
if diagResult.IsTruncated {
issues = append(issues, "TRUNCATED")
}
if diagResult.IsCorrupted {
issues = append(issues, "CORRUPTED")
}
if len(diagResult.Errors) > 0 {
issues = append(issues, diagResult.Errors[0])
}
return verifyResultMsg{
archive: archive.Name,
valid: false,
err: nil,
details: strings.Join(issues, "; "),
}
}
// Build success details
details := "Verified"
if diagResult.Details != nil {
if diagResult.Details.TableCount > 0 {
details = fmt.Sprintf("%d databases in archive", diagResult.Details.TableCount)
} else if diagResult.Details.PgRestoreListable {
details = "pg_restore verified"
}
}
// Add any warnings
if len(diagResult.Warnings) > 0 {
details += fmt.Sprintf(" [%d warnings]", len(diagResult.Warnings))
}
return verifyResultMsg{archive: archive.Name, valid: true, err: nil, details: details}
}
}
// deleteArchive deletes a backup archive (to be called from confirmation)
func deleteArchive(archivePath string) error {
return os.Remove(archivePath)
}