Files
shell/tui/main.go
Peter Wood 90c6033a95 feat: Add backup viewer and enhance backup management features in TUI
refactor: Improve backup item structure and add detailed descriptions for services
feat: Implement loading of backup directories and log files with detailed information
feat: Enhance file content loading with size limits for display
2025-06-01 09:47:18 -04:00

1118 lines
33 KiB
Go

package main
import (
"context"
"fmt"
"io"
"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 (
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"`
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 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
}
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"
selectedIndex int // Track currently selected menu item
}
// Initialize the model
func initialModel() Model {
// Get the shell path (parent directory of tui)
shellPath, _ := filepath.Abs(filepath.Dir(os.Args[0]) + "/..")
// Create backup directory viewer items
backupBasePath := "/mnt/share/media/backups"
items := []list.Item{
BackupItem{
title: "📦 Plex Backups",
description: "Browse Plex Media Server backup files and history",
itemType: "directory",
path: filepath.Join(backupBasePath, "plex"),
service: "plex",
},
BackupItem{
title: "🖼️ Immich Backups",
description: "Browse Immich photo management backup files",
itemType: "directory",
path: filepath.Join(backupBasePath, "immich"),
service: "immich",
},
BackupItem{
title: "🎬 Sonarr Backups",
description: "Browse Sonarr TV series management backups",
itemType: "directory",
path: filepath.Join(backupBasePath, "sonarr"),
service: "sonarr",
},
BackupItem{
title: "🎭 Radarr Backups",
description: "Browse Radarr movie management backups",
itemType: "directory",
path: filepath.Join(backupBasePath, "radarr"),
service: "radarr",
},
BackupItem{
title: "🔍 Prowlarr Backups",
description: "Browse Prowlarr indexer management backups",
itemType: "directory",
path: filepath.Join(backupBasePath, "prowlarr"),
service: "prowlarr",
},
BackupItem{
title: "🎯 Jellyseerr Backups",
description: "Browse Jellyseerr request management backups",
itemType: "directory",
path: filepath.Join(backupBasePath, "jellyseerr"),
service: "jellyseerr",
},
BackupItem{
title: "📥 SABnzbd Backups",
description: "Browse SABnzbd download client backups",
itemType: "directory",
path: filepath.Join(backupBasePath, "sabnzbd"),
service: "sabnzbd",
},
BackupItem{
title: "📊 Tautulli Backups",
description: "Browse Tautulli Plex statistics backups",
itemType: "directory",
path: filepath.Join(backupBasePath, "tautulli"),
service: "tautulli",
},
BackupItem{
title: "📚 Audiobookshelf Backups",
description: "Browse Audiobookshelf audiobook server backups",
itemType: "directory",
path: filepath.Join(backupBasePath, "audiobookshelf"),
service: "audiobookshelf",
},
BackupItem{
title: "🐳 Docker Data Backups",
description: "Browse Docker container data and volumes",
itemType: "directory",
path: filepath.Join(backupBasePath, "docker-data"),
service: "docker-data",
},
BackupItem{
title: "📋 Backup Logs",
description: "View backup operation logs and history",
itemType: "logs",
path: filepath.Join(backupBasePath, "logs"),
service: "logs",
},
}
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",
selectedIndex: 0, // Initialize to first item
}
}
// 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 m.spinner.Tick
}
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
// 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
}
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):
// Refresh current view
if len(m.items) > 0 && m.currentView == "main" {
m.showItemDescription()
}
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):
// 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 {
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):
m.showHelp = !m.showHelp
}
// 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)
}
return m, tea.Batch(cmds...)
}
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(" 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 path exists
if _, err := os.Stat(item.path); os.IsNotExist(err) {
content.WriteString(" ⚠️ Status: Path not found\n")
} else {
content.WriteString(" ✅ Status: Available\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 (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 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())
}
// showItemDescription displays detailed information about the currently selected menu item
func (m *Model) showItemDescription() {
if m.selectedIndex < 0 || m.selectedIndex >= len(m.items) {
return
}
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":
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":
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")
}
// 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")
// Add path information
content.WriteString(fmt.Sprintf("\n📁 Path: %s\n", item.path))
m.viewport.SetContent(content.String())
}
// 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)}
}
var backupFiles []BackupFile
var totalSize int64
var lastModified time.Time
for _, file := range files {
if file.IsDir() {
continue
}
info, err := file.Info()
if err != nil {
continue
}
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),
}
backupFiles = append(backupFiles, backupFile)
totalSize += info.Size()
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)
}
// Sort by date (newest first)
sort.Slice(logFiles, func(i, j int) bool {
return logFiles[i].Date.After(logFiles[j].Date)
})
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)}
}
}