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