Initial commit: Database Backup Tool v1.1.0

- PostgreSQL and MySQL support
- Interactive TUI with fixed menu navigation
- Line-by-line progress display
- CPU-aware parallel processing
- Cross-platform build support
- Configuration settings menu
- Silent mode for TUI operations
This commit is contained in:
2025-10-22 19:27:38 +00:00
commit 9b3c3f2b1b
39 changed files with 6498 additions and 0 deletions

465
internal/tui/settings.go Normal file
View File

@ -0,0 +1,465 @@
package tui
import (
"fmt"
"path/filepath"
"strconv"
"strings"
tea "github.com/charmbracelet/bubbletea"
"dbbackup/internal/config"
"dbbackup/internal/logger"
)
// 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
}
// 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: "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:
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 "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":
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)
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, value)
b.WriteString(selectedStyle.Render(line))
}
} else {
line := fmt.Sprintf("%s %s: %s", cursor, setting.DisplayName, value)
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")
}
}
// 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("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 {
footer = infoStyle.Render("\n⌨ ↑/↓ navigate • Enter to edit • 's' save • 'r' reset • 'q' back to menu")
}
b.WriteString(footer)
return b.String()
}
// 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
}