diff --git a/tui/backup-manager b/tui/backup-manager index bb090af..ceb7b71 100755 Binary files a/tui/backup-manager and b/tui/backup-manager differ diff --git a/tui/backup-viewer b/tui/backup-viewer new file mode 100755 index 0000000..208666b Binary files /dev/null and b/tui/backup-viewer differ diff --git a/tui/main.go b/tui/main.go index ea33ca6..a37c2f6 100644 --- a/tui/main.go +++ b/tui/main.go @@ -1,11 +1,9 @@ package main import ( - "bufio" "context" "fmt" "io" - "log" "os" "os/exec" "path/filepath" @@ -24,18 +22,53 @@ import ( // 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{} + backupDirLoadedMsg struct{ backupDirs []BackupDirectory } + logFilesLoadedMsg struct{ logFiles []LogFile } + fileContentLoadedMsg struct{ content string } + backupDetailsLoadedMsg struct{ details BackupDetails } + errorMsg struct{ error string } ) +// BackupDirectory represents a backup service directory +type BackupDirectory struct { + Name string `json:"name"` + Path string `json:"path"` + FileCount int `json:"file_count"` + TotalSize int64 `json:"total_size"` + LastModified time.Time `json:"last_modified"` + Description string `json:"description"` +} + +// BackupFile represents an individual backup file +type BackupFile struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + ModifiedTime time.Time `json:"modified_time"` + IsCompressed bool `json:"is_compressed"` + Service string `json:"service"` +} + +// LogFile represents a log file +type LogFile struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + Date time.Time `json:"date"` + Service string `json:"service"` + Content string `json:"content,omitempty"` +} + +// BackupDetails represents detailed information about a backup +type BackupDetails struct { + Directory BackupDirectory `json:"directory"` + Files []BackupFile `json:"files"` + TotalFiles int `json:"total_files"` + TotalSize int64 `json:"total_size"` + OldestBackup time.Time `json:"oldest_backup"` + NewestBackup time.Time `json:"newest_backup"` +} + // BackupStatus represents the status of a backup operation type BackupStatus struct { Service string `json:"service"` @@ -71,13 +104,13 @@ type LogEntry struct { Message string `json:"message"` } -// BackupItem represents a backup service in the list +// BackupItem represents a backup service directory or view option in the list type BackupItem struct { title string description string + itemType string // "directory", "logs", "about" + path string // path to backup directory or logs service string - script string - args []string } func (i BackupItem) FilterValue() string { return i.title } @@ -220,6 +253,7 @@ type Model struct { items []BackupItem // Store backup items for reference runningBackups map[string]*RunningProcess // Track running processes with enhanced control currentView string // "main", "logs", "status" + selectedIndex int // Track currently selected menu item } // Initialize the model @@ -227,84 +261,85 @@ func initialModel() Model { // Get the shell path (parent directory of tui) shellPath, _ := filepath.Abs(filepath.Dir(os.Args[0]) + "/..") - // Create backup items + // Create backup directory viewer items + backupBasePath := "/mnt/share/media/backups" items := []list.Item{ BackupItem{ - title: "šŸ“¦ Plex Backup", - description: "Complete Plex Media Server backup with integrity checking", + title: "šŸ“¦ Plex Backups", + description: "Browse Plex Media Server backup files and history", + itemType: "directory", + path: filepath.Join(backupBasePath, "plex"), 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", + title: "šŸ–¼ļø Immich Backups", + description: "Browse Immich photo management backup files", + itemType: "directory", + path: filepath.Join(backupBasePath, "immich"), 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{}, + title: "šŸŽ¬ Sonarr Backups", + description: "Browse Sonarr TV series management backups", + itemType: "directory", + path: filepath.Join(backupBasePath, "sonarr"), + service: "sonarr", }, 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{}, + title: "šŸŽ­ Radarr Backups", + description: "Browse Radarr movie management backups", + itemType: "directory", + path: filepath.Join(backupBasePath, "radarr"), + service: "radarr", }, 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{}, + title: "šŸ” Prowlarr Backups", + description: "Browse Prowlarr indexer management backups", + itemType: "directory", + path: filepath.Join(backupBasePath, "prowlarr"), + service: "prowlarr", }, 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{}, + title: "šŸŽÆ Jellyseerr Backups", + description: "Browse Jellyseerr request management backups", + itemType: "directory", + path: filepath.Join(backupBasePath, "jellyseerr"), + service: "jellyseerr", }, BackupItem{ - title: "šŸ”§ Environment Files Backup", - description: "Backup Docker environment and configuration files", - service: "env", - script: filepath.Join(shellPath, "backup-env-files.sh"), - args: []string{}, + title: "šŸ“„ SABnzbd Backups", + description: "Browse SABnzbd download client backups", + itemType: "directory", + path: filepath.Join(backupBasePath, "sabnzbd"), + service: "sabnzbd", }, BackupItem{ - title: "🐳 Docker Configuration Backup", - description: "Backup Docker containers and compose files", - service: "docker", - script: filepath.Join(shellPath, "backup-docker.sh"), - args: []string{}, + title: "šŸ“Š Tautulli Backups", + description: "Browse Tautulli Plex statistics backups", + itemType: "directory", + path: filepath.Join(backupBasePath, "tautulli"), + service: "tautulli", }, 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{}, + title: "šŸ“š Audiobookshelf Backups", + description: "Browse Audiobookshelf audiobook server backups", + itemType: "directory", + path: filepath.Join(backupBasePath, "audiobookshelf"), + service: "audiobookshelf", }, 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"}, + title: "🐳 Docker Data Backups", + description: "Browse Docker container data and volumes", + itemType: "directory", + path: filepath.Join(backupBasePath, "docker-data"), + service: "docker-data", }, BackupItem{ - title: "šŸ”„ Restore Immich", - description: "Restore Immich from backup", - service: "immich-restore", - script: filepath.Join(shellPath, "immich", "restore-immich.sh"), - args: []string{}, + title: "šŸ“‹ Backup Logs", + description: "View backup operation logs and history", + itemType: "logs", + path: filepath.Join(backupBasePath, "logs"), + service: "logs", }, } @@ -339,6 +374,7 @@ func initialModel() Model { items: convertToBackupItems(items), // Store the items runningBackups: make(map[string]*RunningProcess), currentView: "main", + selectedIndex: 0, // Initialize to first item } } @@ -384,11 +420,7 @@ func convertToBackupItems(items []list.Item) []BackupItem { } func (m Model) Init() tea.Cmd { - return tea.Batch( - m.spinner.Tick, - m.loadBackupStatus(), - m.loadLogs(), - ) + return m.spinner.Tick } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -408,9 +440,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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.") + // Show description for the first selected item instead of generic welcome + if len(m.items) > 0 && len(m.runningBackups) == 0 { + m.showItemDescription() + } else { + 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 @@ -428,8 +463,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.activePanel = (m.activePanel + 1) % 2 case key.Matches(msg, m.keys.Refresh): - cmds = append(cmds, m.loadBackupStatus()) - cmds = append(cmds, m.loadLogs()) + // Refresh current view + if len(m.items) > 0 && m.currentView == "main" { + m.showItemDescription() + } case key.Matches(msg, m.keys.ViewLogs): m.showLogs() @@ -444,23 +481,48 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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)) - } + // For backup viewer, Stop key can be used to go back to main view + m.currentView = "main" + if len(m.items) > 0 { + m.showItemDescription() } 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" + // Show description for currently selected item + if len(m.runningBackups) == 0 { + m.showItemDescription() + } 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" + // Show description for currently selected item + if len(m.runningBackups) == 0 { + m.showItemDescription() + } case key.Matches(msg, m.keys.Enter) && m.activePanel == 0: if i, ok := m.list.SelectedItem().(BackupItem); ok { - cmds = append(cmds, m.executeBackup(i)) + switch i.itemType { + case "directory": + // Load backup directory details + cmds = append(cmds, m.loadBackupDirectory(i.path)) + case "logs": + // Load log files + cmds = append(cmds, m.loadLogFiles()) + default: + // Show item description for other types + m.showItemDescription() + } + } + + case key.Matches(msg, m.keys.Stop): + // For backup viewer, Stop key can be used to go back to main view + m.currentView = "main" + if len(m.items) > 0 { + m.showItemDescription() } case key.Matches(msg, m.keys.Help): @@ -468,217 +530,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Update the focused component + prevIndex := m.list.Index() if m.activePanel == 0 { m.list, cmd = m.list.Update(msg) + // Check if selection changed and we're in main view + if m.list.Index() != prevIndex && m.currentView == "main" { + m.selectedIndex = m.list.Index() + m.showItemDescription() + } } 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 - } - - // Process output to handle escape sequences properly - processedOutput := processEscapeSequences(msg.output) - - 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, processedOutput)) - - 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." - } - - // Process error output to handle escape sequences properly - processedError := processEscapeSequences(msg.error) - - m.viewport.SetContent(fmt.Sprintf("āŒ Backup failed for %s!\n\nDuration: %s%s\n\n🚨 Error Details:\n%s", - msg.service, duration, errorAnalysis, processedError)) - - 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)) - } - } - - // Process output to handle escape sequences properly - processedOutput := processEscapeSequences(outputForDisplay) - - 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, processedOutput)) -} func (m Model) View() string { if !m.ready { @@ -921,16 +790,15 @@ func (m *Model) showConfig() { 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, " "))) - } + content.WriteString(fmt.Sprintf(" Type: %s\n", item.itemType)) + content.WriteString(fmt.Sprintf(" Path: %s\n", item.path)) + content.WriteString(fmt.Sprintf(" Service: %s\n", item.service)) - // Check if script exists - if _, err := os.Stat(item.script); os.IsNotExist(err) { - content.WriteString(" āš ļø Status: Script not found\n") + // Check if path exists + if _, err := os.Stat(item.path); os.IsNotExist(err) { + content.WriteString(" āš ļø Status: Path not found\n") } else { - content.WriteString(" āœ… Status: Ready\n") + content.WriteString(" āœ… Status: Available\n") } content.WriteString("\n") } @@ -938,381 +806,312 @@ func (m *Model) showConfig() { // 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(" Actions: Enter (browse), x (back), 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") + content.WriteString(" • Use 'r' to refresh current view\n") + content.WriteString(" • Browse backup directories with Enter\n") + content.WriteString(" • Check 's' for detailed status information\n") + content.WriteString(" • Use 'x' to go back to main view\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 +// showItemDescription displays detailed information about the currently selected menu item +func (m *Model) showItemDescription() { + if m.selectedIndex < 0 || m.selectedIndex >= len(m.items) { + return } - switch service { + item := m.items[m.selectedIndex] + + var content strings.Builder + content.WriteString(fmt.Sprintf("šŸ“‹ %s\n", item.title)) + content.WriteString(strings.Repeat("=", len(item.title)+3) + "\n\n") + + content.WriteString(fmt.Sprintf("šŸ“ Description:\n%s\n\n", item.description)) + + // Add detailed information based on service type + switch item.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 - } - } - } - + content.WriteString("šŸŽ¬ Plex Media Server Backups:\n") + content.WriteString("• Database and metadata backups\n") + content.WriteString("• Configuration and preferences\n") + content.WriteString("• Plugin and custom data\n") + content.WriteString("• Compressed archive files (.tar.gz)\n\n") + + content.WriteString("šŸ“ Typical Contents:\n") + content.WriteString("• Library database files\n") + content.WriteString("• User preferences and settings\n") + content.WriteString("• Custom artwork and thumbnails\n") + content.WriteString("• Plugin configurations\n\n") + 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) + content.WriteString("šŸ“ø Immich Photo Management Backups:\n") + content.WriteString("• PostgreSQL database dumps\n") + content.WriteString("• Uploaded photos and videos\n") + content.WriteString("• User settings and albums\n") + content.WriteString("• Machine learning models\n\n") + + content.WriteString("šŸ“ Typical Contents:\n") + content.WriteString("• Database backup files (.sql)\n") + content.WriteString("• Media file archives\n") + content.WriteString("• Configuration backups\n") + content.WriteString("• Thumbnail caches\n\n") + + case "sonarr": + content.WriteString("šŸ“ŗ Sonarr TV Series Management:\n") + content.WriteString("• Series tracking database\n") + content.WriteString("• Download client configurations\n") + content.WriteString("• Quality profiles and settings\n") + content.WriteString("• Custom scripts and metadata\n\n") + + case "radarr": + content.WriteString("šŸŽ¬ Radarr Movie Management:\n") + content.WriteString("• Movie collection database\n") + content.WriteString("• Indexer and download settings\n") + content.WriteString("• Quality and format preferences\n") + content.WriteString("• Custom filters and lists\n\n") + + case "prowlarr": + content.WriteString("šŸ” Prowlarr Indexer Management:\n") + content.WriteString("• Indexer configurations\n") + content.WriteString("• API keys and credentials\n") + content.WriteString("• Sync profiles and settings\n") + content.WriteString("• Application mappings\n\n") + + case "jellyseerr": + content.WriteString("šŸŽÆ Jellyseerr Request Management:\n") + content.WriteString("• User request history\n") + content.WriteString("• Notification configurations\n") + content.WriteString("• Integration settings\n") + content.WriteString("• Custom user permissions\n\n") + + case "sabnzbd": + content.WriteString("šŸ“„ SABnzbd Download Client:\n") + content.WriteString("• Server and category configs\n") + content.WriteString("• Download queue history\n") + content.WriteString("• Post-processing scripts\n") + content.WriteString("• User settings and passwords\n\n") + + case "tautulli": + content.WriteString("šŸ“Š Tautulli Plex Statistics:\n") + content.WriteString("• View history and statistics\n") + content.WriteString("• User activity tracking\n") + content.WriteString("• Notification configurations\n") + content.WriteString("• Custom dashboard settings\n\n") + + case "audiobookshelf": + content.WriteString("šŸ“š Audiobookshelf Server:\n") + content.WriteString("• Library and user data\n") + content.WriteString("• Listening progress tracking\n") + content.WriteString("• Playlist and collection info\n") + content.WriteString("• Server configuration\n\n") + + case "docker-data": + content.WriteString("🐳 Docker Container Data:\n") + content.WriteString("• Volume and bind mount backups\n") + content.WriteString("• Container persistent data\n") + content.WriteString("• Application state files\n") + content.WriteString("• Configuration volumes\n\n") + + case "logs": + content.WriteString("šŸ“‹ Backup Operation Logs:\n") + content.WriteString("• Daily backup operation logs\n") + content.WriteString("• Error and success reports\n") + content.WriteString("• Backup timing and performance\n") + content.WriteString("• System health monitoring\n\n") } - - // 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 + + // Add usage instructions based on item type + content.WriteString("\nšŸš€ Actions:\n") + switch item.itemType { + case "directory": + content.WriteString("• Press Enter to browse backup files\n") + content.WriteString("• View file sizes and modification dates\n") + content.WriteString("• Check backup history and trends\n") + content.WriteString("• Navigate with arrow keys\n") + case "logs": + content.WriteString("• Press Enter to view log files\n") + content.WriteString("• Browse by date and time\n") + content.WriteString("• View backup operation details\n") + content.WriteString("• Check for errors or warnings\n") } + + content.WriteString("\nšŸ’” Navigation:\n") + content.WriteString("• Tab: Switch between panels\n") + content.WriteString("• R: Refresh current view\n") + content.WriteString("• ?: Toggle help\n") + content.WriteString("• Q: Quit application\n") - return 90 + // Add path information + content.WriteString(fmt.Sprintf("\nšŸ“ Path: %s\n", item.path)) + + m.viewport.SetContent(content.String()) } -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"), + + +// formatSize converts bytes to human readable format +func formatSize(bytes int64) string { + const unit = 1024 + if bytes < unit { + return fmt.Sprintf("%d B", bytes) + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) +} + +// loadBackupDirectory loads backup files from a directory +func (m *Model) loadBackupDirectory(dirPath string) tea.Cmd { + return func() tea.Msg { + files, err := os.ReadDir(dirPath) + if err != nil { + return errorMsg{error: fmt.Sprintf("Failed to read directory %s: %v", dirPath, err)} } - for _, logDir := range logDirs { - if _, err := os.Stat(logDir); os.IsNotExist(err) { + var backupFiles []BackupFile + var totalSize int64 + var lastModified time.Time + + for _, file := range files { + if file.IsDir() { continue } - // Look for recent log files to determine last backup status - files, err := os.ReadDir(logDir) + info, err := file.Info() 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" - } + backupFile := BackupFile{ + Name: file.Name(), + Path: filepath.Join(dirPath, file.Name()), + Size: info.Size(), + ModifiedTime: info.ModTime(), + IsCompressed: strings.HasSuffix(file.Name(), ".tar.gz") || strings.HasSuffix(file.Name(), ".zip"), + Service: filepath.Base(dirPath), + } - // Get file info for last modified time - info, err := file.Info() - if err != nil { - continue - } + backupFiles = append(backupFiles, backupFile) + totalSize += info.Size() - // 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 + if info.ModTime().After(lastModified) { + lastModified = info.ModTime() + } + } + + // Sort files by modification time (newest first) + sort.Slice(backupFiles, func(i, j int) bool { + return backupFiles[i].ModifiedTime.After(backupFiles[j].ModifiedTime) + }) + + var oldestBackup, newestBackup time.Time + if len(backupFiles) > 0 { + newestBackup = backupFiles[0].ModifiedTime + oldestBackup = backupFiles[len(backupFiles)-1].ModifiedTime + } + + details := BackupDetails{ + Directory: BackupDirectory{ + Name: filepath.Base(dirPath), + Path: dirPath, + FileCount: len(backupFiles), + TotalSize: totalSize, + LastModified: lastModified, + }, + Files: backupFiles, + TotalFiles: len(backupFiles), + TotalSize: totalSize, + OldestBackup: oldestBackup, + NewestBackup: newestBackup, + } + + return backupDetailsLoadedMsg{details: details} + } +} + +// loadLogFiles loads log files from the logs directory +func (m *Model) loadLogFiles() tea.Cmd { + return func() tea.Msg { + logsPath := "/mnt/share/media/backups/logs" + files, err := os.ReadDir(logsPath) + if err != nil { + return errorMsg{error: fmt.Sprintf("Failed to read logs directory: %v", err)} + } + + var logFiles []LogFile + + for _, file := range files { + if file.IsDir() { + continue + } + + info, err := file.Info() + if err != nil { + continue + } + + // Parse log file name to extract date and service + name := file.Name() + var logDate time.Time + var service string + + // Parse backup_log_YYYYMMDD_HHMMSS.md format + if strings.HasPrefix(name, "backup_log_") && strings.HasSuffix(name, ".md") { + datePart := strings.TrimPrefix(name, "backup_log_") + datePart = strings.TrimSuffix(datePart, ".md") + + if len(datePart) >= 15 { // YYYYMMDD_HHMMSS + if parsedDate, err := time.Parse("20060102_150405", datePart); err == nil { + logDate = parsedDate } } + service = "system" } + + logFile := LogFile{ + Name: name, + Path: filepath.Join(logsPath, name), + Size: info.Size(), + Date: logDate, + Service: service, + } + + logFiles = append(logFiles, logFile) } - return statusUpdatedMsg{} - }) -} + // Sort by date (newest first) + sort.Slice(logFiles, func(i, j int) bool { + return logFiles[i].Date.After(logFiles[j].Date) + }) -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) -} - -// Helper function to process escape sequences in output text -func processEscapeSequences(text string) string { - processed := strings.ReplaceAll(text, "\\n", "\n") - processed = strings.ReplaceAll(processed, "\\t", "\t") - processed = strings.ReplaceAll(processed, "\\r", "\r") - return processed -} - -// 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) + return logFilesLoadedMsg{logFiles: logFiles} + } +} + +// loadFileContent loads content of a specific file +func (m *Model) loadFileContent(filePath string) tea.Cmd { + return func() tea.Msg { + content, err := os.ReadFile(filePath) + if err != nil { + return errorMsg{error: fmt.Sprintf("Failed to read file %s: %v", filePath, err)} + } + + // Limit content size for display + maxSize := 50000 // 50KB limit for display + if len(content) > maxSize { + content = content[:maxSize] + content = append(content, []byte("\n\n... (file truncated for display)")...) + } + + return fileContentLoadedMsg{content: string(content)} } }