mirror of
https://github.com/acedanger/shell.git
synced 2025-12-06 01:10:12 -08:00
- Implement a comprehensive test script to validate the enhanced functionality of the Backup TUI. - Include tests for compilation, startup, feature validation, backup script integration, dependency checks, performance, and documentation. - Utilize color-coded output for better readability of test results.
1302 lines
38 KiB
Go
1302 lines
38 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/list"
|
|
"github.com/charmbracelet/bubbles/spinner"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
)
|
|
|
|
// Message types
|
|
type (
|
|
backupStartMsg struct{ service string }
|
|
backupDoneMsg struct{ service, output string }
|
|
backupErrorMsg struct{ service, error string }
|
|
backupProgressMsg struct{ service string; progress int; output string }
|
|
backupStopMsg struct{ service string }
|
|
backupOutputMsg struct{ service, line string }
|
|
logsUpdatedMsg struct{}
|
|
statusUpdatedMsg struct{}
|
|
clearOutputMsg struct{}
|
|
configUpdateMsg struct{}
|
|
)
|
|
|
|
// BackupStatus represents the status of a backup operation
|
|
type BackupStatus struct {
|
|
Service string `json:"service"`
|
|
Status string `json:"status"` // running, success, error, idle
|
|
LastRun time.Time `json:"last_run"`
|
|
Duration string `json:"duration"`
|
|
Output string `json:"output"`
|
|
Error string `json:"error"`
|
|
Progress int `json:"progress"` // 0-100 percentage
|
|
StartTime time.Time `json:"start_time"`
|
|
EndTime time.Time `json:"end_time"`
|
|
BackupSize string `json:"backup_size"`
|
|
FilesCount int `json:"files_count"`
|
|
PID int `json:"pid"` // Process ID if running
|
|
Context context.Context `json:"-"` // Context for cancellation
|
|
Cancel context.CancelFunc `json:"-"` // Cancel function
|
|
}
|
|
|
|
// RunningProcess represents a running backup process
|
|
type RunningProcess struct {
|
|
Cmd *exec.Cmd
|
|
Context context.Context
|
|
Cancel context.CancelFunc
|
|
Output []string
|
|
Mutex sync.RWMutex
|
|
}
|
|
|
|
// LogEntry represents a log entry from backup operations
|
|
type LogEntry struct {
|
|
Timestamp time.Time `json:"timestamp"`
|
|
Service string `json:"service"`
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// BackupItem represents a backup service in the list
|
|
type BackupItem struct {
|
|
title string
|
|
description string
|
|
service string
|
|
script string
|
|
args []string
|
|
}
|
|
|
|
func (i BackupItem) FilterValue() string { return i.title }
|
|
func (i BackupItem) Title() string { return i.title }
|
|
func (i BackupItem) Description() string { return i.description }
|
|
|
|
// Styles
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#FAFAFA")).
|
|
Background(lipgloss.Color("#7D56F4")).
|
|
Padding(0, 1)
|
|
|
|
statusBarStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.AdaptiveColor{Light: "#343433", Dark: "#C1C6B2"}).
|
|
Background(lipgloss.AdaptiveColor{Light: "#D9DCCF", Dark: "#353533"})
|
|
|
|
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#04B575"))
|
|
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444"))
|
|
runningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B"))
|
|
idleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
|
|
|
|
// Style for the selected item in the list
|
|
selectedItemStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("170")).
|
|
Background(lipgloss.Color("57"))
|
|
|
|
docStyle = lipgloss.NewStyle().Margin(1, 2)
|
|
)
|
|
|
|
// Key bindings
|
|
type keyMap struct {
|
|
Up key.Binding
|
|
Down key.Binding
|
|
Left key.Binding
|
|
Right key.Binding
|
|
Help key.Binding
|
|
Quit key.Binding
|
|
Enter key.Binding
|
|
Tab key.Binding
|
|
Escape key.Binding
|
|
Refresh key.Binding
|
|
ViewLogs key.Binding
|
|
ViewStatus key.Binding
|
|
ViewConfig key.Binding
|
|
Stop key.Binding
|
|
Clear key.Binding
|
|
}
|
|
|
|
func (k keyMap) ShortHelp() []key.Binding {
|
|
return []key.Binding{k.Help, k.Quit}
|
|
}
|
|
|
|
func (k keyMap) FullHelp() [][]key.Binding {
|
|
return [][]key.Binding{
|
|
{k.Up, k.Down, k.Enter},
|
|
{k.Tab, k.ViewLogs, k.ViewStatus, k.ViewConfig},
|
|
{k.Refresh, k.Stop, k.Clear},
|
|
{k.Help, k.Quit},
|
|
}
|
|
}
|
|
|
|
var keys = keyMap{
|
|
Up: key.NewBinding(
|
|
key.WithKeys("up", "k"),
|
|
key.WithHelp("↑/k", "move up"),
|
|
),
|
|
Down: key.NewBinding(
|
|
key.WithKeys("down", "j"),
|
|
key.WithHelp("↓/j", "move down"),
|
|
),
|
|
Left: key.NewBinding(
|
|
key.WithKeys("left", "h"),
|
|
key.WithHelp("←/h", "move left"),
|
|
),
|
|
Right: key.NewBinding(
|
|
key.WithKeys("right", "l"),
|
|
key.WithHelp("→/l", "move right"),
|
|
),
|
|
Help: key.NewBinding(
|
|
key.WithKeys("?"),
|
|
key.WithHelp("?", "toggle help"),
|
|
),
|
|
Quit: key.NewBinding(
|
|
key.WithKeys("q", "esc", "ctrl+c"),
|
|
key.WithHelp("q", "quit"),
|
|
),
|
|
Enter: key.NewBinding(
|
|
key.WithKeys("enter", " "),
|
|
key.WithHelp("enter", "run backup"),
|
|
),
|
|
Tab: key.NewBinding(
|
|
key.WithKeys("tab"),
|
|
key.WithHelp("tab", "switch panel"),
|
|
),
|
|
Escape: key.NewBinding(
|
|
key.WithKeys("esc"),
|
|
key.WithHelp("esc", "back"),
|
|
),
|
|
Refresh: key.NewBinding(
|
|
key.WithKeys("r"),
|
|
key.WithHelp("r", "refresh"),
|
|
),
|
|
ViewLogs: key.NewBinding(
|
|
key.WithKeys("v"),
|
|
key.WithHelp("v", "view logs"),
|
|
),
|
|
ViewStatus: key.NewBinding(
|
|
key.WithKeys("s"),
|
|
key.WithHelp("s", "view status"),
|
|
),
|
|
ViewConfig: key.NewBinding(
|
|
key.WithKeys("f"),
|
|
key.WithHelp("f", "view config"),
|
|
),
|
|
Stop: key.NewBinding(
|
|
key.WithKeys("x"),
|
|
key.WithHelp("x", "stop backup"),
|
|
),
|
|
Clear: key.NewBinding(
|
|
key.WithKeys("c"),
|
|
key.WithHelp("c", "clear output"),
|
|
),
|
|
}
|
|
|
|
// Model represents the application state
|
|
type Model struct {
|
|
list list.Model
|
|
viewport viewport.Model
|
|
spinner spinner.Model
|
|
keys keyMap
|
|
width int
|
|
height int
|
|
activePanel int // 0: list, 1: viewport, 2: status
|
|
showHelp bool
|
|
ready bool
|
|
backupStatus map[string]*BackupStatus
|
|
logs []LogEntry
|
|
shellPath string
|
|
items []BackupItem // Store backup items for reference
|
|
runningBackups map[string]*RunningProcess // Track running processes with enhanced control
|
|
currentView string // "main", "logs", "status"
|
|
}
|
|
|
|
// Initialize the model
|
|
func initialModel() Model {
|
|
// Get the shell path (parent directory of tui)
|
|
shellPath, _ := filepath.Abs(filepath.Dir(os.Args[0]) + "/..")
|
|
|
|
// Create backup items
|
|
items := []list.Item{
|
|
BackupItem{
|
|
title: "📦 Plex Backup",
|
|
description: "Complete Plex Media Server backup with integrity checking",
|
|
service: "plex",
|
|
script: filepath.Join(shellPath, "plex", "backup-plex.sh"),
|
|
args: []string{"--non-interactive"},
|
|
},
|
|
BackupItem{
|
|
title: "🖼️ Immich Backup",
|
|
description: "Database and uploads backup with B2 sync",
|
|
service: "immich",
|
|
script: filepath.Join(shellPath, "immich", "backup-immich.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "🎬 Media Services Backup",
|
|
description: "Sonarr, Radarr, Prowlarr, and other media services",
|
|
service: "media",
|
|
script: filepath.Join(shellPath, "backup-media.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "✅ Validate Plex Backups",
|
|
description: "Check integrity and completeness of Plex backups",
|
|
service: "plex-validate",
|
|
script: filepath.Join(shellPath, "plex", "validate-plex-backups.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "✅ Validate Immich Backups",
|
|
description: "Check integrity and completeness of Immich backups",
|
|
service: "immich-validate",
|
|
script: filepath.Join(shellPath, "immich", "validate-immich-backups.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "📊 Monitor Plex Status",
|
|
description: "Real-time Plex backup monitoring and status",
|
|
service: "plex-monitor",
|
|
script: filepath.Join(shellPath, "plex", "monitor-plex-backup.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "🔧 Environment Files Backup",
|
|
description: "Backup Docker environment and configuration files",
|
|
service: "env",
|
|
script: filepath.Join(shellPath, "backup-env-files.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "🐳 Docker Configuration Backup",
|
|
description: "Backup Docker containers and compose files",
|
|
service: "docker",
|
|
script: filepath.Join(shellPath, "backup-docker.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "✅ Validate Environment Backups",
|
|
description: "Check integrity of environment and Docker backups",
|
|
service: "env-validate",
|
|
script: filepath.Join(shellPath, "validate-env-backups.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "🔄 Restore Plex",
|
|
description: "Restore Plex from backup with validation",
|
|
service: "plex-restore",
|
|
script: filepath.Join(shellPath, "plex", "restore-plex.sh"),
|
|
args: []string{"--dry-run"},
|
|
},
|
|
BackupItem{
|
|
title: "🔄 Restore Immich",
|
|
description: "Restore Immich from backup",
|
|
service: "immich-restore",
|
|
script: filepath.Join(shellPath, "immich", "restore-immich.sh"),
|
|
args: []string{},
|
|
},
|
|
}
|
|
|
|
const defaultWidth = 20
|
|
|
|
l := list.New(items, BackupItemDelegate{}, defaultWidth, 14)
|
|
l.Title = "Backup Manager"
|
|
l.SetShowStatusBar(false)
|
|
l.SetFilteringEnabled(false)
|
|
l.Styles.Title = titleStyle
|
|
l.Styles.PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4)
|
|
l.Styles.HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1)
|
|
|
|
s := spinner.New()
|
|
s.Spinner = spinner.Dot
|
|
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
|
|
|
|
vp := viewport.New(78, 20)
|
|
vp.SetContent("Welcome to Backup Manager!\n\nSelect a backup operation from the list and press Enter to execute.\n\nUse 'v' to view logs, 'r' to refresh status, and 'tab' to switch between panels.")
|
|
|
|
return Model{
|
|
list: l,
|
|
viewport: vp,
|
|
spinner: s,
|
|
keys: keys,
|
|
activePanel: 0,
|
|
showHelp: false,
|
|
ready: false,
|
|
backupStatus: make(map[string]*BackupStatus),
|
|
logs: []LogEntry{},
|
|
shellPath: shellPath,
|
|
items: convertToBackupItems(items), // Store the items
|
|
runningBackups: make(map[string]*RunningProcess),
|
|
currentView: "main",
|
|
}
|
|
}
|
|
|
|
// BackupItemDelegate for custom rendering
|
|
type BackupItemDelegate struct{}
|
|
|
|
func (d BackupItemDelegate) Height() int { return 1 }
|
|
func (d BackupItemDelegate) Spacing() int { return 0 }
|
|
func (d BackupItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil }
|
|
func (d BackupItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
|
|
i, ok := listItem.(BackupItem)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
itemTitle := fmt.Sprintf("%d. %s", index+1, i.title)
|
|
|
|
if index == m.Index() {
|
|
fmt.Fprint(w, selectedItemStyle.Render("> "+itemTitle))
|
|
} else {
|
|
// Add leading spaces to align with the selected item's "> " prefix
|
|
fmt.Fprint(w, idleStyle.Render(" "+itemTitle))
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Helper function to convert list items to BackupItems
|
|
func convertToBackupItems(items []list.Item) []BackupItem {
|
|
backupItems := make([]BackupItem, len(items))
|
|
for i, item := range items {
|
|
if bi, ok := item.(BackupItem); ok {
|
|
backupItems[i] = bi
|
|
}
|
|
}
|
|
return backupItems
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
m.spinner.Tick,
|
|
m.loadBackupStatus(),
|
|
m.loadLogs(),
|
|
)
|
|
}
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmd tea.Cmd
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
|
|
if !m.ready {
|
|
// Since this program can be run in a variety of terminals and
|
|
// we want to ensure the viewport takes up the right amount of space,
|
|
// we calculate the viewport height based on the terminal height.
|
|
m.viewport.Width = msg.Width - 4
|
|
m.viewport.Height = msg.Height - 10
|
|
m.ready = true
|
|
|
|
// This is only necessary for high performance rendering, which in
|
|
// most cases you won't need.
|
|
m.viewport.SetContent("Welcome to Backup Manager!\n\nSelect a backup operation from the list and press Enter to execute.\n\nUse 'v' to view logs, 'r' to refresh status, and 'tab' to switch between panels.")
|
|
} else {
|
|
m.viewport.Width = msg.Width - 4
|
|
m.viewport.Height = msg.Height - 10
|
|
}
|
|
|
|
h, v := docStyle.GetFrameSize()
|
|
m.list.SetSize(msg.Width-h, msg.Height-v)
|
|
|
|
case tea.KeyMsg:
|
|
switch {
|
|
case key.Matches(msg, m.keys.Quit):
|
|
return m, tea.Quit
|
|
|
|
case key.Matches(msg, m.keys.Tab):
|
|
m.activePanel = (m.activePanel + 1) % 2
|
|
|
|
case key.Matches(msg, m.keys.Refresh):
|
|
cmds = append(cmds, m.loadBackupStatus())
|
|
cmds = append(cmds, m.loadLogs())
|
|
|
|
case key.Matches(msg, m.keys.ViewLogs):
|
|
m.showLogs()
|
|
m.currentView = "logs"
|
|
|
|
case key.Matches(msg, m.keys.ViewStatus):
|
|
m.showStatus()
|
|
m.currentView = "status"
|
|
|
|
case key.Matches(msg, m.keys.ViewConfig):
|
|
m.showConfig()
|
|
m.currentView = "config"
|
|
|
|
case key.Matches(msg, m.keys.Stop):
|
|
if i, ok := m.list.SelectedItem().(BackupItem); ok {
|
|
if process, exists := m.runningBackups[i.service]; exists {
|
|
cmds = append(cmds, m.stopBackup(i.service, process))
|
|
}
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Clear):
|
|
m.viewport.SetContent("Output cleared.\n\nSelect a backup operation from the list and press Enter to execute.")
|
|
m.currentView = "main"
|
|
|
|
case key.Matches(msg, m.keys.Escape) && m.currentView != "main":
|
|
m.viewport.SetContent("Welcome to Backup Manager!\n\nSelect a backup operation from the list and press Enter to execute.\n\nUse 'v' to view logs, 's' to view status, and 'tab' to switch between panels.")
|
|
m.currentView = "main"
|
|
|
|
case key.Matches(msg, m.keys.Enter) && m.activePanel == 0:
|
|
if i, ok := m.list.SelectedItem().(BackupItem); ok {
|
|
cmds = append(cmds, m.executeBackup(i))
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Help):
|
|
m.showHelp = !m.showHelp
|
|
}
|
|
|
|
// Update the focused component
|
|
if m.activePanel == 0 {
|
|
m.list, cmd = m.list.Update(msg)
|
|
} else {
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
}
|
|
cmds = append(cmds, cmd)
|
|
|
|
case backupStartMsg:
|
|
if m.backupStatus[msg.service] == nil {
|
|
m.backupStatus[msg.service] = &BackupStatus{}
|
|
}
|
|
|
|
// Create context for this backup
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
m.backupStatus[msg.service].Service = msg.service
|
|
m.backupStatus[msg.service].Status = "running"
|
|
m.backupStatus[msg.service].LastRun = time.Now()
|
|
m.backupStatus[msg.service].StartTime = time.Now()
|
|
m.backupStatus[msg.service].Progress = 0
|
|
m.backupStatus[msg.service].Context = ctx
|
|
m.backupStatus[msg.service].Cancel = cancel
|
|
|
|
m.viewport.SetContent(fmt.Sprintf("🚀 Starting backup for %s...\n\nInitializing backup process...\nPress 'x' to cancel if needed.", msg.service))
|
|
|
|
// Start progress ticker for time-based progress estimation
|
|
cmds = append(cmds, m.startProgressTicker(msg.service))
|
|
|
|
case backupProgressMsg:
|
|
if status, ok := m.backupStatus[msg.service]; ok && status.Status == "running" {
|
|
status.Progress = msg.progress
|
|
// msg.output from the ticker is a general status line (e.g., "Backup in progress... (X elapsed)")
|
|
// This will be used for the main output area in the viewport for this specific message.
|
|
m.updateViewportForRunningService(msg.service, msg.output)
|
|
}
|
|
|
|
case backupOutputMsg:
|
|
if status, ok := m.backupStatus[msg.service]; ok {
|
|
// Append to output history
|
|
currentOutput := status.Output
|
|
if currentOutput != "" {
|
|
currentOutput += "\n"
|
|
}
|
|
currentOutput += msg.line
|
|
|
|
// Keep only last 20 lines to prevent memory issues
|
|
lines := strings.Split(currentOutput, "\n")
|
|
if len(lines) > 20 {
|
|
lines = lines[len(lines)-20:]
|
|
currentOutput = strings.Join(lines, "\n")
|
|
}
|
|
|
|
status.Output = currentOutput // Update the canonical script output log
|
|
|
|
// Update display if this service is currently running
|
|
if status.Status == "running" {
|
|
// Display the accumulated script output from status.Output
|
|
m.updateViewportForRunningService(msg.service, status.Output)
|
|
}
|
|
}
|
|
|
|
case backupStopMsg:
|
|
if m.backupStatus[msg.service] != nil {
|
|
m.backupStatus[msg.service].Status = "stopped"
|
|
m.backupStatus[msg.service].EndTime = time.Now()
|
|
if m.backupStatus[msg.service].Cancel != nil {
|
|
m.backupStatus[msg.service].Cancel()
|
|
}
|
|
delete(m.runningBackups, msg.service)
|
|
}
|
|
m.viewport.SetContent(fmt.Sprintf("🛑 Backup stopped for %s.\n\nThe backup process was cancelled by user request.", msg.service))
|
|
|
|
case backupDoneMsg:
|
|
if m.backupStatus[msg.service] != nil {
|
|
m.backupStatus[msg.service].Status = "success"
|
|
m.backupStatus[msg.service].Output = msg.output
|
|
m.backupStatus[msg.service].EndTime = time.Now()
|
|
m.backupStatus[msg.service].Duration = m.backupStatus[msg.service].EndTime.Sub(m.backupStatus[msg.service].StartTime).String()
|
|
m.backupStatus[msg.service].Progress = 100
|
|
|
|
// Clean up context
|
|
if m.backupStatus[msg.service].Cancel != nil {
|
|
m.backupStatus[msg.service].Cancel()
|
|
}
|
|
}
|
|
delete(m.runningBackups, msg.service)
|
|
|
|
// Parse output for additional info
|
|
lines := strings.Split(msg.output, "\n")
|
|
var summary strings.Builder
|
|
|
|
// Extract meaningful summary information
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "Total files:") ||
|
|
strings.Contains(line, "Total size:") ||
|
|
strings.Contains(line, "Backup completed") ||
|
|
strings.Contains(line, "Archive created") ||
|
|
strings.Contains(line, "Files backed up:") {
|
|
summary.WriteString("📊 " + strings.TrimSpace(line) + "\n")
|
|
}
|
|
}
|
|
|
|
summaryText := summary.String()
|
|
if summaryText == "" {
|
|
summaryText = "📊 Backup completed successfully"
|
|
}
|
|
|
|
duration := ""
|
|
if m.backupStatus[msg.service] != nil {
|
|
duration = m.backupStatus[msg.service].Duration
|
|
}
|
|
|
|
m.viewport.SetContent(fmt.Sprintf("✅ Backup completed successfully for %s!\n\nDuration: %s\n\n%s\n\n📋 Full Output:\n%s",
|
|
msg.service, duration, summaryText, msg.output))
|
|
|
|
case backupErrorMsg:
|
|
if m.backupStatus[msg.service] != nil {
|
|
m.backupStatus[msg.service].Status = "error"
|
|
m.backupStatus[msg.service].Error = msg.error
|
|
m.backupStatus[msg.service].EndTime = time.Now()
|
|
m.backupStatus[msg.service].Duration = m.backupStatus[msg.service].EndTime.Sub(m.backupStatus[msg.service].StartTime).String()
|
|
|
|
// Clean up context
|
|
if m.backupStatus[msg.service].Cancel != nil {
|
|
m.backupStatus[msg.service].Cancel()
|
|
}
|
|
}
|
|
delete(m.runningBackups, msg.service)
|
|
|
|
duration := ""
|
|
if m.backupStatus[msg.service] != nil {
|
|
duration = m.backupStatus[msg.service].Duration
|
|
}
|
|
|
|
// Provide helpful error analysis
|
|
var errorAnalysis string
|
|
if strings.Contains(msg.error, "permission denied") {
|
|
errorAnalysis = "\n💡 This appears to be a permission issue. Try running with appropriate privileges."
|
|
} else if strings.Contains(msg.error, "command not found") {
|
|
errorAnalysis = "\n💡 The backup script was not found. Check that the script exists and is executable."
|
|
} else if strings.Contains(msg.error, "docker") {
|
|
errorAnalysis = "\n💡 This appears to be a Docker-related issue. Ensure Docker is running and accessible."
|
|
} else if strings.Contains(msg.error, "space") {
|
|
errorAnalysis = "\n💡 This might be a disk space issue. Check available storage."
|
|
}
|
|
|
|
m.viewport.SetContent(fmt.Sprintf("❌ Backup failed for %s!\n\nDuration: %s%s\n\n🚨 Error Details:\n%s",
|
|
msg.service, duration, errorAnalysis, msg.error))
|
|
|
|
case spinner.TickMsg:
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
case logsUpdatedMsg:
|
|
// Logs have been updated, refresh if in logs view
|
|
if m.currentView == "logs" {
|
|
m.showLogs()
|
|
}
|
|
|
|
case statusUpdatedMsg:
|
|
// Status has been updated, refresh if in status view
|
|
if m.currentView == "status" {
|
|
m.showStatus()
|
|
}
|
|
|
|
case configUpdateMsg:
|
|
// Configuration has been updated, refresh if in config view
|
|
if m.currentView == "config" {
|
|
m.showConfig()
|
|
}
|
|
|
|
case clearOutputMsg:
|
|
m.viewport.SetContent("Output cleared.\n\nSelect a backup operation and press Enter to execute.")
|
|
m.currentView = "main"
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
// Helper method to update viewport for a running service
|
|
func (m *Model) updateViewportForRunningService(service string, outputForDisplay string) {
|
|
status := m.backupStatus[service] // Assume status exists and is valid, checked by callers
|
|
|
|
elapsed := time.Since(status.StartTime)
|
|
progress := status.Progress
|
|
progressBar := createProgressBar(progress)
|
|
|
|
var etaStr string
|
|
if progress > 0 && progress < 100 && elapsed > 0 {
|
|
// Consistent ETA calculation based on current progress and elapsed time
|
|
// Assumes 'progress' is a percentage of completion (0-100).
|
|
totalEstimatedRaw := float64(elapsed.Nanoseconds()) * (100.0 / float64(progress))
|
|
totalEstimatedDuration := time.Duration(totalEstimatedRaw)
|
|
remainingDuration := totalEstimatedDuration - elapsed
|
|
if remainingDuration > 0 {
|
|
etaStr = fmt.Sprintf("\\nETA: %s", remainingDuration.Round(time.Second))
|
|
}
|
|
}
|
|
|
|
m.viewport.SetContent(fmt.Sprintf("⏳ Backup in progress for %s...\\n\\n%s %d%%%%\\nElapsed: %s%s\\n\\n📋 Latest output:\\n%s\\n\\nPress 'x' to stop the backup.",
|
|
service, progressBar, progress, elapsed.Round(time.Second), etaStr, outputForDisplay))
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
if !m.ready {
|
|
return "\n Initializing..."
|
|
}
|
|
|
|
// Create status bar
|
|
statusBar := m.createStatusBar()
|
|
|
|
// Create help section
|
|
helpText := ""
|
|
if m.showHelp {
|
|
help := m.keys.FullHelp()
|
|
var helpLines []string
|
|
for i, group := range help {
|
|
var groupHelp []string
|
|
for _, binding := range group {
|
|
groupHelp = append(groupHelp, fmt.Sprintf("%s: %s", binding.Help().Key, binding.Help().Desc))
|
|
}
|
|
helpLines = append(helpLines, strings.Join(groupHelp, " | "))
|
|
if i < len(help)-1 {
|
|
helpLines = append(helpLines, "")
|
|
}
|
|
}
|
|
helpText = "\n" + strings.Join(helpLines, "\n")
|
|
}
|
|
|
|
// Create viewport style based on active panel and current view
|
|
viewportStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder())
|
|
if m.activePanel == 1 {
|
|
viewportStyle = viewportStyle.BorderStyle(lipgloss.ThickBorder()).BorderForeground(lipgloss.Color("#7D56F4"))
|
|
}
|
|
|
|
// Add view indicator to viewport title
|
|
viewportTitle := ""
|
|
switch m.currentView {
|
|
case "logs":
|
|
viewportTitle = "📋 Backup Logs"
|
|
case "status":
|
|
viewportTitle = "📊 Backup Status"
|
|
case "config":
|
|
viewportTitle = "⚙️ Configuration"
|
|
default:
|
|
viewportTitle = "💻 Output"
|
|
}
|
|
|
|
// Layout based on active panel
|
|
return lipgloss.JoinVertical(lipgloss.Left,
|
|
titleStyle.Render("🔧 Media & Plex Backup Manager"),
|
|
lipgloss.JoinHorizontal(lipgloss.Top,
|
|
lipgloss.NewStyle().
|
|
Border(lipgloss.RoundedBorder()).
|
|
BorderForeground(func() lipgloss.Color {
|
|
if m.activePanel == 0 {
|
|
return lipgloss.Color("#7D56F4")
|
|
}
|
|
return lipgloss.Color("#444444")
|
|
}()).
|
|
Width(50).
|
|
Render(m.list.View()),
|
|
viewportStyle.
|
|
Width(m.width-54).
|
|
Render(lipgloss.JoinVertical(lipgloss.Left,
|
|
lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#7D56F4")).
|
|
Bold(true).
|
|
Render(viewportTitle),
|
|
m.viewport.View(),
|
|
)),
|
|
),
|
|
statusBar,
|
|
helpText,
|
|
)
|
|
}
|
|
|
|
func (m Model) createStatusBar() string {
|
|
var parts []string
|
|
|
|
// Add current view indicator
|
|
viewIndicator := fmt.Sprintf("View: %s", strings.ToUpper(m.currentView))
|
|
parts = append(parts, idleStyle.Render(viewIndicator))
|
|
|
|
// Add running backups count
|
|
runningCount := len(m.runningBackups)
|
|
if runningCount > 0 {
|
|
parts = append(parts, runningStyle.Render(fmt.Sprintf("Running: %d", runningCount)))
|
|
}
|
|
|
|
// Add service statuses
|
|
for service, status := range m.backupStatus {
|
|
var statusText string
|
|
switch status.Status {
|
|
case "running":
|
|
progressText := ""
|
|
if status.Progress > 0 {
|
|
progressText = fmt.Sprintf(" (%d%%)", status.Progress)
|
|
}
|
|
statusText = runningStyle.Render(fmt.Sprintf("%s: %s%s %s", service, status.Status, progressText, m.spinner.View()))
|
|
case "success":
|
|
statusText = successStyle.Render(fmt.Sprintf("%s: ✓ %s", service, status.Duration))
|
|
case "error":
|
|
statusText = errorStyle.Render(fmt.Sprintf("%s: ✗ failed", service))
|
|
case "stopped":
|
|
statusText = idleStyle.Render(fmt.Sprintf("%s: 🛑 stopped", service))
|
|
default:
|
|
statusText = idleStyle.Render(fmt.Sprintf("%s: idle", service))
|
|
}
|
|
parts = append(parts, statusText)
|
|
}
|
|
|
|
if len(m.backupStatus) == 0 {
|
|
parts = append(parts, idleStyle.Render("No backups running"))
|
|
}
|
|
|
|
// Add navigation help
|
|
if m.activePanel == 0 {
|
|
parts = append(parts, "Panel: Backup List (Press Tab to switch)")
|
|
} else {
|
|
parts = append(parts, "Panel: Output (Press Tab to switch)")
|
|
}
|
|
|
|
return statusBarStyle.Render(strings.Join(parts, " | "))
|
|
}
|
|
|
|
func (m *Model) showLogs() {
|
|
var content strings.Builder
|
|
content.WriteString("=== BACKUP LOGS ===\n\n")
|
|
|
|
if len(m.logs) == 0 {
|
|
content.WriteString("No logs available.\n")
|
|
} else {
|
|
// Sort logs by timestamp (newest first)
|
|
sort.Slice(m.logs, func(i, j int) bool {
|
|
return m.logs[i].Timestamp.After(m.logs[j].Timestamp)
|
|
})
|
|
|
|
for _, entry := range m.logs {
|
|
timestamp := entry.Timestamp.Format("2006-01-02 15:04:05")
|
|
content.WriteString(fmt.Sprintf("[%s] %s - %s: %s\n",
|
|
timestamp, entry.Service, entry.Level, entry.Message))
|
|
}
|
|
}
|
|
|
|
m.viewport.SetContent(content.String())
|
|
}
|
|
|
|
func (m *Model) showStatus() {
|
|
var content strings.Builder
|
|
content.WriteString("=== BACKUP STATUS ===\n\n")
|
|
|
|
if len(m.backupStatus) == 0 {
|
|
content.WriteString("No backup status available.\n\nRun some backups to see their status here.")
|
|
} else {
|
|
// Sort services alphabetically
|
|
var services []string
|
|
for service := range m.backupStatus {
|
|
services = append(services, service)
|
|
}
|
|
sort.Strings(services)
|
|
|
|
for _, service := range services {
|
|
status := m.backupStatus[service]
|
|
content.WriteString(fmt.Sprintf("🔧 Service: %s\n", strings.ToUpper(service)))
|
|
|
|
// Status with emoji
|
|
statusIcon := "⭕"
|
|
switch status.Status {
|
|
case "running":
|
|
statusIcon = "🔄"
|
|
case "success":
|
|
statusIcon = "✅"
|
|
case "error":
|
|
statusIcon = "❌"
|
|
case "stopped":
|
|
statusIcon = "🛑"
|
|
}
|
|
content.WriteString(fmt.Sprintf(" Status: %s %s\n", statusIcon, status.Status))
|
|
|
|
if !status.LastRun.IsZero() {
|
|
content.WriteString(fmt.Sprintf(" Last Run: %s\n", status.LastRun.Format("2006-01-02 15:04:05")))
|
|
}
|
|
|
|
if status.Duration != "" {
|
|
content.WriteString(fmt.Sprintf(" Duration: %s\n", status.Duration))
|
|
}
|
|
|
|
if status.Progress > 0 && status.Status == "running" {
|
|
content.WriteString(fmt.Sprintf(" Progress: %d%%\n", status.Progress))
|
|
}
|
|
|
|
if status.BackupSize != "" {
|
|
content.WriteString(fmt.Sprintf(" Size: %s\n", status.BackupSize))
|
|
}
|
|
|
|
if status.FilesCount > 0 {
|
|
content.WriteString(fmt.Sprintf(" Files: %d\n", status.FilesCount))
|
|
}
|
|
|
|
if status.Error != "" {
|
|
content.WriteString(fmt.Sprintf(" Error: %s\n", status.Error[:min(100, len(status.Error))]))
|
|
}
|
|
|
|
content.WriteString("\n")
|
|
}
|
|
|
|
// Add summary
|
|
running := 0
|
|
success := 0
|
|
failed := 0
|
|
for _, status := range m.backupStatus {
|
|
switch status.Status {
|
|
case "running":
|
|
running++
|
|
case "success":
|
|
success++
|
|
case "error":
|
|
failed++
|
|
}
|
|
}
|
|
|
|
content.WriteString("📊 SUMMARY:\n")
|
|
content.WriteString(fmt.Sprintf(" Running: %d | Success: %d | Failed: %d\n", running, success, failed))
|
|
}
|
|
|
|
m.viewport.SetContent(content.String())
|
|
}
|
|
|
|
func (m *Model) showConfig() {
|
|
var content strings.Builder
|
|
content.WriteString("=== BACKUP CONFIGURATION ===\n\n")
|
|
|
|
// System Information
|
|
content.WriteString("🖥️ SYSTEM INFO:\n")
|
|
content.WriteString(fmt.Sprintf(" Shell Path: %s\n", m.shellPath))
|
|
content.WriteString(fmt.Sprintf(" Terminal Size: %dx%d\n", m.width, m.height))
|
|
content.WriteString(fmt.Sprintf(" Active Services: %d\n", len(m.items)))
|
|
content.WriteString("\n")
|
|
|
|
// Available Backup Services
|
|
content.WriteString("📦 AVAILABLE SERVICES:\n")
|
|
for i, item := range m.items {
|
|
content.WriteString(fmt.Sprintf(" %d. %s\n", i+1, strings.TrimPrefix(item.title, "📦 ")))
|
|
content.WriteString(fmt.Sprintf(" Script: %s\n", item.script))
|
|
if len(item.args) > 0 {
|
|
content.WriteString(fmt.Sprintf(" Args: %s\n", strings.Join(item.args, " ")))
|
|
}
|
|
|
|
// Check if script exists
|
|
if _, err := os.Stat(item.script); os.IsNotExist(err) {
|
|
content.WriteString(" ⚠️ Status: Script not found\n")
|
|
} else {
|
|
content.WriteString(" ✅ Status: Ready\n")
|
|
}
|
|
content.WriteString("\n")
|
|
}
|
|
|
|
// Key Bindings Summary
|
|
content.WriteString("⌨️ KEY BINDINGS:\n")
|
|
content.WriteString(" Navigation: ↑/↓ or k/j (move), Tab (switch panels)\n")
|
|
content.WriteString(" Actions: Enter (run), x (stop), c (clear)\n")
|
|
content.WriteString(" Views: v (logs), s (status), f (config)\n")
|
|
content.WriteString(" System: r (refresh), ? (help), q (quit)\n\n")
|
|
|
|
// Performance Tips
|
|
content.WriteString("💡 TIPS:\n")
|
|
content.WriteString(" • Use 'r' to refresh status after running external scripts\n")
|
|
content.WriteString(" • Monitor logs with 'v' during long-running backups\n")
|
|
content.WriteString(" • Check 's' for detailed backup statistics\n")
|
|
content.WriteString(" • Use 'x' to gracefully stop running backups\n")
|
|
|
|
m.viewport.SetContent(content.String())
|
|
}
|
|
|
|
func (m *Model) stopBackup(service string, process *RunningProcess) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
if process != nil {
|
|
// Cancel the context to signal graceful shutdown
|
|
if process.Cancel != nil {
|
|
process.Cancel()
|
|
}
|
|
|
|
// Give process time to shut down gracefully
|
|
time.Sleep(2 * time.Second)
|
|
|
|
// Force kill if still running
|
|
if process.Cmd != nil && process.Cmd.Process != nil {
|
|
if process.Cmd.ProcessState == nil || !process.Cmd.ProcessState.Exited() {
|
|
process.Cmd.Process.Kill()
|
|
}
|
|
}
|
|
}
|
|
return backupStopMsg{service: service}
|
|
})
|
|
}
|
|
|
|
func (m Model) executeBackup(item BackupItem) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
return backupStartMsg{service: item.service}
|
|
})
|
|
}
|
|
|
|
// Enhanced backup command with real-time progress tracking
|
|
func (m Model) runBackupCommand(item BackupItem) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
// Create context for cancellation
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// Execute the backup script with context
|
|
cmd := exec.CommandContext(ctx, item.script, item.args...)
|
|
cmd.Dir = m.shellPath
|
|
|
|
// Create pipes for stdout and stderr
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return backupErrorMsg{
|
|
service: item.service,
|
|
error: fmt.Sprintf("Failed to create stdout pipe: %s", err.Error()),
|
|
}
|
|
}
|
|
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return backupErrorMsg{
|
|
service: item.service,
|
|
error: fmt.Sprintf("Failed to create stderr pipe: %s", err.Error()),
|
|
}
|
|
}
|
|
|
|
// Start the command
|
|
if err := cmd.Start(); err != nil {
|
|
return backupErrorMsg{
|
|
service: item.service,
|
|
error: fmt.Sprintf("Failed to start backup: %s", err.Error()),
|
|
}
|
|
}
|
|
|
|
var outputBuilder strings.Builder
|
|
var allOutput []string
|
|
|
|
// Monitor stdout and stderr in goroutines
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
|
|
// Read stdout
|
|
go func() {
|
|
defer wg.Done()
|
|
scanner := bufio.NewScanner(stdout)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
outputBuilder.WriteString(line + "\n")
|
|
allOutput = append(allOutput, line)
|
|
}
|
|
}()
|
|
|
|
// Read stderr
|
|
go func() {
|
|
defer wg.Done()
|
|
scanner := bufio.NewScanner(stderr)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
outputBuilder.WriteString("[STDERR] " + line + "\n")
|
|
allOutput = append(allOutput, "[STDERR] "+line)
|
|
}
|
|
}()
|
|
|
|
// Wait for the command to complete
|
|
err = cmd.Wait()
|
|
|
|
// Wait for output readers to finish
|
|
wg.Wait()
|
|
|
|
finalOutput := outputBuilder.String()
|
|
|
|
if err != nil {
|
|
// Check if it was cancelled
|
|
if ctx.Err() == context.Canceled {
|
|
return backupStopMsg{service: item.service}
|
|
}
|
|
|
|
return backupErrorMsg{
|
|
service: item.service,
|
|
error: fmt.Sprintf("Backup failed: %s\n\n📋 Output:\n%s", err.Error(), finalOutput),
|
|
}
|
|
}
|
|
|
|
return backupDoneMsg{
|
|
service: item.service,
|
|
output: finalOutput,
|
|
}
|
|
})
|
|
}
|
|
|
|
// Estimate progress based on output patterns and service type
|
|
func estimateProgress(output []string, service string) int {
|
|
if len(output) == 0 {
|
|
return 0
|
|
}
|
|
|
|
switch service {
|
|
case "plex":
|
|
// Plex backup stages: stop service (10%), backup files (60%), verify (20%), archive (10%)
|
|
stages := map[string]int{
|
|
"stopping": 10,
|
|
"backing": 40,
|
|
"copying": 60,
|
|
"verifying": 80,
|
|
"archiving": 90,
|
|
"completed": 100,
|
|
}
|
|
|
|
for _, line := range output {
|
|
line = strings.ToLower(line)
|
|
for stage, progress := range stages {
|
|
if strings.Contains(line, stage) {
|
|
return progress
|
|
}
|
|
}
|
|
}
|
|
|
|
case "immich":
|
|
// Immich stages: database dump (50%), uploads backup (40%), upload to B2 (10%)
|
|
stages := map[string]int{
|
|
"database backup": 25,
|
|
"compressing": 50,
|
|
"upload directory": 75,
|
|
"uploading to": 90,
|
|
"completed": 100,
|
|
}
|
|
|
|
for _, line := range output {
|
|
line = strings.ToLower(line)
|
|
for stage, progress := range stages {
|
|
if strings.Contains(line, stage) {
|
|
return progress
|
|
}
|
|
}
|
|
}
|
|
|
|
case "media":
|
|
// Media services backup: each service adds progress
|
|
services := []string{"sonarr", "radarr", "prowlarr", "audiobookshelf", "tautulli", "sabnzbd", "jellyseerr"}
|
|
completed := 0
|
|
|
|
for _, service := range services {
|
|
for _, line := range output {
|
|
if strings.Contains(strings.ToLower(line), service) &&
|
|
(strings.Contains(strings.ToLower(line), "success") ||
|
|
strings.Contains(strings.ToLower(line), "completed")) {
|
|
completed++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return (completed * 100) / len(services)
|
|
}
|
|
|
|
// Default progress estimation based on line count
|
|
if len(output) < 10 {
|
|
return 20
|
|
} else if len(output) < 50 {
|
|
return 50
|
|
} else if len(output) < 100 {
|
|
return 80
|
|
}
|
|
|
|
return 90
|
|
}
|
|
|
|
func (m Model) loadBackupStatus() tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
// Try to load status from log files
|
|
logDirs := []string{
|
|
filepath.Join(m.shellPath, "logs"),
|
|
filepath.Join(m.shellPath, "plex", "logs"),
|
|
filepath.Join(m.shellPath, "immich_backups"),
|
|
}
|
|
|
|
for _, logDir := range logDirs {
|
|
if _, err := os.Stat(logDir); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
// Look for recent log files to determine last backup status
|
|
files, err := os.ReadDir(logDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, file := range files {
|
|
if strings.Contains(file.Name(), "backup") && strings.HasSuffix(file.Name(), ".log") {
|
|
// Determine service from filename
|
|
service := "unknown"
|
|
if strings.Contains(file.Name(), "plex") {
|
|
service = "plex"
|
|
} else if strings.Contains(file.Name(), "immich") {
|
|
service = "immich"
|
|
} else if strings.Contains(file.Name(), "media") {
|
|
service = "media"
|
|
}
|
|
|
|
// Get file info for last modified time
|
|
info, err := file.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Update status based on recent activity
|
|
if time.Since(info.ModTime()) < 24*time.Hour {
|
|
if m.backupStatus[service] == nil {
|
|
m.backupStatus[service] = &BackupStatus{}
|
|
}
|
|
m.backupStatus[service].Service = service
|
|
m.backupStatus[service].LastRun = info.ModTime()
|
|
m.backupStatus[service].Status = "idle" // Default to idle, will be updated if running
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return statusUpdatedMsg{}
|
|
})
|
|
}
|
|
|
|
func (m Model) loadLogs() tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
var logs []LogEntry
|
|
|
|
// Load logs from various sources
|
|
logFiles := []struct {
|
|
path string
|
|
service string
|
|
}{
|
|
{filepath.Join(m.shellPath, "logs", "plex-backup.log"), "plex"},
|
|
{filepath.Join(m.shellPath, "logs", "immich-backup.log"), "immich"},
|
|
{filepath.Join(m.shellPath, "logs", "media-backup.log"), "media"},
|
|
}
|
|
|
|
for _, logFile := range logFiles {
|
|
if content, err := os.ReadFile(logFile.path); err == nil {
|
|
lines := strings.Split(string(content), "\n")
|
|
for _, line := range lines {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
|
|
// Parse log line (basic parsing)
|
|
parts := strings.SplitN(line, " - ", 2)
|
|
if len(parts) == 2 {
|
|
if timestamp, err := time.Parse("2006-01-02 15:04:05", parts[0]); err == nil {
|
|
logs = append(logs, LogEntry{
|
|
Timestamp: timestamp,
|
|
Service: logFile.service,
|
|
Level: "INFO",
|
|
Message: parts[1],
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update model logs
|
|
m.logs = logs
|
|
|
|
return logsUpdatedMsg{}
|
|
})
|
|
}
|
|
|
|
// Helper function to create a visual progress bar
|
|
func createProgressBar(progress int) string {
|
|
if progress < 0 {
|
|
progress = 0
|
|
}
|
|
if progress > 100 {
|
|
progress = 100
|
|
}
|
|
|
|
const barWidth = 30
|
|
filled := (progress * barWidth) / 100
|
|
empty := barWidth - filled
|
|
|
|
bar := strings.Repeat("█", filled) + strings.Repeat("░", empty)
|
|
return fmt.Sprintf("[%s]", bar)
|
|
}
|
|
|
|
// Start a progress ticker for time-based progress estimation
|
|
func (m Model) startProgressTicker(service string) tea.Cmd {
|
|
return tea.Tick(time.Second*2, func(t time.Time) tea.Msg {
|
|
// Check if backup is still running
|
|
if status, exists := m.backupStatus[service]; exists && status.Status == "running" {
|
|
elapsed := time.Since(status.StartTime)
|
|
|
|
// Estimate progress based on typical backup durations
|
|
var estimatedDuration time.Duration
|
|
switch service {
|
|
case "plex":
|
|
estimatedDuration = 5 * time.Minute // Plex backups typically take 3-8 minutes
|
|
case "immich":
|
|
estimatedDuration = 10 * time.Minute // Immich can take 5-15 minutes depending on data
|
|
case "media":
|
|
estimatedDuration = 3 * time.Minute // Media services backup is usually faster
|
|
default:
|
|
estimatedDuration = 5 * time.Minute
|
|
}
|
|
|
|
// Calculate progress (cap at 95% until actual completion)
|
|
progress := int((elapsed.Seconds() / estimatedDuration.Seconds()) * 95)
|
|
if progress > 95 {
|
|
progress = 95
|
|
}
|
|
|
|
return backupProgressMsg{
|
|
service: service,
|
|
progress: progress,
|
|
output: fmt.Sprintf("Backup in progress... (%s elapsed)", elapsed.Round(time.Second)),
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func main() {
|
|
p := tea.NewProgram(initialModel(), tea.WithAltScreen())
|
|
if _, err := p.Run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|