feat: Remove unused features and enhance backup file browsing functionality

This commit is contained in:
Peter Wood
2025-06-01 19:17:36 -04:00
parent 5b249c89bd
commit c38dd066f7
4 changed files with 158 additions and 363 deletions

View File

@@ -1,20 +1,16 @@
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"
@@ -22,7 +18,6 @@ import (
// Message types
type (
backupDirLoadedMsg struct{ backupDirs []BackupDirectory }
logFilesLoadedMsg struct{ logFiles []LogFile }
fileContentLoadedMsg struct{ content string }
backupDetailsLoadedMsg struct{ details BackupDetails }
@@ -69,33 +64,6 @@ type BackupDetails struct {
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"`
@@ -128,9 +96,6 @@ var (
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
@@ -156,7 +121,6 @@ type keyMap struct {
ViewLogs key.Binding
ViewStatus key.Binding
ViewConfig key.Binding
Stop key.Binding
Clear key.Binding
}
@@ -168,7 +132,7 @@ 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.Refresh, k.Clear},
{k.Help, k.Quit},
}
}
@@ -195,12 +159,12 @@ var keys = keyMap{
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("q", "esc", "ctrl+c"),
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q", "quit"),
),
Enter: key.NewBinding(
key.WithKeys("enter", " "),
key.WithHelp("enter", "run backup"),
key.WithHelp("enter", "browse directory"),
),
Tab: key.NewBinding(
key.WithKeys("tab"),
@@ -226,10 +190,6 @@ var keys = keyMap{
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"),
@@ -240,20 +200,16 @@ var keys = keyMap{
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
@@ -353,28 +309,20 @@ func initialModel() Model {
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.")
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,
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
}
}
@@ -400,18 +348,10 @@ func (d BackupItemDelegate) Render(w io.Writer, m list.Model, index int, listIte
}
}
// 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 {
for i, item := range items {
if bi, ok := item.(BackupItem); ok {
backupItems[i] = bi
}
@@ -420,7 +360,7 @@ func convertToBackupItems(items []list.Item) []BackupItem {
}
func (m Model) Init() tea.Cmd {
return m.spinner.Tick
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -441,10 +381,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.ready = true
// Show description for the first selected item instead of generic welcome
if len(m.items) > 0 && len(m.runningBackups) == 0 {
if len(m.items) > 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.")
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
@@ -480,28 +420,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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.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
if len(m.runningBackups) == 0 {
m.showItemDescription()
}
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.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
if len(m.runningBackups) == 0 {
m.showItemDescription()
}
m.showItemDescription()
case key.Matches(msg, m.keys.Enter) && m.activePanel == 0:
if i, ok := m.list.SelectedItem().(BackupItem); ok {
@@ -518,30 +447,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
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
// 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.selectedIndex = m.list.Index()
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...)
@@ -630,37 +567,8 @@ func (m Model) createStatusBar() string {
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 file browser status
parts = append(parts, idleStyle.Render("Mode: Browse Only"))
// Add navigation help
if m.activePanel == 0 {
@@ -696,82 +604,30 @@ func (m *Model) showLogs() {
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("=== 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")
}
// 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))
}
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())
}
@@ -812,7 +668,6 @@ func (m *Model) showConfig() {
// 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")
@@ -822,11 +677,13 @@ func (m *Model) showConfig() {
// showItemDescription displays detailed information about the currently selected menu item
func (m *Model) showItemDescription() {
if m.selectedIndex < 0 || m.selectedIndex >= len(m.items) {
// 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[m.selectedIndex]
item := m.items[selectedIndex]
var content strings.Builder
content.WriteString(fmt.Sprintf("📋 %s\n", item.title))
@@ -943,9 +800,9 @@ func (m *Model) showItemDescription() {
content.WriteString("\n💡 Navigation:\n")
content.WriteString("• Tab: Switch between panels\n")
content.WriteString("• R: Refresh current view\n")
content.WriteString("• r: Refresh current view\n")
content.WriteString("• ?: Toggle help\n")
content.WriteString("• Q: Quit application\n")
content.WriteString("• q: Quit application\n")
// Add path information
content.WriteString(fmt.Sprintf("\n📁 Path: %s\n", item.path))
@@ -953,7 +810,103 @@ func (m *Model) showItemDescription() {
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))
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 {
@@ -1097,21 +1050,11 @@ func (m *Model) loadLogFiles() tea.Cmd {
}
}
// 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)}
// 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)
}
}