|
|
|
@@ -4,47 +4,110 @@ import (
|
|
|
|
"context"
|
|
|
|
"context"
|
|
|
|
"fmt"
|
|
|
|
"fmt"
|
|
|
|
"os"
|
|
|
|
"os"
|
|
|
|
|
|
|
|
"os/exec"
|
|
|
|
"strings"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
|
|
|
|
"github.com/mattn/go-runewidth"
|
|
|
|
|
|
|
|
|
|
|
|
"dbbackup/internal/config"
|
|
|
|
"dbbackup/internal/config"
|
|
|
|
"dbbackup/internal/logger"
|
|
|
|
"dbbackup/internal/logger"
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// OperationState represents the current operation state
|
|
|
|
|
|
|
|
type OperationState int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
|
|
OpIdle OperationState = iota
|
|
|
|
|
|
|
|
OpVerifying
|
|
|
|
|
|
|
|
OpDeleting
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// BackupManagerModel manages backup archives
|
|
|
|
// BackupManagerModel manages backup archives
|
|
|
|
type BackupManagerModel struct {
|
|
|
|
type BackupManagerModel struct {
|
|
|
|
config *config.Config
|
|
|
|
config *config.Config
|
|
|
|
logger logger.Logger
|
|
|
|
logger logger.Logger
|
|
|
|
parent tea.Model
|
|
|
|
parent tea.Model
|
|
|
|
ctx context.Context
|
|
|
|
ctx context.Context
|
|
|
|
archives []ArchiveInfo
|
|
|
|
archives []ArchiveInfo
|
|
|
|
cursor int
|
|
|
|
cursor int
|
|
|
|
loading bool
|
|
|
|
loading bool
|
|
|
|
err error
|
|
|
|
err error
|
|
|
|
message string
|
|
|
|
message string
|
|
|
|
totalSize int64
|
|
|
|
totalSize int64
|
|
|
|
freeSpace int64
|
|
|
|
freeSpace int64
|
|
|
|
|
|
|
|
opState OperationState
|
|
|
|
|
|
|
|
opTarget string // Name of archive being operated on
|
|
|
|
|
|
|
|
spinnerFrame int
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewBackupManager creates a new backup manager
|
|
|
|
// NewBackupManager creates a new backup manager
|
|
|
|
func NewBackupManager(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context) BackupManagerModel {
|
|
|
|
func NewBackupManager(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context) BackupManagerModel {
|
|
|
|
return BackupManagerModel{
|
|
|
|
return BackupManagerModel{
|
|
|
|
config: cfg,
|
|
|
|
config: cfg,
|
|
|
|
logger: log,
|
|
|
|
logger: log,
|
|
|
|
parent: parent,
|
|
|
|
parent: parent,
|
|
|
|
ctx: ctx,
|
|
|
|
ctx: ctx,
|
|
|
|
loading: true,
|
|
|
|
loading: true,
|
|
|
|
|
|
|
|
opState: OpIdle,
|
|
|
|
|
|
|
|
spinnerFrame: 0,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (m BackupManagerModel) Init() tea.Cmd {
|
|
|
|
func (m BackupManagerModel) Init() tea.Cmd {
|
|
|
|
return loadArchives(m.config, m.logger)
|
|
|
|
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) {
|
|
|
|
func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
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:
|
|
|
|
case archiveListMsg:
|
|
|
|
m.loading = false
|
|
|
|
m.loading = false
|
|
|
|
if msg.err != nil {
|
|
|
|
if msg.err != nil {
|
|
|
|
@@ -68,10 +131,24 @@ func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
|
return m, nil
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
|
|
|
|
case tea.KeyMsg:
|
|
|
|
case tea.KeyMsg:
|
|
|
|
switch msg.String() {
|
|
|
|
// Allow escape/cancel even during operations
|
|
|
|
case "ctrl+c", "q", "esc":
|
|
|
|
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
|
|
|
|
return m.parent, nil
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Block other input during operations
|
|
|
|
|
|
|
|
if m.opState != OpIdle {
|
|
|
|
|
|
|
|
return m, nil
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
switch msg.String() {
|
|
|
|
case "up", "k":
|
|
|
|
case "up", "k":
|
|
|
|
if m.cursor > 0 {
|
|
|
|
if m.cursor > 0 {
|
|
|
|
m.cursor--
|
|
|
|
m.cursor--
|
|
|
|
@@ -83,11 +160,13 @@ func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case "v":
|
|
|
|
case "v":
|
|
|
|
// Verify archive
|
|
|
|
// Verify archive with real verification
|
|
|
|
if len(m.archives) > 0 && m.cursor < len(m.archives) {
|
|
|
|
if len(m.archives) > 0 && m.cursor < len(m.archives) {
|
|
|
|
selected := m.archives[m.cursor]
|
|
|
|
selected := m.archives[m.cursor]
|
|
|
|
m.message = fmt.Sprintf("[SEARCH] Verifying %s...", selected.Name)
|
|
|
|
m.opState = OpVerifying
|
|
|
|
// In real implementation, would run verification
|
|
|
|
m.opTarget = selected.Name
|
|
|
|
|
|
|
|
m.message = ""
|
|
|
|
|
|
|
|
return m, verifyArchiveCmd(selected)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case "d":
|
|
|
|
case "d":
|
|
|
|
@@ -150,13 +229,47 @@ func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
|
|
|
|
|
|
|
|
|
func (m BackupManagerModel) View() string {
|
|
|
|
func (m BackupManagerModel) View() string {
|
|
|
|
var s strings.Builder
|
|
|
|
var s strings.Builder
|
|
|
|
|
|
|
|
const boxWidth = 60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Helper to pad string to box width (handles UTF-8)
|
|
|
|
|
|
|
|
padToWidth := func(text string, width int) string {
|
|
|
|
|
|
|
|
textWidth := runewidth.StringWidth(text)
|
|
|
|
|
|
|
|
if textWidth >= width {
|
|
|
|
|
|
|
|
return runewidth.Truncate(text, width-3, "...")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return text + strings.Repeat(" ", width-textWidth)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Title
|
|
|
|
// Title
|
|
|
|
s.WriteString(titleStyle.Render("[DB] Backup Archive Manager"))
|
|
|
|
s.WriteString(titleStyle.Render("[DB] Backup Archive Manager"))
|
|
|
|
s.WriteString("\n\n")
|
|
|
|
s.WriteString("\n\n")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Operation Status Box (always visible)
|
|
|
|
|
|
|
|
s.WriteString("+--[ STATUS ]" + strings.Repeat("-", boxWidth-13) + "+\n")
|
|
|
|
|
|
|
|
switch m.opState {
|
|
|
|
|
|
|
|
case OpVerifying:
|
|
|
|
|
|
|
|
spinner := spinnerFrames[m.spinnerFrame]
|
|
|
|
|
|
|
|
statusText := fmt.Sprintf(" %s Verifying: %s", spinner, m.opTarget)
|
|
|
|
|
|
|
|
s.WriteString("|" + padToWidth(statusText, boxWidth) + "|\n")
|
|
|
|
|
|
|
|
case OpDeleting:
|
|
|
|
|
|
|
|
spinner := spinnerFrames[m.spinnerFrame]
|
|
|
|
|
|
|
|
statusText := fmt.Sprintf(" %s Deleting: %s", spinner, m.opTarget)
|
|
|
|
|
|
|
|
s.WriteString("|" + padToWidth(statusText, boxWidth) + "|\n")
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
|
|
if m.loading {
|
|
|
|
|
|
|
|
spinner := spinnerFrames[m.spinnerFrame]
|
|
|
|
|
|
|
|
statusText := fmt.Sprintf(" %s Loading archives...", spinner)
|
|
|
|
|
|
|
|
s.WriteString("|" + padToWidth(statusText, boxWidth) + "|\n")
|
|
|
|
|
|
|
|
} else if m.message != "" {
|
|
|
|
|
|
|
|
msgText := " " + m.message
|
|
|
|
|
|
|
|
s.WriteString("|" + padToWidth(msgText, boxWidth) + "|\n")
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
s.WriteString("|" + padToWidth(" Ready", boxWidth) + "|\n")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
s.WriteString("+" + strings.Repeat("-", boxWidth) + "+\n\n")
|
|
|
|
|
|
|
|
|
|
|
|
if m.loading {
|
|
|
|
if m.loading {
|
|
|
|
s.WriteString(infoStyle.Render("Loading archives..."))
|
|
|
|
|
|
|
|
return s.String()
|
|
|
|
return s.String()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -233,30 +346,98 @@ func (m BackupManagerModel) View() string {
|
|
|
|
|
|
|
|
|
|
|
|
// Footer
|
|
|
|
// Footer
|
|
|
|
s.WriteString("\n")
|
|
|
|
s.WriteString("\n")
|
|
|
|
if m.message != "" {
|
|
|
|
|
|
|
|
s.WriteString(infoStyle.Render(m.message))
|
|
|
|
|
|
|
|
s.WriteString("\n")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
s.WriteString(infoStyle.Render(fmt.Sprintf("Selected: %d/%d", m.cursor+1, len(m.archives))))
|
|
|
|
s.WriteString(infoStyle.Render(fmt.Sprintf("Selected: %d/%d", m.cursor+1, len(m.archives))))
|
|
|
|
s.WriteString("\n\n")
|
|
|
|
s.WriteString("\n\n")
|
|
|
|
|
|
|
|
|
|
|
|
// Grouped keyboard shortcuts for better readability
|
|
|
|
// Grouped keyboard shortcuts - simple aligned format
|
|
|
|
s.WriteString(infoStyle.Render("NAVIGATE ACTIONS OTHER"))
|
|
|
|
s.WriteString("SHORTCUTS: Up/Down=Move | r=Restore | v=Verify | d=Delete | i=Info | R=Refresh | Esc=Back | q=Quit")
|
|
|
|
s.WriteString("\n")
|
|
|
|
|
|
|
|
s.WriteString(infoStyle.Render("-------- ------- -----"))
|
|
|
|
|
|
|
|
s.WriteString("\n")
|
|
|
|
|
|
|
|
s.WriteString(infoStyle.Render("Up/Down: Move r: Restore R: Refresh"))
|
|
|
|
|
|
|
|
s.WriteString("\n")
|
|
|
|
|
|
|
|
s.WriteString(infoStyle.Render(" v: Verify Esc: Back"))
|
|
|
|
|
|
|
|
s.WriteString("\n")
|
|
|
|
|
|
|
|
s.WriteString(infoStyle.Render(" d: Delete q: Quit"))
|
|
|
|
|
|
|
|
s.WriteString("\n")
|
|
|
|
|
|
|
|
s.WriteString(infoStyle.Render(" i: Info"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return s.String()
|
|
|
|
return s.String()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// verifyArchiveCmd runs actual archive verification
|
|
|
|
|
|
|
|
func verifyArchiveCmd(archive ArchiveInfo) tea.Cmd {
|
|
|
|
|
|
|
|
return func() tea.Msg {
|
|
|
|
|
|
|
|
// Determine verification method based on format
|
|
|
|
|
|
|
|
var valid bool
|
|
|
|
|
|
|
|
var details string
|
|
|
|
|
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
|
|
|
case strings.HasSuffix(archive.Path, ".tar.gz") || strings.HasSuffix(archive.Path, ".tgz"):
|
|
|
|
|
|
|
|
// Verify tar.gz archive
|
|
|
|
|
|
|
|
cmd := exec.Command("tar", "-tzf", archive.Path)
|
|
|
|
|
|
|
|
output, cmdErr := cmd.CombinedOutput()
|
|
|
|
|
|
|
|
if cmdErr != nil {
|
|
|
|
|
|
|
|
return verifyResultMsg{archive: archive.Name, valid: false, err: nil, details: "Archive corrupt or incomplete"}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
lines := strings.Split(string(output), "\n")
|
|
|
|
|
|
|
|
fileCount := 0
|
|
|
|
|
|
|
|
for _, l := range lines {
|
|
|
|
|
|
|
|
if l != "" {
|
|
|
|
|
|
|
|
fileCount++
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
valid = true
|
|
|
|
|
|
|
|
details = fmt.Sprintf("%d files in archive", fileCount)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
case strings.HasSuffix(archive.Path, ".dump") || strings.HasSuffix(archive.Path, ".sql"):
|
|
|
|
|
|
|
|
// Verify PostgreSQL dump with pg_restore --list
|
|
|
|
|
|
|
|
cmd := exec.Command("pg_restore", "--list", archive.Path)
|
|
|
|
|
|
|
|
output, cmdErr := cmd.CombinedOutput()
|
|
|
|
|
|
|
|
if cmdErr != nil {
|
|
|
|
|
|
|
|
// Try as plain SQL
|
|
|
|
|
|
|
|
if strings.HasSuffix(archive.Path, ".sql") {
|
|
|
|
|
|
|
|
// Just check file is readable and has content
|
|
|
|
|
|
|
|
fi, statErr := os.Stat(archive.Path)
|
|
|
|
|
|
|
|
if statErr == nil && fi.Size() > 0 {
|
|
|
|
|
|
|
|
valid = true
|
|
|
|
|
|
|
|
details = "Plain SQL file readable"
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
return verifyResultMsg{archive: archive.Name, valid: false, err: nil, details: "File empty or unreadable"}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
return verifyResultMsg{archive: archive.Name, valid: false, err: nil, details: "pg_restore cannot read dump"}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
lines := strings.Split(string(output), "\n")
|
|
|
|
|
|
|
|
objectCount := 0
|
|
|
|
|
|
|
|
for _, l := range lines {
|
|
|
|
|
|
|
|
if l != "" && !strings.HasPrefix(l, ";") {
|
|
|
|
|
|
|
|
objectCount++
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
valid = true
|
|
|
|
|
|
|
|
details = fmt.Sprintf("%d objects in dump", objectCount)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
case strings.HasSuffix(archive.Path, ".sql.gz"):
|
|
|
|
|
|
|
|
// Verify gzipped SQL
|
|
|
|
|
|
|
|
cmd := exec.Command("gzip", "-t", archive.Path)
|
|
|
|
|
|
|
|
if cmdErr := cmd.Run(); cmdErr != nil {
|
|
|
|
|
|
|
|
return verifyResultMsg{archive: archive.Name, valid: false, err: nil, details: "Gzip archive corrupt"}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
valid = true
|
|
|
|
|
|
|
|
details = "Gzip integrity OK"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
|
|
// Unknown format - just check file exists and has size
|
|
|
|
|
|
|
|
fi, statErr := os.Stat(archive.Path)
|
|
|
|
|
|
|
|
if statErr != nil {
|
|
|
|
|
|
|
|
return verifyResultMsg{archive: archive.Name, valid: false, err: statErr, details: "Cannot access file"}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if fi.Size() == 0 {
|
|
|
|
|
|
|
|
return verifyResultMsg{archive: archive.Name, valid: false, err: nil, details: "File is empty"}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
valid = true
|
|
|
|
|
|
|
|
details = "File exists and has content"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return verifyResultMsg{archive: archive.Name, valid: valid, err: err, details: details}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// deleteArchive deletes a backup archive (to be called from confirmation)
|
|
|
|
// deleteArchive deletes a backup archive (to be called from confirmation)
|
|
|
|
func deleteArchive(archivePath string) error {
|
|
|
|
func deleteArchive(archivePath string) error {
|
|
|
|
return os.Remove(archivePath)
|
|
|
|
return os.Remove(archivePath)
|
|
|
|
|