Files
shell/tui/main.go
Peter Wood f2c415cc1b Add test script for Enhanced Backup TUI Features
- 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.
2025-05-30 07:24:03 -04:00

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)
}
}