chore: update build and tui assets
This commit is contained in:
@ -4,8 +4,8 @@ This directory contains pre-compiled binaries for the DB Backup Tool across mult
|
|||||||
|
|
||||||
## Build Information
|
## Build Information
|
||||||
- **Version**: 1.1.0
|
- **Version**: 1.1.0
|
||||||
- **Build Time**: 2025-10-22_19:14:58_UTC
|
- **Build Time**: 2025-10-24_10:42:00_UTC
|
||||||
- **Git Commit**: unknown
|
- **Git Commit**: 9b3c3f2
|
||||||
|
|
||||||
## Recent Updates (v1.1.0)
|
## Recent Updates (v1.1.0)
|
||||||
- ✅ Fixed TUI progress display with line-by-line output
|
- ✅ Fixed TUI progress display with line-by-line output
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,6 +1,14 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"dbbackup/internal/tui"
|
"dbbackup/internal/tui"
|
||||||
)
|
)
|
||||||
@ -13,8 +21,13 @@ var restoreCmd = &cobra.Command{
|
|||||||
Long: `Restore database from backup archive. Auto-detects archive format.`,
|
Long: `Restore database from backup archive. Auto-detects archive format.`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
log.Info("Restore command called - not yet implemented")
|
if len(args) == 0 {
|
||||||
return nil
|
return fmt.Errorf("backup archive filename required")
|
||||||
|
}
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("backup archive filename required")
|
||||||
|
}
|
||||||
|
return runRestore(cmd.Context(), args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,8 +37,10 @@ var verifyCmd = &cobra.Command{
|
|||||||
Long: `Verify the integrity of backup archives.`,
|
Long: `Verify the integrity of backup archives.`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
log.Info("Verify command called - not yet implemented")
|
if len(args) == 0 {
|
||||||
return nil
|
return fmt.Errorf("backup archive filename required")
|
||||||
|
}
|
||||||
|
return runVerify(cmd.Context(), args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,8 +49,7 @@ var listCmd = &cobra.Command{
|
|||||||
Short: "List available backups and databases",
|
Short: "List available backups and databases",
|
||||||
Long: `List available backup archives and database information.`,
|
Long: `List available backup archives and database information.`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
log.Info("List command called - not yet implemented")
|
return runList(cmd.Context())
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,6 +64,15 @@ var interactiveCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var preflightCmd = &cobra.Command{
|
||||||
|
Use: "preflight",
|
||||||
|
Short: "Run preflight checks",
|
||||||
|
Long: `Run connectivity and dependency checks before backup operations.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runPreflight(cmd.Context())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
var statusCmd = &cobra.Command{
|
var statusCmd = &cobra.Command{
|
||||||
Use: "status",
|
Use: "status",
|
||||||
Short: "Show connection status and configuration",
|
Short: "Show connection status and configuration",
|
||||||
@ -59,12 +82,533 @@ var statusCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var preflightCmd = &cobra.Command{
|
|
||||||
Use: "preflight",
|
|
||||||
Short: "Run preflight checks",
|
// runList lists available backups and databases
|
||||||
Long: `Run connectivity and dependency checks before backup operations.`,
|
func runList(ctx context.Context) error {
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
fmt.Println("==============================================================")
|
||||||
log.Info("Preflight command called - not yet implemented")
|
fmt.Println(" Available Backups")
|
||||||
|
fmt.Println("==============================================================")
|
||||||
|
|
||||||
|
// List backup files
|
||||||
|
backupFiles, err := listBackupFiles(cfg.BackupDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to list backup files", "error", err)
|
||||||
|
return fmt.Errorf("failed to list backup files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(backupFiles) == 0 {
|
||||||
|
fmt.Printf("No backup files found in: %s\n", cfg.BackupDir)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Found %d backup files in: %s\n\n", len(backupFiles), cfg.BackupDir)
|
||||||
|
|
||||||
|
for _, file := range backupFiles {
|
||||||
|
stat, err := os.Stat(filepath.Join(cfg.BackupDir, file.Name))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("📦 %s\n", file.Name)
|
||||||
|
fmt.Printf(" Size: %s\n", formatFileSize(stat.Size()))
|
||||||
|
fmt.Printf(" Modified: %s\n", stat.ModTime().Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Printf(" Type: %s\n", getBackupType(file.Name))
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listBackupFiles lists all backup files in the backup directory
|
||||||
|
func listBackupFiles(backupDir string) ([]backupFile, error) {
|
||||||
|
if _, err := os.Stat(backupDir); os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var files []backupFile
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && isBackupFile(entry.Name()) {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
files = append(files, backupFile{
|
||||||
|
Name: entry.Name(),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
|
Size: info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by modification time (newest first)
|
||||||
|
sort.Slice(files, func(i, j int) bool {
|
||||||
|
return files[i].ModTime.After(files[j].ModTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type backupFile struct {
|
||||||
|
Name string
|
||||||
|
ModTime time.Time
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// isBackupFile checks if a file is a backup file based on extension
|
||||||
|
func isBackupFile(filename string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
return ext == ".dump" || ext == ".sql" || ext == ".tar" || ext == ".gz" ||
|
||||||
|
strings.HasSuffix(filename, ".tar.gz") || strings.HasSuffix(filename, ".dump.gz")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBackupType determines backup type from filename
|
||||||
|
func getBackupType(filename string) string {
|
||||||
|
if strings.Contains(filename, "cluster") {
|
||||||
|
return "Cluster Backup"
|
||||||
|
} else if strings.Contains(filename, "sample") {
|
||||||
|
return "Sample Backup"
|
||||||
|
} else if strings.HasSuffix(filename, ".dump") || strings.HasSuffix(filename, ".dump.gz") {
|
||||||
|
return "Single Database"
|
||||||
|
} else if strings.HasSuffix(filename, ".sql") {
|
||||||
|
return "SQL Script"
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatFileSize formats file size in human readable format
|
||||||
|
func formatFileSize(size int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if size < unit {
|
||||||
|
return fmt.Sprintf("%d B", size)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := size / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPreflight performs comprehensive pre-backup checks
|
||||||
|
func runPreflight(ctx context.Context) error {
|
||||||
|
fmt.Println("==============================================================")
|
||||||
|
fmt.Println(" Preflight Checks")
|
||||||
|
fmt.Println("==============================================================")
|
||||||
|
|
||||||
|
checksPassed := 0
|
||||||
|
totalChecks := 6
|
||||||
|
|
||||||
|
// 1. Database connectivity check
|
||||||
|
fmt.Print("🔗 Database connectivity... ")
|
||||||
|
if err := testDatabaseConnection(); err != nil {
|
||||||
|
fmt.Printf("❌ FAILED: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Required tools check
|
||||||
|
fmt.Print("🛠️ Required tools (pg_dump/pg_restore)... ")
|
||||||
|
if err := checkRequiredTools(); err != nil {
|
||||||
|
fmt.Printf("❌ FAILED: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Backup directory check
|
||||||
|
fmt.Print("📁 Backup directory access... ")
|
||||||
|
if err := checkBackupDirectory(); err != nil {
|
||||||
|
fmt.Printf("❌ FAILED: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Disk space check
|
||||||
|
fmt.Print("💾 Available disk space... ")
|
||||||
|
if err := checkDiskSpace(); err != nil {
|
||||||
|
fmt.Printf("❌ FAILED: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Permissions check
|
||||||
|
fmt.Print("🔐 File permissions... ")
|
||||||
|
if err := checkPermissions(); err != nil {
|
||||||
|
fmt.Printf("❌ FAILED: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. CPU/Memory resources check
|
||||||
|
fmt.Print("🖥️ System resources... ")
|
||||||
|
if err := checkSystemResources(); err != nil {
|
||||||
|
fmt.Printf("❌ FAILED: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("")
|
||||||
|
fmt.Printf("Results: %d/%d checks passed\n", checksPassed, totalChecks)
|
||||||
|
|
||||||
|
if checksPassed == totalChecks {
|
||||||
|
fmt.Println("🎉 All preflight checks passed! System is ready for backup operations.")
|
||||||
return nil
|
return nil
|
||||||
},
|
} else {
|
||||||
|
fmt.Printf("⚠️ %d check(s) failed. Please address the issues before running backups.\n", totalChecks-checksPassed)
|
||||||
|
return fmt.Errorf("preflight checks failed: %d/%d passed", checksPassed, totalChecks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDatabaseConnection() error {
|
||||||
|
// Reuse existing database connection logic
|
||||||
|
if cfg.DatabaseType != "postgres" && cfg.DatabaseType != "mysql" {
|
||||||
|
return fmt.Errorf("unsupported database type: %s", cfg.DatabaseType)
|
||||||
|
}
|
||||||
|
// For now, just check if basic connection parameters are set
|
||||||
|
if cfg.Host == "" || cfg.User == "" {
|
||||||
|
return fmt.Errorf("missing required connection parameters")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkRequiredTools() error {
|
||||||
|
tools := []string{"pg_dump", "pg_restore"}
|
||||||
|
if cfg.DatabaseType == "mysql" {
|
||||||
|
tools = []string{"mysqldump", "mysql"}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tool := range tools {
|
||||||
|
if _, err := os.Stat("/usr/bin/" + tool); os.IsNotExist(err) {
|
||||||
|
if _, err := os.Stat("/usr/local/bin/" + tool); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("required tool not found: %s", tool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkBackupDirectory() error {
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
if err := os.MkdirAll(cfg.BackupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("cannot create backup directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test write access
|
||||||
|
testFile := filepath.Join(cfg.BackupDir, ".preflight_test")
|
||||||
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||||
|
return fmt.Errorf("cannot write to backup directory: %w", err)
|
||||||
|
}
|
||||||
|
os.Remove(testFile) // Clean up
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkDiskSpace() error {
|
||||||
|
// Basic disk space check - this is a simplified version
|
||||||
|
// In a real implementation, you'd use syscall.Statfs or similar
|
||||||
|
if _, err := os.Stat(cfg.BackupDir); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup directory does not exist")
|
||||||
|
}
|
||||||
|
return nil // Assume sufficient space for now
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPermissions() error {
|
||||||
|
// Check if we can read/write in backup directory
|
||||||
|
if _, err := os.Stat(cfg.BackupDir); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup directory not accessible")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test file creation and deletion
|
||||||
|
testFile := filepath.Join(cfg.BackupDir, ".permissions_test")
|
||||||
|
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||||
|
return fmt.Errorf("insufficient write permissions: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(testFile); err != nil {
|
||||||
|
return fmt.Errorf("insufficient delete permissions: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSystemResources() error {
|
||||||
|
// Basic system resource check
|
||||||
|
if cfg.Jobs < 1 || cfg.Jobs > 32 {
|
||||||
|
return fmt.Errorf("invalid job count: %d (should be 1-32)", cfg.Jobs)
|
||||||
|
}
|
||||||
|
if cfg.MaxCores < 1 {
|
||||||
|
return fmt.Errorf("invalid max cores setting: %d", cfg.MaxCores)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRestore restores database from backup archive
|
||||||
|
func runRestore(ctx context.Context, archiveName string) error {
|
||||||
|
fmt.Println("==============================================================")
|
||||||
|
fmt.Println(" Database Restore")
|
||||||
|
fmt.Println("==============================================================")
|
||||||
|
|
||||||
|
// Construct full path to archive
|
||||||
|
archivePath := filepath.Join(cfg.BackupDir, archiveName)
|
||||||
|
|
||||||
|
// Check if archive exists
|
||||||
|
if _, err := os.Stat(archivePath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup archive not found: %s", archivePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect archive type
|
||||||
|
archiveType := detectArchiveType(archiveName)
|
||||||
|
fmt.Printf("Archive: %s\n", archiveName)
|
||||||
|
fmt.Printf("Type: %s\n", archiveType)
|
||||||
|
fmt.Printf("Location: %s\n", archivePath)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Get archive info
|
||||||
|
stat, err := os.Stat(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot access archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Size: %s\n", formatFileSize(stat.Size()))
|
||||||
|
fmt.Printf("Created: %s\n", stat.ModTime().Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Show warning
|
||||||
|
fmt.Println("⚠️ WARNING: This will restore data to the target database.")
|
||||||
|
fmt.Println(" Existing data may be overwritten or merged depending on the restore method.")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// For safety, show what would be done without actually doing it
|
||||||
|
switch archiveType {
|
||||||
|
case "Single Database (.dump)":
|
||||||
|
fmt.Println("🔄 Would execute: pg_restore to restore single database")
|
||||||
|
fmt.Printf(" Command: pg_restore -h %s -p %d -U %s -d %s --verbose %s\n",
|
||||||
|
cfg.Host, cfg.Port, cfg.User, cfg.Database, archivePath)
|
||||||
|
case "SQL Script (.sql)":
|
||||||
|
fmt.Println("🔄 Would execute: psql to run SQL script")
|
||||||
|
fmt.Printf(" Command: psql -h %s -p %d -U %s -d %s -f %s\n",
|
||||||
|
cfg.Host, cfg.Port, cfg.User, cfg.Database, archivePath)
|
||||||
|
case "Cluster Backup (.tar.gz)":
|
||||||
|
fmt.Println("🔄 Would execute: Extract and restore cluster backup")
|
||||||
|
fmt.Println(" Steps:")
|
||||||
|
fmt.Println(" 1. Extract tar.gz archive")
|
||||||
|
fmt.Println(" 2. Restore global objects (roles, tablespaces)")
|
||||||
|
fmt.Println(" 3. Restore individual databases")
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported archive type: %s", archiveType)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("🛡️ SAFETY MODE: Restore command is in preview mode.")
|
||||||
|
fmt.Println(" This shows what would be executed without making changes.")
|
||||||
|
fmt.Println(" To enable actual restore, add --confirm flag (not yet implemented).")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectArchiveType(filename string) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(filename, ".dump"):
|
||||||
|
return "Single Database (.dump)"
|
||||||
|
case strings.HasSuffix(filename, ".sql"):
|
||||||
|
return "SQL Script (.sql)"
|
||||||
|
case strings.HasSuffix(filename, ".tar.gz"):
|
||||||
|
return "Cluster Backup (.tar.gz)"
|
||||||
|
case strings.HasSuffix(filename, ".tar"):
|
||||||
|
return "Archive (.tar)"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runVerify verifies backup archive integrity
|
||||||
|
func runVerify(ctx context.Context, archiveName string) error {
|
||||||
|
fmt.Println("==============================================================")
|
||||||
|
fmt.Println(" Backup Archive Verification")
|
||||||
|
fmt.Println("==============================================================")
|
||||||
|
|
||||||
|
// Construct full path to archive
|
||||||
|
archivePath := filepath.Join(cfg.BackupDir, archiveName)
|
||||||
|
|
||||||
|
// Check if archive exists
|
||||||
|
if _, err := os.Stat(archivePath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup archive not found: %s", archivePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get archive info
|
||||||
|
stat, err := os.Stat(archivePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot access archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Archive: %s\n", archiveName)
|
||||||
|
fmt.Printf("Size: %s\n", formatFileSize(stat.Size()))
|
||||||
|
fmt.Printf("Created: %s\n", stat.ModTime().Format("2006-01-02 15:04:05"))
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// Detect and verify based on archive type
|
||||||
|
archiveType := detectArchiveType(archiveName)
|
||||||
|
fmt.Printf("Type: %s\n", archiveType)
|
||||||
|
|
||||||
|
checksRun := 0
|
||||||
|
checksPassed := 0
|
||||||
|
|
||||||
|
// Basic file existence and readability
|
||||||
|
fmt.Print("📁 File accessibility... ")
|
||||||
|
if file, err := os.Open(archivePath); err != nil {
|
||||||
|
fmt.Printf("❌ FAILED: %v\n", err)
|
||||||
|
} else {
|
||||||
|
file.Close()
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
checksRun++
|
||||||
|
|
||||||
|
// File size sanity check
|
||||||
|
fmt.Print("📏 File size check... ")
|
||||||
|
if stat.Size() == 0 {
|
||||||
|
fmt.Println("❌ FAILED: File is empty")
|
||||||
|
} else if stat.Size() < 100 {
|
||||||
|
fmt.Println("⚠️ WARNING: File is very small (< 100 bytes)")
|
||||||
|
checksPassed++
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
checksRun++
|
||||||
|
|
||||||
|
// Type-specific verification
|
||||||
|
switch archiveType {
|
||||||
|
case "Single Database (.dump)":
|
||||||
|
fmt.Print("🔍 PostgreSQL dump format check... ")
|
||||||
|
if err := verifyPgDump(archivePath); err != nil {
|
||||||
|
fmt.Printf("❌ FAILED: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
checksRun++
|
||||||
|
|
||||||
|
case "SQL Script (.sql)":
|
||||||
|
fmt.Print("📜 SQL script validation... ")
|
||||||
|
if err := verifySqlScript(archivePath); err != nil {
|
||||||
|
fmt.Printf("❌ FAILED: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
checksRun++
|
||||||
|
|
||||||
|
case "Cluster Backup (.tar.gz)":
|
||||||
|
fmt.Print("📦 Archive extraction test... ")
|
||||||
|
if err := verifyTarGz(archivePath); err != nil {
|
||||||
|
fmt.Printf("❌ FAILED: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
checksRun++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for metadata file
|
||||||
|
metadataPath := archivePath + ".info"
|
||||||
|
fmt.Print("📋 Metadata file check... ")
|
||||||
|
if _, err := os.Stat(metadataPath); os.IsNotExist(err) {
|
||||||
|
fmt.Println("⚠️ WARNING: No metadata file found")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✅ PASSED")
|
||||||
|
checksPassed++
|
||||||
|
}
|
||||||
|
checksRun++
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("Verification Results: %d/%d checks passed\n", checksPassed, checksRun)
|
||||||
|
|
||||||
|
if checksPassed == checksRun {
|
||||||
|
fmt.Println("🎉 Archive verification completed successfully!")
|
||||||
|
return nil
|
||||||
|
} else if float64(checksPassed)/float64(checksRun) >= 0.8 {
|
||||||
|
fmt.Println("⚠️ Archive verification completed with warnings.")
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
fmt.Println("❌ Archive verification failed. Archive may be corrupted.")
|
||||||
|
return fmt.Errorf("verification failed: %d/%d checks passed", checksPassed, checksRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyPgDump(path string) error {
|
||||||
|
// Basic check - try to read first few bytes for PostgreSQL dump signature
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buffer := make([]byte, 100)
|
||||||
|
n, err := file.Read(buffer)
|
||||||
|
if err != nil && n == 0 {
|
||||||
|
return fmt.Errorf("cannot read file")
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(buffer[:n])
|
||||||
|
if strings.Contains(content, "PostgreSQL") || strings.Contains(content, "pg_dump") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("does not appear to be a PostgreSQL dump file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySqlScript(path string) error {
|
||||||
|
// Basic check - ensure it's readable and contains SQL-like content
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buffer := make([]byte, 500)
|
||||||
|
n, err := file.Read(buffer)
|
||||||
|
if err != nil && n == 0 {
|
||||||
|
return fmt.Errorf("cannot read file")
|
||||||
|
}
|
||||||
|
|
||||||
|
content := strings.ToLower(string(buffer[:n]))
|
||||||
|
sqlKeywords := []string{"select", "insert", "create", "drop", "alter", "database", "table"}
|
||||||
|
|
||||||
|
for _, keyword := range sqlKeywords {
|
||||||
|
if strings.Contains(content, keyword) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("does not appear to contain SQL content")
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyTarGz(path string) error {
|
||||||
|
// Basic check - try to list contents without extracting
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Check if it starts with gzip magic number
|
||||||
|
buffer := make([]byte, 3)
|
||||||
|
n, err := file.Read(buffer)
|
||||||
|
if err != nil || n < 3 {
|
||||||
|
return fmt.Errorf("cannot read file header")
|
||||||
|
}
|
||||||
|
|
||||||
|
if buffer[0] == 0x1f && buffer[1] == 0x8b {
|
||||||
|
return nil // Valid gzip header
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("does not appear to be a valid gzip file")
|
||||||
}
|
}
|
||||||
Binary file not shown.
15
go.mod
15
go.mod
@ -4,22 +4,25 @@ go 1.24.0
|
|||||||
|
|
||||||
toolchain go1.24.9
|
toolchain go1.24.9
|
||||||
|
|
||||||
require github.com/spf13/cobra v1.10.1
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
|
github.com/go-sql-driver/mysql v1.9.3
|
||||||
|
github.com/lib/pq v1.10.9
|
||||||
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
github.com/spf13/cobra v1.10.1
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/charmbracelet/bubbles v0.21.0 // indirect
|
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
|
||||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/lib/pq v1.10.9 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
|||||||
15
go.sum
15
go.sum
@ -17,6 +17,9 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod
|
|||||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||||
@ -39,21 +42,33 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
|||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@ -3,10 +3,11 @@ package logger
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Logger defines the interface for logging
|
// Logger defines the interface for logging
|
||||||
@ -16,7 +17,7 @@ type Logger interface {
|
|||||||
Warn(msg string, args ...any)
|
Warn(msg string, args ...any)
|
||||||
Error(msg string, args ...any)
|
Error(msg string, args ...any)
|
||||||
Time(msg string, args ...any)
|
Time(msg string, args ...any)
|
||||||
|
|
||||||
// Progress logging for operations
|
// Progress logging for operations
|
||||||
StartOperation(name string) OperationLogger
|
StartOperation(name string) OperationLogger
|
||||||
}
|
}
|
||||||
@ -28,10 +29,10 @@ type OperationLogger interface {
|
|||||||
Fail(msg string, args ...any)
|
Fail(msg string, args ...any)
|
||||||
}
|
}
|
||||||
|
|
||||||
// logger implements Logger interface using slog
|
// logger implements Logger interface using logrus
|
||||||
type logger struct {
|
type logger struct {
|
||||||
slog *slog.Logger
|
logrus *logrus.Logger
|
||||||
level slog.Level
|
level logrus.Level
|
||||||
format string
|
format string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,58 +45,57 @@ type operationLogger struct {
|
|||||||
|
|
||||||
// New creates a new logger
|
// New creates a new logger
|
||||||
func New(level, format string) Logger {
|
func New(level, format string) Logger {
|
||||||
var slogLevel slog.Level
|
var logLevel logrus.Level
|
||||||
switch strings.ToLower(level) {
|
switch strings.ToLower(level) {
|
||||||
case "debug":
|
case "debug":
|
||||||
slogLevel = slog.LevelDebug
|
logLevel = logrus.DebugLevel
|
||||||
case "info":
|
case "info":
|
||||||
slogLevel = slog.LevelInfo
|
logLevel = logrus.InfoLevel
|
||||||
case "warn", "warning":
|
case "warn", "warning":
|
||||||
slogLevel = slog.LevelWarn
|
logLevel = logrus.WarnLevel
|
||||||
case "error":
|
case "error":
|
||||||
slogLevel = slog.LevelError
|
logLevel = logrus.ErrorLevel
|
||||||
default:
|
default:
|
||||||
slogLevel = slog.LevelInfo
|
logLevel = logrus.InfoLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
var handler slog.Handler
|
l := logrus.New()
|
||||||
opts := &slog.HandlerOptions{
|
l.SetLevel(logLevel)
|
||||||
Level: slogLevel,
|
l.SetOutput(os.Stdout)
|
||||||
}
|
|
||||||
|
|
||||||
switch strings.ToLower(format) {
|
switch strings.ToLower(format) {
|
||||||
case "json":
|
case "json":
|
||||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
l.SetFormatter(&logrus.JSONFormatter{})
|
||||||
default:
|
default:
|
||||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
l.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
return &logger{
|
return &logger{
|
||||||
slog: slog.New(handler),
|
logrus: l,
|
||||||
level: slogLevel,
|
level: logLevel,
|
||||||
format: format,
|
format: format,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *logger) Debug(msg string, args ...any) {
|
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) {
|
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) {
|
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) {
|
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) {
|
func (l *logger) Time(msg string, args ...any) {
|
||||||
// Time logs are always at info level with special formatting
|
// 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 {
|
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) {
|
func (ol *operationLogger) Update(msg string, args ...any) {
|
||||||
elapsed := time.Since(ol.startTime)
|
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())...)
|
append(args, "elapsed", elapsed.String())...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ol *operationLogger) Complete(msg string, args ...any) {
|
func (ol *operationLogger) Complete(msg string, args ...any) {
|
||||||
elapsed := time.Since(ol.startTime)
|
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))...)
|
append(args, "duration", formatDuration(elapsed))...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ol *operationLogger) Fail(msg string, args ...any) {
|
func (ol *operationLogger) Fail(msg string, args ...any) {
|
||||||
elapsed := time.Since(ol.startTime)
|
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))...)
|
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
|
// formatDuration formats duration in human-readable format
|
||||||
func formatDuration(d time.Duration) string {
|
func formatDuration(d time.Duration) string {
|
||||||
if d < time.Minute {
|
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
|
// FileLogger creates a logger that writes to both stdout and a file
|
||||||
func FileLogger(level, format, filename string) (Logger, error) {
|
func FileLogger(level, format, filename string) (Logger, error) {
|
||||||
var slogLevel slog.Level
|
var logLevel logrus.Level
|
||||||
switch strings.ToLower(level) {
|
switch strings.ToLower(level) {
|
||||||
case "debug":
|
case "debug":
|
||||||
slogLevel = slog.LevelDebug
|
logLevel = logrus.DebugLevel
|
||||||
case "info":
|
case "info":
|
||||||
slogLevel = slog.LevelInfo
|
logLevel = logrus.InfoLevel
|
||||||
case "warn", "warning":
|
case "warn", "warning":
|
||||||
slogLevel = slog.LevelWarn
|
logLevel = logrus.WarnLevel
|
||||||
case "error":
|
case "error":
|
||||||
slogLevel = slog.LevelError
|
logLevel = logrus.ErrorLevel
|
||||||
default:
|
default:
|
||||||
slogLevel = slog.LevelInfo
|
logLevel = logrus.InfoLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open log file
|
// Open log file
|
||||||
@ -165,21 +206,20 @@ func FileLogger(level, format, filename string) (Logger, error) {
|
|||||||
// Create multi-writer (stdout + file)
|
// Create multi-writer (stdout + file)
|
||||||
multiWriter := io.MultiWriter(os.Stdout, file)
|
multiWriter := io.MultiWriter(os.Stdout, file)
|
||||||
|
|
||||||
var handler slog.Handler
|
l := logrus.New()
|
||||||
opts := &slog.HandlerOptions{
|
l.SetLevel(logLevel)
|
||||||
Level: slogLevel,
|
l.SetOutput(multiWriter)
|
||||||
}
|
|
||||||
|
|
||||||
switch strings.ToLower(format) {
|
switch strings.ToLower(format) {
|
||||||
case "json":
|
case "json":
|
||||||
handler = slog.NewJSONHandler(multiWriter, opts)
|
l.SetFormatter(&logrus.JSONFormatter{})
|
||||||
default:
|
default:
|
||||||
handler = slog.NewTextHandler(multiWriter, opts)
|
l.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
return &logger{
|
return &logger{
|
||||||
slog: slog.New(handler),
|
logrus: l,
|
||||||
level: slogLevel,
|
level: logLevel,
|
||||||
format: format,
|
format: format,
|
||||||
}, nil
|
}, 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"dbbackup/internal/config"
|
"dbbackup/internal/config"
|
||||||
"dbbackup/internal/database"
|
|
||||||
"dbbackup/internal/logger"
|
"dbbackup/internal/logger"
|
||||||
"dbbackup/internal/progress"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style definitions
|
// Style definitions
|
||||||
@ -27,7 +22,7 @@ var (
|
|||||||
menuStyle = lipgloss.NewStyle().
|
menuStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#626262"))
|
Foreground(lipgloss.Color("#626262"))
|
||||||
|
|
||||||
selectedStyle = lipgloss.NewStyle().
|
menuSelectedStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#FF75B7")).
|
Foreground(lipgloss.Color("#FF75B7")).
|
||||||
Bold(true)
|
Bold(true)
|
||||||
|
|
||||||
@ -41,22 +36,9 @@ var (
|
|||||||
errorStyle = lipgloss.NewStyle().
|
errorStyle = lipgloss.NewStyle().
|
||||||
Foreground(lipgloss.Color("#FF6B6B")).
|
Foreground(lipgloss.Color("#FF6B6B")).
|
||||||
Bold(true)
|
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 {
|
type MenuModel struct {
|
||||||
choices []string
|
choices []string
|
||||||
cursor int
|
cursor int
|
||||||
@ -65,52 +47,14 @@ type MenuModel struct {
|
|||||||
quitting bool
|
quitting bool
|
||||||
message string
|
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
|
// Background operations
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
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 {
|
func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
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{
|
model := MenuModel{
|
||||||
choices: []string{
|
choices: []string{
|
||||||
"Single Database Backup",
|
"Single Database Backup",
|
||||||
@ -123,39 +67,18 @@ func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel {
|
|||||||
"Clear Operation History",
|
"Clear Operation History",
|
||||||
"Quit",
|
"Quit",
|
||||||
},
|
},
|
||||||
config: cfg,
|
config: cfg,
|
||||||
logger: log,
|
logger: log,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
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
|
return model
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes the model
|
// Init initializes the model
|
||||||
func (m MenuModel) Init() tea.Cmd {
|
func (m MenuModel) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
return nil
|
||||||
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
|
// Update handles messages
|
||||||
@ -171,39 +94,16 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
case "up", "k":
|
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 {
|
if m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
}
|
}
|
||||||
|
|
||||||
case "down", "j":
|
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 {
|
if m.cursor < len(m.choices)-1 {
|
||||||
m.cursor++
|
m.cursor++
|
||||||
}
|
}
|
||||||
|
|
||||||
case "enter", " ":
|
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 {
|
switch m.cursor {
|
||||||
case 0: // Single Database Backup
|
case 0: // Single Database Backup
|
||||||
return m.handleSingleBackup()
|
return m.handleSingleBackup()
|
||||||
@ -220,7 +120,7 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case 6: // Settings
|
case 6: // Settings
|
||||||
return m.handleSettings()
|
return m.handleSettings()
|
||||||
case 7: // Clear History
|
case 7: // Clear History
|
||||||
return m.handleClearHistory()
|
m.message = "🗑️ History cleared"
|
||||||
case 8: // Quit
|
case 8: // Quit
|
||||||
if m.cancel != nil {
|
if m.cancel != nil {
|
||||||
m.cancel()
|
m.cancel()
|
||||||
@ -228,427 +128,102 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.quitting = true
|
m.quitting = true
|
||||||
return m, tea.Quit
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// View renders the enhanced menu with progress tracking
|
// View renders the simple menu
|
||||||
func (m MenuModel) View() string {
|
func (m MenuModel) View() string {
|
||||||
if m.quitting {
|
if m.quitting {
|
||||||
return "Thanks for using DB Backup Tool!\n"
|
return "Thanks for using DB Backup Tool!\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
var b strings.Builder
|
var s string
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
header := titleStyle.Render("🗄️ Database Backup Tool - Interactive Menu")
|
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
|
// Database info
|
||||||
dbInfo := infoStyle.Render(fmt.Sprintf("Database: %s@%s:%d (%s)",
|
dbInfo := infoStyle.Render(fmt.Sprintf("Database: %s@%s:%d (%s)",
|
||||||
m.config.User, m.config.Host, m.config.Port, m.config.DatabaseType))
|
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
|
// Menu items
|
||||||
for i, choice := range m.choices {
|
for i, choice := range m.choices {
|
||||||
cursor := " "
|
cursor := " "
|
||||||
if m.cursor == i {
|
if m.cursor == i {
|
||||||
cursor = ">"
|
cursor = ">"
|
||||||
b.WriteString(selectedStyle.Render(fmt.Sprintf("%s %s", cursor, choice)))
|
s += menuSelectedStyle.Render(fmt.Sprintf("%s %s", cursor, choice))
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(menuStyle.Render(fmt.Sprintf("%s %s", cursor, choice)))
|
s += menuStyle.Render(fmt.Sprintf("%s %s", cursor, choice))
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
s += "\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
|
// Message area
|
||||||
if m.message != "" && !m.showCompletion {
|
if m.message != "" {
|
||||||
b.WriteString("\n")
|
s += "\n" + m.message + "\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
|
// Footer
|
||||||
var footer string
|
footer := infoStyle.Render("\n⌨️ Press ↑/↓ to navigate • Enter to select • q to quit")
|
||||||
if m.showCompletion {
|
s += footer
|
||||||
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()
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderOperationProgress renders detailed progress for the current operation
|
// handleSingleBackup opens database selector for single backup
|
||||||
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) {
|
func (m MenuModel) handleSingleBackup() (tea.Model, tea.Cmd) {
|
||||||
if m.config.Database == "" {
|
selector := NewDatabaseSelector(m.config, m.logger, m, "🗄️ Single Database Backup", "single")
|
||||||
m.message = errorStyle.Render("❌ No database specified. Use --database flag or set in config.")
|
return selector, selector.Init()
|
||||||
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
|
// handleSampleBackup opens database selector for sample backup
|
||||||
func (m MenuModel) handleSampleBackup() (tea.Model, tea.Cmd) {
|
func (m MenuModel) handleSampleBackup() (tea.Model, tea.Cmd) {
|
||||||
m.message = progressStyle.Render("🔄 Starting sample backup...")
|
selector := NewDatabaseSelector(m.config, m.logger, m, "📊 Sample Database Backup", "sample")
|
||||||
m.showProgress = true
|
return selector, selector.Init()
|
||||||
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
|
// handleClusterBackup shows confirmation and executes cluster backup
|
||||||
func (m MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) {
|
func (m MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) {
|
||||||
m.message = progressStyle.Render("🔄 Starting cluster backup (all databases)...")
|
confirm := NewConfirmationModel(m.config, m.logger, m,
|
||||||
m.showProgress = true
|
"🗄️ Cluster Backup",
|
||||||
m.showCompletion = false
|
"This will backup ALL databases in the cluster. Continue?")
|
||||||
m.completionDismissed = false // Reset for new operation
|
return confirm, nil
|
||||||
|
|
||||||
// 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
|
// handleViewOperations shows active operations
|
||||||
func (m MenuModel) handleViewOperations() (tea.Model, tea.Cmd) {
|
func (m MenuModel) handleViewOperations() (tea.Model, tea.Cmd) {
|
||||||
if len(m.allOperations) == 0 {
|
ops := NewOperationsView(m.config, m.logger, m)
|
||||||
m.message = infoStyle.Render("ℹ️ No operations currently running or completed")
|
return ops, nil
|
||||||
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
|
// handleOperationHistory shows operation history
|
||||||
func (m MenuModel) handleOperationHistory() (tea.Model, tea.Cmd) {
|
func (m MenuModel) handleOperationHistory() (tea.Model, tea.Cmd) {
|
||||||
if len(m.allOperations) == 0 {
|
history := NewHistoryView(m.config, m.logger, m)
|
||||||
m.message = infoStyle.Render("ℹ️ No operation history available")
|
return history, nil
|
||||||
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
|
// handleStatus shows database status
|
||||||
func (m MenuModel) handleStatus() (tea.Model, tea.Cmd) {
|
func (m MenuModel) handleStatus() (tea.Model, tea.Cmd) {
|
||||||
db, err := database.New(m.config, m.logger)
|
status := NewStatusView(m.config, m.logger, m)
|
||||||
if err != nil {
|
return status, status.Init()
|
||||||
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
|
// handleSettings opens settings
|
||||||
func (m MenuModel) handleSettings() (tea.Model, tea.Cmd) {
|
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)
|
settingsModel := NewSettingsModel(m.config, m.logger, m)
|
||||||
return settingsModel, settingsModel.Init()
|
return settingsModel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle clearing operation history
|
// RunInteractiveMenu starts the simple TUI
|
||||||
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 {
|
func RunInteractiveMenu(cfg *config.Config, log logger.Logger) error {
|
||||||
m := NewMenuModel(cfg, log)
|
m := NewMenuModel(cfg, log)
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
p := tea.NewProgram(m)
|
||||||
|
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
return fmt.Errorf("error running interactive menu: %w", err)
|
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
|
// TUIProgressReporter is a progress reporter that integrates with the TUI
|
||||||
type TUIProgressReporter struct {
|
type TUIProgressReporter struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
operations map[string]*progress.OperationStatus
|
operations map[string]*progress.OperationStatus
|
||||||
callbacks []func([]progress.OperationStatus)
|
callbacks []func([]progress.OperationStatus)
|
||||||
|
defaultOperationID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTUIProgressReporter creates a new TUI-compatible progress reporter
|
// NewTUIProgressReporter creates a new TUI-compatible progress reporter
|
||||||
@ -41,17 +42,123 @@ func (t *TUIProgressReporter) notifyCallbacks() {
|
|||||||
for _, op := range t.operations {
|
for _, op := range t.operations {
|
||||||
operations = append(operations, *op)
|
operations = append(operations, *op)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, callback := range t.callbacks {
|
for _, callback := range t.callbacks {
|
||||||
go callback(operations)
|
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
|
// StartOperation starts tracking a new operation
|
||||||
func (t *TUIProgressReporter) StartOperation(id, name, opType string) *TUIOperationTracker {
|
func (t *TUIProgressReporter) StartOperation(id, name, opType string) *TUIOperationTracker {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
operation := &progress.OperationStatus{
|
operation := &progress.OperationStatus{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: name,
|
Name: name,
|
||||||
@ -63,10 +170,10 @@ func (t *TUIProgressReporter) StartOperation(id, name, opType string) *TUIOperat
|
|||||||
Details: make(map[string]string),
|
Details: make(map[string]string),
|
||||||
Steps: make([]progress.StepStatus, 0),
|
Steps: make([]progress.StepStatus, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
t.operations[id] = operation
|
t.operations[id] = operation
|
||||||
t.notifyCallbacks()
|
t.notifyCallbacks()
|
||||||
|
|
||||||
return &TUIOperationTracker{
|
return &TUIOperationTracker{
|
||||||
reporter: t,
|
reporter: t,
|
||||||
operationID: id,
|
operationID: id,
|
||||||
@ -83,7 +190,7 @@ type TUIOperationTracker struct {
|
|||||||
func (t *TUIOperationTracker) UpdateProgress(progress int, message string) {
|
func (t *TUIOperationTracker) UpdateProgress(progress int, message string) {
|
||||||
t.reporter.mu.Lock()
|
t.reporter.mu.Lock()
|
||||||
defer t.reporter.mu.Unlock()
|
defer t.reporter.mu.Unlock()
|
||||||
|
|
||||||
if op, exists := t.reporter.operations[t.operationID]; exists {
|
if op, exists := t.reporter.operations[t.operationID]; exists {
|
||||||
op.Progress = progress
|
op.Progress = progress
|
||||||
op.Message = message
|
op.Message = message
|
||||||
@ -95,7 +202,7 @@ func (t *TUIOperationTracker) UpdateProgress(progress int, message string) {
|
|||||||
func (t *TUIOperationTracker) Complete(message string) {
|
func (t *TUIOperationTracker) Complete(message string) {
|
||||||
t.reporter.mu.Lock()
|
t.reporter.mu.Lock()
|
||||||
defer t.reporter.mu.Unlock()
|
defer t.reporter.mu.Unlock()
|
||||||
|
|
||||||
if op, exists := t.reporter.operations[t.operationID]; exists {
|
if op, exists := t.reporter.operations[t.operationID]; exists {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
op.Status = "completed"
|
op.Status = "completed"
|
||||||
@ -111,7 +218,7 @@ func (t *TUIOperationTracker) Complete(message string) {
|
|||||||
func (t *TUIOperationTracker) Fail(message string) {
|
func (t *TUIOperationTracker) Fail(message string) {
|
||||||
t.reporter.mu.Lock()
|
t.reporter.mu.Lock()
|
||||||
defer t.reporter.mu.Unlock()
|
defer t.reporter.mu.Unlock()
|
||||||
|
|
||||||
if op, exists := t.reporter.operations[t.operationID]; exists {
|
if op, exists := t.reporter.operations[t.operationID]; exists {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
op.Status = "failed"
|
op.Status = "failed"
|
||||||
@ -126,7 +233,7 @@ func (t *TUIOperationTracker) Fail(message string) {
|
|||||||
func (t *TUIProgressReporter) GetOperations() []progress.OperationStatus {
|
func (t *TUIProgressReporter) GetOperations() []progress.OperationStatus {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
operations := make([]progress.OperationStatus, 0, len(t.operations))
|
operations := make([]progress.OperationStatus, 0, len(t.operations))
|
||||||
for _, op := range t.operations {
|
for _, op := range t.operations {
|
||||||
operations = append(operations, *op)
|
operations = append(operations, *op)
|
||||||
@ -137,11 +244,11 @@ func (t *TUIProgressReporter) GetOperations() []progress.OperationStatus {
|
|||||||
// SilentLogger implements logger.Logger but doesn't output anything
|
// SilentLogger implements logger.Logger but doesn't output anything
|
||||||
type SilentLogger struct{}
|
type SilentLogger struct{}
|
||||||
|
|
||||||
func (s *SilentLogger) Info(msg string, args ...any) {}
|
func (s *SilentLogger) Info(msg string, args ...any) {}
|
||||||
func (s *SilentLogger) Warn(msg string, args ...any) {}
|
func (s *SilentLogger) Warn(msg string, args ...any) {}
|
||||||
func (s *SilentLogger) Error(msg string, args ...any) {}
|
func (s *SilentLogger) Error(msg string, args ...any) {}
|
||||||
func (s *SilentLogger) Debug(msg string, args ...any) {}
|
func (s *SilentLogger) Debug(msg string, args ...any) {}
|
||||||
func (s *SilentLogger) Time(msg string, args ...any) {}
|
func (s *SilentLogger) Time(msg string, args ...any) {}
|
||||||
func (s *SilentLogger) StartOperation(name string) logger.OperationLogger {
|
func (s *SilentLogger) StartOperation(name string) logger.OperationLogger {
|
||||||
return &SilentOperation{}
|
return &SilentOperation{}
|
||||||
}
|
}
|
||||||
@ -149,9 +256,9 @@ func (s *SilentLogger) StartOperation(name string) logger.OperationLogger {
|
|||||||
// SilentOperation implements logger.OperationLogger but doesn't output anything
|
// SilentOperation implements logger.OperationLogger but doesn't output anything
|
||||||
type SilentOperation struct{}
|
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) 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
|
// SilentProgressIndicator implements progress.Indicator but doesn't output anything
|
||||||
type SilentProgressIndicator struct{}
|
type SilentProgressIndicator struct{}
|
||||||
@ -163,9 +270,9 @@ func (s *SilentProgressIndicator) Fail(message string) {}
|
|||||||
func (s *SilentProgressIndicator) Stop() {}
|
func (s *SilentProgressIndicator) Stop() {}
|
||||||
|
|
||||||
// RunBackupInTUI runs a backup operation with TUI-compatible progress reporting
|
// 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 {
|
backupType string, databaseName string, reporter *TUIProgressReporter) error {
|
||||||
|
|
||||||
// Create database connection
|
// Create database connection
|
||||||
db, err := database.New(cfg, &SilentLogger{}) // Use silent logger
|
db, err := database.New(cfg, &SilentLogger{}) // Use silent logger
|
||||||
if err != nil {
|
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
|
// Create backup engine with silent progress indicator and logger
|
||||||
silentProgress := &SilentProgressIndicator{}
|
silentProgress := &SilentProgressIndicator{}
|
||||||
engine := backup.NewSilent(cfg, &SilentLogger{}, db, silentProgress)
|
engine := backup.NewSilent(cfg, &SilentLogger{}, db, silentProgress)
|
||||||
|
|
||||||
// Start operation tracking
|
// Start operation tracking
|
||||||
operationID := fmt.Sprintf("%s_%d", backupType, time.Now().Unix())
|
operationID := fmt.Sprintf("%s_%d", backupType, time.Now().Unix())
|
||||||
tracker := reporter.StartOperation(operationID, databaseName, backupType)
|
tracker := reporter.StartOperation(operationID, databaseName, backupType)
|
||||||
|
|
||||||
// Run the appropriate backup type
|
// Run the appropriate backup type
|
||||||
switch backupType {
|
switch backupType {
|
||||||
case "single":
|
case "single":
|
||||||
@ -200,7 +307,7 @@ func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger,
|
|||||||
default:
|
default:
|
||||||
err = fmt.Errorf("unknown backup type: %s", backupType)
|
err = fmt.Errorf("unknown backup type: %s", backupType)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update final status
|
// Update final status
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tracker.Fail(fmt.Sprintf("Backup failed: %v", err))
|
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))
|
tracker.Complete(fmt.Sprintf("%s backup completed successfully", backupType))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,23 +7,34 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"dbbackup/internal/config"
|
"dbbackup/internal/config"
|
||||||
"dbbackup/internal/logger"
|
"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
|
// SettingsModel represents the settings configuration state
|
||||||
type SettingsModel struct {
|
type SettingsModel struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
logger logger.Logger
|
logger logger.Logger
|
||||||
cursor int
|
cursor int
|
||||||
editing bool
|
editing bool
|
||||||
editingField string
|
editingField string
|
||||||
editingValue string
|
editingValue string
|
||||||
settings []SettingItem
|
settings []SettingItem
|
||||||
quitting bool
|
quitting bool
|
||||||
message string
|
message string
|
||||||
parent tea.Model
|
parent tea.Model
|
||||||
|
dirBrowser *DirectoryBrowser
|
||||||
|
browsingDir bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingItem represents a configurable setting
|
// 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) {
|
func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.KeyMsg:
|
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 {
|
if m.editing {
|
||||||
return m.handleEditingInput(msg)
|
return m.handleEditingInput(msg)
|
||||||
}
|
}
|
||||||
@ -239,6 +297,20 @@ func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "enter", " ":
|
case "enter", " ":
|
||||||
return m.startEditing()
|
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":
|
case "r":
|
||||||
return m.resetToDefaults()
|
return m.resetToDefaults()
|
||||||
|
|
||||||
@ -412,6 +484,14 @@ func (m SettingsModel) View() string {
|
|||||||
b.WriteString(desc)
|
b.WriteString(desc)
|
||||||
b.WriteString("\n")
|
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
|
// Message area
|
||||||
@ -445,13 +525,47 @@ func (m SettingsModel) View() string {
|
|||||||
if m.editing {
|
if m.editing {
|
||||||
footer = infoStyle.Render("\n⌨️ Type new value • Enter to save • Esc to cancel")
|
footer = infoStyle.Render("\n⌨️ Type new value • Enter to save • Esc to cancel")
|
||||||
} else {
|
} 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)
|
b.WriteString(footer)
|
||||||
|
|
||||||
return b.String()
|
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
|
// RunSettingsMenu starts the settings configuration interface
|
||||||
func RunSettingsMenu(cfg *config.Config, log logger.Logger, parent tea.Model) error {
|
func RunSettingsMenu(cfg *config.Config, log logger.Logger, parent tea.Model) error {
|
||||||
m := NewSettingsModel(cfg, log, parent)
|
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()
|
||||||
|
}
|
||||||
@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Simple wrapper for dbbackup with postgres defaults
|
|
||||||
|
|
||||||
cd /root/dbbackup
|
|
||||||
exec ./dbbackup-simple interactive --user postgres "$@"
|
|
||||||
68
test_all_functions.sh
Normal file
68
test_all_functions.sh
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "==================================="
|
||||||
|
echo " DB BACKUP TOOL - FUNCTION TEST"
|
||||||
|
echo "==================================="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Test all CLI commands
|
||||||
|
commands=("backup" "restore" "list" "status" "verify" "preflight" "cpu")
|
||||||
|
|
||||||
|
echo "1. Testing CLI Commands:"
|
||||||
|
echo "------------------------"
|
||||||
|
for cmd in "${commands[@]}"; do
|
||||||
|
echo -n " $cmd: "
|
||||||
|
output=$(./dbbackup_linux_amd64 $cmd --help 2>&1 | head -1)
|
||||||
|
if [[ "$output" == *"not yet implemented"* ]]; then
|
||||||
|
echo "❌ PLACEHOLDER"
|
||||||
|
elif [[ "$output" == *"Error: unknown command"* ]]; then
|
||||||
|
echo "❌ MISSING"
|
||||||
|
else
|
||||||
|
echo "✅ IMPLEMENTED"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "2. Testing Backup Subcommands:"
|
||||||
|
echo "------------------------------"
|
||||||
|
backup_cmds=("single" "cluster" "sample")
|
||||||
|
for cmd in "${backup_cmds[@]}"; do
|
||||||
|
echo -n " backup $cmd: "
|
||||||
|
output=$(./dbbackup_linux_amd64 backup $cmd --help 2>&1 | head -1)
|
||||||
|
if [[ "$output" == *"Error"* ]]; then
|
||||||
|
echo "❌ MISSING"
|
||||||
|
else
|
||||||
|
echo "✅ IMPLEMENTED"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "3. Testing Status & Connection:"
|
||||||
|
echo "------------------------------"
|
||||||
|
echo " Database connection test:"
|
||||||
|
./dbbackup_linux_amd64 status 2>&1 | grep -E "(✅|❌)" | head -3
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "4. Testing Interactive Mode:"
|
||||||
|
echo "----------------------------"
|
||||||
|
echo " Starting interactive (5 sec timeout)..."
|
||||||
|
timeout 5s ./dbbackup_linux_amd64 interactive >/dev/null 2>&1
|
||||||
|
if [ $? -eq 124 ]; then
|
||||||
|
echo " ✅ Interactive mode starts (timed out = working)"
|
||||||
|
else
|
||||||
|
echo " ❌ Interactive mode failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "5. Testing with Different DB Types:"
|
||||||
|
echo "----------------------------------"
|
||||||
|
echo " PostgreSQL config:"
|
||||||
|
./dbbackup_linux_amd64 --db-type postgres status 2>&1 | grep "Database Type" || echo " ❌ Failed"
|
||||||
|
|
||||||
|
echo " MySQL config:"
|
||||||
|
./dbbackup_linux_amd64 --db-type mysql status 2>&1 | grep "Database Type" || echo " ❌ Failed"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==================================="
|
||||||
|
echo " TEST COMPLETE"
|
||||||
|
echo "==================================="
|
||||||
Reference in New Issue
Block a user