A Go-based CLI tool for recovering servers from backups to new cloud VMs. Features: - Multi-cloud support: Exoscale, Cloudscale, Hetzner Cloud - Backup sources: Local filesystem, Hetzner Storage Box - 6-stage restore pipeline with /etc whitelist protection - DNS migration with safety checks and auto-rollback - Dry-run by default, requires --yes to execute - Cloud-init for SSH key injection Packages: - cmd/recover-server: CLI commands (recover, migrate-dns, list, cleanup) - internal/providers: Cloud provider implementations - internal/backup: Backup source implementations - internal/restore: 6-stage restore pipeline - internal/dns: Exoscale DNS management - internal/ui: Prompts, progress, dry-run display - internal/config: Environment and host configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
112 lines
2.3 KiB
Go
112 lines
2.3 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Progress tracks and displays progress
|
|
type Progress struct {
|
|
Total int
|
|
Current int
|
|
StartTime time.Time
|
|
Message string
|
|
}
|
|
|
|
// NewProgress creates a new progress tracker
|
|
func NewProgress(total int, message string) *Progress {
|
|
return &Progress{
|
|
Total: total,
|
|
Current: 0,
|
|
StartTime: time.Now(),
|
|
Message: message,
|
|
}
|
|
}
|
|
|
|
// Increment advances progress by one
|
|
func (p *Progress) Increment() {
|
|
p.Current++
|
|
p.Print()
|
|
}
|
|
|
|
// SetMessage updates the current message
|
|
func (p *Progress) SetMessage(msg string) {
|
|
p.Message = msg
|
|
}
|
|
|
|
// Print displays current progress
|
|
func (p *Progress) Print() {
|
|
elapsed := time.Since(p.StartTime)
|
|
percent := float64(p.Current) / float64(p.Total) * 100
|
|
|
|
bar := p.bar(20)
|
|
|
|
fmt.Printf("\r[%s] %.1f%% (%d/%d) %s - %s ",
|
|
bar, percent, p.Current, p.Total, elapsed.Round(time.Second), p.Message)
|
|
}
|
|
|
|
// Complete marks progress as done
|
|
func (p *Progress) Complete() {
|
|
p.Current = p.Total
|
|
elapsed := time.Since(p.StartTime)
|
|
fmt.Printf("\r[%s] 100%% (%d/%d) %s - Complete\n",
|
|
p.bar(20), p.Total, p.Total, elapsed.Round(time.Second))
|
|
}
|
|
|
|
func (p *Progress) bar(width int) string {
|
|
filled := int(float64(p.Current) / float64(p.Total) * float64(width))
|
|
empty := width - filled
|
|
|
|
return strings.Repeat("█", filled) + strings.Repeat("░", empty)
|
|
}
|
|
|
|
// Spinner displays a spinning indicator
|
|
type Spinner struct {
|
|
Message string
|
|
stop chan bool
|
|
done chan bool
|
|
}
|
|
|
|
// NewSpinner creates a new spinner
|
|
func NewSpinner(message string) *Spinner {
|
|
return &Spinner{
|
|
Message: message,
|
|
stop: make(chan bool),
|
|
done: make(chan bool),
|
|
}
|
|
}
|
|
|
|
// Start begins the spinner animation
|
|
func (s *Spinner) Start() {
|
|
go func() {
|
|
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
|
i := 0
|
|
for {
|
|
select {
|
|
case <-s.stop:
|
|
s.done <- true
|
|
return
|
|
default:
|
|
fmt.Printf("\r%s %s", frames[i%len(frames)], s.Message)
|
|
i++
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Stop stops the spinner
|
|
func (s *Spinner) Stop() {
|
|
s.stop <- true
|
|
<-s.done
|
|
fmt.Printf("\r%s\n", strings.Repeat(" ", len(s.Message)+5))
|
|
}
|
|
|
|
// StopWithMessage stops spinner with a final message
|
|
func (s *Spinner) StopWithMessage(msg string) {
|
|
s.stop <- true
|
|
<-s.done
|
|
fmt.Printf("\r%s\n", msg)
|
|
}
|