Some checks failed
CI/CD / Test (push) Has been cancelled
CI/CD / Integration Tests (push) Has been cancelled
CI/CD / Native Engine Tests (push) Has been cancelled
CI/CD / Lint (push) Has been cancelled
CI/CD / Build Binary (push) Has been cancelled
CI/CD / Test Release Build (push) Has been cancelled
CI/CD / Release Binaries (push) Has been cancelled
### Fixed (5.7.3 - 5.7.7) - MariaDB binlog position bug (4 vs 5 columns) - Notify test command ENV variable reading - SMTP 250 Ok response treated as error - Verify command absolute path handling - DR Drill for modern MariaDB containers: - Use mariadb-admin/mariadb client - TCP instead of socket connections - DROP DATABASE before restore ### Improved - Better --password flag error message - PostgreSQL peer auth fallback logging - Binlog warnings at DEBUG level
355 lines
10 KiB
Go
355 lines
10 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"dbbackup/internal/progress"
|
|
)
|
|
|
|
// RichClusterProgressView renders detailed cluster restore progress
|
|
type RichClusterProgressView struct {
|
|
width int
|
|
height int
|
|
spinnerFrames []string
|
|
spinnerFrame int
|
|
}
|
|
|
|
// NewRichClusterProgressView creates a new rich progress view
|
|
func NewRichClusterProgressView() *RichClusterProgressView {
|
|
return &RichClusterProgressView{
|
|
width: 80,
|
|
height: 24,
|
|
spinnerFrames: []string{
|
|
"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏",
|
|
},
|
|
}
|
|
}
|
|
|
|
// SetSize updates the terminal size
|
|
func (v *RichClusterProgressView) SetSize(width, height int) {
|
|
v.width = width
|
|
v.height = height
|
|
}
|
|
|
|
// AdvanceSpinner moves to the next spinner frame
|
|
func (v *RichClusterProgressView) AdvanceSpinner() {
|
|
v.spinnerFrame = (v.spinnerFrame + 1) % len(v.spinnerFrames)
|
|
}
|
|
|
|
// RenderUnified renders progress from UnifiedClusterProgress
|
|
func (v *RichClusterProgressView) RenderUnified(p *progress.UnifiedClusterProgress) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
|
|
snapshot := p.GetSnapshot()
|
|
return v.RenderSnapshot(&snapshot)
|
|
}
|
|
|
|
// RenderSnapshot renders progress from a ProgressSnapshot
|
|
func (v *RichClusterProgressView) RenderSnapshot(snapshot *progress.ProgressSnapshot) string {
|
|
if snapshot == nil {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
b.Grow(2048)
|
|
|
|
// Header with overall progress
|
|
b.WriteString(v.renderHeader(snapshot))
|
|
b.WriteString("\n\n")
|
|
|
|
// Overall progress bar
|
|
b.WriteString(v.renderOverallProgress(snapshot))
|
|
b.WriteString("\n\n")
|
|
|
|
// Phase-specific details
|
|
b.WriteString(v.renderPhaseDetails(snapshot))
|
|
|
|
// Performance metrics
|
|
if v.height > 15 {
|
|
b.WriteString("\n")
|
|
b.WriteString(v.renderMetricsFromSnapshot(snapshot))
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (v *RichClusterProgressView) renderHeader(snapshot *progress.ProgressSnapshot) string {
|
|
elapsed := time.Since(snapshot.StartTime)
|
|
|
|
// Calculate ETA based on progress
|
|
overall := v.calculateOverallPercent(snapshot)
|
|
var etaStr string
|
|
if overall > 0 && overall < 100 {
|
|
eta := time.Duration(float64(elapsed) / float64(overall) * float64(100-overall))
|
|
etaStr = fmt.Sprintf("ETA: %s", formatDuration(eta))
|
|
} else if overall >= 100 {
|
|
etaStr = "Complete!"
|
|
} else {
|
|
etaStr = "ETA: calculating..."
|
|
}
|
|
|
|
title := "Cluster Restore Progress"
|
|
// Separator under title
|
|
separator := strings.Repeat("━", len(title))
|
|
|
|
return fmt.Sprintf("%s\n%s\n Elapsed: %s | %s",
|
|
title, separator,
|
|
formatDuration(elapsed), etaStr)
|
|
}
|
|
|
|
func (v *RichClusterProgressView) renderOverallProgress(snapshot *progress.ProgressSnapshot) string {
|
|
overall := v.calculateOverallPercent(snapshot)
|
|
|
|
// Phase indicator
|
|
phaseLabel := v.getPhaseLabel(snapshot)
|
|
|
|
// Progress bar
|
|
barWidth := v.width - 20
|
|
if barWidth < 20 {
|
|
barWidth = 20
|
|
}
|
|
bar := v.renderProgressBarWidth(overall, barWidth)
|
|
|
|
return fmt.Sprintf(" Overall: %s %3d%%\n Phase: %s", bar, overall, phaseLabel)
|
|
}
|
|
|
|
func (v *RichClusterProgressView) getPhaseLabel(snapshot *progress.ProgressSnapshot) string {
|
|
switch snapshot.Phase {
|
|
case progress.PhaseExtracting:
|
|
return fmt.Sprintf("📦 Extracting archive (%s / %s)",
|
|
FormatBytes(snapshot.ExtractBytes), FormatBytes(snapshot.ExtractTotal))
|
|
case progress.PhaseGlobals:
|
|
return "🔧 Restoring globals (roles, tablespaces)"
|
|
case progress.PhaseDatabases:
|
|
return fmt.Sprintf("🗄️ Databases (%d/%d) %s",
|
|
snapshot.DatabasesDone, snapshot.DatabasesTotal, snapshot.CurrentDB)
|
|
case progress.PhaseVerifying:
|
|
return fmt.Sprintf("✅ Verifying (%d/%d)", snapshot.VerifyDone, snapshot.VerifyTotal)
|
|
case progress.PhaseComplete:
|
|
return "🎉 Complete!"
|
|
case progress.PhaseFailed:
|
|
return "❌ Failed"
|
|
default:
|
|
return string(snapshot.Phase)
|
|
}
|
|
}
|
|
|
|
func (v *RichClusterProgressView) calculateOverallPercent(snapshot *progress.ProgressSnapshot) int {
|
|
// Use the same logic as UnifiedClusterProgress
|
|
phaseWeights := map[progress.Phase]int{
|
|
progress.PhaseExtracting: 20,
|
|
progress.PhaseGlobals: 5,
|
|
progress.PhaseDatabases: 70,
|
|
progress.PhaseVerifying: 5,
|
|
}
|
|
|
|
switch snapshot.Phase {
|
|
case progress.PhaseIdle:
|
|
return 0
|
|
case progress.PhaseExtracting:
|
|
if snapshot.ExtractTotal > 0 {
|
|
return int(float64(snapshot.ExtractBytes) / float64(snapshot.ExtractTotal) * float64(phaseWeights[progress.PhaseExtracting]))
|
|
}
|
|
return 0
|
|
case progress.PhaseGlobals:
|
|
return phaseWeights[progress.PhaseExtracting] + phaseWeights[progress.PhaseGlobals]
|
|
case progress.PhaseDatabases:
|
|
basePercent := phaseWeights[progress.PhaseExtracting] + phaseWeights[progress.PhaseGlobals]
|
|
if snapshot.DatabasesTotal == 0 {
|
|
return basePercent
|
|
}
|
|
dbProgress := float64(snapshot.DatabasesDone) / float64(snapshot.DatabasesTotal)
|
|
if snapshot.CurrentDBTotal > 0 {
|
|
currentProgress := float64(snapshot.CurrentDBBytes) / float64(snapshot.CurrentDBTotal)
|
|
dbProgress += currentProgress / float64(snapshot.DatabasesTotal)
|
|
}
|
|
return basePercent + int(dbProgress*float64(phaseWeights[progress.PhaseDatabases]))
|
|
case progress.PhaseVerifying:
|
|
basePercent := phaseWeights[progress.PhaseExtracting] + phaseWeights[progress.PhaseGlobals] + phaseWeights[progress.PhaseDatabases]
|
|
if snapshot.VerifyTotal > 0 {
|
|
verifyProgress := float64(snapshot.VerifyDone) / float64(snapshot.VerifyTotal)
|
|
return basePercent + int(verifyProgress*float64(phaseWeights[progress.PhaseVerifying]))
|
|
}
|
|
return basePercent
|
|
case progress.PhaseComplete:
|
|
return 100
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func (v *RichClusterProgressView) renderPhaseDetails(snapshot *progress.ProgressSnapshot) string {
|
|
var b strings.Builder
|
|
|
|
switch snapshot.Phase {
|
|
case progress.PhaseExtracting:
|
|
pct := 0
|
|
if snapshot.ExtractTotal > 0 {
|
|
pct = int(float64(snapshot.ExtractBytes) / float64(snapshot.ExtractTotal) * 100)
|
|
}
|
|
bar := v.renderMiniProgressBar(pct)
|
|
b.WriteString(fmt.Sprintf(" 📦 Extraction: %s %d%%\n", bar, pct))
|
|
b.WriteString(fmt.Sprintf(" %s / %s\n",
|
|
FormatBytes(snapshot.ExtractBytes), FormatBytes(snapshot.ExtractTotal)))
|
|
|
|
case progress.PhaseDatabases:
|
|
b.WriteString(" 📊 Databases:\n\n")
|
|
|
|
// Show completed databases if any
|
|
if snapshot.DatabasesDone > 0 {
|
|
avgTime := time.Duration(0)
|
|
if len(snapshot.DatabaseTimes) > 0 {
|
|
var total time.Duration
|
|
for _, t := range snapshot.DatabaseTimes {
|
|
total += t
|
|
}
|
|
avgTime = total / time.Duration(len(snapshot.DatabaseTimes))
|
|
}
|
|
b.WriteString(fmt.Sprintf(" ✓ %d completed (avg: %s)\n",
|
|
snapshot.DatabasesDone, formatDuration(avgTime)))
|
|
}
|
|
|
|
// Show current database
|
|
if snapshot.CurrentDB != "" {
|
|
spinner := v.spinnerFrames[v.spinnerFrame]
|
|
pct := 0
|
|
if snapshot.CurrentDBTotal > 0 {
|
|
pct = int(float64(snapshot.CurrentDBBytes) / float64(snapshot.CurrentDBTotal) * 100)
|
|
}
|
|
bar := v.renderMiniProgressBar(pct)
|
|
|
|
phaseElapsed := time.Since(snapshot.PhaseStartTime)
|
|
|
|
// Better display when we have progress info vs when we're waiting
|
|
if snapshot.CurrentDBTotal > 0 {
|
|
b.WriteString(fmt.Sprintf(" %s %-20s %s %3d%%\n",
|
|
spinner, truncateString(snapshot.CurrentDB, 20), bar, pct))
|
|
b.WriteString(fmt.Sprintf(" └─ %s / %s (running %s)\n",
|
|
FormatBytes(snapshot.CurrentDBBytes), FormatBytes(snapshot.CurrentDBTotal),
|
|
formatDuration(phaseElapsed)))
|
|
} else {
|
|
// No byte-level progress available - show activity indicator with elapsed time
|
|
b.WriteString(fmt.Sprintf(" %s %-20s [restoring...] running %s\n",
|
|
spinner, truncateString(snapshot.CurrentDB, 20),
|
|
formatDuration(phaseElapsed)))
|
|
if snapshot.UseNativeEngine {
|
|
b.WriteString(fmt.Sprintf(" └─ native Go engine in progress (pure Go, no external tools)\n"))
|
|
} else {
|
|
b.WriteString(fmt.Sprintf(" └─ pg_restore in progress (progress updates every 5s)\n"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show remaining count
|
|
remaining := snapshot.DatabasesTotal - snapshot.DatabasesDone
|
|
if snapshot.CurrentDB != "" {
|
|
remaining--
|
|
}
|
|
if remaining > 0 {
|
|
b.WriteString(fmt.Sprintf(" ⏳ %d remaining\n", remaining))
|
|
}
|
|
|
|
case progress.PhaseVerifying:
|
|
pct := 0
|
|
if snapshot.VerifyTotal > 0 {
|
|
pct = snapshot.VerifyDone * 100 / snapshot.VerifyTotal
|
|
}
|
|
bar := v.renderMiniProgressBar(pct)
|
|
b.WriteString(fmt.Sprintf(" ✅ Verification: %s %d%%\n", bar, pct))
|
|
b.WriteString(fmt.Sprintf(" %d / %d databases verified\n",
|
|
snapshot.VerifyDone, snapshot.VerifyTotal))
|
|
|
|
case progress.PhaseComplete:
|
|
elapsed := time.Since(snapshot.StartTime)
|
|
b.WriteString(fmt.Sprintf(" 🎉 Restore complete!\n"))
|
|
b.WriteString(fmt.Sprintf(" %d databases restored in %s\n",
|
|
snapshot.DatabasesDone, formatDuration(elapsed)))
|
|
|
|
case progress.PhaseFailed:
|
|
b.WriteString(" ❌ Restore failed:\n")
|
|
for _, err := range snapshot.Errors {
|
|
b.WriteString(fmt.Sprintf(" • %s\n", truncateString(err, v.width-10)))
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func (v *RichClusterProgressView) renderMetricsFromSnapshot(snapshot *progress.ProgressSnapshot) string {
|
|
var b strings.Builder
|
|
b.WriteString(" 📈 Performance:\n")
|
|
|
|
elapsed := time.Since(snapshot.StartTime)
|
|
if elapsed > 0 {
|
|
// Calculate throughput from extraction phase if we have data
|
|
if snapshot.ExtractBytes > 0 && elapsed.Seconds() > 0 {
|
|
throughput := float64(snapshot.ExtractBytes) / elapsed.Seconds()
|
|
b.WriteString(fmt.Sprintf(" Throughput: %s/s\n", FormatBytes(int64(throughput))))
|
|
}
|
|
|
|
// Database timing info
|
|
if len(snapshot.DatabaseTimes) > 0 {
|
|
var total time.Duration
|
|
for _, t := range snapshot.DatabaseTimes {
|
|
total += t
|
|
}
|
|
avg := total / time.Duration(len(snapshot.DatabaseTimes))
|
|
b.WriteString(fmt.Sprintf(" Avg DB time: %s\n", formatDuration(avg)))
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func (v *RichClusterProgressView) renderProgressBarWidth(pct, width int) string {
|
|
if width < 10 {
|
|
width = 10
|
|
}
|
|
filled := (pct * width) / 100
|
|
empty := width - filled
|
|
|
|
bar := strings.Repeat("█", filled) + strings.Repeat("░", empty)
|
|
return "[" + bar + "]"
|
|
}
|
|
|
|
func (v *RichClusterProgressView) renderMiniProgressBar(pct int) string {
|
|
width := 20
|
|
filled := (pct * width) / 100
|
|
empty := width - filled
|
|
return strings.Repeat("█", filled) + strings.Repeat("░", empty)
|
|
}
|
|
|
|
func truncateString(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
if maxLen < 4 {
|
|
return s[:maxLen]
|
|
}
|
|
return s[:maxLen-3] + "..."
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func formatNumShort(n int64) string {
|
|
if n >= 1e9 {
|
|
return fmt.Sprintf("%.1fB", float64(n)/1e9)
|
|
} else if n >= 1e6 {
|
|
return fmt.Sprintf("%.1fM", float64(n)/1e6)
|
|
} else if n >= 1e3 {
|
|
return fmt.Sprintf("%.1fK", float64(n)/1e3)
|
|
}
|
|
return fmt.Sprintf("%d", n)
|
|
}
|