- [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]
423 lines
11 KiB
Go
Executable File
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)
|
|
}
|