Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92402f0fdb | |||
| 682510d1bc | |||
| 83ad62b6b5 | |||
| 55d34be32e | |||
| 1831bd7c1f | |||
| 24377eab8f |
21
SYSTEMD.md
21
SYSTEMD.md
@@ -116,8 +116,9 @@ sudo chmod 755 /usr/local/bin/dbbackup
|
||||
### Step 2: Create Configuration
|
||||
|
||||
```bash
|
||||
# Main configuration
|
||||
sudo tee /etc/dbbackup/dbbackup.conf << 'EOF'
|
||||
# Main configuration in working directory (where service runs from)
|
||||
# dbbackup reads .dbbackup.conf from WorkingDirectory
|
||||
sudo tee /var/lib/dbbackup/.dbbackup.conf << 'EOF'
|
||||
# DBBackup Configuration
|
||||
db-type=postgres
|
||||
host=localhost
|
||||
@@ -128,6 +129,8 @@ compression=6
|
||||
retention-days=30
|
||||
min-backups=7
|
||||
EOF
|
||||
sudo chown dbbackup:dbbackup /var/lib/dbbackup/.dbbackup.conf
|
||||
sudo chmod 600 /var/lib/dbbackup/.dbbackup.conf
|
||||
|
||||
# Instance credentials (secure permissions)
|
||||
sudo tee /etc/dbbackup/env.d/cluster.conf << 'EOF'
|
||||
@@ -157,13 +160,15 @@ Group=dbbackup
|
||||
# Load configuration
|
||||
EnvironmentFile=-/etc/dbbackup/env.d/cluster.conf
|
||||
|
||||
# Working directory
|
||||
# Working directory (config is loaded from .dbbackup.conf here)
|
||||
WorkingDirectory=/var/lib/dbbackup
|
||||
|
||||
# Execute backup
|
||||
# Execute backup (reads .dbbackup.conf from WorkingDirectory)
|
||||
ExecStart=/usr/local/bin/dbbackup backup cluster \
|
||||
--config /etc/dbbackup/dbbackup.conf \
|
||||
--backup-dir /var/lib/dbbackup/backups \
|
||||
--host localhost \
|
||||
--port 5432 \
|
||||
--user postgres \
|
||||
--allow-root
|
||||
|
||||
# Security hardening
|
||||
@@ -443,12 +448,12 @@ sudo systemctl status dbbackup-cluster.service
|
||||
# View detailed error
|
||||
sudo journalctl -u dbbackup-cluster.service -n 50 --no-pager
|
||||
|
||||
# Test manually as dbbackup user
|
||||
sudo -u dbbackup /usr/local/bin/dbbackup backup cluster --config /etc/dbbackup/dbbackup.conf
|
||||
# Test manually as dbbackup user (run from working directory with .dbbackup.conf)
|
||||
cd /var/lib/dbbackup && sudo -u dbbackup /usr/local/bin/dbbackup backup cluster
|
||||
|
||||
# Check permissions
|
||||
ls -la /var/lib/dbbackup/
|
||||
ls -la /etc/dbbackup/
|
||||
ls -la /var/lib/dbbackup/.dbbackup.conf
|
||||
```
|
||||
|
||||
### Permission Denied
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
This directory contains pre-compiled binaries for the DB Backup Tool across multiple platforms and architectures.
|
||||
|
||||
## Build Information
|
||||
- **Version**: 3.42.1
|
||||
- **Build Time**: 2026-01-08_05:03:53_UTC
|
||||
- **Git Commit**: 9c65821
|
||||
- **Version**: 3.42.10
|
||||
- **Build Time**: 2026-01-08_10:18:23_UTC
|
||||
- **Git Commit**: 682510d
|
||||
|
||||
## Recent Updates (v1.1.0)
|
||||
- ✅ Fixed TUI progress display with line-by-line output
|
||||
|
||||
@@ -37,9 +37,9 @@ var (
|
||||
restoreSaveDebugLog string // Path to save debug log on failure
|
||||
|
||||
// Diagnose flags
|
||||
diagnoseJSON bool
|
||||
diagnoseDeep bool
|
||||
diagnoseKeepTemp bool
|
||||
diagnoseJSON bool
|
||||
diagnoseDeep bool
|
||||
diagnoseKeepTemp bool
|
||||
|
||||
// Encryption flags
|
||||
restoreEncryptionKeyFile string
|
||||
@@ -565,7 +565,7 @@ func runRestoreSingle(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Create restore engine
|
||||
engine := restore.New(cfg, log, db)
|
||||
|
||||
|
||||
// Enable debug logging if requested
|
||||
if restoreSaveDebugLog != "" {
|
||||
engine.SetDebugLogPath(restoreSaveDebugLog)
|
||||
@@ -589,15 +589,15 @@ func runRestoreSingle(cmd *cobra.Command, args []string) error {
|
||||
// Run pre-restore diagnosis if requested
|
||||
if restoreDiagnose {
|
||||
log.Info("[DIAG] Running pre-restore diagnosis...")
|
||||
|
||||
|
||||
diagnoser := restore.NewDiagnoser(log, restoreVerbose)
|
||||
result, err := diagnoser.DiagnoseFile(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("diagnosis failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
diagnoser.PrintDiagnosis(result)
|
||||
|
||||
|
||||
if !result.IsValid {
|
||||
log.Error("[FAIL] Pre-restore diagnosis found issues")
|
||||
if result.IsTruncated {
|
||||
@@ -607,7 +607,7 @@ func runRestoreSingle(cmd *cobra.Command, args []string) error {
|
||||
log.Error(" The backup file appears to be CORRUPTED")
|
||||
}
|
||||
fmt.Println("\nUse --force to attempt restore anyway.")
|
||||
|
||||
|
||||
if !restoreForce {
|
||||
return fmt.Errorf("aborting restore due to backup file issues")
|
||||
}
|
||||
@@ -785,7 +785,7 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Create restore engine
|
||||
engine := restore.New(cfg, log, db)
|
||||
|
||||
|
||||
// Enable debug logging if requested
|
||||
if restoreSaveDebugLog != "" {
|
||||
engine.SetDebugLogPath(restoreSaveDebugLog)
|
||||
@@ -830,7 +830,7 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
|
||||
// Run pre-restore diagnosis if requested
|
||||
if restoreDiagnose {
|
||||
log.Info("[DIAG] Running pre-restore diagnosis...")
|
||||
|
||||
|
||||
// Create temp directory for extraction in configured WorkDir
|
||||
workDir := cfg.GetEffectiveWorkDir()
|
||||
diagTempDir, err := os.MkdirTemp(workDir, "dbbackup-diagnose-*")
|
||||
@@ -838,13 +838,13 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("failed to create temp directory for diagnosis in %s: %w", workDir, err)
|
||||
}
|
||||
defer os.RemoveAll(diagTempDir)
|
||||
|
||||
|
||||
diagnoser := restore.NewDiagnoser(log, restoreVerbose)
|
||||
results, err := diagnoser.DiagnoseClusterDumps(archivePath, diagTempDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("diagnosis failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Check for any invalid dumps
|
||||
var invalidDumps []string
|
||||
for _, result := range results {
|
||||
@@ -853,7 +853,7 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
|
||||
diagnoser.PrintDiagnosis(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if len(invalidDumps) > 0 {
|
||||
log.Error("[FAIL] Pre-restore diagnosis found issues",
|
||||
"invalid_dumps", len(invalidDumps),
|
||||
@@ -864,7 +864,7 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
fmt.Println("\nRun 'dbbackup restore diagnose <archive> --deep' for full details.")
|
||||
fmt.Println("Use --force to attempt restore anyway.")
|
||||
|
||||
|
||||
if !restoreForce {
|
||||
return fmt.Errorf("aborting restore due to %d invalid dump(s)", len(invalidDumps))
|
||||
}
|
||||
|
||||
@@ -53,16 +53,16 @@ type InstallOptions struct {
|
||||
|
||||
// ServiceStatus contains information about installed services
|
||||
type ServiceStatus struct {
|
||||
Installed bool
|
||||
Enabled bool
|
||||
Active bool
|
||||
TimerEnabled bool
|
||||
TimerActive bool
|
||||
LastRun string
|
||||
NextRun string
|
||||
ServicePath string
|
||||
TimerPath string
|
||||
ExporterPath string
|
||||
Installed bool
|
||||
Enabled bool
|
||||
Active bool
|
||||
TimerEnabled bool
|
||||
TimerActive bool
|
||||
LastRun string
|
||||
NextRun string
|
||||
ServicePath string
|
||||
TimerPath string
|
||||
ExporterPath string
|
||||
}
|
||||
|
||||
// NewInstaller creates a new Installer
|
||||
@@ -188,7 +188,7 @@ func (i *Installer) Uninstall(ctx context.Context, instance string, purge bool)
|
||||
if instance != "cluster" && instance != "" {
|
||||
templateService := filepath.Join(i.unitDir, "dbbackup@.service")
|
||||
templateTimer := filepath.Join(i.unitDir, "dbbackup@.timer")
|
||||
|
||||
|
||||
// Only remove templates if no other instances are using them
|
||||
if i.canRemoveTemplates() {
|
||||
if !i.dryRun {
|
||||
@@ -644,11 +644,11 @@ func (i *Installer) canRemoveTemplates() bool {
|
||||
// Check if any dbbackup@*.service instances exist
|
||||
pattern := filepath.Join(i.unitDir, "dbbackup@*.service")
|
||||
matches, _ := filepath.Glob(pattern)
|
||||
|
||||
|
||||
// Also check for running instances
|
||||
cmd := exec.Command("systemctl", "list-units", "--type=service", "--all", "dbbackup@*")
|
||||
output, _ := cmd.Output()
|
||||
|
||||
|
||||
return len(matches) == 0 && !strings.Contains(string(output), "dbbackup@")
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,11 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
# Environment
|
||||
EnvironmentFile=-/etc/dbbackup/env.d/cluster.conf
|
||||
|
||||
# Working directory (config is loaded from .dbbackup.conf here)
|
||||
WorkingDirectory=/var/lib/dbbackup
|
||||
|
||||
# Execution - cluster backup (all databases)
|
||||
ExecStart={{.BinaryPath}} backup cluster --config {{.ConfigPath}}
|
||||
ExecStart={{.BinaryPath}} backup cluster --backup-dir {{.BackupDir}}
|
||||
TimeoutStartSec={{.TimeoutSeconds}}
|
||||
|
||||
# Post-backup metrics export
|
||||
|
||||
@@ -33,8 +33,11 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
# Environment
|
||||
EnvironmentFile=-/etc/dbbackup/env.d/%i.conf
|
||||
|
||||
# Working directory (config is loaded from .dbbackup.conf here)
|
||||
WorkingDirectory=/var/lib/dbbackup
|
||||
|
||||
# Execution
|
||||
ExecStart={{.BinaryPath}} backup {{.BackupType}} %i --config {{.ConfigPath}}
|
||||
ExecStart={{.BinaryPath}} backup {{.BackupType}} %i --backup-dir {{.BackupDir}}
|
||||
TimeoutStartSec={{.TimeoutSeconds}}
|
||||
|
||||
# Post-backup metrics export
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,38 +14,99 @@ import (
|
||||
"dbbackup/internal/logger"
|
||||
)
|
||||
|
||||
// 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
|
||||
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,
|
||||
config: cfg,
|
||||
logger: log,
|
||||
parent: parent,
|
||||
ctx: ctx,
|
||||
loading: true,
|
||||
opState: OpIdle,
|
||||
spinnerFrame: 0,
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
@@ -68,10 +130,24 @@ func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q", "esc":
|
||||
// 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--
|
||||
@@ -83,11 +159,13 @@ func (m BackupManagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case "v":
|
||||
// Verify archive
|
||||
// Verify archive with real verification
|
||||
if len(m.archives) > 0 && m.cursor < len(m.archives) {
|
||||
selected := m.archives[m.cursor]
|
||||
m.message = fmt.Sprintf("[SEARCH] Verifying %s...", selected.Name)
|
||||
// In real implementation, would run verification
|
||||
m.opState = OpVerifying
|
||||
m.opTarget = selected.Name
|
||||
m.message = ""
|
||||
return m, verifyArchiveCmd(selected)
|
||||
}
|
||||
|
||||
case "d":
|
||||
@@ -152,39 +230,67 @@ func (m BackupManagerModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
// Title
|
||||
s.WriteString(titleStyle.Render("[DB] Backup Archive Manager"))
|
||||
s.WriteString(TitleStyle.Render("[DB] 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 {
|
||||
s.WriteString(infoStyle.Render("Loading archives..."))
|
||||
return s.String()
|
||||
}
|
||||
|
||||
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(infoStyle.Render("Press Esc to go back"))
|
||||
s.WriteString(ShortcutStyle.Render("Press Esc to go back"))
|
||||
return s.String()
|
||||
}
|
||||
|
||||
// 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))))
|
||||
s.WriteString("\n\n")
|
||||
|
||||
// Archives list
|
||||
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(infoStyle.Render("Press Esc to go back"))
|
||||
s.WriteString(ShortcutStyle.Render("Press Esc to go back"))
|
||||
return s.String()
|
||||
}
|
||||
|
||||
// Column headers
|
||||
s.WriteString(archiveHeaderStyle.Render(fmt.Sprintf("%-35s %-25s %-12s %-20s",
|
||||
// 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("-", 95))
|
||||
s.WriteString(strings.Repeat("-", 90))
|
||||
s.WriteString("\n")
|
||||
|
||||
// Show archives (limit to visible area)
|
||||
@@ -199,27 +305,27 @@ func (m BackupManagerModel) View() string {
|
||||
|
||||
for i := start; i < end; i++ {
|
||||
archive := m.archives[i]
|
||||
cursor := " "
|
||||
style := archiveNormalStyle
|
||||
cursor := " "
|
||||
style := ListNormalStyle
|
||||
|
||||
if i == m.cursor {
|
||||
cursor = ">"
|
||||
style = archiveSelectedStyle
|
||||
cursor = "> "
|
||||
style = ListSelectedStyle
|
||||
}
|
||||
|
||||
// Status icon
|
||||
statusIcon := "[+]"
|
||||
// Status icon - consistent 4-char width
|
||||
statusIcon := " [+]"
|
||||
if !archive.Valid {
|
||||
statusIcon = "[-]"
|
||||
style = archiveInvalidStyle
|
||||
statusIcon = " [-]"
|
||||
style = ItemInvalidStyle
|
||||
} else if time.Since(archive.Modified) > 30*24*time.Hour {
|
||||
statusIcon = "[WARN]"
|
||||
statusIcon = " [!]"
|
||||
}
|
||||
|
||||
filename := truncate(archive.Name, 33)
|
||||
format := truncate(archive.Format.String(), 23)
|
||||
filename := truncate(archive.Name, 32)
|
||||
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,
|
||||
statusIcon,
|
||||
filename,
|
||||
@@ -233,18 +339,98 @@ func (m BackupManagerModel) View() string {
|
||||
|
||||
// Footer
|
||||
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("\n")
|
||||
s.WriteString(infoStyle.Render("[KEY] ↑/↓: Navigate | r: Restore | v: Verify | d: Delete | i: Info | R: Refresh | Esc: Back"))
|
||||
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 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)
|
||||
func deleteArchive(archivePath string) error {
|
||||
return os.Remove(archivePath)
|
||||
|
||||
@@ -204,124 +204,132 @@ func (m DiagnoseViewModel) View() string {
|
||||
func (m DiagnoseViewModel) renderSingleResult(result *restore.DiagnoseResult) string {
|
||||
var s strings.Builder
|
||||
|
||||
// Status
|
||||
s.WriteString(strings.Repeat("-", 60))
|
||||
s.WriteString("\n")
|
||||
// Status Box
|
||||
s.WriteString("+--[ VALIDATION STATUS ]" + strings.Repeat("-", 37) + "+\n")
|
||||
|
||||
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 {
|
||||
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 {
|
||||
s.WriteString(diagnoseFailStyle.Render("[WARN] TRUNCATED: File appears incomplete"))
|
||||
s.WriteString("\n")
|
||||
s.WriteString("| " + diagnoseFailStyle.Render("[!] TRUNCATED - File is incomplete") + strings.Repeat(" ", 22) + "|\n")
|
||||
}
|
||||
|
||||
if result.IsCorrupted {
|
||||
s.WriteString(diagnoseFailStyle.Render("[WARN] CORRUPTED: File structure is damaged"))
|
||||
s.WriteString("\n")
|
||||
s.WriteString("| " + diagnoseFailStyle.Render("[!] CORRUPTED - File structure damaged") + strings.Repeat(" ", 18) + "|\n")
|
||||
}
|
||||
|
||||
s.WriteString(strings.Repeat("-", 60))
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString("+" + strings.Repeat("-", 60) + "+\n\n")
|
||||
|
||||
// Details
|
||||
// Details Box
|
||||
if result.Details != nil {
|
||||
s.WriteString(diagnoseHeaderStyle.Render("[STATS] DETAILS:"))
|
||||
s.WriteString("\n")
|
||||
s.WriteString("+--[ DETAILS ]" + strings.Repeat("-", 46) + "+\n")
|
||||
|
||||
if result.Details.HasPGDMPSignature {
|
||||
s.WriteString(diagnosePassStyle.Render(" [+] "))
|
||||
s.WriteString("Has PGDMP signature (custom format)\n")
|
||||
s.WriteString("| " + diagnosePassStyle.Render("[+]") + " PostgreSQL custom format (PGDMP)" + strings.Repeat(" ", 20) + "|\n")
|
||||
}
|
||||
|
||||
if result.Details.HasSQLHeader {
|
||||
s.WriteString(diagnosePassStyle.Render(" [+] "))
|
||||
s.WriteString("Has PostgreSQL SQL header\n")
|
||||
s.WriteString("| " + diagnosePassStyle.Render("[+]") + " PostgreSQL SQL header found" + strings.Repeat(" ", 25) + "|\n")
|
||||
}
|
||||
|
||||
if result.Details.GzipValid {
|
||||
s.WriteString(diagnosePassStyle.Render(" [+] "))
|
||||
s.WriteString("Gzip compression valid\n")
|
||||
s.WriteString("| " + diagnosePassStyle.Render("[+]") + " Gzip compression valid" + strings.Repeat(" ", 30) + "|\n")
|
||||
}
|
||||
|
||||
if result.Details.PgRestoreListable {
|
||||
s.WriteString(diagnosePassStyle.Render(" [+] "))
|
||||
s.WriteString(fmt.Sprintf("pg_restore can list contents (%d tables)\n", result.Details.TableCount))
|
||||
tableInfo := fmt.Sprintf(" (%d tables)", 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 {
|
||||
s.WriteString(diagnoseInfoStyle.Render(" - "))
|
||||
s.WriteString(fmt.Sprintf("Contains %d COPY blocks\n", result.Details.CopyBlockCount))
|
||||
blockInfo := fmt.Sprintf("%d COPY blocks found", result.Details.CopyBlockCount)
|
||||
padding := 50 - len(blockInfo)
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
s.WriteString("| [-] " + blockInfo + strings.Repeat(" ", padding) + "|\n")
|
||||
}
|
||||
|
||||
if result.Details.UnterminatedCopy {
|
||||
s.WriteString(diagnoseFailStyle.Render(" [-] "))
|
||||
s.WriteString(fmt.Sprintf("Unterminated COPY block: %s (line %d)\n",
|
||||
result.Details.LastCopyTable, result.Details.LastCopyLineNumber))
|
||||
s.WriteString("| " + diagnoseFailStyle.Render("[-]") + " Unterminated COPY: " + truncate(result.Details.LastCopyTable, 30) + strings.Repeat(" ", 5) + "|\n")
|
||||
}
|
||||
|
||||
if result.Details.ProperlyTerminated {
|
||||
s.WriteString(diagnosePassStyle.Render(" [+] "))
|
||||
s.WriteString("All COPY blocks properly terminated\n")
|
||||
s.WriteString("| " + diagnosePassStyle.Render("[+]") + " All COPY blocks properly terminated" + strings.Repeat(" ", 17) + "|\n")
|
||||
}
|
||||
|
||||
if result.Details.ExpandedSize > 0 {
|
||||
s.WriteString(diagnoseInfoStyle.Render(" - "))
|
||||
s.WriteString(fmt.Sprintf("Expanded size: %s (ratio: %.1fx)\n",
|
||||
formatSize(result.Details.ExpandedSize), result.Details.CompressionRatio))
|
||||
sizeInfo := fmt.Sprintf("Expanded: %s (%.1fx)", formatSize(result.Details.ExpandedSize), result.Details.CompressionRatio)
|
||||
padding := 50 - len(sizeInfo)
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
s.WriteString("| [-] " + sizeInfo + strings.Repeat(" ", padding) + "|\n")
|
||||
}
|
||||
|
||||
s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
|
||||
}
|
||||
|
||||
// Errors
|
||||
// Errors Box
|
||||
if len(result.Errors) > 0 {
|
||||
s.WriteString("\n")
|
||||
s.WriteString(diagnoseFailStyle.Render("[FAIL] ERRORS:"))
|
||||
s.WriteString("\n")
|
||||
s.WriteString("\n+--[ ERRORS ]" + strings.Repeat("-", 47) + "+\n")
|
||||
for i, e := range result.Errors {
|
||||
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
|
||||
}
|
||||
s.WriteString(diagnoseFailStyle.Render(" - "))
|
||||
s.WriteString(truncate(e, 70))
|
||||
s.WriteString("\n")
|
||||
errText := truncate(e, 54)
|
||||
padding := 56 - len(errText)
|
||||
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 {
|
||||
s.WriteString("\n")
|
||||
s.WriteString(diagnoseWarnStyle.Render("[WARN] WARNINGS:"))
|
||||
s.WriteString("\n")
|
||||
s.WriteString("\n+--[ WARNINGS ]" + strings.Repeat("-", 45) + "+\n")
|
||||
for i, w := range result.Warnings {
|
||||
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
|
||||
}
|
||||
s.WriteString(diagnoseWarnStyle.Render(" - "))
|
||||
s.WriteString(truncate(w, 70))
|
||||
s.WriteString("\n")
|
||||
warnText := truncate(w, 54)
|
||||
padding := 56 - len(warnText)
|
||||
if padding < 0 {
|
||||
padding = 0
|
||||
}
|
||||
s.WriteString("| " + warnText + strings.Repeat(" ", padding) + "|\n")
|
||||
}
|
||||
s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
// Recommendations Box
|
||||
if !result.IsValid {
|
||||
s.WriteString("\n")
|
||||
s.WriteString(diagnoseHeaderStyle.Render("[HINT] RECOMMENDATIONS:"))
|
||||
s.WriteString("\n")
|
||||
s.WriteString("\n+--[ RECOMMENDATIONS ]" + strings.Repeat("-", 38) + "+\n")
|
||||
if result.IsTruncated {
|
||||
s.WriteString(" 1. Re-run the backup process for this database\n")
|
||||
s.WriteString(" 2. Check disk space on backup server\n")
|
||||
s.WriteString(" 3. Verify network stability for remote backups\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("| 3. Verify network stability for remote backups |\n")
|
||||
}
|
||||
if result.IsCorrupted {
|
||||
s.WriteString(" 1. Verify backup was transferred completely\n")
|
||||
s.WriteString(" 2. Try restoring from a previous backup\n")
|
||||
s.WriteString("| 1. Verify backup was transferred completely |\n")
|
||||
s.WriteString("| 2. Try restoring from a previous backup |\n")
|
||||
}
|
||||
s.WriteString("+" + strings.Repeat("-", 60) + "+\n")
|
||||
}
|
||||
|
||||
return s.String()
|
||||
|
||||
@@ -306,6 +306,12 @@ func (m RestorePreviewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Cluster-specific check: must enable cleanup if existing databases found
|
||||
if m.mode == "restore-cluster" && m.existingDBCount > 0 && !m.cleanClusterFirst {
|
||||
m.message = errorStyle.Render("[FAIL] Cannot proceed - press 'c' to enable cleanup of " + fmt.Sprintf("%d", m.existingDBCount) + " existing database(s) first")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Proceed to restore execution
|
||||
exec := NewRestoreExecution(m.config, m.logger, m.parent, m.ctx, m.archive, m.targetDB, m.cleanFirst, m.createIfMissing, m.mode, m.cleanClusterFirst, m.existingDBs, m.saveDebugLog, m.workDir)
|
||||
return exec, exec.Init()
|
||||
|
||||
@@ -146,11 +146,10 @@ func (m StatusViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if !m.loading {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q", "esc", "enter":
|
||||
return m.parent, nil
|
||||
}
|
||||
// Always allow escape, even during loading
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q", "esc", "enter":
|
||||
return m.parent, 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