package main import ( "fmt" "io" "os" "path/filepath" "sort" "strings" "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // Message types type ( 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"` } // 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"}) 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 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.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", "ctrl+c"), key.WithHelp("q", "quit"), ), Enter: key.NewBinding( key.WithKeys("enter", " "), key.WithHelp("enter", "browse directory"), ), 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"), ), 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 keys keyMap width int height int activePanel int // 0: list, 1: viewport, 2: status showHelp bool ready bool logs []LogEntry shellPath string items []BackupItem // Store backup items for reference 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 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) vp := viewport.New(78, 20) vp.SetContent("Welcome to Backup File Browser!\n\nSelect a backup directory from the list and press Enter to browse backup files.\n\nUse 'v' to view logs, 'r' to refresh status, and 'tab' to switch between panels.") return Model{ list: l, viewport: vp, keys: keys, activePanel: 0, showHelp: false, ready: false, logs: []LogEntry{}, shellPath: shellPath, items: convertToBackupItems(items), // Store the items 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 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 nil } 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 { m.showItemDescription() } else { m.viewport.SetContent("Welcome to Backup File Browser!\n\nSelect a backup directory from the list and press Enter to browse backup files.\n\nUse 'v' to view logs, 's' to view 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.Clear): m.viewport.SetContent("Output cleared.\n\nSelect a backup directory from the list and press Enter to browse.") m.currentView = "main" // Show description for currently selected item m.showItemDescription() case key.Matches(msg, m.keys.Escape) && m.currentView != "main": m.viewport.SetContent("Welcome to Backup File Browser!\n\nSelect a backup directory from the list and press Enter to browse.\n\nUse 'v' to view logs, 's' for status, and 'tab' to switch between panels.") m.currentView = "main" // Show description for currently selected item 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.Help): m.showHelp = !m.showHelp } // Update the focused component (CRITICAL: this must be outside the switch) 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.showItemDescription() } } else { m.viewport, cmd = m.viewport.Update(msg) } cmds = append(cmds, cmd) case backupDetailsLoadedMsg: m.showBackupDetails(msg.details) m.currentView = "directory" case logFilesLoadedMsg: m.showLogFiles(msg.logFiles) m.currentView = "logs" case fileContentLoadedMsg: m.viewport.SetContent(msg.content) m.currentView = "file" case errorMsg: m.viewport.SetContent(fmt.Sprintf("Error: %s", msg.error)) m.currentView = "error" } 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 file browser status parts = append(parts, idleStyle.Render("Mode: Browse Only")) // 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 DIRECTORY STATUS ===\n\n") content.WriteString("šŸ“‚ Available Backup Directories:\n\n") for _, item := range m.items { if item.itemType == "directory" { content.WriteString(fmt.Sprintf("šŸ”§ Service: %s\n", strings.ToUpper(item.service))) content.WriteString(fmt.Sprintf(" Path: %s\n", item.path)) content.WriteString(" Type: Backup Directory\n") content.WriteString(" Status: Available for browsing\n") content.WriteString("\n") } } content.WriteString("šŸ“Š SUMMARY:\n") directoryCount := 0 for _, item := range m.items { if item.itemType == "directory" { directoryCount++ } } content.WriteString(fmt.Sprintf(" Available Directories: %d\n", directoryCount)) content.WriteString(" Mode: Read-Only Browser\n") 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(" • 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() { // Use the list's actual selection index instead of a separate field selectedIndex := m.list.Index() if selectedIndex < 0 || selectedIndex >= len(m.items) { return } item := m.items[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()) } // showBackupDetails displays detailed information about backup files in a directory func (m *Model) showBackupDetails(details BackupDetails) { var content strings.Builder content.WriteString(fmt.Sprintf("šŸ“ %s Backup Directory\n", details.Directory.Name)) content.WriteString(strings.Repeat("=", len(details.Directory.Name)+20) + "\n\n") // Directory summary content.WriteString("šŸ“Š Directory Summary:\n") content.WriteString(fmt.Sprintf(" šŸ“ Path: %s\n", details.Directory.Path)) // Show if this is using a scheduled subfolder if strings.HasSuffix(details.Directory.Path, "/scheduled") { content.WriteString(" šŸ“… Source: Scheduled backups (from 'scheduled' subfolder)\n") } content.WriteString(fmt.Sprintf(" šŸ“„ Files: %d\n", details.TotalFiles)) content.WriteString(fmt.Sprintf(" šŸ’¾ Total Size: %s\n", formatSize(details.TotalSize))) if !details.NewestBackup.IsZero() { content.WriteString(fmt.Sprintf(" šŸ•’ Newest: %s\n", details.NewestBackup.Format("2006-01-02 15:04:05"))) } if !details.OldestBackup.IsZero() { content.WriteString(fmt.Sprintf(" šŸ“… Oldest: %s\n", details.OldestBackup.Format("2006-01-02 15:04:05"))) } content.WriteString("\n") // File listings if len(details.Files) > 0 { content.WriteString("šŸ“„ Backup Files:\n") content.WriteString(" Name Size Modified\n") content.WriteString(" " + strings.Repeat("-", 50) + "\n") for _, file := range details.Files { // Truncate filename if too long displayName := file.Name if len(displayName) > 25 { displayName = displayName[:22] + "..." } compressionIndicator := "" if file.IsCompressed { compressionIndicator = "šŸ“¦ " } content.WriteString(fmt.Sprintf(" %s%-25s %8s %s\n", compressionIndicator, displayName, formatSize(file.Size), file.ModifiedTime.Format("2006-01-02 15:04"))) } } else { content.WriteString("šŸ“‚ No backup files found in this directory.\n") } content.WriteString("\nšŸ’” Navigation:\n") content.WriteString(" • x: Return to main view\n") content.WriteString(" • r: Refresh directory listing\n") content.WriteString(" • ?: Toggle help\n") m.viewport.SetContent(content.String()) } // showLogFiles displays log files for browsing func (m *Model) showLogFiles(logFiles []LogFile) { var content strings.Builder content.WriteString("šŸ“‹ Backup Operation Logs\n") content.WriteString("=========================\n\n") if len(logFiles) == 0 { content.WriteString("šŸ“‚ No log files found.\n") content.WriteString("Log files are typically stored in /mnt/share/media/backups/logs/\n") } else { content.WriteString("šŸ“„ Available Log Files:\n") content.WriteString(" Filename Size Date\n") content.WriteString(" " + strings.Repeat("-", 45) + "\n") for _, logFile := range logFiles { displayName := logFile.Name if len(displayName) > 25 { displayName = displayName[:22] + "..." } dateStr := "Unknown" if !logFile.Date.IsZero() { dateStr = logFile.Date.Format("2006-01-02 15:04") } content.WriteString(fmt.Sprintf(" %-25s %8s %s\n", displayName, formatSize(logFile.Size), dateStr)) } content.WriteString(fmt.Sprintf("\nšŸ“Š Total: %d log files\n", len(logFiles))) } content.WriteString("\nšŸ’” Navigation:\n") content.WriteString(" • Enter: View log content (when a specific log is selected)\n") content.WriteString(" • x: Return to main view\n") content.WriteString(" • r: Refresh log listing\n") 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 { // Check if there's a "scheduled" subfolder - if so, use that instead scheduledPath := filepath.Join(dirPath, "scheduled") actualPath := dirPath if info, err := os.Stat(scheduledPath); err == nil && info.IsDir() { actualPath = scheduledPath } files, err := os.ReadDir(actualPath) if err != nil { return errorMsg{error: fmt.Sprintf("Failed to read directory %s: %v", actualPath, 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(actualPath, file.Name()), Size: info.Size(), ModifiedTime: info.ModTime(), IsCompressed: strings.HasSuffix(file.Name(), ".tar.gz") || strings.HasSuffix(file.Name(), ".zip"), Service: filepath.Base(dirPath), // Keep using original dirPath for service name } 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: actualPath, // Show the actual path where files are located 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} } } // main function to initialize and run the TUI application func main() { p := tea.NewProgram(initialModel(), tea.WithAltScreen()) if _, err := p.Run(); err != nil { fmt.Printf("Alas, there's been an error: %v", err) os.Exit(1) } }