Compare commits

..

2 Commits

Author SHA1 Message Date
55d34be32e v3.42.14: TUI Backup Manager - status box with spinner, real verify function
All checks were successful
CI/CD / Test (push) Successful in 1m13s
CI/CD / Lint (push) Successful in 1m21s
CI/CD / Build & Release (push) Successful in 3m6s
2026-01-08 10:35:23 +01:00
1831bd7c1f v3.42.13: TUI improvements - grouped shortcuts, box layout, better alignment
All checks were successful
CI/CD / Test (push) Successful in 1m14s
CI/CD / Lint (push) Successful in 1m22s
CI/CD / Build & Release (push) Successful in 3m9s
2026-01-08 10:16:19 +01:00
5 changed files with 318 additions and 127 deletions

View File

@@ -3,9 +3,9 @@
This directory contains pre-compiled binaries for the DB Backup Tool across multiple platforms and architectures. This directory contains pre-compiled binaries for the DB Backup Tool across multiple platforms and architectures.
## Build Information ## Build Information
- **Version**: 3.42.1 - **Version**: 3.42.10
- **Build Time**: 2026-01-08_05:03:53_UTC - **Build Time**: 2026-01-08_09:19:02_UTC
- **Git Commit**: 9c65821 - **Git Commit**: 1831bd7
## Recent Updates (v1.1.0) ## Recent Updates (v1.1.0)
- ✅ Fixed TUI progress display with line-by-line output - ✅ Fixed TUI progress display with line-by-line output

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec"
"strings" "strings"
"time" "time"
@@ -13,6 +14,15 @@ import (
"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
@@ -26,6 +36,9 @@ type BackupManagerModel struct {
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
@@ -36,15 +49,64 @@ func NewBackupManager(cfg *config.Config, log logger.Logger, parent tea.Model, c
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,6 +130,11 @@ func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
// Block input during operations
if m.opState != OpIdle {
return m, nil
}
switch msg.String() { switch msg.String() {
case "ctrl+c", "q", "esc": case "ctrl+c", "q", "esc":
return m.parent, nil return m.parent, nil
@@ -83,11 +150,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":
@@ -155,8 +224,36 @@ func (m BackupManagerModel) View() string {
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("-", 47) + "+\n")
switch m.opState {
case OpVerifying:
spinner := spinnerFrames[m.spinnerFrame]
statusText := fmt.Sprintf(" %s Verifying: %s", spinner, m.opTarget)
s.WriteString("|" + statusText + strings.Repeat(" ", 59-len(statusText)) + "|\n")
case OpDeleting:
spinner := spinnerFrames[m.spinnerFrame]
statusText := fmt.Sprintf(" %s Deleting: %s", spinner, m.opTarget)
s.WriteString("|" + statusText + strings.Repeat(" ", 59-len(statusText)) + "|\n")
default:
if m.loading {
spinner := spinnerFrames[m.spinnerFrame]
statusText := fmt.Sprintf(" %s Loading archives...", spinner)
s.WriteString("|" + statusText + strings.Repeat(" ", 59-len(statusText)) + "|\n")
} else if m.message != "" {
msgText := " " + m.message
if len(msgText) > 58 {
msgText = msgText[:55] + "..."
}
s.WriteString("|" + msgText + strings.Repeat(" ", 59-len(msgText)) + "|\n")
} else {
statusText := " Ready"
s.WriteString("|" + statusText + strings.Repeat(" ", 59-len(statusText)) + "|\n")
}
}
s.WriteString("+" + strings.Repeat("-", 60) + "+\n\n")
if m.loading { if m.loading {
s.WriteString(infoStyle.Render("Loading archives..."))
return s.String() return s.String()
} }
@@ -180,11 +277,11 @@ func (m BackupManagerModel) View() string {
return s.String() return s.String()
} }
// Column headers // Column headers with better alignment
s.WriteString(archiveHeaderStyle.Render(fmt.Sprintf("%-35s %-25s %-12s %-20s", s.WriteString(archiveHeaderStyle.Render(fmt.Sprintf(" %-32s %-22s %10s %-16s",
"FILENAME", "FORMAT", "SIZE", "MODIFIED"))) "FILENAME", "FORMAT", "SIZE", "MODIFIED")))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(strings.Repeat("-", 95)) s.WriteString(strings.Repeat("-", 90))
s.WriteString("\n") s.WriteString("\n")
// Show archives (limit to visible area) // Show archives (limit to visible area)
@@ -203,23 +300,23 @@ func (m BackupManagerModel) View() string {
style := archiveNormalStyle style := archiveNormalStyle
if i == m.cursor { if i == m.cursor {
cursor = ">" cursor = "> "
style = archiveSelectedStyle style = archiveSelectedStyle
} }
// Status icon // Status icon - consistent 4-char width
statusIcon := "[+]" statusIcon := " [+]"
if !archive.Valid { if !archive.Valid {
statusIcon = "[-]" statusIcon = " [-]"
style = archiveInvalidStyle style = archiveInvalidStyle
} else if time.Since(archive.Modified) > 30*24*time.Hour { } else if time.Since(archive.Modified) > 30*24*time.Hour {
statusIcon = "[WARN]" statusIcon = " [!]"
} }
filename := truncate(archive.Name, 33) filename := truncate(archive.Name, 32)
format := truncate(archive.Format.String(), 23) format := truncate(archive.Format.String(), 22)
line := fmt.Sprintf("%s %s %-33s %-23s %-10s %-19s", line := fmt.Sprintf("%s%s %-32s %-22s %10s %-16s",
cursor, cursor,
statusIcon, statusIcon,
filename, filename,
@@ -233,18 +330,104 @@ 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") s.WriteString("\n\n")
s.WriteString(infoStyle.Render("[KEY] ↑/↓: Navigate | r: Restore | v: Verify | d: Delete | i: Info | R: Refresh | Esc: Back"))
// Grouped keyboard shortcuts for better readability
s.WriteString("+--[ SHORTCUTS ]" + strings.Repeat("-", 44) + "+\n")
s.WriteString("| NAVIGATE ACTIONS OTHER |\n")
s.WriteString("| Up/Down: Move r: Restore R: Refresh |\n")
s.WriteString("| v: Verify Esc: Back |\n")
s.WriteString("| d: Delete q: Quit |\n")
s.WriteString("| i: Info |\n")
s.WriteString("+" + strings.Repeat("-", 60) + "+")
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)

View File

@@ -204,124 +204,132 @@ func (m DiagnoseViewModel) View() string {
func (m DiagnoseViewModel) renderSingleResult(result *restore.DiagnoseResult) string { func (m DiagnoseViewModel) renderSingleResult(result *restore.DiagnoseResult) string {
var s strings.Builder var s strings.Builder
// Status // Status Box
s.WriteString(strings.Repeat("-", 60)) s.WriteString("+--[ VALIDATION STATUS ]" + strings.Repeat("-", 37) + "+\n")
s.WriteString("\n")
if result.IsValid { if result.IsValid {
s.WriteString(diagnosePassStyle.Render("[OK] STATUS: VALID")) s.WriteString("| " + diagnosePassStyle.Render("[OK] VALID - Archive passed all checks") + strings.Repeat(" ", 18) + "|\n")
} else { } else {
s.WriteString(diagnoseFailStyle.Render("[FAIL] STATUS: INVALID")) s.WriteString("| " + diagnoseFailStyle.Render("[FAIL] INVALID - Archive has problems") + strings.Repeat(" ", 19) + "|\n")
} }
s.WriteString("\n")
if result.IsTruncated { if result.IsTruncated {
s.WriteString(diagnoseFailStyle.Render("[WARN] TRUNCATED: File appears incomplete")) s.WriteString("| " + diagnoseFailStyle.Render("[!] TRUNCATED - File is incomplete") + strings.Repeat(" ", 22) + "|\n")
s.WriteString("\n")
} }
if result.IsCorrupted { if result.IsCorrupted {
s.WriteString(diagnoseFailStyle.Render("[WARN] CORRUPTED: File structure is damaged")) s.WriteString("| " + diagnoseFailStyle.Render("[!] CORRUPTED - File structure damaged") + strings.Repeat(" ", 18) + "|\n")
s.WriteString("\n")
} }
s.WriteString(strings.Repeat("-", 60)) s.WriteString("+" + strings.Repeat("-", 60) + "+\n\n")
s.WriteString("\n\n")
// Details // Details Box
if result.Details != nil { if result.Details != nil {
s.WriteString(diagnoseHeaderStyle.Render("[STATS] DETAILS:")) s.WriteString("+--[ DETAILS ]" + strings.Repeat("-", 46) + "+\n")
s.WriteString("\n")
if result.Details.HasPGDMPSignature { if result.Details.HasPGDMPSignature {
s.WriteString(diagnosePassStyle.Render(" [+] ")) s.WriteString("| " + diagnosePassStyle.Render("[+]") + " PostgreSQL custom format (PGDMP)" + strings.Repeat(" ", 20) + "|\n")
s.WriteString("Has PGDMP signature (custom format)\n")
} }
if result.Details.HasSQLHeader { if result.Details.HasSQLHeader {
s.WriteString(diagnosePassStyle.Render(" [+] ")) s.WriteString("| " + diagnosePassStyle.Render("[+]") + " PostgreSQL SQL header found" + strings.Repeat(" ", 25) + "|\n")
s.WriteString("Has PostgreSQL SQL header\n")
} }
if result.Details.GzipValid { if result.Details.GzipValid {
s.WriteString(diagnosePassStyle.Render(" [+] ")) s.WriteString("| " + diagnosePassStyle.Render("[+]") + " Gzip compression valid" + strings.Repeat(" ", 30) + "|\n")
s.WriteString("Gzip compression valid\n")
} }
if result.Details.PgRestoreListable { if result.Details.PgRestoreListable {
s.WriteString(diagnosePassStyle.Render(" [+] ")) tableInfo := fmt.Sprintf(" (%d tables)", result.Details.TableCount)
s.WriteString(fmt.Sprintf("pg_restore can list contents (%d tables)\n", result.Details.TableCount)) padding := 36 - len(tableInfo)
if padding < 0 {
padding = 0
}
s.WriteString("| " + diagnosePassStyle.Render("[+]") + " pg_restore can list contents" + tableInfo + strings.Repeat(" ", padding) + "|\n")
} }
if result.Details.CopyBlockCount > 0 { if result.Details.CopyBlockCount > 0 {
s.WriteString(diagnoseInfoStyle.Render(" - ")) blockInfo := fmt.Sprintf("%d COPY blocks found", result.Details.CopyBlockCount)
s.WriteString(fmt.Sprintf("Contains %d COPY blocks\n", result.Details.CopyBlockCount)) padding := 50 - len(blockInfo)
if padding < 0 {
padding = 0
}
s.WriteString("| [-] " + blockInfo + strings.Repeat(" ", padding) + "|\n")
} }
if result.Details.UnterminatedCopy { if result.Details.UnterminatedCopy {
s.WriteString(diagnoseFailStyle.Render(" [-] ")) s.WriteString("| " + diagnoseFailStyle.Render("[-]") + " Unterminated COPY: " + truncate(result.Details.LastCopyTable, 30) + strings.Repeat(" ", 5) + "|\n")
s.WriteString(fmt.Sprintf("Unterminated COPY block: %s (line %d)\n",
result.Details.LastCopyTable, result.Details.LastCopyLineNumber))
} }
if result.Details.ProperlyTerminated { if result.Details.ProperlyTerminated {
s.WriteString(diagnosePassStyle.Render(" [+] ")) s.WriteString("| " + diagnosePassStyle.Render("[+]") + " All COPY blocks properly terminated" + strings.Repeat(" ", 17) + "|\n")
s.WriteString("All COPY blocks properly terminated\n")
} }
if result.Details.ExpandedSize > 0 { if result.Details.ExpandedSize > 0 {
s.WriteString(diagnoseInfoStyle.Render(" - ")) sizeInfo := fmt.Sprintf("Expanded: %s (%.1fx)", formatSize(result.Details.ExpandedSize), result.Details.CompressionRatio)
s.WriteString(fmt.Sprintf("Expanded size: %s (ratio: %.1fx)\n", padding := 50 - len(sizeInfo)
formatSize(result.Details.ExpandedSize), result.Details.CompressionRatio)) if padding < 0 {
padding = 0
} }
s.WriteString("| [-] " + sizeInfo + strings.Repeat(" ", padding) + "|\n")
} }
// Errors s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
}
// Errors Box
if len(result.Errors) > 0 { if len(result.Errors) > 0 {
s.WriteString("\n") s.WriteString("\n+--[ ERRORS ]" + strings.Repeat("-", 47) + "+\n")
s.WriteString(diagnoseFailStyle.Render("[FAIL] ERRORS:"))
s.WriteString("\n")
for i, e := range result.Errors { for i, e := range result.Errors {
if i >= 5 { if i >= 5 {
s.WriteString(diagnoseInfoStyle.Render(fmt.Sprintf(" ... and %d more\n", len(result.Errors)-5))) remaining := fmt.Sprintf("... and %d more errors", len(result.Errors)-5)
padding := 56 - len(remaining)
s.WriteString("| " + remaining + strings.Repeat(" ", padding) + "|\n")
break break
} }
s.WriteString(diagnoseFailStyle.Render(" - ")) errText := truncate(e, 54)
s.WriteString(truncate(e, 70)) padding := 56 - len(errText)
s.WriteString("\n") if padding < 0 {
padding = 0
} }
s.WriteString("| " + errText + strings.Repeat(" ", padding) + "|\n")
}
s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
} }
// Warnings // Warnings Box
if len(result.Warnings) > 0 { if len(result.Warnings) > 0 {
s.WriteString("\n") s.WriteString("\n+--[ WARNINGS ]" + strings.Repeat("-", 45) + "+\n")
s.WriteString(diagnoseWarnStyle.Render("[WARN] WARNINGS:"))
s.WriteString("\n")
for i, w := range result.Warnings { for i, w := range result.Warnings {
if i >= 3 { if i >= 3 {
s.WriteString(diagnoseInfoStyle.Render(fmt.Sprintf(" ... and %d more\n", len(result.Warnings)-3))) remaining := fmt.Sprintf("... and %d more warnings", len(result.Warnings)-3)
padding := 56 - len(remaining)
s.WriteString("| " + remaining + strings.Repeat(" ", padding) + "|\n")
break break
} }
s.WriteString(diagnoseWarnStyle.Render(" - ")) warnText := truncate(w, 54)
s.WriteString(truncate(w, 70)) padding := 56 - len(warnText)
s.WriteString("\n") if padding < 0 {
padding = 0
} }
s.WriteString("| " + warnText + strings.Repeat(" ", padding) + "|\n")
}
s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
} }
// Recommendations // Recommendations Box
if !result.IsValid { if !result.IsValid {
s.WriteString("\n") s.WriteString("\n+--[ RECOMMENDATIONS ]" + strings.Repeat("-", 38) + "+\n")
s.WriteString(diagnoseHeaderStyle.Render("[HINT] RECOMMENDATIONS:"))
s.WriteString("\n")
if result.IsTruncated { if result.IsTruncated {
s.WriteString(" 1. Re-run the backup process for this database\n") s.WriteString("| 1. Re-run backup with current version (v3.42.12+) |\n")
s.WriteString(" 2. Check disk space on backup server\n") s.WriteString("| 2. Check disk space on backup server |\n")
s.WriteString(" 3. Verify network stability for remote backups\n") s.WriteString("| 3. Verify network stability for remote backups |\n")
} }
if result.IsCorrupted { if result.IsCorrupted {
s.WriteString(" 1. Verify backup was transferred completely\n") s.WriteString("| 1. Verify backup was transferred completely |\n")
s.WriteString(" 2. Try restoring from a previous backup\n") s.WriteString("| 2. Try restoring from a previous backup |\n")
} }
s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
} }
return s.String() return s.String()