mirror of
https://github.com/acedanger/shell.git
synced 2025-12-06 07:50:11 -08:00
- Add processEscapeSequences() function to convert literal \n to actual newlines - Apply fix to backup success, error, and running output displays - Add TUI binary to gitignore - Include comprehensive documentation of the fix Resolves issue where backup output showed literal escape sequences instead of properly formatted text with line breaks.
1319 lines
38 KiB
Go
1319 lines
38 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"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 (
|
|
backupStartMsg struct{ service string }
|
|
backupDoneMsg struct{ service, output string }
|
|
backupErrorMsg struct{ service, error string }
|
|
backupProgressMsg struct{ service string; progress int; output string }
|
|
backupStopMsg struct{ service string }
|
|
backupOutputMsg struct{ service, line string }
|
|
logsUpdatedMsg struct{}
|
|
statusUpdatedMsg struct{}
|
|
clearOutputMsg struct{}
|
|
configUpdateMsg struct{}
|
|
)
|
|
|
|
// 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 in the list
|
|
type BackupItem struct {
|
|
title string
|
|
description string
|
|
service string
|
|
script string
|
|
args []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"
|
|
}
|
|
|
|
// Initialize the model
|
|
func initialModel() Model {
|
|
// Get the shell path (parent directory of tui)
|
|
shellPath, _ := filepath.Abs(filepath.Dir(os.Args[0]) + "/..")
|
|
|
|
// Create backup items
|
|
items := []list.Item{
|
|
BackupItem{
|
|
title: "📦 Plex Backup",
|
|
description: "Complete Plex Media Server backup with integrity checking",
|
|
service: "plex",
|
|
script: filepath.Join(shellPath, "plex", "backup-plex.sh"),
|
|
args: []string{"--non-interactive"},
|
|
},
|
|
BackupItem{
|
|
title: "🖼️ Immich Backup",
|
|
description: "Database and uploads backup with B2 sync",
|
|
service: "immich",
|
|
script: filepath.Join(shellPath, "immich", "backup-immich.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "🎬 Media Services Backup",
|
|
description: "Sonarr, Radarr, Prowlarr, and other media services",
|
|
service: "media",
|
|
script: filepath.Join(shellPath, "backup-media.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "✅ Validate Plex Backups",
|
|
description: "Check integrity and completeness of Plex backups",
|
|
service: "plex-validate",
|
|
script: filepath.Join(shellPath, "plex", "validate-plex-backups.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "✅ Validate Immich Backups",
|
|
description: "Check integrity and completeness of Immich backups",
|
|
service: "immich-validate",
|
|
script: filepath.Join(shellPath, "immich", "validate-immich-backups.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "📊 Monitor Plex Status",
|
|
description: "Real-time Plex backup monitoring and status",
|
|
service: "plex-monitor",
|
|
script: filepath.Join(shellPath, "plex", "monitor-plex-backup.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "🔧 Environment Files Backup",
|
|
description: "Backup Docker environment and configuration files",
|
|
service: "env",
|
|
script: filepath.Join(shellPath, "backup-env-files.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "🐳 Docker Configuration Backup",
|
|
description: "Backup Docker containers and compose files",
|
|
service: "docker",
|
|
script: filepath.Join(shellPath, "backup-docker.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "✅ Validate Environment Backups",
|
|
description: "Check integrity of environment and Docker backups",
|
|
service: "env-validate",
|
|
script: filepath.Join(shellPath, "validate-env-backups.sh"),
|
|
args: []string{},
|
|
},
|
|
BackupItem{
|
|
title: "🔄 Restore Plex",
|
|
description: "Restore Plex from backup with validation",
|
|
service: "plex-restore",
|
|
script: filepath.Join(shellPath, "plex", "restore-plex.sh"),
|
|
args: []string{"--dry-run"},
|
|
},
|
|
BackupItem{
|
|
title: "🔄 Restore Immich",
|
|
description: "Restore Immich from backup",
|
|
service: "immich-restore",
|
|
script: filepath.Join(shellPath, "immich", "restore-immich.sh"),
|
|
args: []string{},
|
|
},
|
|
}
|
|
|
|
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",
|
|
}
|
|
}
|
|
|
|
// 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 tea.Batch(
|
|
m.spinner.Tick,
|
|
m.loadBackupStatus(),
|
|
m.loadLogs(),
|
|
)
|
|
}
|
|
|
|
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
|
|
|
|
// This is only necessary for high performance rendering, which in
|
|
// most cases you won't need.
|
|
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):
|
|
cmds = append(cmds, m.loadBackupStatus())
|
|
cmds = append(cmds, m.loadLogs())
|
|
|
|
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):
|
|
if i, ok := m.list.SelectedItem().(BackupItem); ok {
|
|
if process, exists := m.runningBackups[i.service]; exists {
|
|
cmds = append(cmds, m.stopBackup(i.service, process))
|
|
}
|
|
}
|
|
|
|
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"
|
|
|
|
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"
|
|
|
|
case key.Matches(msg, m.keys.Enter) && m.activePanel == 0:
|
|
if i, ok := m.list.SelectedItem().(BackupItem); ok {
|
|
cmds = append(cmds, m.executeBackup(i))
|
|
}
|
|
|
|
case key.Matches(msg, m.keys.Help):
|
|
m.showHelp = !m.showHelp
|
|
}
|
|
|
|
// Update the focused component
|
|
if m.activePanel == 0 {
|
|
m.list, cmd = m.list.Update(msg)
|
|
} else {
|
|
m.viewport, cmd = m.viewport.Update(msg)
|
|
}
|
|
cmds = append(cmds, cmd)
|
|
|
|
case backupStartMsg:
|
|
if m.backupStatus[msg.service] == nil {
|
|
m.backupStatus[msg.service] = &BackupStatus{}
|
|
}
|
|
|
|
// Create context for this backup
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
m.backupStatus[msg.service].Service = msg.service
|
|
m.backupStatus[msg.service].Status = "running"
|
|
m.backupStatus[msg.service].LastRun = time.Now()
|
|
m.backupStatus[msg.service].StartTime = time.Now()
|
|
m.backupStatus[msg.service].Progress = 0
|
|
m.backupStatus[msg.service].Context = ctx
|
|
m.backupStatus[msg.service].Cancel = cancel
|
|
|
|
m.viewport.SetContent(fmt.Sprintf("🚀 Starting backup for %s...\n\nInitializing backup process...\nPress 'x' to cancel if needed.", msg.service))
|
|
|
|
// Start progress ticker for time-based progress estimation
|
|
cmds = append(cmds, m.startProgressTicker(msg.service))
|
|
|
|
case backupProgressMsg:
|
|
if status, ok := m.backupStatus[msg.service]; ok && status.Status == "running" {
|
|
status.Progress = msg.progress
|
|
// msg.output from the ticker is a general status line (e.g., "Backup in progress... (X elapsed)")
|
|
// This will be used for the main output area in the viewport for this specific message.
|
|
m.updateViewportForRunningService(msg.service, msg.output)
|
|
}
|
|
|
|
case backupOutputMsg:
|
|
if status, ok := m.backupStatus[msg.service]; ok {
|
|
// Append to output history
|
|
currentOutput := status.Output
|
|
if currentOutput != "" {
|
|
currentOutput += "\n"
|
|
}
|
|
currentOutput += msg.line
|
|
|
|
// Keep only last 20 lines to prevent memory issues
|
|
lines := strings.Split(currentOutput, "\n")
|
|
if len(lines) > 20 {
|
|
lines = lines[len(lines)-20:]
|
|
currentOutput = strings.Join(lines, "\n")
|
|
}
|
|
|
|
status.Output = currentOutput // Update the canonical script output log
|
|
|
|
// Update display if this service is currently running
|
|
if status.Status == "running" {
|
|
// Display the accumulated script output from status.Output
|
|
m.updateViewportForRunningService(msg.service, status.Output)
|
|
}
|
|
}
|
|
|
|
case backupStopMsg:
|
|
if m.backupStatus[msg.service] != nil {
|
|
m.backupStatus[msg.service].Status = "stopped"
|
|
m.backupStatus[msg.service].EndTime = time.Now()
|
|
if m.backupStatus[msg.service].Cancel != nil {
|
|
m.backupStatus[msg.service].Cancel()
|
|
}
|
|
delete(m.runningBackups, msg.service)
|
|
}
|
|
m.viewport.SetContent(fmt.Sprintf("🛑 Backup stopped for %s.\n\nThe backup process was cancelled by user request.", msg.service))
|
|
|
|
case backupDoneMsg:
|
|
if m.backupStatus[msg.service] != nil {
|
|
m.backupStatus[msg.service].Status = "success"
|
|
m.backupStatus[msg.service].Output = msg.output
|
|
m.backupStatus[msg.service].EndTime = time.Now()
|
|
m.backupStatus[msg.service].Duration = m.backupStatus[msg.service].EndTime.Sub(m.backupStatus[msg.service].StartTime).String()
|
|
m.backupStatus[msg.service].Progress = 100
|
|
|
|
// Clean up context
|
|
if m.backupStatus[msg.service].Cancel != nil {
|
|
m.backupStatus[msg.service].Cancel()
|
|
}
|
|
}
|
|
delete(m.runningBackups, msg.service)
|
|
|
|
// Parse output for additional info
|
|
lines := strings.Split(msg.output, "\n")
|
|
var summary strings.Builder
|
|
|
|
// Extract meaningful summary information
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "Total files:") ||
|
|
strings.Contains(line, "Total size:") ||
|
|
strings.Contains(line, "Backup completed") ||
|
|
strings.Contains(line, "Archive created") ||
|
|
strings.Contains(line, "Files backed up:") {
|
|
summary.WriteString("📊 " + strings.TrimSpace(line) + "\n")
|
|
}
|
|
}
|
|
|
|
summaryText := summary.String()
|
|
if summaryText == "" {
|
|
summaryText = "📊 Backup completed successfully"
|
|
}
|
|
|
|
duration := ""
|
|
if m.backupStatus[msg.service] != nil {
|
|
duration = m.backupStatus[msg.service].Duration
|
|
}
|
|
|
|
// Process output to handle escape sequences properly
|
|
processedOutput := processEscapeSequences(msg.output)
|
|
|
|
m.viewport.SetContent(fmt.Sprintf("✅ Backup completed successfully for %s!\n\nDuration: %s\n\n%s\n\n📋 Full Output:\n%s",
|
|
msg.service, duration, summaryText, processedOutput))
|
|
|
|
case backupErrorMsg:
|
|
if m.backupStatus[msg.service] != nil {
|
|
m.backupStatus[msg.service].Status = "error"
|
|
m.backupStatus[msg.service].Error = msg.error
|
|
m.backupStatus[msg.service].EndTime = time.Now()
|
|
m.backupStatus[msg.service].Duration = m.backupStatus[msg.service].EndTime.Sub(m.backupStatus[msg.service].StartTime).String()
|
|
|
|
// Clean up context
|
|
if m.backupStatus[msg.service].Cancel != nil {
|
|
m.backupStatus[msg.service].Cancel()
|
|
}
|
|
}
|
|
delete(m.runningBackups, msg.service)
|
|
|
|
duration := ""
|
|
if m.backupStatus[msg.service] != nil {
|
|
duration = m.backupStatus[msg.service].Duration
|
|
}
|
|
|
|
// Provide helpful error analysis
|
|
var errorAnalysis string
|
|
if strings.Contains(msg.error, "permission denied") {
|
|
errorAnalysis = "\n💡 This appears to be a permission issue. Try running with appropriate privileges."
|
|
} else if strings.Contains(msg.error, "command not found") {
|
|
errorAnalysis = "\n💡 The backup script was not found. Check that the script exists and is executable."
|
|
} else if strings.Contains(msg.error, "docker") {
|
|
errorAnalysis = "\n💡 This appears to be a Docker-related issue. Ensure Docker is running and accessible."
|
|
} else if strings.Contains(msg.error, "space") {
|
|
errorAnalysis = "\n💡 This might be a disk space issue. Check available storage."
|
|
}
|
|
|
|
// Process error output to handle escape sequences properly
|
|
processedError := processEscapeSequences(msg.error)
|
|
|
|
m.viewport.SetContent(fmt.Sprintf("❌ Backup failed for %s!\n\nDuration: %s%s\n\n🚨 Error Details:\n%s",
|
|
msg.service, duration, errorAnalysis, processedError))
|
|
|
|
case spinner.TickMsg:
|
|
m.spinner, cmd = m.spinner.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
case logsUpdatedMsg:
|
|
// Logs have been updated, refresh if in logs view
|
|
if m.currentView == "logs" {
|
|
m.showLogs()
|
|
}
|
|
|
|
case statusUpdatedMsg:
|
|
// Status has been updated, refresh if in status view
|
|
if m.currentView == "status" {
|
|
m.showStatus()
|
|
}
|
|
|
|
case configUpdateMsg:
|
|
// Configuration has been updated, refresh if in config view
|
|
if m.currentView == "config" {
|
|
m.showConfig()
|
|
}
|
|
|
|
case clearOutputMsg:
|
|
m.viewport.SetContent("Output cleared.\n\nSelect a backup operation and press Enter to execute.")
|
|
m.currentView = "main"
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
// Helper method to update viewport for a running service
|
|
func (m *Model) updateViewportForRunningService(service string, outputForDisplay string) {
|
|
status := m.backupStatus[service] // Assume status exists and is valid, checked by callers
|
|
|
|
elapsed := time.Since(status.StartTime)
|
|
progress := status.Progress
|
|
progressBar := createProgressBar(progress)
|
|
|
|
var etaStr string
|
|
if progress > 0 && progress < 100 && elapsed > 0 {
|
|
// Consistent ETA calculation based on current progress and elapsed time
|
|
// Assumes 'progress' is a percentage of completion (0-100).
|
|
totalEstimatedRaw := float64(elapsed.Nanoseconds()) * (100.0 / float64(progress))
|
|
totalEstimatedDuration := time.Duration(totalEstimatedRaw)
|
|
remainingDuration := totalEstimatedDuration - elapsed
|
|
if remainingDuration > 0 {
|
|
etaStr = fmt.Sprintf("\nETA: %s", remainingDuration.Round(time.Second))
|
|
}
|
|
}
|
|
|
|
// Process output to handle escape sequences properly
|
|
processedOutput := processEscapeSequences(outputForDisplay)
|
|
|
|
m.viewport.SetContent(fmt.Sprintf("⏳ Backup in progress for %s...\n\n%s %d%%\nElapsed: %s%s\n\n📋 Latest output:\n%s\n\nPress 'x' to stop the backup.",
|
|
service, progressBar, progress, elapsed.Round(time.Second), etaStr, processedOutput))
|
|
}
|
|
|
|
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(" Script: %s\n", item.script))
|
|
if len(item.args) > 0 {
|
|
content.WriteString(fmt.Sprintf(" Args: %s\n", strings.Join(item.args, " ")))
|
|
}
|
|
|
|
// Check if script exists
|
|
if _, err := os.Stat(item.script); os.IsNotExist(err) {
|
|
content.WriteString(" ⚠️ Status: Script not found\n")
|
|
} else {
|
|
content.WriteString(" ✅ Status: Ready\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 (run), x (stop), 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 status after running external scripts\n")
|
|
content.WriteString(" • Monitor logs with 'v' during long-running backups\n")
|
|
content.WriteString(" • Check 's' for detailed backup statistics\n")
|
|
content.WriteString(" • Use 'x' to gracefully stop running backups\n")
|
|
|
|
m.viewport.SetContent(content.String())
|
|
}
|
|
|
|
func (m *Model) stopBackup(service string, process *RunningProcess) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
if process != nil {
|
|
// Cancel the context to signal graceful shutdown
|
|
if process.Cancel != nil {
|
|
process.Cancel()
|
|
}
|
|
|
|
// Give process time to shut down gracefully
|
|
time.Sleep(2 * time.Second)
|
|
|
|
// Force kill if still running
|
|
if process.Cmd != nil && process.Cmd.Process != nil {
|
|
if process.Cmd.ProcessState == nil || !process.Cmd.ProcessState.Exited() {
|
|
process.Cmd.Process.Kill()
|
|
}
|
|
}
|
|
}
|
|
return backupStopMsg{service: service}
|
|
})
|
|
}
|
|
|
|
func (m Model) executeBackup(item BackupItem) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
return backupStartMsg{service: item.service}
|
|
})
|
|
}
|
|
|
|
// Enhanced backup command with real-time progress tracking
|
|
func (m Model) runBackupCommand(item BackupItem) tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
// Create context for cancellation
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
// Execute the backup script with context
|
|
cmd := exec.CommandContext(ctx, item.script, item.args...)
|
|
cmd.Dir = m.shellPath
|
|
|
|
// Create pipes for stdout and stderr
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return backupErrorMsg{
|
|
service: item.service,
|
|
error: fmt.Sprintf("Failed to create stdout pipe: %s", err.Error()),
|
|
}
|
|
}
|
|
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return backupErrorMsg{
|
|
service: item.service,
|
|
error: fmt.Sprintf("Failed to create stderr pipe: %s", err.Error()),
|
|
}
|
|
}
|
|
|
|
// Start the command
|
|
if err := cmd.Start(); err != nil {
|
|
return backupErrorMsg{
|
|
service: item.service,
|
|
error: fmt.Sprintf("Failed to start backup: %s", err.Error()),
|
|
}
|
|
}
|
|
|
|
var outputBuilder strings.Builder
|
|
var allOutput []string
|
|
|
|
// Monitor stdout and stderr in goroutines
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
|
|
// Read stdout
|
|
go func() {
|
|
defer wg.Done()
|
|
scanner := bufio.NewScanner(stdout)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
outputBuilder.WriteString(line + "\n")
|
|
allOutput = append(allOutput, line)
|
|
}
|
|
}()
|
|
|
|
// Read stderr
|
|
go func() {
|
|
defer wg.Done()
|
|
scanner := bufio.NewScanner(stderr)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
outputBuilder.WriteString("[STDERR] " + line + "\n")
|
|
allOutput = append(allOutput, "[STDERR] "+line)
|
|
}
|
|
}()
|
|
|
|
// Wait for the command to complete
|
|
err = cmd.Wait()
|
|
|
|
// Wait for output readers to finish
|
|
wg.Wait()
|
|
|
|
finalOutput := outputBuilder.String()
|
|
|
|
if err != nil {
|
|
// Check if it was cancelled
|
|
if ctx.Err() == context.Canceled {
|
|
return backupStopMsg{service: item.service}
|
|
}
|
|
|
|
return backupErrorMsg{
|
|
service: item.service,
|
|
error: fmt.Sprintf("Backup failed: %s\n\n📋 Output:\n%s", err.Error(), finalOutput),
|
|
}
|
|
}
|
|
|
|
return backupDoneMsg{
|
|
service: item.service,
|
|
output: finalOutput,
|
|
}
|
|
})
|
|
}
|
|
|
|
// Estimate progress based on output patterns and service type
|
|
func estimateProgress(output []string, service string) int {
|
|
if len(output) == 0 {
|
|
return 0
|
|
}
|
|
|
|
switch service {
|
|
case "plex":
|
|
// Plex backup stages: stop service (10%), backup files (60%), verify (20%), archive (10%)
|
|
stages := map[string]int{
|
|
"stopping": 10,
|
|
"backing": 40,
|
|
"copying": 60,
|
|
"verifying": 80,
|
|
"archiving": 90,
|
|
"completed": 100,
|
|
}
|
|
|
|
for _, line := range output {
|
|
line = strings.ToLower(line)
|
|
for stage, progress := range stages {
|
|
if strings.Contains(line, stage) {
|
|
return progress
|
|
}
|
|
}
|
|
}
|
|
|
|
case "immich":
|
|
// Immich stages: database dump (50%), uploads backup (40%), upload to B2 (10%)
|
|
stages := map[string]int{
|
|
"database backup": 25,
|
|
"compressing": 50,
|
|
"upload directory": 75,
|
|
"uploading to": 90,
|
|
"completed": 100,
|
|
}
|
|
|
|
for _, line := range output {
|
|
line = strings.ToLower(line)
|
|
for stage, progress := range stages {
|
|
if strings.Contains(line, stage) {
|
|
return progress
|
|
}
|
|
}
|
|
}
|
|
|
|
case "media":
|
|
// Media services backup: each service adds progress
|
|
services := []string{"sonarr", "radarr", "prowlarr", "audiobookshelf", "tautulli", "sabnzbd", "jellyseerr"}
|
|
completed := 0
|
|
|
|
for _, service := range services {
|
|
for _, line := range output {
|
|
if strings.Contains(strings.ToLower(line), service) &&
|
|
(strings.Contains(strings.ToLower(line), "success") ||
|
|
strings.Contains(strings.ToLower(line), "completed")) {
|
|
completed++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return (completed * 100) / len(services)
|
|
}
|
|
|
|
// Default progress estimation based on line count
|
|
if len(output) < 10 {
|
|
return 20
|
|
} else if len(output) < 50 {
|
|
return 50
|
|
} else if len(output) < 100 {
|
|
return 80
|
|
}
|
|
|
|
return 90
|
|
}
|
|
|
|
func (m Model) loadBackupStatus() tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
// Try to load status from log files
|
|
logDirs := []string{
|
|
filepath.Join(m.shellPath, "logs"),
|
|
filepath.Join(m.shellPath, "plex", "logs"),
|
|
filepath.Join(m.shellPath, "immich_backups"),
|
|
}
|
|
|
|
for _, logDir := range logDirs {
|
|
if _, err := os.Stat(logDir); os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
|
|
// Look for recent log files to determine last backup status
|
|
files, err := os.ReadDir(logDir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, file := range files {
|
|
if strings.Contains(file.Name(), "backup") && strings.HasSuffix(file.Name(), ".log") {
|
|
// Determine service from filename
|
|
service := "unknown"
|
|
if strings.Contains(file.Name(), "plex") {
|
|
service = "plex"
|
|
} else if strings.Contains(file.Name(), "immich") {
|
|
service = "immich"
|
|
} else if strings.Contains(file.Name(), "media") {
|
|
service = "media"
|
|
}
|
|
|
|
// Get file info for last modified time
|
|
info, err := file.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Update status based on recent activity
|
|
if time.Since(info.ModTime()) < 24*time.Hour {
|
|
if m.backupStatus[service] == nil {
|
|
m.backupStatus[service] = &BackupStatus{}
|
|
}
|
|
m.backupStatus[service].Service = service
|
|
m.backupStatus[service].LastRun = info.ModTime()
|
|
m.backupStatus[service].Status = "idle" // Default to idle, will be updated if running
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return statusUpdatedMsg{}
|
|
})
|
|
}
|
|
|
|
func (m Model) loadLogs() tea.Cmd {
|
|
return tea.Cmd(func() tea.Msg {
|
|
var logs []LogEntry
|
|
|
|
// Load logs from various sources
|
|
logFiles := []struct {
|
|
path string
|
|
service string
|
|
}{
|
|
{filepath.Join(m.shellPath, "logs", "plex-backup.log"), "plex"},
|
|
{filepath.Join(m.shellPath, "logs", "immich-backup.log"), "immich"},
|
|
{filepath.Join(m.shellPath, "logs", "media-backup.log"), "media"},
|
|
}
|
|
|
|
for _, logFile := range logFiles {
|
|
if content, err := os.ReadFile(logFile.path); err == nil {
|
|
lines := strings.Split(string(content), "\n")
|
|
for _, line := range lines {
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
|
|
// Parse log line (basic parsing)
|
|
parts := strings.SplitN(line, " - ", 2)
|
|
if len(parts) == 2 {
|
|
if timestamp, err := time.Parse("2006-01-02 15:04:05", parts[0]); err == nil {
|
|
logs = append(logs, LogEntry{
|
|
Timestamp: timestamp,
|
|
Service: logFile.service,
|
|
Level: "INFO",
|
|
Message: parts[1],
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update model logs
|
|
m.logs = logs
|
|
|
|
return logsUpdatedMsg{}
|
|
})
|
|
}
|
|
|
|
// Helper function to create a visual progress bar
|
|
func createProgressBar(progress int) string {
|
|
if progress < 0 {
|
|
progress = 0
|
|
}
|
|
if progress > 100 {
|
|
progress = 100
|
|
}
|
|
|
|
const barWidth = 30
|
|
filled := (progress * barWidth) / 100
|
|
empty := barWidth - filled
|
|
|
|
bar := strings.Repeat("█", filled) + strings.Repeat("░", empty)
|
|
return fmt.Sprintf("[%s]", bar)
|
|
}
|
|
|
|
// Helper function to process escape sequences in output text
|
|
func processEscapeSequences(text string) string {
|
|
processed := strings.ReplaceAll(text, "\\n", "\n")
|
|
processed = strings.ReplaceAll(processed, "\\t", "\t")
|
|
processed = strings.ReplaceAll(processed, "\\r", "\r")
|
|
return processed
|
|
}
|
|
|
|
// Start a progress ticker for time-based progress estimation
|
|
func (m Model) startProgressTicker(service string) tea.Cmd {
|
|
return tea.Tick(time.Second*2, func(t time.Time) tea.Msg {
|
|
// Check if backup is still running
|
|
if status, exists := m.backupStatus[service]; exists && status.Status == "running" {
|
|
elapsed := time.Since(status.StartTime)
|
|
|
|
// Estimate progress based on typical backup durations
|
|
var estimatedDuration time.Duration
|
|
switch service {
|
|
case "plex":
|
|
estimatedDuration = 5 * time.Minute // Plex backups typically take 3-8 minutes
|
|
case "immich":
|
|
estimatedDuration = 10 * time.Minute // Immich can take 5-15 minutes depending on data
|
|
case "media":
|
|
estimatedDuration = 3 * time.Minute // Media services backup is usually faster
|
|
default:
|
|
estimatedDuration = 5 * time.Minute
|
|
}
|
|
|
|
// Calculate progress (cap at 95% until actual completion)
|
|
progress := int((elapsed.Seconds() / estimatedDuration.Seconds()) * 95)
|
|
if progress > 95 {
|
|
progress = 95
|
|
}
|
|
|
|
return backupProgressMsg{
|
|
service: service,
|
|
progress: progress,
|
|
output: fmt.Sprintf("Backup in progress... (%s elapsed)", elapsed.Round(time.Second)),
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func main() {
|
|
p := tea.NewProgram(initialModel(), tea.WithAltScreen())
|
|
if _, err := p.Run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|