chore: update build and tui assets
This commit is contained in:
@ -3,10 +3,11 @@ package logger
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Logger defines the interface for logging
|
||||
@ -16,7 +17,7 @@ type Logger interface {
|
||||
Warn(msg string, args ...any)
|
||||
Error(msg string, args ...any)
|
||||
Time(msg string, args ...any)
|
||||
|
||||
|
||||
// Progress logging for operations
|
||||
StartOperation(name string) OperationLogger
|
||||
}
|
||||
@ -28,10 +29,10 @@ type OperationLogger interface {
|
||||
Fail(msg string, args ...any)
|
||||
}
|
||||
|
||||
// logger implements Logger interface using slog
|
||||
// logger implements Logger interface using logrus
|
||||
type logger struct {
|
||||
slog *slog.Logger
|
||||
level slog.Level
|
||||
logrus *logrus.Logger
|
||||
level logrus.Level
|
||||
format string
|
||||
}
|
||||
|
||||
@ -44,58 +45,57 @@ type operationLogger struct {
|
||||
|
||||
// New creates a new logger
|
||||
func New(level, format string) Logger {
|
||||
var slogLevel slog.Level
|
||||
var logLevel logrus.Level
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
slogLevel = slog.LevelDebug
|
||||
logLevel = logrus.DebugLevel
|
||||
case "info":
|
||||
slogLevel = slog.LevelInfo
|
||||
logLevel = logrus.InfoLevel
|
||||
case "warn", "warning":
|
||||
slogLevel = slog.LevelWarn
|
||||
logLevel = logrus.WarnLevel
|
||||
case "error":
|
||||
slogLevel = slog.LevelError
|
||||
logLevel = logrus.ErrorLevel
|
||||
default:
|
||||
slogLevel = slog.LevelInfo
|
||||
logLevel = logrus.InfoLevel
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: slogLevel,
|
||||
}
|
||||
l := logrus.New()
|
||||
l.SetLevel(logLevel)
|
||||
l.SetOutput(os.Stdout)
|
||||
|
||||
switch strings.ToLower(format) {
|
||||
case "json":
|
||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
l.SetFormatter(&logrus.JSONFormatter{})
|
||||
default:
|
||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||
l.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
|
||||
}
|
||||
|
||||
return &logger{
|
||||
slog: slog.New(handler),
|
||||
level: slogLevel,
|
||||
logrus: l,
|
||||
level: logLevel,
|
||||
format: format,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *logger) Debug(msg string, args ...any) {
|
||||
l.slog.Debug(msg, args...)
|
||||
l.logWithFields(logrus.DebugLevel, msg, args...)
|
||||
}
|
||||
|
||||
func (l *logger) Info(msg string, args ...any) {
|
||||
l.slog.Info(msg, args...)
|
||||
l.logWithFields(logrus.InfoLevel, msg, args...)
|
||||
}
|
||||
|
||||
func (l *logger) Warn(msg string, args ...any) {
|
||||
l.slog.Warn(msg, args...)
|
||||
l.logWithFields(logrus.WarnLevel, msg, args...)
|
||||
}
|
||||
|
||||
func (l *logger) Error(msg string, args ...any) {
|
||||
l.slog.Error(msg, args...)
|
||||
l.logWithFields(logrus.ErrorLevel, msg, args...)
|
||||
}
|
||||
|
||||
func (l *logger) Time(msg string, args ...any) {
|
||||
// Time logs are always at info level with special formatting
|
||||
l.slog.Info("[TIME] "+msg, args...)
|
||||
l.logWithFields(logrus.InfoLevel, "[TIME] "+msg, args...)
|
||||
}
|
||||
|
||||
func (l *logger) StartOperation(name string) OperationLogger {
|
||||
@ -108,22 +108,63 @@ func (l *logger) StartOperation(name string) OperationLogger {
|
||||
|
||||
func (ol *operationLogger) Update(msg string, args ...any) {
|
||||
elapsed := time.Since(ol.startTime)
|
||||
ol.parent.Info(fmt.Sprintf("[%s] %s", ol.name, msg),
|
||||
ol.parent.Info(fmt.Sprintf("[%s] %s", ol.name, msg),
|
||||
append(args, "elapsed", elapsed.String())...)
|
||||
}
|
||||
|
||||
func (ol *operationLogger) Complete(msg string, args ...any) {
|
||||
elapsed := time.Since(ol.startTime)
|
||||
ol.parent.Info(fmt.Sprintf("[%s] COMPLETED: %s", ol.name, msg),
|
||||
ol.parent.Info(fmt.Sprintf("[%s] COMPLETED: %s", ol.name, msg),
|
||||
append(args, "duration", formatDuration(elapsed))...)
|
||||
}
|
||||
|
||||
func (ol *operationLogger) Fail(msg string, args ...any) {
|
||||
elapsed := time.Since(ol.startTime)
|
||||
ol.parent.Error(fmt.Sprintf("[%s] FAILED: %s", ol.name, msg),
|
||||
ol.parent.Error(fmt.Sprintf("[%s] FAILED: %s", ol.name, msg),
|
||||
append(args, "duration", formatDuration(elapsed))...)
|
||||
}
|
||||
|
||||
// logWithFields forwards log messages with structured fields to logrus
|
||||
func (l *logger) logWithFields(level logrus.Level, msg string, args ...any) {
|
||||
if l == nil || l.logrus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fields := fieldsFromArgs(args...)
|
||||
entry := l.logrus.WithFields(fields)
|
||||
|
||||
switch level {
|
||||
case logrus.DebugLevel:
|
||||
entry.Debug(msg)
|
||||
case logrus.WarnLevel:
|
||||
entry.Warn(msg)
|
||||
case logrus.ErrorLevel:
|
||||
entry.Error(msg)
|
||||
default:
|
||||
entry.Info(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// fieldsFromArgs converts variadic key/value pairs into logrus fields
|
||||
func fieldsFromArgs(args ...any) logrus.Fields {
|
||||
fields := logrus.Fields{}
|
||||
|
||||
for i := 0; i < len(args); {
|
||||
if i+1 < len(args) {
|
||||
if key, ok := args[i].(string); ok {
|
||||
fields[key] = args[i+1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
fields[fmt.Sprintf("arg%d", i)] = args[i]
|
||||
i++
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// formatDuration formats duration in human-readable format
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
@ -142,18 +183,18 @@ func formatDuration(d time.Duration) string {
|
||||
|
||||
// FileLogger creates a logger that writes to both stdout and a file
|
||||
func FileLogger(level, format, filename string) (Logger, error) {
|
||||
var slogLevel slog.Level
|
||||
var logLevel logrus.Level
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
slogLevel = slog.LevelDebug
|
||||
logLevel = logrus.DebugLevel
|
||||
case "info":
|
||||
slogLevel = slog.LevelInfo
|
||||
logLevel = logrus.InfoLevel
|
||||
case "warn", "warning":
|
||||
slogLevel = slog.LevelWarn
|
||||
logLevel = logrus.WarnLevel
|
||||
case "error":
|
||||
slogLevel = slog.LevelError
|
||||
logLevel = logrus.ErrorLevel
|
||||
default:
|
||||
slogLevel = slog.LevelInfo
|
||||
logLevel = logrus.InfoLevel
|
||||
}
|
||||
|
||||
// Open log file
|
||||
@ -165,21 +206,20 @@ func FileLogger(level, format, filename string) (Logger, error) {
|
||||
// Create multi-writer (stdout + file)
|
||||
multiWriter := io.MultiWriter(os.Stdout, file)
|
||||
|
||||
var handler slog.Handler
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: slogLevel,
|
||||
}
|
||||
l := logrus.New()
|
||||
l.SetLevel(logLevel)
|
||||
l.SetOutput(multiWriter)
|
||||
|
||||
switch strings.ToLower(format) {
|
||||
case "json":
|
||||
handler = slog.NewJSONHandler(multiWriter, opts)
|
||||
l.SetFormatter(&logrus.JSONFormatter{})
|
||||
default:
|
||||
handler = slog.NewTextHandler(multiWriter, opts)
|
||||
l.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
|
||||
}
|
||||
|
||||
return &logger{
|
||||
slog: slog.New(handler),
|
||||
level: slogLevel,
|
||||
logrus: l,
|
||||
level: logLevel,
|
||||
format: format,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
210
internal/tui/backup_exec.go
Normal file
210
internal/tui/backup_exec.go
Normal file
@ -0,0 +1,210 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"dbbackup/internal/backup"
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/database"
|
||||
"dbbackup/internal/logger"
|
||||
"dbbackup/internal/progress"
|
||||
)
|
||||
|
||||
// BackupExecutionModel handles backup execution with progress
|
||||
type BackupExecutionModel struct {
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
parent tea.Model
|
||||
backupType string
|
||||
databaseName string
|
||||
ratio int
|
||||
status string
|
||||
progress int
|
||||
done bool
|
||||
err error
|
||||
result string
|
||||
startTime time.Time
|
||||
details []string
|
||||
}
|
||||
|
||||
func NewBackupExecution(cfg *config.Config, log logger.Logger, parent tea.Model, backupType, dbName string, ratio int) BackupExecutionModel {
|
||||
return BackupExecutionModel{
|
||||
config: cfg,
|
||||
logger: log,
|
||||
parent: parent,
|
||||
backupType: backupType,
|
||||
databaseName: dbName,
|
||||
ratio: ratio,
|
||||
status: "Initializing...",
|
||||
startTime: time.Now(),
|
||||
details: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m BackupExecutionModel) Init() tea.Cmd {
|
||||
reporter := NewTUIProgressReporter()
|
||||
reporter.AddCallback(func(ops []progress.OperationStatus) {
|
||||
if len(ops) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
latest := ops[len(ops)-1]
|
||||
tea.Println(backupProgressMsg{
|
||||
status: latest.Message,
|
||||
progress: latest.Progress,
|
||||
detail: latest.Status,
|
||||
})
|
||||
})
|
||||
|
||||
return executeBackupWithTUIProgress(m.config, m.logger, m.backupType, m.databaseName, m.ratio, reporter)
|
||||
}
|
||||
|
||||
type backupProgressMsg struct {
|
||||
status string
|
||||
progress int
|
||||
detail string
|
||||
}
|
||||
|
||||
type backupCompleteMsg struct {
|
||||
result string
|
||||
err error
|
||||
}
|
||||
|
||||
func executeBackupWithTUIProgress(cfg *config.Config, log logger.Logger, backupType, dbName string, ratio int, reporter *TUIProgressReporter) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
dbClient, err := database.New(cfg, log)
|
||||
if err != nil {
|
||||
return backupCompleteMsg{
|
||||
result: "",
|
||||
err: fmt.Errorf("failed to create database client: %w", err),
|
||||
}
|
||||
}
|
||||
defer dbClient.Close()
|
||||
|
||||
if err := dbClient.Connect(ctx); err != nil {
|
||||
return backupCompleteMsg{
|
||||
result: "",
|
||||
err: fmt.Errorf("database connection failed: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
engine := backup.NewSilent(cfg, log, dbClient, reporter)
|
||||
|
||||
var backupErr error
|
||||
switch backupType {
|
||||
case "single":
|
||||
backupErr = engine.BackupSingle(ctx, dbName)
|
||||
case "sample":
|
||||
cfg.SampleStrategy = "ratio"
|
||||
cfg.SampleValue = ratio
|
||||
backupErr = engine.BackupSample(ctx, dbName)
|
||||
case "cluster":
|
||||
backupErr = engine.BackupCluster(ctx)
|
||||
default:
|
||||
return backupCompleteMsg{err: fmt.Errorf("unknown backup type: %s", backupType)}
|
||||
}
|
||||
|
||||
if backupErr != nil {
|
||||
return backupCompleteMsg{
|
||||
result: "",
|
||||
err: fmt.Errorf("backup failed: %w", backupErr),
|
||||
}
|
||||
}
|
||||
|
||||
elapsed := time.Since(start).Round(time.Second)
|
||||
|
||||
var result string
|
||||
switch backupType {
|
||||
case "single":
|
||||
result = fmt.Sprintf("✓ Single database backup of '%s' completed successfully in %v", dbName, elapsed)
|
||||
case "sample":
|
||||
result = fmt.Sprintf("✓ Sample backup of '%s' (ratio: %d) completed successfully in %v", dbName, ratio, elapsed)
|
||||
case "cluster":
|
||||
result = fmt.Sprintf("✓ Cluster backup completed successfully in %v", elapsed)
|
||||
}
|
||||
|
||||
return backupCompleteMsg{
|
||||
result: result,
|
||||
err: nil,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case backupProgressMsg:
|
||||
m.status = msg.status
|
||||
m.progress = msg.progress
|
||||
return m, nil
|
||||
|
||||
case backupCompleteMsg:
|
||||
m.done = true
|
||||
m.err = msg.err
|
||||
m.result = msg.result
|
||||
if m.err == nil {
|
||||
m.status = "✅ Backup completed successfully!"
|
||||
} else {
|
||||
m.status = fmt.Sprintf("❌ Backup failed: %v", m.err)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.done {
|
||||
switch msg.String() {
|
||||
case "enter", "esc", "q":
|
||||
return m.parent, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m BackupExecutionModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
header := titleStyle.Render("🔄 Backup Execution")
|
||||
s.WriteString(fmt.Sprintf("\n%s\n\n", header))
|
||||
|
||||
s.WriteString(fmt.Sprintf("Type: %s\n", m.backupType))
|
||||
if m.databaseName != "" {
|
||||
s.WriteString(fmt.Sprintf("Database: %s\n", m.databaseName))
|
||||
}
|
||||
if m.ratio > 0 {
|
||||
s.WriteString(fmt.Sprintf("Sample Ratio: %d\n", m.ratio))
|
||||
}
|
||||
s.WriteString(fmt.Sprintf("Duration: %s\n\n", time.Since(m.startTime).Round(time.Second)))
|
||||
|
||||
s.WriteString(fmt.Sprintf("Status: %s\n", m.status))
|
||||
|
||||
if !m.done {
|
||||
spinner := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
frame := int(time.Since(m.startTime).Milliseconds()/100) % len(spinner)
|
||||
s.WriteString(fmt.Sprintf("\n%s Processing...\n", spinner[frame]))
|
||||
} else {
|
||||
s.WriteString("\n")
|
||||
if m.err != nil {
|
||||
s.WriteString(fmt.Sprintf("Error: %v\n\n", m.err))
|
||||
}
|
||||
lines := strings.Split(m.result, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "✅") || strings.Contains(line, "completed") ||
|
||||
strings.Contains(line, "Size:") || strings.Contains(line, "backup_") {
|
||||
s.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
s.WriteString("\n⌨️ Press Enter or ESC to return to menu\n")
|
||||
}
|
||||
|
||||
return s.String()
|
||||
}
|
||||
98
internal/tui/confirmation.go
Normal file
98
internal/tui/confirmation.go
Normal file
@ -0,0 +1,98 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/logger"
|
||||
)
|
||||
|
||||
// ConfirmationModel for yes/no confirmations
|
||||
type ConfirmationModel struct {
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
parent tea.Model
|
||||
title string
|
||||
message string
|
||||
cursor int
|
||||
choices []string
|
||||
confirmed bool
|
||||
}
|
||||
|
||||
func NewConfirmationModel(cfg *config.Config, log logger.Logger, parent tea.Model, title, message string) ConfirmationModel {
|
||||
return ConfirmationModel{
|
||||
config: cfg,
|
||||
logger: log,
|
||||
parent: parent,
|
||||
title: title,
|
||||
message: message,
|
||||
choices: []string{"Yes", "No"},
|
||||
}
|
||||
}
|
||||
|
||||
func (m ConfirmationModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m ConfirmationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q", "esc", "n":
|
||||
return m.parent, nil
|
||||
|
||||
case "left", "h":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
|
||||
case "right", "l":
|
||||
if m.cursor < len(m.choices)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
|
||||
case "enter", "y":
|
||||
if msg.String() == "y" || m.cursor == 0 {
|
||||
m.confirmed = true
|
||||
// Execute cluster backup
|
||||
executor := NewBackupExecution(m.config, m.logger, m.parent, "cluster", "", 0)
|
||||
return executor, executor.Init()
|
||||
}
|
||||
return m.parent, nil
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m ConfirmationModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
header := titleStyle.Render(m.title)
|
||||
s.WriteString(fmt.Sprintf("\n%s\n\n", header))
|
||||
|
||||
s.WriteString(fmt.Sprintf("%s\n\n", m.message))
|
||||
|
||||
// Show choices
|
||||
for i, choice := range m.choices {
|
||||
cursor := " "
|
||||
if m.cursor == i {
|
||||
cursor = ">"
|
||||
s.WriteString(selectedStyle.Render(fmt.Sprintf("%s [%s]", cursor, choice)))
|
||||
} else {
|
||||
s.WriteString(fmt.Sprintf("%s [%s]", cursor, choice))
|
||||
}
|
||||
s.WriteString(" ")
|
||||
}
|
||||
|
||||
s.WriteString("\n\n⌨️ ←/→: Select • Enter/y: Confirm • n/ESC: Cancel\n")
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (m ConfirmationModel) IsConfirmed() bool {
|
||||
return m.confirmed
|
||||
}
|
||||
168
internal/tui/dbselector.go
Normal file
168
internal/tui/dbselector.go
Normal file
@ -0,0 +1,168 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/database"
|
||||
"dbbackup/internal/logger"
|
||||
)
|
||||
|
||||
// DatabaseSelectorModel for selecting a database
|
||||
type DatabaseSelectorModel struct {
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
parent tea.Model
|
||||
databases []string
|
||||
cursor int
|
||||
selected string
|
||||
loading bool
|
||||
err error
|
||||
title string
|
||||
message string
|
||||
backupType string // "single" or "sample"
|
||||
}
|
||||
|
||||
func NewDatabaseSelector(cfg *config.Config, log logger.Logger, parent tea.Model, title string, backupType string) DatabaseSelectorModel {
|
||||
return DatabaseSelectorModel{
|
||||
config: cfg,
|
||||
logger: log,
|
||||
parent: parent,
|
||||
databases: []string{"Loading databases..."},
|
||||
title: title,
|
||||
loading: true,
|
||||
backupType: backupType,
|
||||
}
|
||||
}
|
||||
|
||||
func (m DatabaseSelectorModel) Init() tea.Cmd {
|
||||
return fetchDatabases(m.config, m.logger)
|
||||
}
|
||||
|
||||
type databaseListMsg struct {
|
||||
databases []string
|
||||
err error
|
||||
}
|
||||
|
||||
func fetchDatabases(cfg *config.Config, log logger.Logger) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dbClient, err := database.New(cfg, log)
|
||||
if err != nil {
|
||||
return databaseListMsg{databases: nil, err: fmt.Errorf("failed to create database client: %w", err)}
|
||||
}
|
||||
defer dbClient.Close()
|
||||
|
||||
if err := dbClient.Connect(ctx); err != nil {
|
||||
return databaseListMsg{databases: nil, err: fmt.Errorf("connection failed: %w", err)}
|
||||
}
|
||||
|
||||
databases, err := dbClient.ListDatabases(ctx)
|
||||
if err != nil {
|
||||
return databaseListMsg{databases: nil, err: fmt.Errorf("failed to list databases: %w", err)}
|
||||
}
|
||||
|
||||
return databaseListMsg{databases: databases, err: nil}
|
||||
}
|
||||
}
|
||||
|
||||
func (m DatabaseSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case databaseListMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
m.databases = []string{"Error loading databases"}
|
||||
} else {
|
||||
m.databases = msg.databases
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
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.databases)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
|
||||
case "enter":
|
||||
if !m.loading && m.err == nil && len(m.databases) > 0 {
|
||||
m.selected = m.databases[m.cursor]
|
||||
|
||||
// If sample backup, ask for ratio first
|
||||
if m.backupType == "sample" {
|
||||
inputModel := NewInputModel(m.config, m.logger, m,
|
||||
"📊 Sample Ratio",
|
||||
"Enter sample ratio (1-100):",
|
||||
"10",
|
||||
ValidateInt(1, 100))
|
||||
return inputModel, nil
|
||||
}
|
||||
|
||||
// For single backup, go directly to execution
|
||||
executor := NewBackupExecution(m.config, m.logger, m.parent, m.backupType, m.selected, 0)
|
||||
return executor, executor.Init()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m DatabaseSelectorModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
header := titleStyle.Render(m.title)
|
||||
s.WriteString(fmt.Sprintf("\n%s\n\n", header))
|
||||
|
||||
if m.loading {
|
||||
s.WriteString("⏳ Loading databases...\n")
|
||||
return s.String()
|
||||
}
|
||||
|
||||
if m.err != nil {
|
||||
s.WriteString(fmt.Sprintf("❌ Error: %v\n", m.err))
|
||||
s.WriteString("\nPress ESC to go back\n")
|
||||
return s.String()
|
||||
}
|
||||
|
||||
s.WriteString("Select a database:\n\n")
|
||||
|
||||
for i, db := range m.databases {
|
||||
cursor := " "
|
||||
if m.cursor == i {
|
||||
cursor = ">"
|
||||
s.WriteString(selectedStyle.Render(fmt.Sprintf("%s %s", cursor, db)))
|
||||
} else {
|
||||
s.WriteString(fmt.Sprintf("%s %s", cursor, db))
|
||||
}
|
||||
s.WriteString("\n")
|
||||
}
|
||||
|
||||
if m.message != "" {
|
||||
s.WriteString(fmt.Sprintf("\n%s\n", m.message))
|
||||
}
|
||||
|
||||
s.WriteString("\n⌨️ ↑/↓: Navigate • Enter: Select • ESC: Back • q: Quit\n")
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (m DatabaseSelectorModel) GetSelected() string {
|
||||
return m.selected
|
||||
}
|
||||
167
internal/tui/dirbrowser.go
Normal file
167
internal/tui/dirbrowser.go
Normal file
@ -0,0 +1,167 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DirectoryBrowser is an integrated directory browser for the settings
|
||||
type DirectoryBrowser struct {
|
||||
CurrentPath string
|
||||
items []string
|
||||
cursor int
|
||||
visible bool
|
||||
}
|
||||
|
||||
func NewDirectoryBrowser(startPath string) *DirectoryBrowser {
|
||||
db := &DirectoryBrowser{
|
||||
CurrentPath: startPath,
|
||||
visible: false,
|
||||
}
|
||||
db.LoadItems()
|
||||
return db
|
||||
}
|
||||
|
||||
func (db *DirectoryBrowser) LoadItems() {
|
||||
db.items = []string{}
|
||||
db.cursor = 0
|
||||
|
||||
// Add parent directory if not at root
|
||||
if db.CurrentPath != "/" && db.CurrentPath != "" {
|
||||
db.items = append(db.items, "..")
|
||||
}
|
||||
|
||||
// Read current directory
|
||||
entries, err := os.ReadDir(db.CurrentPath)
|
||||
if err != nil {
|
||||
db.items = append(db.items, "[Error reading directory]")
|
||||
return
|
||||
}
|
||||
|
||||
// Collect directories only
|
||||
var dirs []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") {
|
||||
dirs = append(dirs, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Sort directories
|
||||
sort.Strings(dirs)
|
||||
db.items = append(db.items, dirs...)
|
||||
}
|
||||
|
||||
func (db *DirectoryBrowser) Show() {
|
||||
db.visible = true
|
||||
}
|
||||
|
||||
func (db *DirectoryBrowser) Hide() {
|
||||
db.visible = false
|
||||
}
|
||||
|
||||
func (db *DirectoryBrowser) IsVisible() bool {
|
||||
return db.visible
|
||||
}
|
||||
|
||||
func (db *DirectoryBrowser) GetCurrentPath() string {
|
||||
return db.CurrentPath
|
||||
}
|
||||
|
||||
func (db *DirectoryBrowser) Navigate(direction int) {
|
||||
if direction < 0 && db.cursor > 0 {
|
||||
db.cursor--
|
||||
} else if direction > 0 && db.cursor < len(db.items)-1 {
|
||||
db.cursor++
|
||||
}
|
||||
}
|
||||
|
||||
func (db *DirectoryBrowser) Enter() bool {
|
||||
if len(db.items) == 0 || db.cursor >= len(db.items) {
|
||||
return false
|
||||
}
|
||||
|
||||
selected := db.items[db.cursor]
|
||||
if selected == ".." {
|
||||
db.CurrentPath = filepath.Dir(db.CurrentPath)
|
||||
db.LoadItems()
|
||||
return false
|
||||
} else if selected == "[Error reading directory]" {
|
||||
return false
|
||||
} else {
|
||||
// Navigate into directory
|
||||
newPath := filepath.Join(db.CurrentPath, selected)
|
||||
if stat, err := os.Stat(newPath); err == nil && stat.IsDir() {
|
||||
db.CurrentPath = newPath
|
||||
db.LoadItems()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (db *DirectoryBrowser) Select() string {
|
||||
return db.CurrentPath
|
||||
}
|
||||
|
||||
func (db *DirectoryBrowser) Render() string {
|
||||
if !db.visible {
|
||||
return ""
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
// Header
|
||||
lines = append(lines, fmt.Sprintf(" Current: %s", db.CurrentPath))
|
||||
lines = append(lines, fmt.Sprintf(" Found %d directories (cursor: %d)", len(db.items), db.cursor))
|
||||
lines = append(lines, " Directories:")
|
||||
|
||||
// Show directories
|
||||
maxItems := 5 // Show max 5 items to keep it compact
|
||||
start := 0
|
||||
end := len(db.items)
|
||||
|
||||
if len(db.items) > maxItems {
|
||||
// Center the cursor in the view
|
||||
start = db.cursor - maxItems/2
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end = start + maxItems
|
||||
if end > len(db.items) {
|
||||
end = len(db.items)
|
||||
start = end - maxItems
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := start; i < end; i++ {
|
||||
item := db.items[i]
|
||||
prefix := " "
|
||||
if i == db.cursor {
|
||||
prefix = " >> "
|
||||
}
|
||||
|
||||
displayName := item
|
||||
if item == ".." {
|
||||
displayName = "../ (parent directory)"
|
||||
} else if item != "[Error reading directory]" {
|
||||
displayName = item + "/"
|
||||
}
|
||||
|
||||
lines = append(lines, prefix+displayName)
|
||||
}
|
||||
|
||||
// Show navigation info if there are more items
|
||||
if len(db.items) > maxItems {
|
||||
lines = append(lines, fmt.Sprintf(" (%d of %d directories)", db.cursor+1, len(db.items)))
|
||||
}
|
||||
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, " ↑/↓: Navigate | Enter/→: Open | ←: Parent | Space: Select | Esc: Cancel")
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
245
internal/tui/dirpicker.go
Normal file
245
internal/tui/dirpicker.go
Normal file
@ -0,0 +1,245 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// DirectoryPicker is a simple, fast directory and file picker
|
||||
type DirectoryPicker struct {
|
||||
currentPath string
|
||||
items []FileItem
|
||||
cursor int
|
||||
callback func(string)
|
||||
allowFiles bool // Allow file selection for restore operations
|
||||
styles DirectoryPickerStyles
|
||||
}
|
||||
|
||||
type FileItem struct {
|
||||
Name string
|
||||
IsDir bool
|
||||
Path string
|
||||
}
|
||||
|
||||
type DirectoryPickerStyles struct {
|
||||
Container lipgloss.Style
|
||||
Header lipgloss.Style
|
||||
Item lipgloss.Style
|
||||
Selected lipgloss.Style
|
||||
Help lipgloss.Style
|
||||
}
|
||||
|
||||
func DefaultDirectoryPickerStyles() DirectoryPickerStyles {
|
||||
return DirectoryPickerStyles{
|
||||
Container: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("62")).
|
||||
Padding(1, 2),
|
||||
Header: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("205")).
|
||||
Bold(true).
|
||||
MarginBottom(1),
|
||||
Item: lipgloss.NewStyle().
|
||||
PaddingLeft(2),
|
||||
Selected: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("170")).
|
||||
Background(lipgloss.Color("62")).
|
||||
Bold(true).
|
||||
PaddingLeft(1).
|
||||
PaddingRight(1),
|
||||
Help: lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("241")).
|
||||
MarginTop(1),
|
||||
}
|
||||
}
|
||||
|
||||
func NewDirectoryPicker(startPath string, allowFiles bool, callback func(string)) *DirectoryPicker {
|
||||
dp := &DirectoryPicker{
|
||||
currentPath: startPath,
|
||||
allowFiles: allowFiles,
|
||||
callback: callback,
|
||||
styles: DefaultDirectoryPickerStyles(),
|
||||
}
|
||||
dp.loadItems()
|
||||
return dp
|
||||
}
|
||||
|
||||
func (dp *DirectoryPicker) loadItems() {
|
||||
dp.items = []FileItem{}
|
||||
dp.cursor = 0
|
||||
|
||||
// Add parent directory option if not at root
|
||||
if dp.currentPath != "/" && dp.currentPath != "" {
|
||||
dp.items = append(dp.items, FileItem{
|
||||
Name: "..",
|
||||
IsDir: true,
|
||||
Path: filepath.Dir(dp.currentPath),
|
||||
})
|
||||
}
|
||||
|
||||
// Read current directory
|
||||
entries, err := os.ReadDir(dp.currentPath)
|
||||
if err != nil {
|
||||
dp.items = append(dp.items, FileItem{
|
||||
Name: "Error reading directory",
|
||||
IsDir: false,
|
||||
Path: "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Collect directories and optionally files
|
||||
var dirs []FileItem
|
||||
var files []FileItem
|
||||
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".") {
|
||||
continue // Skip hidden files
|
||||
}
|
||||
|
||||
item := FileItem{
|
||||
Name: entry.Name(),
|
||||
IsDir: entry.IsDir(),
|
||||
Path: filepath.Join(dp.currentPath, entry.Name()),
|
||||
}
|
||||
|
||||
if entry.IsDir() {
|
||||
dirs = append(dirs, item)
|
||||
} else if dp.allowFiles {
|
||||
// Only include backup-related files
|
||||
if strings.HasSuffix(entry.Name(), ".sql") ||
|
||||
strings.HasSuffix(entry.Name(), ".dump") ||
|
||||
strings.HasSuffix(entry.Name(), ".gz") ||
|
||||
strings.HasSuffix(entry.Name(), ".tar") {
|
||||
files = append(files, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort directories and files separately
|
||||
sort.Slice(dirs, func(i, j int) bool {
|
||||
return dirs[i].Name < dirs[j].Name
|
||||
})
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].Name < files[j].Name
|
||||
})
|
||||
|
||||
// Add directories first, then files
|
||||
dp.items = append(dp.items, dirs...)
|
||||
dp.items = append(dp.items, files...)
|
||||
}
|
||||
|
||||
func (dp *DirectoryPicker) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dp *DirectoryPicker) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("q", "esc"))):
|
||||
if dp.callback != nil {
|
||||
dp.callback("") // Empty string indicates cancel
|
||||
}
|
||||
return dp, nil
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))):
|
||||
if len(dp.items) == 0 {
|
||||
return dp, nil
|
||||
}
|
||||
|
||||
selected := dp.items[dp.cursor]
|
||||
if selected.Name == ".." {
|
||||
// Go to parent directory
|
||||
dp.currentPath = filepath.Dir(dp.currentPath)
|
||||
dp.loadItems()
|
||||
} else if selected.Name == "Error reading directory" {
|
||||
return dp, nil
|
||||
} else if selected.IsDir {
|
||||
// Navigate into directory
|
||||
dp.currentPath = selected.Path
|
||||
dp.loadItems()
|
||||
} else {
|
||||
// File selected (for restore operations)
|
||||
if dp.callback != nil {
|
||||
dp.callback(selected.Path)
|
||||
}
|
||||
return dp, nil
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("s"))):
|
||||
// Select current directory
|
||||
if dp.callback != nil {
|
||||
dp.callback(dp.currentPath)
|
||||
}
|
||||
return dp, nil
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))):
|
||||
if dp.cursor > 0 {
|
||||
dp.cursor--
|
||||
}
|
||||
|
||||
case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))):
|
||||
if dp.cursor < len(dp.items)-1 {
|
||||
dp.cursor++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dp, nil
|
||||
}
|
||||
|
||||
func (dp *DirectoryPicker) View() string {
|
||||
if len(dp.items) == 0 {
|
||||
return dp.styles.Container.Render("No items found")
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
|
||||
// Header with current path
|
||||
pickerType := "Directory"
|
||||
if dp.allowFiles {
|
||||
pickerType = "File/Directory"
|
||||
}
|
||||
header := fmt.Sprintf("📁 %s Picker - %s", pickerType, dp.currentPath)
|
||||
content.WriteString(dp.styles.Header.Render(header))
|
||||
content.WriteString("\n\n")
|
||||
|
||||
// Items list
|
||||
for i, item := range dp.items {
|
||||
var prefix string
|
||||
if item.Name == ".." {
|
||||
prefix = "⬆️ "
|
||||
} else if item.Name == "Error reading directory" {
|
||||
prefix = "❌ "
|
||||
} else if item.IsDir {
|
||||
prefix = "📁 "
|
||||
} else {
|
||||
prefix = "📄 "
|
||||
}
|
||||
|
||||
line := prefix + item.Name
|
||||
if i == dp.cursor {
|
||||
content.WriteString(dp.styles.Selected.Render(line))
|
||||
} else {
|
||||
content.WriteString(dp.styles.Item.Render(line))
|
||||
}
|
||||
content.WriteString("\n")
|
||||
}
|
||||
|
||||
// Help text
|
||||
help := "\n↑/↓: Navigate • Enter: Open/Select File • s: Select Directory • q/Esc: Cancel"
|
||||
if !dp.allowFiles {
|
||||
help = "\n↑/↓: Navigate • Enter: Open • s: Select Directory • q/Esc: Cancel"
|
||||
}
|
||||
content.WriteString(dp.styles.Help.Render(help))
|
||||
|
||||
return dp.styles.Container.Render(content.String())
|
||||
}
|
||||
152
internal/tui/history.go
Normal file
152
internal/tui/history.go
Normal file
@ -0,0 +1,152 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/logger"
|
||||
)
|
||||
|
||||
// HistoryViewModel shows operation history
|
||||
type HistoryViewModel struct {
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
parent tea.Model
|
||||
history []HistoryEntry
|
||||
cursor int
|
||||
}
|
||||
|
||||
type HistoryEntry struct {
|
||||
Type string
|
||||
Database string
|
||||
Timestamp time.Time
|
||||
Status string
|
||||
Filename string
|
||||
}
|
||||
|
||||
func NewHistoryView(cfg *config.Config, log logger.Logger, parent tea.Model) HistoryViewModel {
|
||||
return HistoryViewModel{
|
||||
config: cfg,
|
||||
logger: log,
|
||||
parent: parent,
|
||||
history: loadHistory(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
func loadHistory(cfg *config.Config) []HistoryEntry {
|
||||
var entries []HistoryEntry
|
||||
|
||||
// Read backup files from backup directory
|
||||
files, err := ioutil.ReadDir(cfg.BackupDir)
|
||||
if err != nil {
|
||||
return entries
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := file.Name()
|
||||
if strings.HasSuffix(name, ".info") {
|
||||
continue
|
||||
}
|
||||
|
||||
var backupType string
|
||||
var database string
|
||||
|
||||
if strings.Contains(name, "cluster") {
|
||||
backupType = "Cluster Backup"
|
||||
database = "All Databases"
|
||||
} else if strings.Contains(name, "sample") {
|
||||
backupType = "Sample Backup"
|
||||
parts := strings.Split(name, "_")
|
||||
if len(parts) > 2 {
|
||||
database = parts[2]
|
||||
}
|
||||
} else {
|
||||
backupType = "Single Backup"
|
||||
parts := strings.Split(name, "_")
|
||||
if len(parts) > 2 {
|
||||
database = parts[2]
|
||||
}
|
||||
}
|
||||
|
||||
entries = append(entries, HistoryEntry{
|
||||
Type: backupType,
|
||||
Database: database,
|
||||
Timestamp: file.ModTime(),
|
||||
Status: "✅ Completed",
|
||||
Filename: name,
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
func (m HistoryViewModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m HistoryViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
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.history)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m HistoryViewModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
header := titleStyle.Render("📜 Operation History")
|
||||
s.WriteString(fmt.Sprintf("\n%s\n\n", header))
|
||||
|
||||
if len(m.history) == 0 {
|
||||
s.WriteString(infoStyle.Render("📭 No backup history found"))
|
||||
s.WriteString("\n\n")
|
||||
} else {
|
||||
s.WriteString(fmt.Sprintf("Found %d backup operations:\n\n", len(m.history)))
|
||||
|
||||
for i, entry := range m.history {
|
||||
cursor := " "
|
||||
line := fmt.Sprintf("%s [%s] %s - %s (%s)",
|
||||
cursor,
|
||||
entry.Timestamp.Format("2006-01-02 15:04"),
|
||||
entry.Type,
|
||||
entry.Database,
|
||||
entry.Status)
|
||||
|
||||
if m.cursor == i {
|
||||
s.WriteString(selectedStyle.Render("> " + line))
|
||||
} else {
|
||||
s.WriteString(" " + line)
|
||||
}
|
||||
s.WriteString("\n")
|
||||
}
|
||||
s.WriteString("\n")
|
||||
}
|
||||
|
||||
s.WriteString("⌨️ ↑/↓: Navigate • ESC: Back • q: Quit\n")
|
||||
|
||||
return s.String()
|
||||
}
|
||||
160
internal/tui/input.go
Normal file
160
internal/tui/input.go
Normal file
@ -0,0 +1,160 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/logger"
|
||||
)
|
||||
|
||||
// InputModel for getting user input
|
||||
type InputModel struct {
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
parent tea.Model
|
||||
title string
|
||||
prompt string
|
||||
value string
|
||||
cursor int
|
||||
done bool
|
||||
err error
|
||||
validate func(string) error
|
||||
}
|
||||
|
||||
func NewInputModel(cfg *config.Config, log logger.Logger, parent tea.Model, title, prompt, defaultValue string, validate func(string) error) InputModel {
|
||||
return InputModel{
|
||||
config: cfg,
|
||||
logger: log,
|
||||
parent: parent,
|
||||
title: title,
|
||||
prompt: prompt,
|
||||
value: defaultValue,
|
||||
validate: validate,
|
||||
cursor: len(defaultValue),
|
||||
}
|
||||
}
|
||||
|
||||
func (m InputModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m InputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
// Return to grandparent (menu) not immediate parent (selector)
|
||||
if selector, ok := m.parent.(DatabaseSelectorModel); ok {
|
||||
return selector.parent, nil
|
||||
}
|
||||
return m.parent, nil
|
||||
|
||||
case "enter":
|
||||
if m.validate != nil {
|
||||
if err := m.validate(m.value); err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
m.done = true
|
||||
|
||||
// If this is from database selector, execute backup with ratio
|
||||
if selector, ok := m.parent.(DatabaseSelectorModel); ok {
|
||||
ratio, _ := strconv.Atoi(m.value)
|
||||
executor := NewBackupExecution(selector.config, selector.logger, selector.parent,
|
||||
selector.backupType, selector.selected, ratio)
|
||||
return executor, executor.Init()
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case "backspace":
|
||||
if len(m.value) > 0 && m.cursor > 0 {
|
||||
m.value = m.value[:m.cursor-1] + m.value[m.cursor:]
|
||||
m.cursor--
|
||||
}
|
||||
|
||||
case "left":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
|
||||
case "right":
|
||||
if m.cursor < len(m.value) {
|
||||
m.cursor++
|
||||
}
|
||||
|
||||
default:
|
||||
// Add character
|
||||
if len(msg.String()) == 1 {
|
||||
m.value = m.value[:m.cursor] + msg.String() + m.value[m.cursor:]
|
||||
m.cursor++
|
||||
m.err = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m InputModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
header := titleStyle.Render(m.title)
|
||||
s.WriteString(fmt.Sprintf("\n%s\n\n", header))
|
||||
|
||||
s.WriteString(fmt.Sprintf("%s\n\n", m.prompt))
|
||||
|
||||
// Show input with cursor
|
||||
before := m.value[:m.cursor]
|
||||
after := ""
|
||||
if m.cursor < len(m.value) {
|
||||
after = m.value[m.cursor:]
|
||||
}
|
||||
s.WriteString(inputStyle.Render(fmt.Sprintf("> %s▎%s", before, after)))
|
||||
s.WriteString("\n\n")
|
||||
|
||||
if m.err != nil {
|
||||
s.WriteString(errorStyle.Render(fmt.Sprintf("❌ Error: %v\n\n", m.err)))
|
||||
}
|
||||
|
||||
s.WriteString("⌨️ Type value • Enter: Confirm • ESC: Cancel\n")
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (m InputModel) GetValue() string {
|
||||
return m.value
|
||||
}
|
||||
|
||||
func (m InputModel) GetIntValue() (int, error) {
|
||||
return strconv.Atoi(m.value)
|
||||
}
|
||||
|
||||
func (m InputModel) IsDone() bool {
|
||||
return m.done
|
||||
}
|
||||
|
||||
// Validation functions
|
||||
func ValidateInt(min, max int) func(string) error {
|
||||
return func(s string) error {
|
||||
val, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("must be a number")
|
||||
}
|
||||
if val < min || val > max {
|
||||
return fmt.Errorf("must be between %d and %d", min, max)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateNotEmpty(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fmt.Errorf("value cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -3,17 +3,12 @@ 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
|
||||
@ -27,7 +22,7 @@ var (
|
||||
menuStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#626262"))
|
||||
|
||||
selectedStyle = lipgloss.NewStyle().
|
||||
menuSelectedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#FF75B7")).
|
||||
Bold(true)
|
||||
|
||||
@ -41,22 +36,9 @@ var (
|
||||
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
|
||||
// MenuModel represents the simple menu state
|
||||
type MenuModel struct {
|
||||
choices []string
|
||||
cursor int
|
||||
@ -65,52 +47,14 @@ type MenuModel struct {
|
||||
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",
|
||||
@ -123,39 +67,18 @@ func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel {
|
||||
"Clear Operation History",
|
||||
"Quit",
|
||||
},
|
||||
config: cfg,
|
||||
logger: log,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
spinner: s,
|
||||
lastUpdate: time.Now(),
|
||||
progressReporter: progressReporter,
|
||||
config: cfg,
|
||||
logger: log,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// 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}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages
|
||||
@ -171,39 +94,16 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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()
|
||||
@ -220,7 +120,7 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case 6: // Settings
|
||||
return m.handleSettings()
|
||||
case 7: // Clear History
|
||||
return m.handleClearHistory()
|
||||
m.message = "🗑️ History cleared"
|
||||
case 8: // Quit
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
@ -228,427 +128,102 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
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
|
||||
// View renders the simple menu
|
||||
func (m MenuModel) View() string {
|
||||
if m.quitting {
|
||||
return "Thanks for using DB Backup Tool!\n"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
var s string
|
||||
|
||||
// Header
|
||||
header := titleStyle.Render("🗄️ Database Backup Tool - Interactive Menu")
|
||||
b.WriteString(fmt.Sprintf("\n%s\n\n", header))
|
||||
s += 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))
|
||||
s += 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)))
|
||||
s += menuSelectedStyle.Render(fmt.Sprintf("%s %s", cursor, choice))
|
||||
} else {
|
||||
b.WriteString(menuStyle.Render(fmt.Sprintf("%s %s", cursor, choice)))
|
||||
s += 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")
|
||||
s += "\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")
|
||||
if m.message != "" {
|
||||
s += "\n" + m.message + "\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)
|
||||
footer := infoStyle.Render("\n⌨️ Press ↑/↓ to navigate • Enter to select • q to quit")
|
||||
s += footer
|
||||
|
||||
return b.String()
|
||||
return s
|
||||
}
|
||||
|
||||
// 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
|
||||
// handleSingleBackup opens database selector for single backup
|
||||
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()
|
||||
selector := NewDatabaseSelector(m.config, m.logger, m, "🗄️ Single Database Backup", "single")
|
||||
return selector, selector.Init()
|
||||
}
|
||||
|
||||
// Handle sample backup with progress
|
||||
// handleSampleBackup opens database selector for sample backup
|
||||
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()
|
||||
selector := NewDatabaseSelector(m.config, m.logger, m, "📊 Sample Database Backup", "sample")
|
||||
return selector, selector.Init()
|
||||
}
|
||||
|
||||
// Handle cluster backup with progress
|
||||
// handleClusterBackup shows confirmation and executes cluster backup
|
||||
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()
|
||||
confirm := NewConfirmationModel(m.config, m.logger, m,
|
||||
"🗄️ Cluster Backup",
|
||||
"This will backup ALL databases in the cluster. Continue?")
|
||||
return confirm, nil
|
||||
}
|
||||
|
||||
// Handle viewing active operations
|
||||
// handleViewOperations shows 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
|
||||
ops := NewOperationsView(m.config, m.logger, m)
|
||||
return ops, nil
|
||||
}
|
||||
|
||||
// Handle showing operation history
|
||||
// handleOperationHistory shows 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
|
||||
history := NewHistoryView(m.config, m.logger, m)
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// Handle status check
|
||||
// handleStatus shows database status
|
||||
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
|
||||
status := NewStatusView(m.config, m.logger, m)
|
||||
return status, status.Init()
|
||||
}
|
||||
|
||||
// Handle settings display
|
||||
// handleSettings opens settings
|
||||
func (m MenuModel) handleSettings() (tea.Model, tea.Cmd) {
|
||||
// Create and switch to settings model
|
||||
// Create and return the settings model
|
||||
settingsModel := NewSettingsModel(m.config, m.logger, m)
|
||||
return settingsModel, settingsModel.Init()
|
||||
return settingsModel, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// RunInteractiveMenu starts the simple TUI
|
||||
func RunInteractiveMenu(cfg *config.Config, log logger.Logger) error {
|
||||
m := NewMenuModel(cfg, log)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
p := tea.NewProgram(m)
|
||||
|
||||
if _, err := p.Run(); err != nil {
|
||||
return fmt.Errorf("error running interactive menu: %w", err)
|
||||
|
||||
57
internal/tui/operations.go
Normal file
57
internal/tui/operations.go
Normal file
@ -0,0 +1,57 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/logger"
|
||||
)
|
||||
|
||||
// OperationsViewModel shows active operations
|
||||
type OperationsViewModel struct {
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
parent tea.Model
|
||||
}
|
||||
|
||||
func NewOperationsView(cfg *config.Config, log logger.Logger, parent tea.Model) OperationsViewModel {
|
||||
return OperationsViewModel{
|
||||
config: cfg,
|
||||
logger: log,
|
||||
parent: parent,
|
||||
}
|
||||
}
|
||||
|
||||
func (m OperationsViewModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m OperationsViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q", "esc", "enter":
|
||||
return m.parent, nil
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m OperationsViewModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
header := titleStyle.Render("📊 Active Operations")
|
||||
s.WriteString(fmt.Sprintf("\n%s\n\n", header))
|
||||
|
||||
s.WriteString("Currently running operations:\n\n")
|
||||
s.WriteString(infoStyle.Render("📭 No active operations"))
|
||||
s.WriteString("\n\n")
|
||||
|
||||
s.WriteString("⌨️ Press any key to return to menu\n")
|
||||
|
||||
return s.String()
|
||||
}
|
||||
@ -15,9 +15,10 @@ import (
|
||||
|
||||
// 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)
|
||||
mu sync.RWMutex
|
||||
operations map[string]*progress.OperationStatus
|
||||
callbacks []func([]progress.OperationStatus)
|
||||
defaultOperationID string
|
||||
}
|
||||
|
||||
// NewTUIProgressReporter creates a new TUI-compatible progress reporter
|
||||
@ -41,17 +42,123 @@ func (t *TUIProgressReporter) notifyCallbacks() {
|
||||
for _, op := range t.operations {
|
||||
operations = append(operations, *op)
|
||||
}
|
||||
|
||||
|
||||
for _, callback := range t.callbacks {
|
||||
go callback(operations)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TUIProgressReporter) ensureDefaultOperationLocked(message string) *progress.OperationStatus {
|
||||
if t.defaultOperationID == "" {
|
||||
t.defaultOperationID = fmt.Sprintf("tui-progress-%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
op, exists := t.operations[t.defaultOperationID]
|
||||
if !exists {
|
||||
op = &progress.OperationStatus{
|
||||
ID: t.defaultOperationID,
|
||||
Name: "Backup Progress",
|
||||
Type: "indicator",
|
||||
Status: "running",
|
||||
StartTime: time.Now(),
|
||||
Message: message,
|
||||
Progress: 0,
|
||||
Details: make(map[string]string),
|
||||
Steps: make([]progress.StepStatus, 0),
|
||||
}
|
||||
t.operations[t.defaultOperationID] = op
|
||||
}
|
||||
|
||||
if message != "" {
|
||||
op.Message = message
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
func (t *TUIProgressReporter) Start(message string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
op := t.ensureDefaultOperationLocked(message)
|
||||
now := time.Now()
|
||||
op.Status = "running"
|
||||
op.StartTime = now
|
||||
op.EndTime = nil
|
||||
op.Progress = 0
|
||||
op.Message = message
|
||||
t.notifyCallbacks()
|
||||
}
|
||||
|
||||
func (t *TUIProgressReporter) Update(message string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
op := t.ensureDefaultOperationLocked(message)
|
||||
if op.Progress < 95 {
|
||||
op.Progress += 5
|
||||
}
|
||||
op.Message = message
|
||||
t.notifyCallbacks()
|
||||
}
|
||||
|
||||
func (t *TUIProgressReporter) Complete(message string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.defaultOperationID == "" {
|
||||
return
|
||||
}
|
||||
if op, exists := t.operations[t.defaultOperationID]; exists {
|
||||
now := time.Now()
|
||||
op.Status = "completed"
|
||||
op.Message = message
|
||||
op.Progress = 100
|
||||
op.EndTime = &now
|
||||
op.Duration = now.Sub(op.StartTime)
|
||||
t.notifyCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TUIProgressReporter) Fail(message string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.defaultOperationID == "" {
|
||||
return
|
||||
}
|
||||
if op, exists := t.operations[t.defaultOperationID]; exists {
|
||||
now := time.Now()
|
||||
op.Status = "failed"
|
||||
op.Message = message
|
||||
op.EndTime = &now
|
||||
op.Duration = now.Sub(op.StartTime)
|
||||
t.notifyCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TUIProgressReporter) Stop() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if t.defaultOperationID == "" {
|
||||
return
|
||||
}
|
||||
if op, exists := t.operations[t.defaultOperationID]; exists {
|
||||
if op.Status == "running" {
|
||||
now := time.Now()
|
||||
op.Status = "stopped"
|
||||
op.EndTime = &now
|
||||
op.Duration = now.Sub(op.StartTime)
|
||||
}
|
||||
t.notifyCallbacks()
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
@ -63,10 +170,10 @@ func (t *TUIProgressReporter) StartOperation(id, name, opType string) *TUIOperat
|
||||
Details: make(map[string]string),
|
||||
Steps: make([]progress.StepStatus, 0),
|
||||
}
|
||||
|
||||
|
||||
t.operations[id] = operation
|
||||
t.notifyCallbacks()
|
||||
|
||||
|
||||
return &TUIOperationTracker{
|
||||
reporter: t,
|
||||
operationID: id,
|
||||
@ -83,7 +190,7 @@ type TUIOperationTracker struct {
|
||||
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
|
||||
@ -95,7 +202,7 @@ func (t *TUIOperationTracker) UpdateProgress(progress int, message string) {
|
||||
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"
|
||||
@ -111,7 +218,7 @@ func (t *TUIOperationTracker) Complete(message string) {
|
||||
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"
|
||||
@ -126,7 +233,7 @@ func (t *TUIOperationTracker) Fail(message string) {
|
||||
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)
|
||||
@ -137,11 +244,11 @@ func (t *TUIProgressReporter) GetOperations() []progress.OperationStatus {
|
||||
// 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) 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{}
|
||||
}
|
||||
@ -149,9 +256,9 @@ func (s *SilentLogger) StartOperation(name string) logger.OperationLogger {
|
||||
// SilentOperation implements logger.OperationLogger but doesn't output anything
|
||||
type SilentOperation struct{}
|
||||
|
||||
func (s *SilentOperation) Update(message string, args ...any) {}
|
||||
func (s *SilentOperation) Update(message string, args ...any) {}
|
||||
func (s *SilentOperation) Complete(message string, args ...any) {}
|
||||
func (s *SilentOperation) Fail(message string, args ...any) {}
|
||||
func (s *SilentOperation) Fail(message string, args ...any) {}
|
||||
|
||||
// SilentProgressIndicator implements progress.Indicator but doesn't output anything
|
||||
type SilentProgressIndicator struct{}
|
||||
@ -163,9 +270,9 @@ 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,
|
||||
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 {
|
||||
@ -181,11 +288,11 @@ func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger,
|
||||
// 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":
|
||||
@ -200,7 +307,7 @@ func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger,
|
||||
default:
|
||||
err = fmt.Errorf("unknown backup type: %s", backupType)
|
||||
}
|
||||
|
||||
|
||||
// Update final status
|
||||
if err != nil {
|
||||
tracker.Fail(fmt.Sprintf("Backup failed: %v", err))
|
||||
@ -209,4 +316,4 @@ func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger,
|
||||
tracker.Complete(fmt.Sprintf("%s backup completed successfully", backupType))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,23 +7,34 @@ import (
|
||||
"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("99")).Padding(1, 2)
|
||||
inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
|
||||
buttonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Background(lipgloss.Color("57")).Padding(0, 2)
|
||||
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Background(lipgloss.Color("57")).Bold(true)
|
||||
detailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true)
|
||||
)
|
||||
|
||||
// SettingsModel represents the settings configuration state
|
||||
type SettingsModel struct {
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
cursor int
|
||||
editing bool
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
cursor int
|
||||
editing bool
|
||||
editingField string
|
||||
editingValue string
|
||||
settings []SettingItem
|
||||
quitting bool
|
||||
message string
|
||||
parent tea.Model
|
||||
settings []SettingItem
|
||||
quitting bool
|
||||
message string
|
||||
parent tea.Model
|
||||
dirBrowser *DirectoryBrowser
|
||||
browsingDir bool
|
||||
}
|
||||
|
||||
// SettingItem represents a configurable setting
|
||||
@ -217,6 +228,53 @@ func (m SettingsModel) Init() tea.Cmd {
|
||||
func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
// Handle directory browsing mode
|
||||
if m.browsingDir && m.dirBrowser != nil {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.browsingDir = false
|
||||
m.dirBrowser.Hide()
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
m.dirBrowser.Navigate(-1)
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
m.dirBrowser.Navigate(1)
|
||||
return m, nil
|
||||
case "enter", "right", "l":
|
||||
m.dirBrowser.Enter()
|
||||
return m, nil
|
||||
case "left", "h":
|
||||
// Go up one level (same as selecting ".." and entering)
|
||||
parentPath := filepath.Dir(m.dirBrowser.CurrentPath)
|
||||
if parentPath != m.dirBrowser.CurrentPath { // Avoid infinite loop at root
|
||||
m.dirBrowser.CurrentPath = parentPath
|
||||
m.dirBrowser.LoadItems()
|
||||
}
|
||||
return m, nil
|
||||
case " ":
|
||||
// Select current directory
|
||||
selectedPath := m.dirBrowser.Select()
|
||||
if m.cursor < len(m.settings) {
|
||||
setting := m.settings[m.cursor]
|
||||
if err := setting.Update(m.config, selectedPath); err != nil {
|
||||
m.message = "❌ Error: " + err.Error()
|
||||
} else {
|
||||
m.message = "✅ Directory updated: " + selectedPath
|
||||
}
|
||||
}
|
||||
m.browsingDir = false
|
||||
m.dirBrowser.Hide()
|
||||
return m, nil
|
||||
case "tab":
|
||||
// Toggle back to settings
|
||||
m.browsingDir = false
|
||||
m.dirBrowser.Hide()
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.editing {
|
||||
return m.handleEditingInput(msg)
|
||||
}
|
||||
@ -239,6 +297,20 @@ func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "enter", " ":
|
||||
return m.startEditing()
|
||||
|
||||
case "tab":
|
||||
// Directory browser for path fields
|
||||
if m.cursor >= 0 && m.cursor < len(m.settings) {
|
||||
if m.settings[m.cursor].Type == "path" {
|
||||
return m.openDirectoryBrowser()
|
||||
} else {
|
||||
m.message = "❌ Tab key only works on directory path fields"
|
||||
return m, nil
|
||||
}
|
||||
} else {
|
||||
m.message = "❌ Invalid selection"
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "r":
|
||||
return m.resetToDefaults()
|
||||
|
||||
@ -412,6 +484,14 @@ func (m SettingsModel) View() string {
|
||||
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
|
||||
@ -445,13 +525,47 @@ func (m SettingsModel) View() 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")
|
||||
if m.browsingDir {
|
||||
footer = infoStyle.Render("\n⌨️ ↑/↓ navigate directories • Enter open • Space select • Tab/Esc back to settings")
|
||||
} else {
|
||||
// Show different help based on current selection
|
||||
if m.cursor >= 0 && m.cursor < len(m.settings) && m.settings[m.cursor].Type == "path" {
|
||||
footer = infoStyle.Render("\n⌨️ ↑/↓ navigate • Enter edit • Tab browse directories • 's' save • 'r' reset • 'q' menu")
|
||||
} else {
|
||||
footer = infoStyle.Render("\n⌨️ ↑/↓ navigate • Enter edit • 's' save • 'r' reset • 'q' menu • Tab=dirs on path fields only")
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString(footer)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m SettingsModel) openDirectoryBrowser() (tea.Model, tea.Cmd) {
|
||||
if m.cursor >= len(m.settings) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
setting := m.settings[m.cursor]
|
||||
currentValue := setting.Value(m.config)
|
||||
if currentValue == "" {
|
||||
currentValue = "/tmp"
|
||||
}
|
||||
|
||||
if m.dirBrowser == nil {
|
||||
m.dirBrowser = NewDirectoryBrowser(currentValue)
|
||||
} else {
|
||||
// Update the browser to start from the current value
|
||||
m.dirBrowser.CurrentPath = currentValue
|
||||
m.dirBrowser.LoadItems()
|
||||
}
|
||||
|
||||
m.dirBrowser.Show()
|
||||
m.browsingDir = true
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// RunSettingsMenu starts the settings configuration interface
|
||||
func RunSettingsMenu(cfg *config.Config, log logger.Logger, parent tea.Model) error {
|
||||
m := NewSettingsModel(cfg, log, parent)
|
||||
|
||||
167
internal/tui/status.go
Normal file
167
internal/tui/status.go
Normal file
@ -0,0 +1,167 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"dbbackup/internal/config"
|
||||
"dbbackup/internal/database"
|
||||
"dbbackup/internal/logger"
|
||||
)
|
||||
|
||||
// StatusViewModel shows database status
|
||||
type StatusViewModel struct {
|
||||
config *config.Config
|
||||
logger logger.Logger
|
||||
parent tea.Model
|
||||
loading bool
|
||||
status string
|
||||
err error
|
||||
dbCount int
|
||||
dbVersion string
|
||||
connected bool
|
||||
}
|
||||
|
||||
func NewStatusView(cfg *config.Config, log logger.Logger, parent tea.Model) StatusViewModel {
|
||||
return StatusViewModel{
|
||||
config: cfg,
|
||||
logger: log,
|
||||
parent: parent,
|
||||
loading: true,
|
||||
status: "Loading status...",
|
||||
}
|
||||
}
|
||||
|
||||
func (m StatusViewModel) Init() tea.Cmd {
|
||||
return fetchStatus(m.config, m.logger)
|
||||
}
|
||||
|
||||
type statusMsg struct {
|
||||
status string
|
||||
err error
|
||||
dbCount int
|
||||
dbVersion string
|
||||
connected bool
|
||||
}
|
||||
|
||||
func fetchStatus(cfg *config.Config, log logger.Logger) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
dbClient, err := database.New(cfg, log)
|
||||
if err != nil {
|
||||
return statusMsg{
|
||||
status: "",
|
||||
err: fmt.Errorf("failed to create database client: %w", err),
|
||||
connected: false,
|
||||
}
|
||||
}
|
||||
defer dbClient.Close()
|
||||
|
||||
if err := dbClient.Connect(ctx); err != nil {
|
||||
return statusMsg{
|
||||
status: "",
|
||||
err: fmt.Errorf("connection failed: %w", err),
|
||||
connected: false,
|
||||
}
|
||||
}
|
||||
|
||||
version, err := dbClient.GetVersion(ctx)
|
||||
if err != nil {
|
||||
log.Warn("failed to get database version", "error", err)
|
||||
version = "Unknown"
|
||||
}
|
||||
|
||||
databases, err := dbClient.ListDatabases(ctx)
|
||||
if err != nil {
|
||||
return statusMsg{
|
||||
status: "Connected, but failed to list databases",
|
||||
err: fmt.Errorf("failed to list databases: %w", err),
|
||||
connected: true,
|
||||
}
|
||||
}
|
||||
|
||||
return statusMsg{
|
||||
status: "Database connection successful",
|
||||
err: nil,
|
||||
dbCount: len(databases),
|
||||
dbVersion: version,
|
||||
connected: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m StatusViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case statusMsg:
|
||||
m.loading = false
|
||||
if msg.status != "" {
|
||||
m.status = msg.status
|
||||
}
|
||||
m.err = msg.err
|
||||
m.dbCount = msg.dbCount
|
||||
if msg.dbVersion != "" {
|
||||
m.dbVersion = msg.dbVersion
|
||||
}
|
||||
m.connected = msg.connected
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if !m.loading {
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "q", "esc", "enter":
|
||||
return m.parent, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m StatusViewModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
header := titleStyle.Render("📊 Database Status & Health Check")
|
||||
s.WriteString(fmt.Sprintf("\n%s\n\n", header))
|
||||
|
||||
if m.loading {
|
||||
spinner := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
frame := int(time.Now().UnixMilli()/100) % len(spinner)
|
||||
s.WriteString(fmt.Sprintf("%s Loading status information...\n", spinner[frame]))
|
||||
return s.String()
|
||||
}
|
||||
|
||||
if m.err != nil {
|
||||
s.WriteString(errorStyle.Render(fmt.Sprintf("❌ Error: %v\n", m.err)))
|
||||
s.WriteString("\n")
|
||||
} else {
|
||||
s.WriteString("Connection Status:\n")
|
||||
if m.connected {
|
||||
s.WriteString(successStyle.Render(" ✓ Connected\n"))
|
||||
} else {
|
||||
s.WriteString(errorStyle.Render(" ✗ Disconnected\n"))
|
||||
}
|
||||
s.WriteString("\n")
|
||||
|
||||
s.WriteString(fmt.Sprintf("Database Type: %s\n", m.config.DatabaseType))
|
||||
s.WriteString(fmt.Sprintf("Host: %s:%d\n", m.config.Host, m.config.Port))
|
||||
s.WriteString(fmt.Sprintf("User: %s\n", m.config.User))
|
||||
s.WriteString(fmt.Sprintf("Backup Directory: %s\n", m.config.BackupDir))
|
||||
s.WriteString(fmt.Sprintf("Version: %s\n\n", m.dbVersion))
|
||||
|
||||
if m.dbCount > 0 {
|
||||
s.WriteString(fmt.Sprintf("Databases Found: %s\n", successStyle.Render(fmt.Sprintf("%d", m.dbCount))))
|
||||
}
|
||||
|
||||
s.WriteString("\n")
|
||||
s.WriteString(successStyle.Render("✓ All systems operational\n"))
|
||||
}
|
||||
|
||||
s.WriteString("\n⌨️ Press any key to return to menu\n")
|
||||
return s.String()
|
||||
}
|
||||
Reference in New Issue
Block a user