mirror of
https://github.com/acedanger/shell.git
synced 2025-12-06 00:00:13 -08:00
1061 lines
32 KiB
Go
1061 lines
32 KiB
Go
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))
|
|
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 {
|
|
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}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|