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:
658
internal/tui/menu.go
Normal file
658
internal/tui/menu.go
Normal file
@ -0,0 +1,658 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/database"
|
||||
"dbbackup/internal/logger"
|
||||
"dbbackup/internal/progress"
|
||||
)
|
||||
|
||||
// Style definitions
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FAFAFA")).
|
||||
Background(lipgloss.Color("#7D56F4")).
|
||||
Padding(0, 1)
|
||||
|
||||
menuStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
|
||||
selectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF75B7")).
|
||||
Bold(true)
|
||||
|
||||
infoStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
|
||||
successStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#04B575")).
|
||||
Bold(true)
|
||||
|
||||
errorStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF6B6B")).
|
||||
Bold(true)
|
||||
|
||||
progressStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FFD93D")).
|
||||
Bold(true)
|
||||
|
||||
stepStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6BCF7F")).
|
||||
MarginLeft(2)
|
||||
|
||||
detailStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#A8A8A8")).
|
||||
MarginLeft(4).
|
||||
Italic(true)
|
||||
)
|
||||
|
||||
// MenuModel represents the enhanced menu state with progress tracking
|
||||
type MenuModel struct {
|
||||
choices []string
|
||||
cursor int
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
quitting bool
|
||||
message string
|
||||
|
||||
// Progress tracking
|
||||
showProgress bool
|
||||
showCompletion bool
|
||||
completionMessage string
|
||||
completionDismissed bool // Track if user manually dismissed completion
|
||||
currentOperation *progress.OperationStatus
|
||||
allOperations []progress.OperationStatus
|
||||
lastUpdate time.Time
|
||||
spinner spinner.Model
|
||||
|
||||
// Background operations
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// TUI Progress Reporter
|
||||
progressReporter *TUIProgressReporter
|
||||
}
|
||||
|
||||
// completionMsg carries completion status
|
||||
type completionMsg struct {
|
||||
success bool
|
||||
message string
|
||||
}
|
||||
|
||||
// operationUpdateMsg carries operation updates
|
||||
type operationUpdateMsg struct {
|
||||
operations []progress.OperationStatus
|
||||
}
|
||||
|
||||
// operationCompleteMsg signals operation completion
|
||||
type operationCompleteMsg struct {
|
||||
operation *progress.OperationStatus
|
||||
success bool
|
||||
}
|
||||
|
||||
// Initialize the menu model
|
||||
func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Dot
|
||||
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D"))
|
||||
|
||||
// Create TUI progress reporter
|
||||
progressReporter := NewTUIProgressReporter()
|
||||
|
||||
model := MenuModel{
|
||||
choices: []string{
|
||||
"Single Database Backup",
|
||||
"Sample Database Backup (with ratio)",
|
||||
"Cluster Backup (all databases)",
|
||||
"View Active Operations",
|
||||
"Show Operation History",
|
||||
"Database Status & Health Check",
|
||||
"Configuration Settings",
|
||||
"Clear Operation History",
|
||||
"Quit",
|
||||
},
|
||||
config: cfg,
|
||||
logger: log,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
spinner: s,
|
||||
lastUpdate: time.Now(),
|
||||
progressReporter: progressReporter,
|
||||
}
|
||||
|
||||
// Set up progress callback
|
||||
progressReporter.AddCallback(func(operations []progress.OperationStatus) {
|
||||
// This will be called when operations update
|
||||
// The TUI will pick up these updates in the pollOperations method
|
||||
})
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
// Init initializes the model
|
||||
func (m MenuModel) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
m.spinner.Tick,
|
||||
m.pollOperations(),
|
||||
)
|
||||
}
|
||||
|
||||
// pollOperations periodically checks for operation updates
|
||||
func (m MenuModel) pollOperations() tea.Cmd {
|
||||
return tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg {
|
||||
// Get operations from our TUI progress reporter
|
||||
operations := m.progressReporter.GetOperations()
|
||||
return operationUpdateMsg{operations: operations}
|
||||
})
|
||||
}
|
||||
|
||||
// Update handles messages
|
||||
func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q":
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
|
||||
case "up", "k":
|
||||
// Clear completion status and allow navigation
|
||||
if m.showCompletion {
|
||||
m.showCompletion = false
|
||||
m.completionMessage = ""
|
||||
m.message = ""
|
||||
m.completionDismissed = true // Mark as manually dismissed
|
||||
}
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
|
||||
case "down", "j":
|
||||
// Clear completion status and allow navigation
|
||||
if m.showCompletion {
|
||||
m.showCompletion = false
|
||||
m.completionMessage = ""
|
||||
m.message = ""
|
||||
m.completionDismissed = true // Mark as manually dismissed
|
||||
}
|
||||
if m.cursor < len(m.choices)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
|
||||
case "enter", " ":
|
||||
// Clear completion status and allow selection
|
||||
if m.showCompletion {
|
||||
m.showCompletion = false
|
||||
m.completionMessage = ""
|
||||
m.message = ""
|
||||
m.completionDismissed = true // Mark as manually dismissed
|
||||
return m, m.pollOperations()
|
||||
}
|
||||
|
||||
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: // View Active Operations
|
||||
return m.handleViewOperations()
|
||||
case 4: // Show Operation History
|
||||
return m.handleOperationHistory()
|
||||
case 5: // Database Status
|
||||
return m.handleStatus()
|
||||
case 6: // Settings
|
||||
return m.handleSettings()
|
||||
case 7: // Clear History
|
||||
return m.handleClearHistory()
|
||||
case 8: // Quit
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case "esc":
|
||||
// Clear completion status on escape
|
||||
if m.showCompletion {
|
||||
m.showCompletion = false
|
||||
m.completionMessage = ""
|
||||
m.message = ""
|
||||
m.completionDismissed = true // Mark as manually dismissed
|
||||
}
|
||||
}
|
||||
|
||||
case operationUpdateMsg:
|
||||
m.allOperations = msg.operations
|
||||
if len(msg.operations) > 0 {
|
||||
latest := msg.operations[len(msg.operations)-1]
|
||||
if latest.Status == "running" {
|
||||
m.currentOperation = &latest
|
||||
m.showProgress = true
|
||||
m.showCompletion = false
|
||||
m.completionDismissed = false // Reset dismissal flag for new operation
|
||||
} else if m.currentOperation != nil && latest.ID == m.currentOperation.ID {
|
||||
m.currentOperation = &latest
|
||||
m.showProgress = false
|
||||
// Only show completion status if user hasn't manually dismissed it
|
||||
if !m.completionDismissed {
|
||||
if latest.Status == "completed" {
|
||||
m.showCompletion = true
|
||||
m.completionMessage = fmt.Sprintf("✅ %s", latest.Message)
|
||||
} else if latest.Status == "failed" {
|
||||
m.showCompletion = true
|
||||
m.completionMessage = fmt.Sprintf("❌ %s", latest.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, m.pollOperations()
|
||||
|
||||
case completionMsg:
|
||||
m.showProgress = false
|
||||
m.showCompletion = true
|
||||
if msg.success {
|
||||
m.completionMessage = fmt.Sprintf("✅ %s", msg.message)
|
||||
} else {
|
||||
m.completionMessage = fmt.Sprintf("❌ %s", msg.message)
|
||||
}
|
||||
return m, m.pollOperations()
|
||||
|
||||
case operationCompleteMsg:
|
||||
m.currentOperation = msg.operation
|
||||
m.showProgress = false
|
||||
if msg.success {
|
||||
m.message = fmt.Sprintf("✅ Operation completed: %s", msg.operation.Message)
|
||||
} else {
|
||||
m.message = fmt.Sprintf("❌ Operation failed: %s", msg.operation.Message)
|
||||
}
|
||||
return m, m.pollOperations()
|
||||
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// View renders the enhanced menu with progress tracking
|
||||
func (m MenuModel) View() string {
|
||||
if m.quitting {
|
||||
return "Thanks for using DB Backup Tool!\n"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Header
|
||||
header := titleStyle.Render("🗄️ Database Backup Tool - Interactive Menu")
|
||||
b.WriteString(fmt.Sprintf("\n%s\n\n", header))
|
||||
|
||||
// Database info
|
||||
dbInfo := infoStyle.Render(fmt.Sprintf("Database: %s@%s:%d (%s)",
|
||||
m.config.User, m.config.Host, m.config.Port, m.config.DatabaseType))
|
||||
b.WriteString(fmt.Sprintf("%s\n\n", dbInfo))
|
||||
|
||||
// Menu items
|
||||
for i, choice := range m.choices {
|
||||
cursor := " "
|
||||
if m.cursor == i {
|
||||
cursor = ">"
|
||||
b.WriteString(selectedStyle.Render(fmt.Sprintf("%s %s", cursor, choice)))
|
||||
} else {
|
||||
b.WriteString(menuStyle.Render(fmt.Sprintf("%s %s", cursor, choice)))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Current operation progress
|
||||
if m.showProgress && m.currentOperation != nil {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.renderOperationProgress(m.currentOperation))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Completion status (persistent until key press)
|
||||
if m.showCompletion {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(successStyle.Render(m.completionMessage))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(infoStyle.Render("💡 Press any key to continue..."))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Message area
|
||||
if m.message != "" && !m.showCompletion {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.message)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Operations summary
|
||||
if len(m.allOperations) > 0 {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.renderOperationsSummary())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Footer
|
||||
var footer string
|
||||
if m.showCompletion {
|
||||
footer = infoStyle.Render("\n⌨️ Press Enter, ↑/↓ arrows, or Esc to continue...")
|
||||
} else {
|
||||
footer = infoStyle.Render("\n⌨️ Press ↑/↓ to navigate • Enter to select • q to quit")
|
||||
}
|
||||
b.WriteString(footer)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderOperationProgress renders detailed progress for the current operation
|
||||
func (m MenuModel) renderOperationProgress(op *progress.OperationStatus) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Operation header with spinner
|
||||
spinnerView := ""
|
||||
if op.Status == "running" {
|
||||
spinnerView = m.spinner.View() + " "
|
||||
}
|
||||
|
||||
status := "🔄"
|
||||
if op.Status == "completed" {
|
||||
status = "✅"
|
||||
} else if op.Status == "failed" {
|
||||
status = "❌"
|
||||
}
|
||||
|
||||
b.WriteString(progressStyle.Render(fmt.Sprintf("%s%s %s [%d%%]",
|
||||
spinnerView, status, strings.Title(op.Type), op.Progress)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Progress bar
|
||||
barWidth := 40
|
||||
filledWidth := (op.Progress * barWidth) / 100
|
||||
if filledWidth > barWidth {
|
||||
filledWidth = barWidth
|
||||
}
|
||||
bar := strings.Repeat("█", filledWidth) + strings.Repeat("░", barWidth-filledWidth)
|
||||
b.WriteString(detailStyle.Render(fmt.Sprintf("[%s] %s", bar, op.Message)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Time and details
|
||||
elapsed := time.Since(op.StartTime)
|
||||
timeInfo := fmt.Sprintf("Elapsed: %s", formatDuration(elapsed))
|
||||
if op.EndTime != nil {
|
||||
timeInfo = fmt.Sprintf("Duration: %s", op.Duration.String())
|
||||
}
|
||||
b.WriteString(detailStyle.Render(timeInfo))
|
||||
b.WriteString("\n")
|
||||
|
||||
// File/byte progress
|
||||
if op.FilesTotal > 0 {
|
||||
b.WriteString(detailStyle.Render(fmt.Sprintf("Files: %d/%d", op.FilesDone, op.FilesTotal)))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if op.BytesTotal > 0 {
|
||||
b.WriteString(detailStyle.Render(fmt.Sprintf("Data: %s/%s",
|
||||
formatBytes(op.BytesDone), formatBytes(op.BytesTotal))))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Current steps
|
||||
if len(op.Steps) > 0 {
|
||||
b.WriteString(stepStyle.Render("Steps:"))
|
||||
b.WriteString("\n")
|
||||
for _, step := range op.Steps {
|
||||
stepStatus := "⏳"
|
||||
if step.Status == "completed" {
|
||||
stepStatus = "✅"
|
||||
} else if step.Status == "failed" {
|
||||
stepStatus = "❌"
|
||||
}
|
||||
b.WriteString(detailStyle.Render(fmt.Sprintf(" %s %s", stepStatus, step.Name)))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderOperationsSummary renders a summary of all operations
|
||||
func (m MenuModel) renderOperationsSummary() string {
|
||||
if len(m.allOperations) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
completed := 0
|
||||
failed := 0
|
||||
running := 0
|
||||
|
||||
for _, op := range m.allOperations {
|
||||
switch op.Status {
|
||||
case "completed":
|
||||
completed++
|
||||
case "failed":
|
||||
failed++
|
||||
case "running":
|
||||
running++
|
||||
}
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("📊 Operations: %d total | %d completed | %d failed | %d running",
|
||||
len(m.allOperations), completed, failed, running)
|
||||
|
||||
return infoStyle.Render(summary)
|
||||
}
|
||||
|
||||
// Enhanced backup handlers with progress tracking
|
||||
|
||||
// Handle single database backup with progress
|
||||
func (m MenuModel) handleSingleBackup() (tea.Model, tea.Cmd) {
|
||||
if m.config.Database == "" {
|
||||
m.message = errorStyle.Render("❌ No database specified. Use --database flag or set in config.")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.message = progressStyle.Render(fmt.Sprintf("🔄 Starting single backup for: %s", m.config.Database))
|
||||
m.showProgress = true
|
||||
m.showCompletion = false
|
||||
|
||||
// Start backup and return polling command
|
||||
go func() {
|
||||
err := RunBackupInTUI(m.ctx, m.config, m.logger, "single", m.config.Database, m.progressReporter)
|
||||
// The completion will be handled by the progress reporter callback system
|
||||
_ = err // Handle error in the progress reporter
|
||||
}()
|
||||
|
||||
return m, m.pollOperations()
|
||||
}
|
||||
|
||||
// Handle sample backup with progress
|
||||
func (m MenuModel) handleSampleBackup() (tea.Model, tea.Cmd) {
|
||||
m.message = progressStyle.Render("🔄 Starting sample backup...")
|
||||
m.showProgress = true
|
||||
m.showCompletion = false
|
||||
m.completionDismissed = false // Reset for new operation
|
||||
|
||||
// Start backup and return polling command
|
||||
go func() {
|
||||
err := RunBackupInTUI(m.ctx, m.config, m.logger, "sample", "", m.progressReporter)
|
||||
// The completion will be handled by the progress reporter callback system
|
||||
_ = err // Handle error in the progress reporter
|
||||
}()
|
||||
|
||||
return m, m.pollOperations()
|
||||
}
|
||||
|
||||
// Handle cluster backup with progress
|
||||
func (m MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) {
|
||||
m.message = progressStyle.Render("🔄 Starting cluster backup (all databases)...")
|
||||
m.showProgress = true
|
||||
m.showCompletion = false
|
||||
m.completionDismissed = false // Reset for new operation
|
||||
|
||||
// Start backup and return polling command
|
||||
go func() {
|
||||
err := RunBackupInTUI(m.ctx, m.config, m.logger, "cluster", "", m.progressReporter)
|
||||
// The completion will be handled by the progress reporter callback system
|
||||
_ = err // Handle error in the progress reporter
|
||||
}()
|
||||
|
||||
return m, m.pollOperations()
|
||||
}
|
||||
|
||||
// Handle viewing active operations
|
||||
func (m MenuModel) handleViewOperations() (tea.Model, tea.Cmd) {
|
||||
if len(m.allOperations) == 0 {
|
||||
m.message = infoStyle.Render("ℹ️ No operations currently running or completed")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var activeOps []progress.OperationStatus
|
||||
for _, op := range m.allOperations {
|
||||
if op.Status == "running" {
|
||||
activeOps = append(activeOps, op)
|
||||
}
|
||||
}
|
||||
|
||||
if len(activeOps) == 0 {
|
||||
m.message = infoStyle.Render("ℹ️ No operations currently running")
|
||||
} else {
|
||||
m.message = progressStyle.Render(fmt.Sprintf("🔄 %d active operations", len(activeOps)))
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Handle showing operation history
|
||||
func (m MenuModel) handleOperationHistory() (tea.Model, tea.Cmd) {
|
||||
if len(m.allOperations) == 0 {
|
||||
m.message = infoStyle.Render("ℹ️ No operation history available")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var history strings.Builder
|
||||
history.WriteString("📋 Operation History:\n")
|
||||
|
||||
for i, op := range m.allOperations {
|
||||
if i >= 5 { // Show last 5 operations
|
||||
break
|
||||
}
|
||||
|
||||
status := "🔄"
|
||||
if op.Status == "completed" {
|
||||
status = "✅"
|
||||
} else if op.Status == "failed" {
|
||||
status = "❌"
|
||||
}
|
||||
|
||||
history.WriteString(fmt.Sprintf("%s %s - %s (%s)\n",
|
||||
status, op.Name, op.Type, op.StartTime.Format("15:04:05")))
|
||||
}
|
||||
|
||||
m.message = history.String()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Handle status check
|
||||
func (m MenuModel) handleStatus() (tea.Model, tea.Cmd) {
|
||||
db, err := database.New(m.config, m.logger)
|
||||
if err != nil {
|
||||
m.message = errorStyle.Render(fmt.Sprintf("❌ Connection failed: %v", err))
|
||||
return m, nil
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
err = db.Connect(m.ctx)
|
||||
if err != nil {
|
||||
m.message = errorStyle.Render(fmt.Sprintf("❌ Connection failed: %v", err))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
err = db.Ping(m.ctx)
|
||||
if err != nil {
|
||||
m.message = errorStyle.Render(fmt.Sprintf("❌ Ping failed: %v", err))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
version, err := db.GetVersion(m.ctx)
|
||||
if err != nil {
|
||||
m.message = errorStyle.Render(fmt.Sprintf("❌ Failed to get version: %v", err))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
m.message = successStyle.Render(fmt.Sprintf("✅ Connected successfully!\nVersion: %s", version))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Handle settings display
|
||||
func (m MenuModel) handleSettings() (tea.Model, tea.Cmd) {
|
||||
// Create and switch to settings model
|
||||
settingsModel := NewSettingsModel(m.config, m.logger, m)
|
||||
return settingsModel, settingsModel.Init()
|
||||
}
|
||||
|
||||
// Handle clearing operation history
|
||||
func (m MenuModel) handleClearHistory() (tea.Model, tea.Cmd) {
|
||||
m.allOperations = []progress.OperationStatus{}
|
||||
m.currentOperation = nil
|
||||
m.showProgress = false
|
||||
m.message = successStyle.Render("✅ Operation history cleared")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
// formatDuration formats a duration in a human-readable way
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%.1fs", d.Seconds())
|
||||
} else if d < time.Hour {
|
||||
return fmt.Sprintf("%.1fm", d.Minutes())
|
||||
}
|
||||
return fmt.Sprintf("%.1fh", d.Hours())
|
||||
}
|
||||
|
||||
// formatBytes formats byte count in human-readable format
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// RunInteractiveMenu starts the enhanced TUI with progress tracking
|
||||
func RunInteractiveMenu(cfg *config.Config, log logger.Logger) error {
|
||||
m := NewMenuModel(cfg, log)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
return fmt.Errorf("error running interactive menu: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
212
internal/tui/progress.go
Normal file
212
internal/tui/progress.go
Normal file
@ -0,0 +1,212 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dbbackup/internal/backup"
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/database"
|
||||
"dbbackup/internal/logger"
|
||||
"dbbackup/internal/progress"
|
||||
)
|
||||
|
||||
// TUIProgressReporter is a progress reporter that integrates with the TUI
|
||||
type TUIProgressReporter struct {
|
||||
mu sync.RWMutex
|
||||
operations map[string]*progress.OperationStatus
|
||||
callbacks []func([]progress.OperationStatus)
|
||||
}
|
||||
|
||||
// NewTUIProgressReporter creates a new TUI-compatible progress reporter
|
||||
func NewTUIProgressReporter() *TUIProgressReporter {
|
||||
return &TUIProgressReporter{
|
||||
operations: make(map[string]*progress.OperationStatus),
|
||||
callbacks: make([]func([]progress.OperationStatus), 0),
|
||||
}
|
||||
}
|
||||
|
||||
// AddCallback adds a callback function to be called when operations update
|
||||
func (t *TUIProgressReporter) AddCallback(callback func([]progress.OperationStatus)) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.callbacks = append(t.callbacks, callback)
|
||||
}
|
||||
|
||||
// notifyCallbacks calls all registered callbacks with current operations
|
||||
func (t *TUIProgressReporter) notifyCallbacks() {
|
||||
operations := make([]progress.OperationStatus, 0, len(t.operations))
|
||||
for _, op := range t.operations {
|
||||
operations = append(operations, *op)
|
||||
}
|
||||
|
||||
for _, callback := range t.callbacks {
|
||||
go callback(operations)
|
||||
}
|
||||
}
|
||||
|
||||
// StartOperation starts tracking a new operation
|
||||
func (t *TUIProgressReporter) StartOperation(id, name, opType string) *TUIOperationTracker {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
operation := &progress.OperationStatus{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Type: opType,
|
||||
Status: "running",
|
||||
StartTime: time.Now(),
|
||||
Progress: 0,
|
||||
Message: fmt.Sprintf("Starting %s: %s", opType, name),
|
||||
Details: make(map[string]string),
|
||||
Steps: make([]progress.StepStatus, 0),
|
||||
}
|
||||
|
||||
t.operations[id] = operation
|
||||
t.notifyCallbacks()
|
||||
|
||||
return &TUIOperationTracker{
|
||||
reporter: t,
|
||||
operationID: id,
|
||||
}
|
||||
}
|
||||
|
||||
// TUIOperationTracker tracks progress for TUI display
|
||||
type TUIOperationTracker struct {
|
||||
reporter *TUIProgressReporter
|
||||
operationID string
|
||||
}
|
||||
|
||||
// UpdateProgress updates the operation progress
|
||||
func (t *TUIOperationTracker) UpdateProgress(progress int, message string) {
|
||||
t.reporter.mu.Lock()
|
||||
defer t.reporter.mu.Unlock()
|
||||
|
||||
if op, exists := t.reporter.operations[t.operationID]; exists {
|
||||
op.Progress = progress
|
||||
op.Message = message
|
||||
t.reporter.notifyCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
// Complete marks the operation as completed
|
||||
func (t *TUIOperationTracker) Complete(message string) {
|
||||
t.reporter.mu.Lock()
|
||||
defer t.reporter.mu.Unlock()
|
||||
|
||||
if op, exists := t.reporter.operations[t.operationID]; exists {
|
||||
now := time.Now()
|
||||
op.Status = "completed"
|
||||
op.Progress = 100
|
||||
op.Message = message
|
||||
op.EndTime = &now
|
||||
op.Duration = now.Sub(op.StartTime)
|
||||
t.reporter.notifyCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
// Fail marks the operation as failed
|
||||
func (t *TUIOperationTracker) Fail(message string) {
|
||||
t.reporter.mu.Lock()
|
||||
defer t.reporter.mu.Unlock()
|
||||
|
||||
if op, exists := t.reporter.operations[t.operationID]; exists {
|
||||
now := time.Now()
|
||||
op.Status = "failed"
|
||||
op.Message = message
|
||||
op.EndTime = &now
|
||||
op.Duration = now.Sub(op.StartTime)
|
||||
t.reporter.notifyCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
// GetOperations returns all current operations
|
||||
func (t *TUIProgressReporter) GetOperations() []progress.OperationStatus {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
operations := make([]progress.OperationStatus, 0, len(t.operations))
|
||||
for _, op := range t.operations {
|
||||
operations = append(operations, *op)
|
||||
}
|
||||
return operations
|
||||
}
|
||||
|
||||
// SilentLogger implements logger.Logger but doesn't output anything
|
||||
type SilentLogger struct{}
|
||||
|
||||
func (s *SilentLogger) Info(msg string, args ...any) {}
|
||||
func (s *SilentLogger) Warn(msg string, args ...any) {}
|
||||
func (s *SilentLogger) Error(msg string, args ...any) {}
|
||||
func (s *SilentLogger) Debug(msg string, args ...any) {}
|
||||
func (s *SilentLogger) Time(msg string, args ...any) {}
|
||||
func (s *SilentLogger) StartOperation(name string) logger.OperationLogger {
|
||||
return &SilentOperation{}
|
||||
}
|
||||
|
||||
// SilentOperation implements logger.OperationLogger but doesn't output anything
|
||||
type SilentOperation struct{}
|
||||
|
||||
func (s *SilentOperation) Update(message string, args ...any) {}
|
||||
func (s *SilentOperation) Complete(message string, args ...any) {}
|
||||
func (s *SilentOperation) Fail(message string, args ...any) {}
|
||||
|
||||
// SilentProgressIndicator implements progress.Indicator but doesn't output anything
|
||||
type SilentProgressIndicator struct{}
|
||||
|
||||
func (s *SilentProgressIndicator) Start(message string) {}
|
||||
func (s *SilentProgressIndicator) Update(message string) {}
|
||||
func (s *SilentProgressIndicator) Complete(message string) {}
|
||||
func (s *SilentProgressIndicator) Fail(message string) {}
|
||||
func (s *SilentProgressIndicator) Stop() {}
|
||||
|
||||
// RunBackupInTUI runs a backup operation with TUI-compatible progress reporting
|
||||
func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger,
|
||||
backupType string, databaseName string, reporter *TUIProgressReporter) error {
|
||||
|
||||
// Create database connection
|
||||
db, err := database.New(cfg, &SilentLogger{}) // Use silent logger
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database connection: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
err = db.Connect(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// Create backup engine with silent progress indicator and logger
|
||||
silentProgress := &SilentProgressIndicator{}
|
||||
engine := backup.NewSilent(cfg, &SilentLogger{}, db, silentProgress)
|
||||
|
||||
// Start operation tracking
|
||||
operationID := fmt.Sprintf("%s_%d", backupType, time.Now().Unix())
|
||||
tracker := reporter.StartOperation(operationID, databaseName, backupType)
|
||||
|
||||
// Run the appropriate backup type
|
||||
switch backupType {
|
||||
case "single":
|
||||
tracker.UpdateProgress(10, "Preparing single database backup...")
|
||||
err = engine.BackupSingle(ctx, databaseName)
|
||||
case "cluster":
|
||||
tracker.UpdateProgress(10, "Preparing cluster backup...")
|
||||
err = engine.BackupCluster(ctx)
|
||||
case "sample":
|
||||
tracker.UpdateProgress(10, "Preparing sample backup...")
|
||||
err = engine.BackupSample(ctx, databaseName)
|
||||
default:
|
||||
err = fmt.Errorf("unknown backup type: %s", backupType)
|
||||
}
|
||||
|
||||
// Update final status
|
||||
if err != nil {
|
||||
tracker.Fail(fmt.Sprintf("Backup failed: %v", err))
|
||||
return err
|
||||
} else {
|
||||
tracker.Complete(fmt.Sprintf("%s backup completed successfully", backupType))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
465
internal/tui/settings.go
Normal file
465
internal/tui/settings.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user