599 lines
16 KiB
Go
599 lines
16 KiB
Go
package tui
|
||
|
||
import (
|
||
"fmt"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
"dbbackup/internal/config"
|
||
"dbbackup/internal/logger"
|
||
)
|
||
|
||
var (
|
||
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("250")).Padding(1, 2)
|
||
inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15"))
|
||
buttonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Background(lipgloss.Color("240")).Padding(0, 2)
|
||
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Background(lipgloss.Color("240")).Bold(true)
|
||
detailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Italic(true)
|
||
)
|
||
|
||
// SettingsModel represents the settings configuration state
|
||
type SettingsModel struct {
|
||
config *config.Config
|
||
logger logger.Logger
|
||
cursor int
|
||
editing bool
|
||
editingField string
|
||
editingValue string
|
||
settings []SettingItem
|
||
quitting bool
|
||
message string
|
||
parent tea.Model
|
||
dirBrowser *DirectoryBrowser
|
||
browsingDir bool
|
||
}
|
||
|
||
// SettingItem represents a configurable setting
|
||
type SettingItem struct {
|
||
Key string
|
||
DisplayName string
|
||
Value func(*config.Config) string
|
||
Update func(*config.Config, string) error
|
||
Type string // "string", "int", "bool", "path"
|
||
Description string
|
||
}
|
||
|
||
// Initialize settings model
|
||
func NewSettingsModel(cfg *config.Config, log logger.Logger, parent tea.Model) SettingsModel {
|
||
settings := []SettingItem{
|
||
{
|
||
Key: "database_type",
|
||
DisplayName: "Database Type",
|
||
Value: func(c *config.Config) string { return c.DatabaseType },
|
||
Update: func(c *config.Config, v string) error {
|
||
return c.SetDatabaseType(v)
|
||
},
|
||
Type: "string",
|
||
Description: "Target database engine (postgres, mysql, mariadb)",
|
||
},
|
||
{
|
||
Key: "backup_dir",
|
||
DisplayName: "Backup Directory",
|
||
Value: func(c *config.Config) string { return c.BackupDir },
|
||
Update: func(c *config.Config, v string) error {
|
||
if v == "" {
|
||
return fmt.Errorf("backup directory cannot be empty")
|
||
}
|
||
c.BackupDir = filepath.Clean(v)
|
||
return nil
|
||
},
|
||
Type: "path",
|
||
Description: "Directory where backup files will be stored",
|
||
},
|
||
{
|
||
Key: "compression_level",
|
||
DisplayName: "Compression Level",
|
||
Value: func(c *config.Config) string { return fmt.Sprintf("%d", c.CompressionLevel) },
|
||
Update: func(c *config.Config, v string) error {
|
||
val, err := strconv.Atoi(v)
|
||
if err != nil {
|
||
return fmt.Errorf("compression level must be a number")
|
||
}
|
||
if val < 0 || val > 9 {
|
||
return fmt.Errorf("compression level must be between 0-9")
|
||
}
|
||
c.CompressionLevel = val
|
||
return nil
|
||
},
|
||
Type: "int",
|
||
Description: "Compression level (0=fastest, 9=smallest)",
|
||
},
|
||
{
|
||
Key: "jobs",
|
||
DisplayName: "Parallel Jobs",
|
||
Value: func(c *config.Config) string { return fmt.Sprintf("%d", c.Jobs) },
|
||
Update: func(c *config.Config, v string) error {
|
||
val, err := strconv.Atoi(v)
|
||
if err != nil {
|
||
return fmt.Errorf("jobs must be a number")
|
||
}
|
||
if val < 1 {
|
||
return fmt.Errorf("jobs must be at least 1")
|
||
}
|
||
c.Jobs = val
|
||
return nil
|
||
},
|
||
Type: "int",
|
||
Description: "Number of parallel jobs for backup operations",
|
||
},
|
||
{
|
||
Key: "dump_jobs",
|
||
DisplayName: "Dump Jobs",
|
||
Value: func(c *config.Config) string { return fmt.Sprintf("%d", c.DumpJobs) },
|
||
Update: func(c *config.Config, v string) error {
|
||
val, err := strconv.Atoi(v)
|
||
if err != nil {
|
||
return fmt.Errorf("dump jobs must be a number")
|
||
}
|
||
if val < 1 {
|
||
return fmt.Errorf("dump jobs must be at least 1")
|
||
}
|
||
c.DumpJobs = val
|
||
return nil
|
||
},
|
||
Type: "int",
|
||
Description: "Number of parallel jobs for database dumps",
|
||
},
|
||
{
|
||
Key: "host",
|
||
DisplayName: "Database Host",
|
||
Value: func(c *config.Config) string { return c.Host },
|
||
Update: func(c *config.Config, v string) error {
|
||
if v == "" {
|
||
return fmt.Errorf("host cannot be empty")
|
||
}
|
||
c.Host = v
|
||
return nil
|
||
},
|
||
Type: "string",
|
||
Description: "Database server hostname or IP address",
|
||
},
|
||
{
|
||
Key: "port",
|
||
DisplayName: "Database Port",
|
||
Value: func(c *config.Config) string { return fmt.Sprintf("%d", c.Port) },
|
||
Update: func(c *config.Config, v string) error {
|
||
val, err := strconv.Atoi(v)
|
||
if err != nil {
|
||
return fmt.Errorf("port must be a number")
|
||
}
|
||
if val < 1 || val > 65535 {
|
||
return fmt.Errorf("port must be between 1-65535")
|
||
}
|
||
c.Port = val
|
||
return nil
|
||
},
|
||
Type: "int",
|
||
Description: "Database server port number",
|
||
},
|
||
{
|
||
Key: "user",
|
||
DisplayName: "Database User",
|
||
Value: func(c *config.Config) string { return c.User },
|
||
Update: func(c *config.Config, v string) error {
|
||
if v == "" {
|
||
return fmt.Errorf("user cannot be empty")
|
||
}
|
||
c.User = v
|
||
return nil
|
||
},
|
||
Type: "string",
|
||
Description: "Database username for connections",
|
||
},
|
||
{
|
||
Key: "database",
|
||
DisplayName: "Default Database",
|
||
Value: func(c *config.Config) string { return c.Database },
|
||
Update: func(c *config.Config, v string) error {
|
||
c.Database = v // Can be empty for cluster operations
|
||
return nil
|
||
},
|
||
Type: "string",
|
||
Description: "Default database name (optional)",
|
||
},
|
||
{
|
||
Key: "ssl_mode",
|
||
DisplayName: "SSL Mode",
|
||
Value: func(c *config.Config) string { return c.SSLMode },
|
||
Update: func(c *config.Config, v string) error {
|
||
validModes := []string{"disable", "allow", "prefer", "require", "verify-ca", "verify-full"}
|
||
for _, mode := range validModes {
|
||
if v == mode {
|
||
c.SSLMode = v
|
||
return nil
|
||
}
|
||
}
|
||
return fmt.Errorf("invalid SSL mode. Valid options: %s", strings.Join(validModes, ", "))
|
||
},
|
||
Type: "string",
|
||
Description: "SSL connection mode (disable, allow, prefer, require, verify-ca, verify-full)",
|
||
},
|
||
{
|
||
Key: "auto_detect_cores",
|
||
DisplayName: "Auto Detect CPU Cores",
|
||
Value: func(c *config.Config) string {
|
||
if c.AutoDetectCores {
|
||
return "true"
|
||
} else {
|
||
return "false"
|
||
}
|
||
},
|
||
Update: func(c *config.Config, v string) error {
|
||
val, err := strconv.ParseBool(v)
|
||
if err != nil {
|
||
return fmt.Errorf("must be true or false")
|
||
}
|
||
c.AutoDetectCores = val
|
||
return nil
|
||
},
|
||
Type: "bool",
|
||
Description: "Automatically detect and optimize for CPU cores",
|
||
},
|
||
}
|
||
|
||
return SettingsModel{
|
||
config: cfg,
|
||
logger: log,
|
||
settings: settings,
|
||
parent: parent,
|
||
}
|
||
}
|
||
|
||
// Init initializes the settings model
|
||
func (m SettingsModel) Init() tea.Cmd {
|
||
return nil
|
||
}
|
||
|
||
// Update handles messages
|
||
func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.KeyMsg:
|
||
// Handle directory browsing mode
|
||
if m.browsingDir && m.dirBrowser != nil {
|
||
switch msg.String() {
|
||
case "esc":
|
||
m.browsingDir = false
|
||
m.dirBrowser.Hide()
|
||
return m, nil
|
||
case "up", "k":
|
||
m.dirBrowser.Navigate(-1)
|
||
return m, nil
|
||
case "down", "j":
|
||
m.dirBrowser.Navigate(1)
|
||
return m, nil
|
||
case "enter", "right", "l":
|
||
m.dirBrowser.Enter()
|
||
return m, nil
|
||
case "left", "h":
|
||
// Go up one level (same as selecting ".." and entering)
|
||
parentPath := filepath.Dir(m.dirBrowser.CurrentPath)
|
||
if parentPath != m.dirBrowser.CurrentPath { // Avoid infinite loop at root
|
||
m.dirBrowser.CurrentPath = parentPath
|
||
m.dirBrowser.LoadItems()
|
||
}
|
||
return m, nil
|
||
case " ":
|
||
// Select current directory
|
||
selectedPath := m.dirBrowser.Select()
|
||
if m.cursor < len(m.settings) {
|
||
setting := m.settings[m.cursor]
|
||
if err := setting.Update(m.config, selectedPath); err != nil {
|
||
m.message = "❌ Error: " + err.Error()
|
||
} else {
|
||
m.message = "✅ Directory updated: " + selectedPath
|
||
}
|
||
}
|
||
m.browsingDir = false
|
||
m.dirBrowser.Hide()
|
||
return m, nil
|
||
case "tab":
|
||
// Toggle back to settings
|
||
m.browsingDir = false
|
||
m.dirBrowser.Hide()
|
||
return m, nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
if m.editing {
|
||
return m.handleEditingInput(msg)
|
||
}
|
||
|
||
switch msg.String() {
|
||
case "ctrl+c", "q", "esc":
|
||
m.quitting = true
|
||
return m.parent, nil
|
||
|
||
case "up", "k":
|
||
if m.cursor > 0 {
|
||
m.cursor--
|
||
}
|
||
|
||
case "down", "j":
|
||
if m.cursor < len(m.settings)-1 {
|
||
m.cursor++
|
||
}
|
||
|
||
case "enter", " ":
|
||
return m.startEditing()
|
||
|
||
case "tab":
|
||
// Directory browser for path fields
|
||
if m.cursor >= 0 && m.cursor < len(m.settings) {
|
||
if m.settings[m.cursor].Type == "path" {
|
||
return m.openDirectoryBrowser()
|
||
} else {
|
||
m.message = "❌ Tab key only works on directory path fields"
|
||
return m, nil
|
||
}
|
||
} else {
|
||
m.message = "❌ Invalid selection"
|
||
return m, nil
|
||
}
|
||
|
||
case "r":
|
||
return m.resetToDefaults()
|
||
|
||
case "s":
|
||
return m.saveSettings()
|
||
}
|
||
}
|
||
|
||
return m, nil
|
||
}
|
||
|
||
// handleEditingInput handles input when editing a setting
|
||
func (m SettingsModel) handleEditingInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
switch msg.String() {
|
||
case "ctrl+c":
|
||
m.quitting = true
|
||
return m.parent, nil
|
||
|
||
case "esc":
|
||
m.editing = false
|
||
m.editingField = ""
|
||
m.editingValue = ""
|
||
m.message = ""
|
||
return m, nil
|
||
|
||
case "enter":
|
||
return m.saveEditedValue()
|
||
|
||
case "backspace", "ctrl+h":
|
||
if len(m.editingValue) > 0 {
|
||
m.editingValue = m.editingValue[:len(m.editingValue)-1]
|
||
}
|
||
|
||
default:
|
||
// Add character to editing value
|
||
if len(msg.String()) == 1 {
|
||
m.editingValue += msg.String()
|
||
}
|
||
}
|
||
|
||
return m, nil
|
||
}
|
||
|
||
// startEditing begins editing a setting
|
||
func (m SettingsModel) startEditing() (tea.Model, tea.Cmd) {
|
||
if m.cursor >= len(m.settings) {
|
||
return m, nil
|
||
}
|
||
|
||
setting := m.settings[m.cursor]
|
||
m.editing = true
|
||
m.editingField = setting.Key
|
||
m.editingValue = setting.Value(m.config)
|
||
m.message = ""
|
||
|
||
return m, nil
|
||
}
|
||
|
||
// saveEditedValue saves the currently edited value
|
||
func (m SettingsModel) saveEditedValue() (tea.Model, tea.Cmd) {
|
||
if m.editingField == "" {
|
||
return m, nil
|
||
}
|
||
|
||
// Find the setting being edited
|
||
var setting *SettingItem
|
||
for i := range m.settings {
|
||
if m.settings[i].Key == m.editingField {
|
||
setting = &m.settings[i]
|
||
break
|
||
}
|
||
}
|
||
|
||
if setting == nil {
|
||
m.message = errorStyle.Render("❌ Setting not found")
|
||
m.editing = false
|
||
return m, nil
|
||
}
|
||
|
||
// Update the configuration
|
||
if err := setting.Update(m.config, m.editingValue); err != nil {
|
||
m.message = errorStyle.Render(fmt.Sprintf("❌ %s", err.Error()))
|
||
return m, nil
|
||
}
|
||
|
||
m.message = successStyle.Render(fmt.Sprintf("✅ Updated %s", setting.DisplayName))
|
||
m.editing = false
|
||
m.editingField = ""
|
||
m.editingValue = ""
|
||
|
||
return m, nil
|
||
}
|
||
|
||
// resetToDefaults resets configuration to default values
|
||
func (m SettingsModel) resetToDefaults() (tea.Model, tea.Cmd) {
|
||
newConfig := config.New()
|
||
|
||
// Copy important connection details
|
||
newConfig.Host = m.config.Host
|
||
newConfig.Port = m.config.Port
|
||
newConfig.User = m.config.User
|
||
newConfig.Database = m.config.Database
|
||
newConfig.DatabaseType = m.config.DatabaseType
|
||
|
||
*m.config = *newConfig
|
||
m.message = successStyle.Render("✅ Settings reset to defaults")
|
||
|
||
return m, nil
|
||
}
|
||
|
||
// saveSettings validates and saves current settings
|
||
func (m SettingsModel) saveSettings() (tea.Model, tea.Cmd) {
|
||
if err := m.config.Validate(); err != nil {
|
||
m.message = errorStyle.Render(fmt.Sprintf("❌ Validation failed: %s", err.Error()))
|
||
return m, nil
|
||
}
|
||
|
||
// Optimize CPU settings if auto-detect is enabled
|
||
if m.config.AutoDetectCores {
|
||
if err := m.config.OptimizeForCPU(); err != nil {
|
||
m.message = errorStyle.Render(fmt.Sprintf("❌ CPU optimization failed: %s", err.Error()))
|
||
return m, nil
|
||
}
|
||
}
|
||
|
||
m.message = successStyle.Render("✅ Settings validated and saved")
|
||
return m, nil
|
||
}
|
||
|
||
// View renders the settings interface
|
||
func (m SettingsModel) View() string {
|
||
if m.quitting {
|
||
return "Returning to main menu...\n"
|
||
}
|
||
|
||
var b strings.Builder
|
||
|
||
// Header
|
||
header := titleStyle.Render("⚙️ Configuration Settings")
|
||
b.WriteString(fmt.Sprintf("\n%s\n\n", header))
|
||
|
||
// Settings list
|
||
for i, setting := range m.settings {
|
||
cursor := " "
|
||
value := setting.Value(m.config)
|
||
displayValue := value
|
||
if setting.Key == "database_type" {
|
||
displayValue = fmt.Sprintf("%s (%s)", value, m.config.DisplayDatabaseType())
|
||
}
|
||
|
||
if m.cursor == i {
|
||
cursor = ">"
|
||
if m.editing && m.editingField == setting.Key {
|
||
// Show editing interface
|
||
editValue := m.editingValue
|
||
if setting.Type == "bool" {
|
||
editValue += " (true/false)"
|
||
}
|
||
line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, editValue)
|
||
b.WriteString(selectedStyle.Render(line))
|
||
b.WriteString(" ✏️")
|
||
} else {
|
||
line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, displayValue)
|
||
b.WriteString(selectedStyle.Render(line))
|
||
}
|
||
} else {
|
||
line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, displayValue)
|
||
b.WriteString(menuStyle.Render(line))
|
||
}
|
||
b.WriteString("\n")
|
||
|
||
// Show description for selected item
|
||
if m.cursor == i && !m.editing {
|
||
desc := detailStyle.Render(fmt.Sprintf(" %s", setting.Description))
|
||
b.WriteString(desc)
|
||
b.WriteString("\n")
|
||
}
|
||
|
||
// Show directory browser for current path field
|
||
if m.cursor == i && m.browsingDir && m.dirBrowser != nil && setting.Type == "path" {
|
||
b.WriteString("\n")
|
||
browserView := m.dirBrowser.Render()
|
||
b.WriteString(browserView)
|
||
b.WriteString("\n")
|
||
}
|
||
}
|
||
|
||
// Message area
|
||
if m.message != "" {
|
||
b.WriteString("\n")
|
||
b.WriteString(m.message)
|
||
b.WriteString("\n")
|
||
}
|
||
|
||
// Current configuration summary
|
||
if !m.editing {
|
||
b.WriteString("\n")
|
||
b.WriteString(infoStyle.Render("📋 Current Configuration:"))
|
||
b.WriteString("\n")
|
||
|
||
summary := []string{
|
||
fmt.Sprintf("Target DB: %s (%s)", m.config.DisplayDatabaseType(), m.config.DatabaseType),
|
||
fmt.Sprintf("Database: %s@%s:%d", m.config.User, m.config.Host, m.config.Port),
|
||
fmt.Sprintf("Backup Dir: %s", m.config.BackupDir),
|
||
fmt.Sprintf("Compression: Level %d", m.config.CompressionLevel),
|
||
fmt.Sprintf("Jobs: %d parallel, %d dump", m.config.Jobs, m.config.DumpJobs),
|
||
}
|
||
|
||
for _, line := range summary {
|
||
b.WriteString(detailStyle.Render(fmt.Sprintf(" %s", line)))
|
||
b.WriteString("\n")
|
||
}
|
||
}
|
||
|
||
// Footer with instructions
|
||
var footer string
|
||
if m.editing {
|
||
footer = infoStyle.Render("\n⌨️ Type new value • Enter to save • Esc to cancel")
|
||
} else {
|
||
if m.browsingDir {
|
||
footer = infoStyle.Render("\n⌨️ ↑/↓ navigate directories • Enter open • Space select • Tab/Esc back to settings")
|
||
} else {
|
||
// Show different help based on current selection
|
||
if m.cursor >= 0 && m.cursor < len(m.settings) && m.settings[m.cursor].Type == "path" {
|
||
footer = infoStyle.Render("\n⌨️ ↑/↓ navigate • Enter edit • Tab browse directories • 's' save • 'r' reset • 'q' menu")
|
||
} else {
|
||
footer = infoStyle.Render("\n⌨️ ↑/↓ navigate • Enter edit • 's' save • 'r' reset • 'q' menu • Tab=dirs on path fields only")
|
||
}
|
||
}
|
||
}
|
||
b.WriteString(footer)
|
||
|
||
return b.String()
|
||
}
|
||
|
||
func (m SettingsModel) openDirectoryBrowser() (tea.Model, tea.Cmd) {
|
||
if m.cursor >= len(m.settings) {
|
||
return m, nil
|
||
}
|
||
|
||
setting := m.settings[m.cursor]
|
||
currentValue := setting.Value(m.config)
|
||
if currentValue == "" {
|
||
currentValue = "/tmp"
|
||
}
|
||
|
||
if m.dirBrowser == nil {
|
||
m.dirBrowser = NewDirectoryBrowser(currentValue)
|
||
} else {
|
||
// Update the browser to start from the current value
|
||
m.dirBrowser.CurrentPath = currentValue
|
||
m.dirBrowser.LoadItems()
|
||
}
|
||
|
||
m.dirBrowser.Show()
|
||
m.browsingDir = true
|
||
|
||
return m, nil
|
||
}
|
||
|
||
// RunSettingsMenu starts the settings configuration interface
|
||
func RunSettingsMenu(cfg *config.Config, log logger.Logger, parent tea.Model) error {
|
||
m := NewSettingsModel(cfg, log, parent)
|
||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||
|
||
if _, err := p.Run(); err != nil {
|
||
return fmt.Errorf("error running settings menu: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|