Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 682510d1bc | |||
| 83ad62b6b5 | |||
| 55d34be32e | |||
| 1831bd7c1f |
@@ -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:54:02_UTC
|
||||||
- **Git Commit**: 9c65821
|
- **Git Commit**: 83ad62b
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
@@ -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,10 +130,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 +159,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":
|
||||||
@@ -152,39 +230,67 @@ func (m BackupManagerModel) View() string {
|
|||||||
var s strings.Builder
|
var s strings.Builder
|
||||||
|
|
||||||
// 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")
|
||||||
|
|
||||||
|
// 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 {
|
if m.loading {
|
||||||
s.WriteString(infoStyle.Render("Loading archives..."))
|
|
||||||
return s.String()
|
return s.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
s.WriteString(errorStyle.Render(fmt.Sprintf("[FAIL] Error: %v", m.err)))
|
s.WriteString(StatusErrorStyle.Render(fmt.Sprintf("[FAIL] Error: %v", m.err)))
|
||||||
s.WriteString("\n\n")
|
s.WriteString("\n\n")
|
||||||
s.WriteString(infoStyle.Render("Press Esc to go back"))
|
s.WriteString(ShortcutStyle.Render("Press Esc to go back"))
|
||||||
return s.String()
|
return s.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
s.WriteString(infoStyle.Render(fmt.Sprintf("Total Archives: %d | Total Size: %s",
|
s.WriteString(LabelStyle.Render(fmt.Sprintf("Total Archives: %d | Total Size: %s",
|
||||||
len(m.archives), formatSize(m.totalSize))))
|
len(m.archives), formatSize(m.totalSize))))
|
||||||
s.WriteString("\n\n")
|
s.WriteString("\n\n")
|
||||||
|
|
||||||
// Archives list
|
// Archives list
|
||||||
if len(m.archives) == 0 {
|
if len(m.archives) == 0 {
|
||||||
s.WriteString(infoStyle.Render("No backup archives found"))
|
s.WriteString(StatusReadyStyle.Render("No backup archives found"))
|
||||||
s.WriteString("\n\n")
|
s.WriteString("\n\n")
|
||||||
s.WriteString(infoStyle.Render("Press Esc to go back"))
|
s.WriteString(ShortcutStyle.Render("Press Esc to go back"))
|
||||||
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(ListHeaderStyle.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)
|
||||||
@@ -200,26 +306,26 @@ func (m BackupManagerModel) View() string {
|
|||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
archive := m.archives[i]
|
archive := m.archives[i]
|
||||||
cursor := " "
|
cursor := " "
|
||||||
style := archiveNormalStyle
|
style := ListNormalStyle
|
||||||
|
|
||||||
if i == m.cursor {
|
if i == m.cursor {
|
||||||
cursor = ">"
|
cursor = "> "
|
||||||
style = archiveSelectedStyle
|
style = ListSelectedStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status icon
|
// Status icon - consistent 4-char width
|
||||||
statusIcon := "[+]"
|
statusIcon := " [+]"
|
||||||
if !archive.Valid {
|
if !archive.Valid {
|
||||||
statusIcon = "[-]"
|
statusIcon = " [-]"
|
||||||
style = archiveInvalidStyle
|
style = ItemInvalidStyle
|
||||||
} 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 +339,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(StatusReadyStyle.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
|
||||||
|
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()
|
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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -146,13 +146,12 @@ func (m StatusViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
if !m.loading {
|
// Always allow escape, even during loading
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "ctrl+c", "q", "esc", "enter":
|
case "ctrl+c", "q", "esc", "enter":
|
||||||
return m.parent, nil
|
return m.parent, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
133
internal/tui/styles.go
Normal file
133
internal/tui/styles.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// GLOBAL TUI STYLE DEFINITIONS
|
||||||
|
// =============================================================================
|
||||||
|
// Design Language:
|
||||||
|
// - Bold text for labels and headers
|
||||||
|
// - Colors for semantic meaning (green=success, red=error, yellow=warning)
|
||||||
|
// - No emoticons - use simple text prefixes like [OK], [FAIL], [!]
|
||||||
|
// - No boxes for inline status - use bold+color accents
|
||||||
|
// - Consistent color palette across all views
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// Color Palette (ANSI 256 colors for terminal compatibility)
|
||||||
|
const (
|
||||||
|
ColorWhite = lipgloss.Color("15") // Bright white
|
||||||
|
ColorGray = lipgloss.Color("250") // Light gray
|
||||||
|
ColorDim = lipgloss.Color("244") // Dim gray
|
||||||
|
ColorDimmer = lipgloss.Color("240") // Darker gray
|
||||||
|
ColorSuccess = lipgloss.Color("2") // Green
|
||||||
|
ColorError = lipgloss.Color("1") // Red
|
||||||
|
ColorWarning = lipgloss.Color("3") // Yellow
|
||||||
|
ColorInfo = lipgloss.Color("6") // Cyan
|
||||||
|
ColorAccent = lipgloss.Color("4") // Blue
|
||||||
|
)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// TITLE & HEADER STYLES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// TitleStyle - main view title (bold white on gray background)
|
||||||
|
var TitleStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(ColorWhite).
|
||||||
|
Background(ColorDimmer).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
// HeaderStyle - section headers (bold gray)
|
||||||
|
var HeaderStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(ColorDim)
|
||||||
|
|
||||||
|
// LabelStyle - field labels (bold cyan)
|
||||||
|
var LabelStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(ColorInfo)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// STATUS STYLES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// StatusReadyStyle - idle/ready state (dim)
|
||||||
|
var StatusReadyStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorDim)
|
||||||
|
|
||||||
|
// StatusActiveStyle - operation in progress (bold cyan)
|
||||||
|
var StatusActiveStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(ColorInfo)
|
||||||
|
|
||||||
|
// StatusSuccessStyle - success messages (bold green)
|
||||||
|
var StatusSuccessStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(ColorSuccess)
|
||||||
|
|
||||||
|
// StatusErrorStyle - error messages (bold red)
|
||||||
|
var StatusErrorStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(ColorError)
|
||||||
|
|
||||||
|
// StatusWarningStyle - warning messages (bold yellow)
|
||||||
|
var StatusWarningStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(ColorWarning)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// LIST & TABLE STYLES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ListNormalStyle - unselected list items
|
||||||
|
var ListNormalStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorGray)
|
||||||
|
|
||||||
|
// ListSelectedStyle - selected/cursor item (bold white)
|
||||||
|
var ListSelectedStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorWhite).
|
||||||
|
Bold(true)
|
||||||
|
|
||||||
|
// ListHeaderStyle - column headers (bold dim)
|
||||||
|
var ListHeaderStyle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(ColorDim)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ITEM STATUS STYLES
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ItemValidStyle - valid/OK items (green)
|
||||||
|
var ItemValidStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorSuccess)
|
||||||
|
|
||||||
|
// ItemInvalidStyle - invalid/failed items (red)
|
||||||
|
var ItemInvalidStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorError)
|
||||||
|
|
||||||
|
// ItemOldStyle - old/stale items (yellow)
|
||||||
|
var ItemOldStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorWarning)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SHORTCUT STYLE
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// ShortcutStyle - keyboard shortcuts footer (dim)
|
||||||
|
var ShortcutStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(ColorDim)
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HELPER PREFIXES (no emoticons)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
PrefixOK = "[OK]"
|
||||||
|
PrefixFail = "[FAIL]"
|
||||||
|
PrefixWarn = "[!]"
|
||||||
|
PrefixInfo = "[i]"
|
||||||
|
PrefixPlus = "[+]"
|
||||||
|
PrefixMinus = "[-]"
|
||||||
|
PrefixArrow = ">"
|
||||||
|
PrefixSpinner = "" // Spinner character added dynamically
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user