- Add ProgressCallback and DatabaseProgressCallback to backup engine - Add SetProgressCallback() and SetDatabaseProgressCallback() methods - Wire database progress callback in backup_exec.go - Show progress bar (Database: [████░░░] X/Y) during cluster backups - Hide spinner when progress bar is displayed - Use shared progress state pattern for thread-safe TUI updates
402 lines
11 KiB
Go
402 lines
11 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"dbbackup/internal/backup"
|
|
"dbbackup/internal/config"
|
|
"dbbackup/internal/database"
|
|
"dbbackup/internal/logger"
|
|
)
|
|
|
|
// BackupExecutionModel handles backup execution with progress
|
|
type BackupExecutionModel struct {
|
|
config *config.Config
|
|
logger logger.Logger
|
|
parent tea.Model
|
|
ctx context.Context
|
|
cancel context.CancelFunc // Cancel function to stop the operation
|
|
backupType string
|
|
databaseName string
|
|
ratio int
|
|
status string
|
|
progress int
|
|
done bool
|
|
cancelling bool // True when user has requested cancellation
|
|
err error
|
|
result string
|
|
startTime time.Time
|
|
details []string
|
|
spinnerFrame int
|
|
|
|
// Database count progress (for cluster backup)
|
|
dbTotal int
|
|
dbDone int
|
|
dbName string // Current database being backed up
|
|
}
|
|
|
|
// sharedBackupProgressState holds progress state that can be safely accessed from callbacks
|
|
type sharedBackupProgressState struct {
|
|
mu sync.Mutex
|
|
dbTotal int
|
|
dbDone int
|
|
dbName string
|
|
hasUpdate bool
|
|
}
|
|
|
|
// Package-level shared progress state for backup operations
|
|
var (
|
|
currentBackupProgressMu sync.Mutex
|
|
currentBackupProgressState *sharedBackupProgressState
|
|
)
|
|
|
|
func setCurrentBackupProgress(state *sharedBackupProgressState) {
|
|
currentBackupProgressMu.Lock()
|
|
defer currentBackupProgressMu.Unlock()
|
|
currentBackupProgressState = state
|
|
}
|
|
|
|
func clearCurrentBackupProgress() {
|
|
currentBackupProgressMu.Lock()
|
|
defer currentBackupProgressMu.Unlock()
|
|
currentBackupProgressState = nil
|
|
}
|
|
|
|
func getCurrentBackupProgress() (dbTotal, dbDone int, dbName string, hasUpdate bool) {
|
|
currentBackupProgressMu.Lock()
|
|
defer currentBackupProgressMu.Unlock()
|
|
|
|
if currentBackupProgressState == nil {
|
|
return 0, 0, "", false
|
|
}
|
|
|
|
currentBackupProgressState.mu.Lock()
|
|
defer currentBackupProgressState.mu.Unlock()
|
|
|
|
hasUpdate = currentBackupProgressState.hasUpdate
|
|
currentBackupProgressState.hasUpdate = false
|
|
|
|
return currentBackupProgressState.dbTotal, currentBackupProgressState.dbDone,
|
|
currentBackupProgressState.dbName, hasUpdate
|
|
}
|
|
|
|
func NewBackupExecution(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, backupType, dbName string, ratio int) BackupExecutionModel {
|
|
// Create a cancellable context derived from parent
|
|
childCtx, cancel := context.WithCancel(ctx)
|
|
return BackupExecutionModel{
|
|
config: cfg,
|
|
logger: log,
|
|
parent: parent,
|
|
ctx: childCtx,
|
|
cancel: cancel,
|
|
backupType: backupType,
|
|
databaseName: dbName,
|
|
ratio: ratio,
|
|
status: "Initializing...",
|
|
startTime: time.Now(),
|
|
details: []string{},
|
|
spinnerFrame: 0,
|
|
}
|
|
}
|
|
|
|
func (m BackupExecutionModel) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
executeBackupWithTUIProgress(m.ctx, m.config, m.logger, m.backupType, m.databaseName, m.ratio),
|
|
backupTickCmd(),
|
|
)
|
|
}
|
|
|
|
type backupTickMsg time.Time
|
|
|
|
func backupTickCmd() tea.Cmd {
|
|
return tea.Tick(time.Millisecond*100, func(t time.Time) tea.Msg {
|
|
return backupTickMsg(t)
|
|
})
|
|
}
|
|
|
|
type backupProgressMsg struct {
|
|
status string
|
|
progress int
|
|
detail string
|
|
}
|
|
|
|
type backupCompleteMsg struct {
|
|
result string
|
|
err error
|
|
}
|
|
|
|
func executeBackupWithTUIProgress(parentCtx context.Context, cfg *config.Config, log logger.Logger, backupType, dbName string, ratio int) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// NO TIMEOUT for backup operations - a backup takes as long as it takes
|
|
// Large databases can take many hours
|
|
// Only manual cancellation (Ctrl+C) should stop the backup
|
|
ctx, cancel := context.WithCancel(parentCtx)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
|
|
// Setup shared progress state for TUI polling
|
|
progressState := &sharedBackupProgressState{}
|
|
setCurrentBackupProgress(progressState)
|
|
defer clearCurrentBackupProgress()
|
|
|
|
dbClient, err := database.New(cfg, log)
|
|
if err != nil {
|
|
return backupCompleteMsg{
|
|
result: "",
|
|
err: fmt.Errorf("failed to create database client: %w", err),
|
|
}
|
|
}
|
|
defer dbClient.Close()
|
|
|
|
if err := dbClient.Connect(ctx); err != nil {
|
|
return backupCompleteMsg{
|
|
result: "",
|
|
err: fmt.Errorf("database connection failed: %w", err),
|
|
}
|
|
}
|
|
|
|
// Pass nil as indicator - TUI itself handles all display, no stdout printing
|
|
engine := backup.NewSilent(cfg, log, dbClient, nil)
|
|
|
|
// Set database progress callback for cluster backups
|
|
engine.SetDatabaseProgressCallback(func(done, total int, currentDB string) {
|
|
progressState.mu.Lock()
|
|
progressState.dbDone = done
|
|
progressState.dbTotal = total
|
|
progressState.dbName = currentDB
|
|
progressState.hasUpdate = true
|
|
progressState.mu.Unlock()
|
|
})
|
|
|
|
var backupErr error
|
|
switch backupType {
|
|
case "single":
|
|
backupErr = engine.BackupSingle(ctx, dbName)
|
|
case "sample":
|
|
cfg.SampleStrategy = "ratio"
|
|
cfg.SampleValue = ratio
|
|
backupErr = engine.BackupSample(ctx, dbName)
|
|
case "cluster":
|
|
backupErr = engine.BackupCluster(ctx)
|
|
default:
|
|
return backupCompleteMsg{err: fmt.Errorf("unknown backup type: %s", backupType)}
|
|
}
|
|
|
|
if backupErr != nil {
|
|
return backupCompleteMsg{
|
|
result: "",
|
|
err: fmt.Errorf("backup failed: %w", backupErr),
|
|
}
|
|
}
|
|
|
|
elapsed := time.Since(start).Round(time.Second)
|
|
|
|
var result string
|
|
switch backupType {
|
|
case "single":
|
|
result = fmt.Sprintf("[+] Single database backup of '%s' completed successfully in %v", dbName, elapsed)
|
|
case "sample":
|
|
result = fmt.Sprintf("[+] Sample backup of '%s' (ratio: %d) completed successfully in %v", dbName, ratio, elapsed)
|
|
case "cluster":
|
|
result = fmt.Sprintf("[+] Cluster backup completed successfully in %v", elapsed)
|
|
}
|
|
|
|
return backupCompleteMsg{
|
|
result: result,
|
|
err: nil,
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case backupTickMsg:
|
|
if !m.done {
|
|
// Increment spinner frame for smooth animation
|
|
m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames)
|
|
|
|
// Poll for database progress updates from callbacks
|
|
dbTotal, dbDone, dbName, hasUpdate := getCurrentBackupProgress()
|
|
if hasUpdate {
|
|
m.dbTotal = dbTotal
|
|
m.dbDone = dbDone
|
|
m.dbName = dbName
|
|
}
|
|
|
|
// Update status based on progress and elapsed time
|
|
elapsedSec := int(time.Since(m.startTime).Seconds())
|
|
|
|
if m.dbTotal > 0 && m.dbDone > 0 {
|
|
// We have real progress from cluster backup
|
|
m.status = fmt.Sprintf("Backing up database: %s", m.dbName)
|
|
} else if elapsedSec < 2 {
|
|
m.status = "Initializing backup..."
|
|
} else if elapsedSec < 5 {
|
|
if m.backupType == "cluster" {
|
|
m.status = "Connecting to database cluster..."
|
|
} else {
|
|
m.status = fmt.Sprintf("Connecting to database '%s'...", m.databaseName)
|
|
}
|
|
} else if elapsedSec < 10 {
|
|
if m.backupType == "cluster" {
|
|
m.status = "Backing up global objects (roles, tablespaces)..."
|
|
} else if m.backupType == "sample" {
|
|
m.status = fmt.Sprintf("Analyzing tables for sampling (ratio: %d)...", m.ratio)
|
|
} else {
|
|
m.status = fmt.Sprintf("Dumping database '%s'...", m.databaseName)
|
|
}
|
|
} else {
|
|
if m.backupType == "cluster" {
|
|
m.status = "Backing up cluster databases..."
|
|
} else if m.backupType == "sample" {
|
|
m.status = fmt.Sprintf("Creating sample backup of '%s'...", m.databaseName)
|
|
} else {
|
|
m.status = fmt.Sprintf("Backing up database '%s'...", m.databaseName)
|
|
}
|
|
}
|
|
|
|
return m, backupTickCmd()
|
|
}
|
|
return m, nil
|
|
|
|
case backupProgressMsg:
|
|
m.status = msg.status
|
|
m.progress = msg.progress
|
|
return m, nil
|
|
|
|
case backupCompleteMsg:
|
|
m.done = true
|
|
m.err = msg.err
|
|
m.result = msg.result
|
|
if m.err == nil {
|
|
m.status = "[OK] Backup completed successfully!"
|
|
} else {
|
|
m.status = fmt.Sprintf("[FAIL] Backup failed: %v", m.err)
|
|
}
|
|
// Auto-forward in debug/auto-confirm mode
|
|
if m.config.TUIAutoConfirm {
|
|
return m.parent, tea.Quit
|
|
}
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "esc":
|
|
if !m.done && !m.cancelling {
|
|
// User requested cancellation - cancel the context
|
|
m.cancelling = true
|
|
m.status = "[STOP] Cancelling backup... (please wait)"
|
|
if m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
return m, nil
|
|
} else if m.done {
|
|
return m.parent, nil
|
|
}
|
|
case "enter", "q":
|
|
if m.done {
|
|
return m.parent, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// renderDatabaseProgressBar renders a progress bar for database count progress
|
|
func renderBackupDatabaseProgressBar(done, total int, dbName string, width int) string {
|
|
if total == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Calculate progress percentage
|
|
percent := float64(done) / float64(total)
|
|
if percent > 1.0 {
|
|
percent = 1.0
|
|
}
|
|
|
|
// Calculate filled width
|
|
barWidth := width - 20 // Leave room for label and percentage
|
|
if barWidth < 10 {
|
|
barWidth = 10
|
|
}
|
|
filled := int(float64(barWidth) * percent)
|
|
if filled > barWidth {
|
|
filled = barWidth
|
|
}
|
|
|
|
// Build progress bar
|
|
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
|
|
|
return fmt.Sprintf(" Database: [%s] %d/%d", bar, done, total)
|
|
}
|
|
|
|
func (m BackupExecutionModel) View() string {
|
|
var s strings.Builder
|
|
s.Grow(512) // Pre-allocate estimated capacity for better performance
|
|
|
|
// Clear screen with newlines and render header
|
|
s.WriteString("\n\n")
|
|
header := titleStyle.Render("[EXEC] Backup Execution")
|
|
s.WriteString(header)
|
|
s.WriteString("\n\n")
|
|
|
|
// Backup details - properly aligned
|
|
s.WriteString(fmt.Sprintf(" %-10s %s\n", "Type:", m.backupType))
|
|
if m.databaseName != "" {
|
|
s.WriteString(fmt.Sprintf(" %-10s %s\n", "Database:", m.databaseName))
|
|
}
|
|
if m.ratio > 0 {
|
|
s.WriteString(fmt.Sprintf(" %-10s %d\n", "Sample:", m.ratio))
|
|
}
|
|
s.WriteString(fmt.Sprintf(" %-10s %s\n", "Duration:", time.Since(m.startTime).Round(time.Second)))
|
|
s.WriteString("\n")
|
|
|
|
// Status display
|
|
if !m.done {
|
|
// Show database progress bar if we have progress data (cluster backup)
|
|
if m.dbTotal > 0 && m.dbDone > 0 {
|
|
// Show progress bar instead of spinner when we have real progress
|
|
progressBar := renderBackupDatabaseProgressBar(m.dbDone, m.dbTotal, m.dbName, 50)
|
|
s.WriteString(progressBar + "\n")
|
|
s.WriteString(fmt.Sprintf(" %s\n", m.status))
|
|
} else {
|
|
// Show spinner during initial phases
|
|
if m.cancelling {
|
|
s.WriteString(fmt.Sprintf(" %s %s\n", spinnerFrames[m.spinnerFrame], m.status))
|
|
} else {
|
|
s.WriteString(fmt.Sprintf(" %s %s\n", spinnerFrames[m.spinnerFrame], m.status))
|
|
}
|
|
}
|
|
|
|
if !m.cancelling {
|
|
s.WriteString("\n [KEY] Press Ctrl+C or ESC to cancel\n")
|
|
}
|
|
} else {
|
|
s.WriteString(fmt.Sprintf(" %s\n\n", m.status))
|
|
|
|
if m.err != nil {
|
|
s.WriteString(fmt.Sprintf(" [FAIL] Error: %v\n", m.err))
|
|
} else if m.result != "" {
|
|
// Parse and display result cleanly
|
|
lines := strings.Split(m.result, "\n")
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
s.WriteString(" " + line + "\n")
|
|
}
|
|
}
|
|
}
|
|
s.WriteString("\n [KEY] Press Enter or ESC to return to menu\n")
|
|
}
|
|
|
|
return s.String()
|
|
}
|