Files
dbbackup/internal/tui/settings.go
Alexander Renz ec5e89eab7
All checks were successful
CI/CD / Test (push) Successful in 1m18s
CI/CD / Lint (push) Successful in 1m26s
CI/CD / Build & Release (push) Successful in 3m13s
v3.42.36: Fix remaining TUI prefix inconsistencies
- diagnose_view.go: Add [STATS], [LIST], [INFO] section prefixes
- status.go: Add [CONN], [INFO] section prefixes
- settings.go: [LOG] → [INFO] for configuration summary
- menu.go: [DB] → [SELECT]/[CHECK] for selectors
2026-01-14 16:59:24 +01:00

832 lines
22 KiB
Go
Executable File

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: "selector",
Description: "Target database engine (press Enter to cycle: PostgreSQL → MySQL → MariaDB)",
},
{
Key: "cpu_workload",
DisplayName: "CPU Workload Type",
Value: func(c *config.Config) string { return c.CPUWorkloadType },
Update: func(c *config.Config, v string) error {
workloads := []string{"balanced", "cpu-intensive", "io-intensive"}
currentIdx := 0
for i, w := range workloads {
if c.CPUWorkloadType == w {
currentIdx = i
break
}
}
nextIdx := (currentIdx + 1) % len(workloads)
c.CPUWorkloadType = workloads[nextIdx]
// Recalculate Jobs and DumpJobs based on workload type
if c.CPUInfo != nil && c.AutoDetectCores {
switch c.CPUWorkloadType {
case "cpu-intensive":
c.Jobs = c.CPUInfo.PhysicalCores * 2
c.DumpJobs = c.CPUInfo.PhysicalCores
case "io-intensive":
c.Jobs = c.CPUInfo.PhysicalCores / 2
if c.Jobs < 1 {
c.Jobs = 1
}
c.DumpJobs = 2
default: // balanced
c.Jobs = c.CPUInfo.PhysicalCores
c.DumpJobs = c.CPUInfo.PhysicalCores / 2
if c.DumpJobs < 2 {
c.DumpJobs = 2
}
}
}
return nil
},
Type: "selector",
Description: "CPU workload profile (press Enter to cycle: Balanced → CPU-Intensive → I/O-Intensive)",
},
{
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: "work_dir",
DisplayName: "Work Directory",
Value: func(c *config.Config) string {
if c.WorkDir == "" {
return "(system temp)"
}
return c.WorkDir
},
Update: func(c *config.Config, v string) error {
if v == "" || v == "(system temp)" {
c.WorkDir = ""
return nil
}
c.WorkDir = filepath.Clean(v)
return nil
},
Type: "path",
Description: "Working directory for large operations (extraction, diagnosis). Use when /tmp is too small.",
},
{
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",
},
{
Key: "cloud_enabled",
DisplayName: "Cloud Storage Enabled",
Value: func(c *config.Config) string {
if c.CloudEnabled {
return "true"
}
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.CloudEnabled = val
return nil
},
Type: "bool",
Description: "Enable cloud storage integration (S3, Azure, GCS)",
},
{
Key: "cloud_provider",
DisplayName: "Cloud Provider",
Value: func(c *config.Config) string { return c.CloudProvider },
Update: func(c *config.Config, v string) error {
providers := []string{"s3", "minio", "b2", "azure", "gcs"}
currentIdx := -1
for i, p := range providers {
if c.CloudProvider == p {
currentIdx = i
break
}
}
nextIdx := (currentIdx + 1) % len(providers)
c.CloudProvider = providers[nextIdx]
return nil
},
Type: "selector",
Description: "Cloud storage provider (press Enter to cycle: S3 → MinIO → B2 → Azure → GCS)",
},
{
Key: "cloud_bucket",
DisplayName: "Cloud Bucket/Container",
Value: func(c *config.Config) string { return c.CloudBucket },
Update: func(c *config.Config, v string) error {
c.CloudBucket = v
return nil
},
Type: "string",
Description: "Bucket name (S3/GCS) or container name (Azure)",
},
{
Key: "cloud_region",
DisplayName: "Cloud Region",
Value: func(c *config.Config) string { return c.CloudRegion },
Update: func(c *config.Config, v string) error {
c.CloudRegion = v
return nil
},
Type: "string",
Description: "Region (e.g., us-east-1 for S3, us-central1 for GCS)",
},
{
Key: "cloud_access_key",
DisplayName: "Cloud Access Key",
Value: func(c *config.Config) string {
if c.CloudAccessKey != "" {
return "***" + c.CloudAccessKey[len(c.CloudAccessKey)-4:]
}
return ""
},
Update: func(c *config.Config, v string) error {
c.CloudAccessKey = v
return nil
},
Type: "string",
Description: "Access key (S3/MinIO), Account name (Azure), or Service account path (GCS)",
},
{
Key: "cloud_secret_key",
DisplayName: "Cloud Secret Key",
Value: func(c *config.Config) string {
if c.CloudSecretKey != "" {
return "********"
}
return ""
},
Update: func(c *config.Config, v string) error {
c.CloudSecretKey = v
return nil
},
Type: "string",
Description: "Secret key (S3/MinIO/B2) or Account key (Azure)",
},
{
Key: "cloud_auto_upload",
DisplayName: "Cloud Auto-Upload",
Value: func(c *config.Config) string {
if c.CloudAutoUpload {
return "true"
}
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.CloudAutoUpload = val
return nil
},
Type: "bool",
Description: "Automatically upload backups to cloud after creation",
},
}
return SettingsModel{
config: cfg,
logger: log,
settings: settings,
parent: parent,
}
}
// Init initializes the settings model
func (m SettingsModel) Init() tea.Cmd {
// Auto-forward in auto-confirm mode
if m.config.TUIAutoConfirm {
return func() tea.Msg {
return settingsAutoQuitMsg{}
}
}
return nil
}
// settingsAutoQuitMsg triggers automatic quit in settings
type settingsAutoQuitMsg struct{}
// Update handles messages
func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case settingsAutoQuitMsg:
return m.parent, tea.Quit
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 = "[FAIL] Error: " + err.Error()
} else {
m.message = "[OK] 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":
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", " ":
// For selector types, cycle through options instead of typing
if m.cursor >= 0 && m.cursor < len(m.settings) {
currentSetting := m.settings[m.cursor]
if currentSetting.Type == "selector" {
if err := currentSetting.Update(m.config, ""); err != nil {
m.message = errorStyle.Render(fmt.Sprintf("[FAIL] %s", err.Error()))
} else {
m.message = successStyle.Render(fmt.Sprintf("[OK] Updated %s", currentSetting.DisplayName))
}
return m, nil
}
}
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 = "[FAIL] Tab key only works on directory path fields"
return m, nil
}
} else {
m.message = "[FAIL] 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("[FAIL] 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("[FAIL] %s", err.Error()))
return m, nil
}
m.message = successStyle.Render(fmt.Sprintf("[OK] 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("[OK] 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("[FAIL] 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("[FAIL] CPU optimization failed: %s", err.Error()))
return m, nil
}
}
m.message = successStyle.Render("[OK] Settings validated and saved")
return m, nil
}
// cycleDatabaseType cycles through database type options
func (m SettingsModel) cycleDatabaseType() (tea.Model, tea.Cmd) {
dbTypes := []string{"postgres", "mysql", "mariadb"}
// Find current index
currentIdx := 0
for i, dbType := range dbTypes {
if m.config.DatabaseType == dbType {
currentIdx = i
break
}
}
// Cycle to next
nextIdx := (currentIdx + 1) % len(dbTypes)
newType := dbTypes[nextIdx]
// Update config
if err := m.config.SetDatabaseType(newType); err != nil {
m.message = errorStyle.Render(fmt.Sprintf("[FAIL] Failed to set database type: %s", err.Error()))
return m, nil
}
m.message = successStyle.Render(fmt.Sprintf("[OK] Database type set to %s", m.config.DisplayDatabaseType()))
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("[CONFIG] 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(" [EDIT]")
} 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("[INFO] 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),
}
if m.config.CloudEnabled {
cloudInfo := fmt.Sprintf("Cloud: %s (%s)", m.config.CloudProvider, m.config.CloudBucket)
if m.config.CloudAutoUpload {
cloudInfo += " [auto-upload]"
}
summary = append(summary, cloudInfo)
}
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[KEYS] Type new value | Enter to save | Esc to cancel")
} else {
if m.browsingDir {
footer = infoStyle.Render("\n[KEYS] Up/Down 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[KEYS] Up/Down navigate | Enter edit | Tab browse directories | 's' save | 'r' reset | 'q' menu")
} else {
footer = infoStyle.Render("\n[KEYS] Up/Down 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 = m.config.GetEffectiveWorkDir()
}
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
}