BREAKING CHANGE: Removed 'large-db' as standalone profile New Design: - Resource Profiles now purely represent VM capacity: conservative, balanced, performance, max-performance - LargeDBMode is a separate boolean toggle that modifies any profile: - Reduces ClusterParallelism and Jobs by 50% - Forces MaxLocksPerTxn = 8192 - Increases MaintenanceWorkMem TUI Changes: - 'l' key now toggles LargeDBMode ON/OFF instead of applying large-db profile - New 'Large DB Mode' setting in settings menu - Settings are persisted to .dbbackup.conf This allows any resource profile to be combined with large database optimization, giving users more flexibility on both small and large VMs.
985 lines
28 KiB
Go
Executable File
985 lines
28 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/cpu"
|
|
"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: "resource_profile",
|
|
DisplayName: "Resource Profile",
|
|
Value: func(c *config.Config) string {
|
|
profile := c.GetCurrentProfile()
|
|
if profile != nil {
|
|
return fmt.Sprintf("%s (P:%d J:%d)", profile.Name, profile.ClusterParallelism, profile.Jobs)
|
|
}
|
|
return c.ResourceProfile
|
|
},
|
|
Update: func(c *config.Config, v string) error {
|
|
profiles := []string{"conservative", "balanced", "performance", "max-performance"}
|
|
currentIdx := 0
|
|
for i, p := range profiles {
|
|
if c.ResourceProfile == p {
|
|
currentIdx = i
|
|
break
|
|
}
|
|
}
|
|
nextIdx := (currentIdx + 1) % len(profiles)
|
|
return c.ApplyResourceProfile(profiles[nextIdx])
|
|
},
|
|
Type: "selector",
|
|
Description: "Resource profile for VM capacity. Toggle 'l' for Large DB Mode on any profile.",
|
|
},
|
|
{
|
|
Key: "large_db_mode",
|
|
DisplayName: "Large DB Mode",
|
|
Value: func(c *config.Config) string {
|
|
if c.LargeDBMode {
|
|
return "ON (↓parallelism, ↑locks)"
|
|
}
|
|
return "OFF"
|
|
},
|
|
Update: func(c *config.Config, v string) error {
|
|
c.LargeDBMode = !c.LargeDBMode
|
|
return nil
|
|
},
|
|
Type: "selector",
|
|
Description: "Enable for databases with many tables/LOBs. Reduces parallelism, increases max_locks_per_transaction.",
|
|
},
|
|
{
|
|
Key: "cluster_parallelism",
|
|
DisplayName: "Cluster Parallelism",
|
|
Value: func(c *config.Config) string { return fmt.Sprintf("%d", c.ClusterParallelism) },
|
|
Update: func(c *config.Config, v string) error {
|
|
val, err := strconv.Atoi(v)
|
|
if err != nil {
|
|
return fmt.Errorf("cluster parallelism must be a number")
|
|
}
|
|
if val < 1 {
|
|
return fmt.Errorf("cluster parallelism must be at least 1")
|
|
}
|
|
c.ClusterParallelism = val
|
|
return nil
|
|
},
|
|
Type: "int",
|
|
Description: "Concurrent databases during cluster backup/restore (1=sequential, safer for large DBs)",
|
|
},
|
|
{
|
|
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()
|
|
|
|
case "l":
|
|
// Quick shortcut: Toggle Large DB Mode
|
|
return m.toggleLargeDBMode()
|
|
|
|
case "c":
|
|
// Quick shortcut: Apply "conservative" profile for constrained VMs
|
|
return m.applyConservativeProfile()
|
|
|
|
case "p":
|
|
// Show profile recommendation
|
|
return m.showProfileRecommendation()
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// toggleLargeDBMode toggles the Large DB Mode flag
|
|
func (m SettingsModel) toggleLargeDBMode() (tea.Model, tea.Cmd) {
|
|
m.config.LargeDBMode = !m.config.LargeDBMode
|
|
if m.config.LargeDBMode {
|
|
profile := m.config.GetCurrentProfile()
|
|
m.message = successStyle.Render(fmt.Sprintf(
|
|
"[ON] Large DB Mode enabled: %s → Parallel=%d, Jobs=%d, MaxLocks=%d",
|
|
profile.Name, profile.ClusterParallelism, profile.Jobs, profile.MaxLocksPerTxn))
|
|
} else {
|
|
profile := m.config.GetCurrentProfile()
|
|
m.message = successStyle.Render(fmt.Sprintf(
|
|
"[OFF] Large DB Mode disabled: %s → Parallel=%d, Jobs=%d",
|
|
profile.Name, profile.ClusterParallelism, profile.Jobs))
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// applyConservativeProfile applies the conservative profile for constrained VMs
|
|
func (m SettingsModel) applyConservativeProfile() (tea.Model, tea.Cmd) {
|
|
if err := m.config.ApplyResourceProfile("conservative"); err != nil {
|
|
m.message = errorStyle.Render(fmt.Sprintf("[FAIL] %s", err.Error()))
|
|
return m, nil
|
|
}
|
|
m.message = successStyle.Render("[OK] Applied 'conservative' profile: Cluster=1, Jobs=1. Safe for small VMs with limited memory.")
|
|
return m, nil
|
|
}
|
|
|
|
// showProfileRecommendation displays the recommended profile based on system resources
|
|
func (m SettingsModel) showProfileRecommendation() (tea.Model, tea.Cmd) {
|
|
profileName, reason := m.config.GetResourceProfileRecommendation(false)
|
|
|
|
var largeDBHint string
|
|
if m.config.LargeDBMode {
|
|
largeDBHint = "Large DB Mode: ON"
|
|
} else {
|
|
largeDBHint = "Large DB Mode: OFF (press 'l' to enable)"
|
|
}
|
|
|
|
m.message = infoStyle.Render(fmt.Sprintf(
|
|
"[RECOMMEND] Profile: %s | %s\n"+
|
|
" → %s\n"+
|
|
" Press 'l' to toggle Large DB Mode, 'c' for conservative",
|
|
profileName, largeDBHint, reason))
|
|
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] System Resources & Configuration"))
|
|
b.WriteString("\n")
|
|
|
|
// System resources
|
|
var sysInfo []string
|
|
if m.config.CPUInfo != nil {
|
|
sysInfo = append(sysInfo, fmt.Sprintf("CPU: %d cores (physical), %d logical",
|
|
m.config.CPUInfo.PhysicalCores, m.config.CPUInfo.LogicalCores))
|
|
}
|
|
if m.config.MemoryInfo != nil {
|
|
sysInfo = append(sysInfo, fmt.Sprintf("Memory: %dGB total, %dGB available",
|
|
m.config.MemoryInfo.TotalGB, m.config.MemoryInfo.AvailableGB))
|
|
}
|
|
|
|
// Recommended profile
|
|
recommendedProfile, reason := m.config.GetResourceProfileRecommendation(false)
|
|
sysInfo = append(sysInfo, fmt.Sprintf("Recommended Profile: %s", recommendedProfile))
|
|
sysInfo = append(sysInfo, fmt.Sprintf(" → %s", reason))
|
|
|
|
for _, line := range sysInfo {
|
|
b.WriteString(detailStyle.Render(fmt.Sprintf(" %s", line)))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
b.WriteString(infoStyle.Render("[CONFIG] Current Settings"))
|
|
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("Profile: %s | Cluster: %d parallel | Jobs: %d",
|
|
m.config.ResourceProfile, m.config.ClusterParallelism, m.config.Jobs),
|
|
}
|
|
|
|
// Show profile warnings if applicable
|
|
profile := m.config.GetCurrentProfile()
|
|
if profile != nil {
|
|
isValid, warnings := cpu.ValidateProfileForSystem(profile, m.config.CPUInfo, m.config.MemoryInfo)
|
|
if !isValid && len(warnings) > 0 {
|
|
summary = append(summary, fmt.Sprintf("⚠️ Warning: %s", warnings[0]))
|
|
}
|
|
}
|
|
|
|
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] ↑↓ navigate | Enter edit | Tab dirs | 'l' toggle LargeDB | 'c' conservative | 'p' recommend | 's' save | 'q' menu")
|
|
} else {
|
|
footer = infoStyle.Render("\n[KEYS] ↑↓ navigate | Enter edit | 'l' toggle LargeDB mode | 'c' conservative | 'p' recommend | 's' save | 'r' reset | 'q' menu")
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|