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