351 lines
9.0 KiB
Go
351 lines
9.0 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 {
|
|
var content strings.Builder
|
|
|
|
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
|
|
}
|