Files
wg-admin/internal/tui/screens/restore.go
2026-01-12 23:18:57 +01:00

357 lines
9.2 KiB
Go

package screens
import (
"fmt"
"strings"
"github.com/calmcacil/wg-admin/internal/backup"
"github.com/calmcacil/wg-admin/internal/tui/components"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// RestoreScreen displays a list of available backups for restoration
type RestoreScreen struct {
table table.Model
backups []backup.Backup
selectedBackup *backup.Backup
confirmModal *components.ConfirmModel
showConfirm bool
isRestoring bool
restoreError error
restoreSuccess bool
message string
spinner spinner.Model
}
// Styles
var (
restoreTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true)
restoreHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("63")).
MarginTop(1)
restoreSuccessStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("46")).
Bold(true)
restoreErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Bold(true)
restoreInfoStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
MarginTop(1)
restoreLoadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("62")).
Bold(true)
)
// NewRestoreScreen creates a new restore screen
func NewRestoreScreen() *RestoreScreen {
// Create spinner for loading states
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
return &RestoreScreen{
showConfirm: false,
spinner: s,
}
}
// Init initializes the restore screen
func (s *RestoreScreen) Init() tea.Cmd {
return s.loadBackups
}
// Update handles messages for the restore screen
func (s *RestoreScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
var cmd tea.Cmd
// If restoring, only update spinner
if s.isRestoring && !s.showConfirm {
s.spinner, cmd = s.spinner.Update(msg)
return s, cmd
}
// Handle confirmation modal
if s.showConfirm && s.confirmModal != nil {
_, cmd = s.confirmModal.Update(msg)
// Handle confirmation result
if !s.confirmModal.Visible {
if s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
// User confirmed restore
s.isRestoring = true
s.showConfirm = false
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
}
// User cancelled - close modal
s.showConfirm = false
return s, nil
}
// Handle Enter key to confirm
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "enter" && s.confirmModal.IsConfirmed() && s.selectedBackup != nil {
s.isRestoring = true
s.showConfirm = false
return s, tea.Sequence(s.spinner.Tick, s.performRestore())
}
}
return s, cmd
}
// Handle normal screen messages
switch msg := msg.(type) {
case backupsLoadedMsg:
s.backups = msg.backups
s.buildTable()
case tea.KeyMsg:
switch msg.String() {
case "q", "esc":
// Return to list screen - signal parent to switch screens
return nil, nil
case "enter":
// Show confirmation for selected backup
if len(s.table.Rows()) > 0 {
selected := s.table.SelectedRow()
if len(selected) > 0 {
// Find the backup by name
for _, b := range s.backups {
if b.Name == selected[0] {
s.selectedBackup = &b
s.confirmModal = components.NewConfirm(
fmt.Sprintf(
"Are you sure you want to restore from backup '%s'?\n\nOperation: %s\nDate: %s\n\nThis will replace current WireGuard configuration.\nA safety backup will be created first.",
b.Name,
b.Operation,
b.Timestamp.Format("2006-01-02 15:04:05"),
),
80,
24,
)
s.showConfirm = true
break
}
}
}
}
}
case RestoreCompletedMsg:
s.isRestoring = false
if msg.Err != nil {
s.restoreError = msg.Err
s.message = fmt.Sprintf("Restore failed: %v", msg.Err)
} else {
s.restoreSuccess = true
s.message = fmt.Sprintf("Restore successful! Safety backup created at: %s", msg.SafetyBackupPath)
}
}
if !s.showConfirm && s.confirmModal != nil {
s.table, cmd = s.table.Update(msg)
}
return s, cmd
}
// View renders the restore screen
func (s *RestoreScreen) View() string {
if s.showConfirm && s.confirmModal != nil {
// Render underlying content dimmed
content := s.renderContent()
dimmedContent := lipgloss.NewStyle().
Foreground(lipgloss.Color("244")).
Render(content)
// Overlay confirmation modal
return lipgloss.JoinVertical(
lipgloss.Left,
dimmedContent,
s.confirmModal.View(),
)
}
return s.renderContent()
}
// renderContent renders the main restore screen content
func (s *RestoreScreen) renderContent() string {
// Breadcrumb: Clients > Restore
breadcrumb := components.RenderBreadcrumb([]components.BreadcrumbItem{{Label: "Clients", ID: "list"}, {Label: "Restore", ID: "restore"}})
var content strings.Builder
content.WriteString(breadcrumb)
content.WriteString("\n")
content.WriteString(restoreTitleStyle.Render("Restore WireGuard Configuration"))
content.WriteString("\n\n")
if len(s.backups) == 0 && !s.isRestoring && s.message == "" {
content.WriteString("No backups found. Press 'q' to return.")
return content.String()
}
if s.isRestoring {
content.WriteString(restoreLoadingStyle.Render(s.spinner.View() + " Restoring from backup, please wait..."))
return content.String()
}
if s.restoreSuccess {
content.WriteString(restoreSuccessStyle.Render("✓ " + s.message))
content.WriteString("\n\n")
content.WriteString(restoreInfoStyle.Render("Press 'q' to return to client list."))
return content.String()
}
if s.restoreError != nil {
content.WriteString(restoreErrorStyle.Render("✗ " + s.message))
content.WriteString("\n\n")
content.WriteString(s.table.View())
content.WriteString("\n\n")
content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
return content.String()
}
// Show backup list
content.WriteString(s.table.View())
content.WriteString("\n\n")
// Show selected backup details
if len(s.table.Rows()) > 0 && s.selectedBackup != nil {
content.WriteString(restoreInfoStyle.Render(
fmt.Sprintf(
"Selected: %s (%s) - %s\nSize: %s",
s.selectedBackup.Operation,
s.selectedBackup.Timestamp.Format("2006-01-02 15:04:05"),
s.selectedBackup.Name,
formatBytes(s.selectedBackup.Size),
),
))
content.WriteString("\n")
}
content.WriteString(restoreHelpStyle.Render("Actions: [Enter] Restore Selected • [↑/↓] Navigate • [q] Back"))
return content.String()
}
// loadBackups loads the list of available backups
func (s *RestoreScreen) loadBackups() tea.Msg {
backups, err := backup.ListBackups()
if err != nil {
return ErrMsg{Err: err}
}
return backupsLoadedMsg{backups: backups}
}
// buildTable creates and configures the backup list table
func (s *RestoreScreen) buildTable() {
columns := []table.Column{
{Title: "Name", Width: 40},
{Title: "Operation", Width: 15},
{Title: "Date", Width: 20},
{Title: "Size", Width: 12},
}
var rows []table.Row
for _, b := range s.backups {
row := table.Row{
b.Name,
b.Operation,
b.Timestamp.Format("2006-01-02 15:04"),
formatBytes(b.Size),
}
rows = append(rows, row)
}
s.table = table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(len(rows)+2), // Header + rows
)
// Apply styles
s.setTableStyles()
}
// setTableStyles applies styling to the table
func (s *RestoreScreen) setTableStyles() {
styles := table.DefaultStyles()
styles.Header = styles.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(true)
styles.Selected = styles.Selected.
Foreground(lipgloss.Color("229")).
Background(lipgloss.Color("57")).
Bold(false)
s.table.SetStyles(styles)
}
// performRestore performs the restore operation
func (s *RestoreScreen) performRestore() tea.Cmd {
return func() tea.Msg {
if s.selectedBackup == nil {
return restoreCompletedMsg{
err: fmt.Errorf("no backup selected"),
}
}
// Get safety backup path from backup.BackupConfig
safetyBackupPath, err := backup.BackupConfig(fmt.Sprintf("pre-restore-from-%s", s.selectedBackup.Name))
if err != nil {
return restoreCompletedMsg{
err: fmt.Errorf("failed to create safety backup: %w", err),
}
}
// Perform restore
if err := backup.RestoreBackup(s.selectedBackup.Name); err != nil {
return restoreCompletedMsg{
err: err,
safetyBackupPath: safetyBackupPath,
}
}
// Restore succeeded - trigger client list refresh
return restoreCompletedMsg{
safetyBackupPath: safetyBackupPath,
}
}
}
// formatBytes formats a byte count into human-readable format
func formatBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
}
// Messages
// backupsLoadedMsg is sent when backups are loaded
type backupsLoadedMsg struct {
backups []backup.Backup
}
// restoreCompletedMsg is sent when a restore operation completes
type restoreCompletedMsg struct {
err error
safetyBackupPath string
}