- Replace all emoji characters with ASCII equivalents throughout codebase - Replace Unicode box-drawing characters (═║╔╗╚╝━─) with ASCII (+|-=) - Replace checkmarks (✓✗) with [OK]/[FAIL] markers - 59 files updated, 741 lines changed - Improves terminal compatibility and reduces visual noise
457 lines
12 KiB
Go
Executable File
457 lines
12 KiB
Go
Executable File
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"dbbackup/internal/cleanup"
|
|
"dbbackup/internal/config"
|
|
"dbbackup/internal/logger"
|
|
)
|
|
|
|
// Style definitions
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("15")).
|
|
Background(lipgloss.Color("240")).
|
|
Padding(0, 1)
|
|
|
|
menuStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("250"))
|
|
|
|
menuSelectedStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("15")).
|
|
Bold(true)
|
|
|
|
infoStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("244"))
|
|
|
|
successStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("2")).
|
|
Bold(true)
|
|
|
|
errorStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("1")).
|
|
Bold(true)
|
|
|
|
dbSelectorLabelStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("6")).
|
|
Bold(true)
|
|
)
|
|
|
|
type dbTypeOption struct {
|
|
label string
|
|
value string
|
|
}
|
|
|
|
// MenuModel represents the simple menu state
|
|
type MenuModel struct {
|
|
choices []string
|
|
cursor int
|
|
config *config.Config
|
|
logger logger.Logger
|
|
quitting bool
|
|
message string
|
|
dbTypes []dbTypeOption
|
|
dbTypeCursor int
|
|
|
|
// Background operations
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
func NewMenuModel(cfg *config.Config, log logger.Logger) *MenuModel {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
dbTypes := []dbTypeOption{
|
|
{label: "PostgreSQL", value: "postgres"},
|
|
{label: "MySQL", value: "mysql"},
|
|
{label: "MariaDB", value: "mariadb"},
|
|
}
|
|
|
|
dbCursor := 0
|
|
if cfg.DatabaseType == "mysql" {
|
|
dbCursor = 1
|
|
} else if cfg.DatabaseType == "mariadb" {
|
|
dbCursor = 2
|
|
}
|
|
|
|
model := &MenuModel{
|
|
choices: []string{
|
|
"Single Database Backup",
|
|
"Sample Database Backup (with ratio)",
|
|
"Cluster Backup (all databases)",
|
|
"--------------------------------",
|
|
"Restore Single Database",
|
|
"Restore Cluster Backup",
|
|
"Diagnose Backup File",
|
|
"List & Manage Backups",
|
|
"--------------------------------",
|
|
"View Active Operations",
|
|
"Show Operation History",
|
|
"Database Status & Health Check",
|
|
"Configuration Settings",
|
|
"Clear Operation History",
|
|
"Quit",
|
|
},
|
|
config: cfg,
|
|
logger: log,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
dbTypes: dbTypes,
|
|
dbTypeCursor: dbCursor,
|
|
}
|
|
|
|
return model
|
|
}
|
|
|
|
// Close implements io.Closer for safe cleanup
|
|
func (m *MenuModel) Close() error {
|
|
m.closeOnce.Do(func() {
|
|
if m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// Ensure MenuModel implements io.Closer
|
|
var _ io.Closer = (*MenuModel)(nil)
|
|
|
|
// autoSelectMsg is sent when auto-select should trigger
|
|
type autoSelectMsg struct{}
|
|
|
|
// Init initializes the model
|
|
func (m *MenuModel) Init() tea.Cmd {
|
|
// Auto-select menu option if specified
|
|
if m.config.TUIAutoSelect >= 0 && m.config.TUIAutoSelect < len(m.choices) {
|
|
m.logger.Info("TUI Auto-select enabled", "option", m.config.TUIAutoSelect, "label", m.choices[m.config.TUIAutoSelect])
|
|
|
|
// Return command to trigger auto-selection
|
|
return func() tea.Msg {
|
|
return autoSelectMsg{}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Update handles messages
|
|
func (m *MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case autoSelectMsg:
|
|
// Handle auto-selection
|
|
if m.config.TUIAutoSelect >= 0 && m.config.TUIAutoSelect < len(m.choices) {
|
|
m.cursor = m.config.TUIAutoSelect
|
|
m.logger.Info("Auto-selecting option", "cursor", m.cursor, "choice", m.choices[m.cursor])
|
|
|
|
// Trigger the selection based on cursor position
|
|
switch m.cursor {
|
|
case 0: // Single Database Backup
|
|
return m.handleSingleBackup()
|
|
case 1: // Sample Database Backup
|
|
return m.handleSampleBackup()
|
|
case 2: // Cluster Backup
|
|
return m.handleClusterBackup()
|
|
case 4: // Restore Single Database
|
|
return m.handleRestoreSingle()
|
|
case 5: // Restore Cluster Backup
|
|
return m.handleRestoreCluster()
|
|
case 6: // Diagnose Backup File
|
|
return m.handleDiagnoseBackup()
|
|
case 7: // List & Manage Backups
|
|
return m.handleBackupManager()
|
|
case 9: // View Active Operations
|
|
return m.handleViewOperations()
|
|
case 10: // Show Operation History
|
|
return m.handleOperationHistory()
|
|
case 11: // Database Status
|
|
return m.handleStatus()
|
|
case 12: // Settings
|
|
return m.handleSettings()
|
|
case 13: // Clear History
|
|
m.message = "[DEL] History cleared"
|
|
case 14: // Quit
|
|
if m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "ctrl+c", "q":
|
|
// Cancel all running operations
|
|
if m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
|
|
// Clean up any orphaned processes before exit
|
|
m.logger.Info("Cleaning up processes before exit")
|
|
if err := cleanup.KillOrphanedProcesses(m.logger); err != nil {
|
|
m.logger.Warn("Failed to clean up all processes", "error", err)
|
|
}
|
|
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
|
|
case "left", "h":
|
|
if m.dbTypeCursor > 0 {
|
|
m.dbTypeCursor--
|
|
m.applyDatabaseSelection()
|
|
}
|
|
|
|
case "right", "l":
|
|
if m.dbTypeCursor < len(m.dbTypes)-1 {
|
|
m.dbTypeCursor++
|
|
m.applyDatabaseSelection()
|
|
}
|
|
|
|
case "t":
|
|
if len(m.dbTypes) > 0 {
|
|
m.dbTypeCursor = (m.dbTypeCursor + 1) % len(m.dbTypes)
|
|
m.applyDatabaseSelection()
|
|
}
|
|
|
|
case "up", "k":
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
|
|
case "down", "j":
|
|
if m.cursor < len(m.choices)-1 {
|
|
m.cursor++
|
|
}
|
|
|
|
case "enter", " ":
|
|
switch m.cursor {
|
|
case 0: // Single Database Backup
|
|
return m.handleSingleBackup()
|
|
case 1: // Sample Database Backup
|
|
return m.handleSampleBackup()
|
|
case 2: // Cluster Backup
|
|
return m.handleClusterBackup()
|
|
case 3: // Separator
|
|
// Do nothing
|
|
case 4: // Restore Single Database
|
|
return m.handleRestoreSingle()
|
|
case 5: // Restore Cluster Backup
|
|
return m.handleRestoreCluster()
|
|
case 6: // Diagnose Backup File
|
|
return m.handleDiagnoseBackup()
|
|
case 7: // List & Manage Backups
|
|
return m.handleBackupManager()
|
|
case 8: // Separator
|
|
// Do nothing
|
|
case 9: // View Active Operations
|
|
return m.handleViewOperations()
|
|
case 10: // Show Operation History
|
|
return m.handleOperationHistory()
|
|
case 11: // Database Status
|
|
return m.handleStatus()
|
|
case 12: // Settings
|
|
return m.handleSettings()
|
|
case 13: // Clear History
|
|
m.message = "[DEL] History cleared"
|
|
case 14: // Quit
|
|
if m.cancel != nil {
|
|
m.cancel()
|
|
}
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// View renders the simple menu
|
|
func (m *MenuModel) View() string {
|
|
if m.quitting {
|
|
return "Thanks for using DB Backup Tool!\n"
|
|
}
|
|
|
|
var s string
|
|
|
|
// Header
|
|
header := titleStyle.Render("[DB] Database Backup Tool - Interactive Menu")
|
|
s += fmt.Sprintf("\n%s\n\n", header)
|
|
|
|
if len(m.dbTypes) > 0 {
|
|
options := make([]string, len(m.dbTypes))
|
|
for i, opt := range m.dbTypes {
|
|
if m.dbTypeCursor == i {
|
|
options[i] = menuSelectedStyle.Render(opt.label)
|
|
} else {
|
|
options[i] = menuStyle.Render(opt.label)
|
|
}
|
|
}
|
|
selector := fmt.Sprintf("Target Engine: %s", strings.Join(options, menuStyle.Render(" | ")))
|
|
s += dbSelectorLabelStyle.Render(selector) + "\n"
|
|
hint := infoStyle.Render("Switch with <-/-> or t | Cluster backup requires PostgreSQL")
|
|
s += hint + "\n"
|
|
}
|
|
|
|
// Database info
|
|
dbInfo := infoStyle.Render(fmt.Sprintf("Database: %s@%s:%d (%s)",
|
|
m.config.User, m.config.Host, m.config.Port, m.config.DisplayDatabaseType()))
|
|
s += fmt.Sprintf("%s\n\n", dbInfo)
|
|
|
|
// Menu items
|
|
for i, choice := range m.choices {
|
|
cursor := " "
|
|
if m.cursor == i {
|
|
cursor = ">"
|
|
s += menuSelectedStyle.Render(fmt.Sprintf("%s %s", cursor, choice))
|
|
} else {
|
|
s += menuStyle.Render(fmt.Sprintf("%s %s", cursor, choice))
|
|
}
|
|
s += "\n"
|
|
}
|
|
|
|
// Message area
|
|
if m.message != "" {
|
|
s += "\n" + m.message + "\n"
|
|
}
|
|
|
|
// Footer
|
|
footer := infoStyle.Render("\n[KEYS] Press Up/Down to navigate | Enter to select | q to quit")
|
|
s += footer
|
|
|
|
return s
|
|
}
|
|
|
|
// handleSingleBackup opens database selector for single backup
|
|
func (m *MenuModel) handleSingleBackup() (tea.Model, tea.Cmd) {
|
|
selector := NewDatabaseSelector(m.config, m.logger, m, m.ctx, "[DB] Single Database Backup", "single")
|
|
return selector, selector.Init()
|
|
}
|
|
|
|
// handleSampleBackup opens database selector for sample backup
|
|
func (m *MenuModel) handleSampleBackup() (tea.Model, tea.Cmd) {
|
|
selector := NewDatabaseSelector(m.config, m.logger, m, m.ctx, "[STATS] Sample Database Backup", "sample")
|
|
return selector, selector.Init()
|
|
}
|
|
|
|
// handleClusterBackup shows confirmation and executes cluster backup
|
|
func (m *MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) {
|
|
if !m.config.IsPostgreSQL() {
|
|
m.message = errorStyle.Render("[FAIL] Cluster backup is available only for PostgreSQL targets")
|
|
return m, nil
|
|
}
|
|
// Skip confirmation in auto-confirm mode
|
|
if m.config.TUIAutoConfirm {
|
|
executor := NewBackupExecution(m.config, m.logger, m, m.ctx, "cluster", "", 0)
|
|
return executor, executor.Init()
|
|
}
|
|
confirm := NewConfirmationModelWithAction(m.config, m.logger, m,
|
|
"[DB] Cluster Backup",
|
|
"This will backup ALL databases in the cluster. Continue?",
|
|
func() (tea.Model, tea.Cmd) {
|
|
executor := NewBackupExecution(m.config, m.logger, m, m.ctx, "cluster", "", 0)
|
|
return executor, executor.Init()
|
|
})
|
|
return confirm, confirm.Init()
|
|
}
|
|
|
|
// handleViewOperations shows active operations
|
|
func (m *MenuModel) handleViewOperations() (tea.Model, tea.Cmd) {
|
|
ops := NewOperationsView(m.config, m.logger, m)
|
|
return ops, nil
|
|
}
|
|
|
|
// handleOperationHistory shows operation history
|
|
func (m *MenuModel) handleOperationHistory() (tea.Model, tea.Cmd) {
|
|
history := NewHistoryView(m.config, m.logger, m)
|
|
return history, nil
|
|
}
|
|
|
|
// handleStatus shows database status
|
|
func (m *MenuModel) handleStatus() (tea.Model, tea.Cmd) {
|
|
status := NewStatusView(m.config, m.logger, m)
|
|
return status, status.Init()
|
|
}
|
|
|
|
// handleSettings opens settings
|
|
func (m *MenuModel) handleSettings() (tea.Model, tea.Cmd) {
|
|
// Create and return the settings model
|
|
settingsModel := NewSettingsModel(m.config, m.logger, m)
|
|
return settingsModel, nil
|
|
}
|
|
|
|
// handleRestoreSingle opens archive browser for single restore
|
|
func (m *MenuModel) handleRestoreSingle() (tea.Model, tea.Cmd) {
|
|
browser := NewArchiveBrowser(m.config, m.logger, m, m.ctx, "restore-single")
|
|
return browser, browser.Init()
|
|
}
|
|
|
|
// handleRestoreCluster opens archive browser for cluster restore
|
|
func (m *MenuModel) handleRestoreCluster() (tea.Model, tea.Cmd) {
|
|
if !m.config.IsPostgreSQL() {
|
|
m.message = errorStyle.Render("[FAIL] Cluster restore is available only for PostgreSQL")
|
|
return m, nil
|
|
}
|
|
browser := NewArchiveBrowser(m.config, m.logger, m, m.ctx, "restore-cluster")
|
|
return browser, browser.Init()
|
|
}
|
|
|
|
// handleBackupManager opens backup management view
|
|
func (m *MenuModel) handleBackupManager() (tea.Model, tea.Cmd) {
|
|
manager := NewBackupManager(m.config, m.logger, m, m.ctx)
|
|
return manager, manager.Init()
|
|
}
|
|
|
|
// handleDiagnoseBackup opens archive browser for diagnosis
|
|
func (m *MenuModel) handleDiagnoseBackup() (tea.Model, tea.Cmd) {
|
|
browser := NewArchiveBrowser(m.config, m.logger, m, m.ctx, "diagnose")
|
|
return browser, browser.Init()
|
|
}
|
|
|
|
func (m *MenuModel) applyDatabaseSelection() {
|
|
if m == nil || len(m.dbTypes) == 0 {
|
|
return
|
|
}
|
|
if m.dbTypeCursor < 0 || m.dbTypeCursor >= len(m.dbTypes) {
|
|
return
|
|
}
|
|
|
|
selection := m.dbTypes[m.dbTypeCursor]
|
|
if err := m.config.SetDatabaseType(selection.value); err != nil {
|
|
m.message = errorStyle.Render(fmt.Sprintf("[FAIL] %v", err))
|
|
return
|
|
}
|
|
|
|
// Refresh default port if unchanged
|
|
if m.config.Port == 0 {
|
|
m.config.Port = m.config.GetDefaultPort()
|
|
}
|
|
|
|
m.message = successStyle.Render(fmt.Sprintf("[SWITCH] Target database set to %s", m.config.DisplayDatabaseType()))
|
|
if m.logger != nil {
|
|
m.logger.Info("updated target database type", "type", m.config.DatabaseType, "port", m.config.Port)
|
|
}
|
|
}
|
|
|
|
// RunInteractiveMenu starts the simple TUI
|
|
func RunInteractiveMenu(cfg *config.Config, log logger.Logger) error {
|
|
m := NewMenuModel(cfg, log)
|
|
p := tea.NewProgram(m)
|
|
|
|
if _, err := p.Run(); err != nil {
|
|
return fmt.Errorf("error running interactive menu: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|