diff --git a/bin/README.md b/bin/README.md index b8088fa..c4aa3d7 100644 --- a/bin/README.md +++ b/bin/README.md @@ -4,8 +4,8 @@ This directory contains pre-compiled binaries for the DB Backup Tool across mult ## Build Information - **Version**: 1.1.0 -- **Build Time**: 2025-10-22_19:14:58_UTC -- **Git Commit**: unknown +- **Build Time**: 2025-10-24_10:42:00_UTC +- **Git Commit**: 9b3c3f2 ## Recent Updates (v1.1.0) - āœ… Fixed TUI progress display with line-by-line output diff --git a/bin/dbbackup_darwin_amd64 b/bin/dbbackup_darwin_amd64 index 7ce28b6..fbaf782 100755 Binary files a/bin/dbbackup_darwin_amd64 and b/bin/dbbackup_darwin_amd64 differ diff --git a/bin/dbbackup_darwin_arm64 b/bin/dbbackup_darwin_arm64 index 3c41765..1c34481 100755 Binary files a/bin/dbbackup_darwin_arm64 and b/bin/dbbackup_darwin_arm64 differ diff --git a/bin/dbbackup_freebsd_amd64 b/bin/dbbackup_freebsd_amd64 index 0854fdf..49fd13b 100755 Binary files a/bin/dbbackup_freebsd_amd64 and b/bin/dbbackup_freebsd_amd64 differ diff --git a/bin/dbbackup_linux_amd64 b/bin/dbbackup_linux_amd64 index dce00f3..a954a64 100755 Binary files a/bin/dbbackup_linux_amd64 and b/bin/dbbackup_linux_amd64 differ diff --git a/bin/dbbackup_linux_arm64 b/bin/dbbackup_linux_arm64 index 4636609..8d44d88 100755 Binary files a/bin/dbbackup_linux_arm64 and b/bin/dbbackup_linux_arm64 differ diff --git a/bin/dbbackup_linux_arm_armv7 b/bin/dbbackup_linux_arm_armv7 index 3aec712..33b8f64 100755 Binary files a/bin/dbbackup_linux_arm_armv7 and b/bin/dbbackup_linux_arm_armv7 differ diff --git a/bin/dbbackup_netbsd_amd64 b/bin/dbbackup_netbsd_amd64 index a4079ac..4ba1cc6 100755 Binary files a/bin/dbbackup_netbsd_amd64 and b/bin/dbbackup_netbsd_amd64 differ diff --git a/bin/dbbackup_openbsd_amd64 b/bin/dbbackup_openbsd_amd64 index 04e7f87..f049f36 100755 Binary files a/bin/dbbackup_openbsd_amd64 and b/bin/dbbackup_openbsd_amd64 differ diff --git a/bin/dbbackup_windows_amd64.exe b/bin/dbbackup_windows_amd64.exe index 28bfb0e..56ba758 100755 Binary files a/bin/dbbackup_windows_amd64.exe and b/bin/dbbackup_windows_amd64.exe differ diff --git a/bin/dbbackup_windows_arm64.exe b/bin/dbbackup_windows_arm64.exe index a54c6ac..2f08bc1 100755 Binary files a/bin/dbbackup_windows_arm64.exe and b/bin/dbbackup_windows_arm64.exe differ diff --git a/cmd/placeholder.go b/cmd/placeholder.go index 140801e..c2bc499 100644 --- a/cmd/placeholder.go +++ b/cmd/placeholder.go @@ -1,6 +1,14 @@ package cmd import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + "github.com/spf13/cobra" "dbbackup/internal/tui" ) @@ -13,8 +21,13 @@ var restoreCmd = &cobra.Command{ Long: `Restore database from backup archive. Auto-detects archive format.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - log.Info("Restore command called - not yet implemented") - return nil + if len(args) == 0 { + 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.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - log.Info("Verify command called - not yet implemented") - return nil + if len(args) == 0 { + 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", Long: `List available backup archives and database information.`, RunE: func(cmd *cobra.Command, args []string) error { - log.Info("List command called - not yet implemented") - return nil + return runList(cmd.Context()) }, } @@ -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{ Use: "status", Short: "Show connection status and configuration", @@ -59,12 +82,533 @@ var statusCmd = &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 { - log.Info("Preflight command called - not yet implemented") + + +// runList lists available backups and databases +func runList(ctx context.Context) error { + fmt.Println("==============================================================") + 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 - }, + } 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") } \ No newline at end of file diff --git a/dbbackup b/dbbackup index c5ab3cc..b3e0031 100755 Binary files a/dbbackup and b/dbbackup differ diff --git a/dbbackup_linux_amd64 b/dbbackup_linux_amd64 index dce00f3..952e18d 100755 Binary files a/dbbackup_linux_amd64 and b/dbbackup_linux_amd64 differ diff --git a/go.mod b/go.mod index d047754..23f1560 100644 --- a/go.mod +++ b/go.mod @@ -4,22 +4,25 @@ go 1.24.0 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 ( filippo.io/edwards25519 v1.1.0 // 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/lipgloss v1.1.0 // 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/term v0.2.1 // 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/lib/pq v1.10.9 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index 406f813..d4a461b 100644 --- a/go.sum +++ b/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/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 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.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 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/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/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/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/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-20220715151400-c0bba94af5f8/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/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 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/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= diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 3228e56..2869c8b 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -3,10 +3,11 @@ package logger import ( "fmt" "io" - "log/slog" "os" "strings" "time" + + "github.com/sirupsen/logrus" ) // Logger defines the interface for logging @@ -16,7 +17,7 @@ type Logger interface { Warn(msg string, args ...any) Error(msg string, args ...any) Time(msg string, args ...any) - + // Progress logging for operations StartOperation(name string) OperationLogger } @@ -28,10 +29,10 @@ type OperationLogger interface { Fail(msg string, args ...any) } -// logger implements Logger interface using slog +// logger implements Logger interface using logrus type logger struct { - slog *slog.Logger - level slog.Level + logrus *logrus.Logger + level logrus.Level format string } @@ -44,58 +45,57 @@ type operationLogger struct { // New creates a new logger func New(level, format string) Logger { - var slogLevel slog.Level + var logLevel logrus.Level switch strings.ToLower(level) { case "debug": - slogLevel = slog.LevelDebug + logLevel = logrus.DebugLevel case "info": - slogLevel = slog.LevelInfo + logLevel = logrus.InfoLevel case "warn", "warning": - slogLevel = slog.LevelWarn + logLevel = logrus.WarnLevel case "error": - slogLevel = slog.LevelError + logLevel = logrus.ErrorLevel default: - slogLevel = slog.LevelInfo + logLevel = logrus.InfoLevel } - var handler slog.Handler - opts := &slog.HandlerOptions{ - Level: slogLevel, - } + l := logrus.New() + l.SetLevel(logLevel) + l.SetOutput(os.Stdout) switch strings.ToLower(format) { case "json": - handler = slog.NewJSONHandler(os.Stdout, opts) + l.SetFormatter(&logrus.JSONFormatter{}) default: - handler = slog.NewTextHandler(os.Stdout, opts) + l.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) } return &logger{ - slog: slog.New(handler), - level: slogLevel, + logrus: l, + level: logLevel, format: format, } } func (l *logger) Debug(msg string, args ...any) { - l.slog.Debug(msg, args...) + l.logWithFields(logrus.DebugLevel, msg, args...) } func (l *logger) Info(msg string, args ...any) { - l.slog.Info(msg, args...) + l.logWithFields(logrus.InfoLevel, msg, args...) } func (l *logger) Warn(msg string, args ...any) { - l.slog.Warn(msg, args...) + l.logWithFields(logrus.WarnLevel, msg, args...) } func (l *logger) Error(msg string, args ...any) { - l.slog.Error(msg, args...) + l.logWithFields(logrus.ErrorLevel, msg, args...) } func (l *logger) Time(msg string, args ...any) { // Time logs are always at info level with special formatting - l.slog.Info("[TIME] "+msg, args...) + l.logWithFields(logrus.InfoLevel, "[TIME] "+msg, args...) } func (l *logger) StartOperation(name string) OperationLogger { @@ -108,22 +108,63 @@ func (l *logger) StartOperation(name string) OperationLogger { func (ol *operationLogger) Update(msg string, args ...any) { elapsed := time.Since(ol.startTime) - ol.parent.Info(fmt.Sprintf("[%s] %s", ol.name, msg), + ol.parent.Info(fmt.Sprintf("[%s] %s", ol.name, msg), append(args, "elapsed", elapsed.String())...) } func (ol *operationLogger) Complete(msg string, args ...any) { elapsed := time.Since(ol.startTime) - ol.parent.Info(fmt.Sprintf("[%s] COMPLETED: %s", ol.name, msg), + ol.parent.Info(fmt.Sprintf("[%s] COMPLETED: %s", ol.name, msg), append(args, "duration", formatDuration(elapsed))...) } func (ol *operationLogger) Fail(msg string, args ...any) { elapsed := time.Since(ol.startTime) - ol.parent.Error(fmt.Sprintf("[%s] FAILED: %s", ol.name, msg), + ol.parent.Error(fmt.Sprintf("[%s] FAILED: %s", ol.name, msg), append(args, "duration", formatDuration(elapsed))...) } +// logWithFields forwards log messages with structured fields to logrus +func (l *logger) logWithFields(level logrus.Level, msg string, args ...any) { + if l == nil || l.logrus == nil { + return + } + + fields := fieldsFromArgs(args...) + entry := l.logrus.WithFields(fields) + + switch level { + case logrus.DebugLevel: + entry.Debug(msg) + case logrus.WarnLevel: + entry.Warn(msg) + case logrus.ErrorLevel: + entry.Error(msg) + default: + entry.Info(msg) + } +} + +// fieldsFromArgs converts variadic key/value pairs into logrus fields +func fieldsFromArgs(args ...any) logrus.Fields { + fields := logrus.Fields{} + + for i := 0; i < len(args); { + if i+1 < len(args) { + if key, ok := args[i].(string); ok { + fields[key] = args[i+1] + i += 2 + continue + } + } + + fields[fmt.Sprintf("arg%d", i)] = args[i] + i++ + } + + return fields +} + // formatDuration formats duration in human-readable format func formatDuration(d time.Duration) string { if d < time.Minute { @@ -142,18 +183,18 @@ func formatDuration(d time.Duration) string { // FileLogger creates a logger that writes to both stdout and a file func FileLogger(level, format, filename string) (Logger, error) { - var slogLevel slog.Level + var logLevel logrus.Level switch strings.ToLower(level) { case "debug": - slogLevel = slog.LevelDebug + logLevel = logrus.DebugLevel case "info": - slogLevel = slog.LevelInfo + logLevel = logrus.InfoLevel case "warn", "warning": - slogLevel = slog.LevelWarn + logLevel = logrus.WarnLevel case "error": - slogLevel = slog.LevelError + logLevel = logrus.ErrorLevel default: - slogLevel = slog.LevelInfo + logLevel = logrus.InfoLevel } // Open log file @@ -165,21 +206,20 @@ func FileLogger(level, format, filename string) (Logger, error) { // Create multi-writer (stdout + file) multiWriter := io.MultiWriter(os.Stdout, file) - var handler slog.Handler - opts := &slog.HandlerOptions{ - Level: slogLevel, - } + l := logrus.New() + l.SetLevel(logLevel) + l.SetOutput(multiWriter) switch strings.ToLower(format) { case "json": - handler = slog.NewJSONHandler(multiWriter, opts) + l.SetFormatter(&logrus.JSONFormatter{}) default: - handler = slog.NewTextHandler(multiWriter, opts) + l.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) } return &logger{ - slog: slog.New(handler), - level: slogLevel, + logrus: l, + level: logLevel, format: format, }, nil -} \ No newline at end of file +} diff --git a/internal/tui/backup_exec.go b/internal/tui/backup_exec.go new file mode 100644 index 0000000..8b73710 --- /dev/null +++ b/internal/tui/backup_exec.go @@ -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() +} diff --git a/internal/tui/confirmation.go b/internal/tui/confirmation.go new file mode 100644 index 0000000..1a9bbf2 --- /dev/null +++ b/internal/tui/confirmation.go @@ -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 +} diff --git a/internal/tui/dbselector.go b/internal/tui/dbselector.go new file mode 100644 index 0000000..ea9b5e1 --- /dev/null +++ b/internal/tui/dbselector.go @@ -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 +} diff --git a/internal/tui/dirbrowser.go b/internal/tui/dirbrowser.go new file mode 100644 index 0000000..3e7980e --- /dev/null +++ b/internal/tui/dirbrowser.go @@ -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") +} \ No newline at end of file diff --git a/internal/tui/dirpicker.go b/internal/tui/dirpicker.go new file mode 100644 index 0000000..463b0de --- /dev/null +++ b/internal/tui/dirpicker.go @@ -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()) +} \ No newline at end of file diff --git a/internal/tui/history.go b/internal/tui/history.go new file mode 100644 index 0000000..942174b --- /dev/null +++ b/internal/tui/history.go @@ -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() +} diff --git a/internal/tui/input.go b/internal/tui/input.go new file mode 100644 index 0000000..8665631 --- /dev/null +++ b/internal/tui/input.go @@ -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 +} diff --git a/internal/tui/menu.go b/internal/tui/menu.go index 5f014e6..fa9ce1c 100644 --- a/internal/tui/menu.go +++ b/internal/tui/menu.go @@ -3,17 +3,12 @@ package tui import ( "context" "fmt" - "strings" - "time" - "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "dbbackup/internal/config" - "dbbackup/internal/database" "dbbackup/internal/logger" - "dbbackup/internal/progress" ) // Style definitions @@ -27,7 +22,7 @@ var ( menuStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#626262")) - selectedStyle = lipgloss.NewStyle(). + menuSelectedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF75B7")). Bold(true) @@ -41,22 +36,9 @@ var ( errorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF6B6B")). Bold(true) - - progressStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#FFD93D")). - Bold(true) - - stepStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#6BCF7F")). - MarginLeft(2) - - detailStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("#A8A8A8")). - MarginLeft(4). - Italic(true) ) -// MenuModel represents the enhanced menu state with progress tracking +// MenuModel represents the simple menu state type MenuModel struct { choices []string cursor int @@ -65,52 +47,14 @@ type MenuModel struct { quitting bool message string - // Progress tracking - showProgress bool - showCompletion bool - completionMessage string - completionDismissed bool // Track if user manually dismissed completion - currentOperation *progress.OperationStatus - allOperations []progress.OperationStatus - lastUpdate time.Time - spinner spinner.Model - // Background operations ctx context.Context cancel context.CancelFunc - - // TUI Progress Reporter - progressReporter *TUIProgressReporter } -// completionMsg carries completion status -type completionMsg struct { - success bool - message string -} - -// operationUpdateMsg carries operation updates -type operationUpdateMsg struct { - operations []progress.OperationStatus -} - -// operationCompleteMsg signals operation completion -type operationCompleteMsg struct { - operation *progress.OperationStatus - success bool -} - -// Initialize the menu model func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel { ctx, cancel := context.WithCancel(context.Background()) - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#FFD93D")) - - // Create TUI progress reporter - progressReporter := NewTUIProgressReporter() - model := MenuModel{ choices: []string{ "Single Database Backup", @@ -123,39 +67,18 @@ func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel { "Clear Operation History", "Quit", }, - config: cfg, - logger: log, - ctx: ctx, - cancel: cancel, - spinner: s, - lastUpdate: time.Now(), - progressReporter: progressReporter, + config: cfg, + logger: log, + ctx: ctx, + cancel: cancel, } - // Set up progress callback - progressReporter.AddCallback(func(operations []progress.OperationStatus) { - // This will be called when operations update - // The TUI will pick up these updates in the pollOperations method - }) - return model } // Init initializes the model func (m MenuModel) Init() tea.Cmd { - return tea.Batch( - m.spinner.Tick, - m.pollOperations(), - ) -} - -// pollOperations periodically checks for operation updates -func (m MenuModel) pollOperations() tea.Cmd { - return tea.Tick(time.Millisecond*500, func(t time.Time) tea.Msg { - // Get operations from our TUI progress reporter - operations := m.progressReporter.GetOperations() - return operationUpdateMsg{operations: operations} - }) + return nil } // Update handles messages @@ -171,39 +94,16 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit case "up", "k": - // Clear completion status and allow navigation - if m.showCompletion { - m.showCompletion = false - m.completionMessage = "" - m.message = "" - m.completionDismissed = true // Mark as manually dismissed - } if m.cursor > 0 { m.cursor-- } case "down", "j": - // Clear completion status and allow navigation - if m.showCompletion { - m.showCompletion = false - m.completionMessage = "" - m.message = "" - m.completionDismissed = true // Mark as manually dismissed - } if m.cursor < len(m.choices)-1 { m.cursor++ } case "enter", " ": - // Clear completion status and allow selection - if m.showCompletion { - m.showCompletion = false - m.completionMessage = "" - m.message = "" - m.completionDismissed = true // Mark as manually dismissed - return m, m.pollOperations() - } - switch m.cursor { case 0: // Single Database Backup return m.handleSingleBackup() @@ -220,7 +120,7 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case 6: // Settings return m.handleSettings() case 7: // Clear History - return m.handleClearHistory() + m.message = "šŸ—‘ļø History cleared" case 8: // Quit if m.cancel != nil { m.cancel() @@ -228,427 +128,102 @@ func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.quitting = true return m, tea.Quit } - - case "esc": - // Clear completion status on escape - if m.showCompletion { - m.showCompletion = false - m.completionMessage = "" - m.message = "" - m.completionDismissed = true // Mark as manually dismissed - } } - - case operationUpdateMsg: - m.allOperations = msg.operations - if len(msg.operations) > 0 { - latest := msg.operations[len(msg.operations)-1] - if latest.Status == "running" { - m.currentOperation = &latest - m.showProgress = true - m.showCompletion = false - m.completionDismissed = false // Reset dismissal flag for new operation - } else if m.currentOperation != nil && latest.ID == m.currentOperation.ID { - m.currentOperation = &latest - m.showProgress = false - // Only show completion status if user hasn't manually dismissed it - if !m.completionDismissed { - if latest.Status == "completed" { - m.showCompletion = true - m.completionMessage = fmt.Sprintf("āœ… %s", latest.Message) - } else if latest.Status == "failed" { - m.showCompletion = true - m.completionMessage = fmt.Sprintf("āŒ %s", latest.Message) - } - } - } - } - return m, m.pollOperations() - - case completionMsg: - m.showProgress = false - m.showCompletion = true - if msg.success { - m.completionMessage = fmt.Sprintf("āœ… %s", msg.message) - } else { - m.completionMessage = fmt.Sprintf("āŒ %s", msg.message) - } - return m, m.pollOperations() - - case operationCompleteMsg: - m.currentOperation = msg.operation - m.showProgress = false - if msg.success { - m.message = fmt.Sprintf("āœ… Operation completed: %s", msg.operation.Message) - } else { - m.message = fmt.Sprintf("āŒ Operation failed: %s", msg.operation.Message) - } - return m, m.pollOperations() - - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd } return m, nil } -// View renders the enhanced menu with progress tracking +// View renders the simple menu func (m MenuModel) View() string { if m.quitting { return "Thanks for using DB Backup Tool!\n" } - var b strings.Builder + var s string // Header header := titleStyle.Render("šŸ—„ļø Database Backup Tool - Interactive Menu") - b.WriteString(fmt.Sprintf("\n%s\n\n", header)) + s += fmt.Sprintf("\n%s\n\n", header) // Database info dbInfo := infoStyle.Render(fmt.Sprintf("Database: %s@%s:%d (%s)", m.config.User, m.config.Host, m.config.Port, m.config.DatabaseType)) - b.WriteString(fmt.Sprintf("%s\n\n", dbInfo)) + s += fmt.Sprintf("%s\n\n", dbInfo) // Menu items for i, choice := range m.choices { cursor := " " if m.cursor == i { cursor = ">" - b.WriteString(selectedStyle.Render(fmt.Sprintf("%s %s", cursor, choice))) + s += menuSelectedStyle.Render(fmt.Sprintf("%s %s", cursor, choice)) } else { - b.WriteString(menuStyle.Render(fmt.Sprintf("%s %s", cursor, choice))) + s += menuStyle.Render(fmt.Sprintf("%s %s", cursor, choice)) } - b.WriteString("\n") - } - - // Current operation progress - if m.showProgress && m.currentOperation != nil { - b.WriteString("\n") - b.WriteString(m.renderOperationProgress(m.currentOperation)) - b.WriteString("\n") - } - - // Completion status (persistent until key press) - if m.showCompletion { - b.WriteString("\n") - b.WriteString(successStyle.Render(m.completionMessage)) - b.WriteString("\n") - b.WriteString(infoStyle.Render("šŸ’” Press any key to continue...")) - b.WriteString("\n") + s += "\n" } // Message area - if m.message != "" && !m.showCompletion { - b.WriteString("\n") - b.WriteString(m.message) - b.WriteString("\n") - } - - // Operations summary - if len(m.allOperations) > 0 { - b.WriteString("\n") - b.WriteString(m.renderOperationsSummary()) - b.WriteString("\n") + if m.message != "" { + s += "\n" + m.message + "\n" } // Footer - var footer string - if m.showCompletion { - footer = infoStyle.Render("\nāŒØļø Press Enter, ↑/↓ arrows, or Esc to continue...") - } else { - footer = infoStyle.Render("\nāŒØļø Press ↑/↓ to navigate • Enter to select • q to quit") - } - b.WriteString(footer) + footer := infoStyle.Render("\nāŒØļø Press ↑/↓ to navigate • Enter to select • q to quit") + s += footer - return b.String() + return s } -// renderOperationProgress renders detailed progress for the current operation -func (m MenuModel) renderOperationProgress(op *progress.OperationStatus) string { - var b strings.Builder - - // Operation header with spinner - spinnerView := "" - if op.Status == "running" { - spinnerView = m.spinner.View() + " " - } - - status := "šŸ”„" - if op.Status == "completed" { - status = "āœ…" - } else if op.Status == "failed" { - status = "āŒ" - } - - b.WriteString(progressStyle.Render(fmt.Sprintf("%s%s %s [%d%%]", - spinnerView, status, strings.Title(op.Type), op.Progress))) - b.WriteString("\n") - - // Progress bar - barWidth := 40 - filledWidth := (op.Progress * barWidth) / 100 - if filledWidth > barWidth { - filledWidth = barWidth - } - bar := strings.Repeat("ā–ˆ", filledWidth) + strings.Repeat("ā–‘", barWidth-filledWidth) - b.WriteString(detailStyle.Render(fmt.Sprintf("[%s] %s", bar, op.Message))) - b.WriteString("\n") - - // Time and details - elapsed := time.Since(op.StartTime) - timeInfo := fmt.Sprintf("Elapsed: %s", formatDuration(elapsed)) - if op.EndTime != nil { - timeInfo = fmt.Sprintf("Duration: %s", op.Duration.String()) - } - b.WriteString(detailStyle.Render(timeInfo)) - b.WriteString("\n") - - // File/byte progress - if op.FilesTotal > 0 { - b.WriteString(detailStyle.Render(fmt.Sprintf("Files: %d/%d", op.FilesDone, op.FilesTotal))) - b.WriteString("\n") - } - if op.BytesTotal > 0 { - b.WriteString(detailStyle.Render(fmt.Sprintf("Data: %s/%s", - formatBytes(op.BytesDone), formatBytes(op.BytesTotal)))) - b.WriteString("\n") - } - - // Current steps - if len(op.Steps) > 0 { - b.WriteString(stepStyle.Render("Steps:")) - b.WriteString("\n") - for _, step := range op.Steps { - stepStatus := "ā³" - if step.Status == "completed" { - stepStatus = "āœ…" - } else if step.Status == "failed" { - stepStatus = "āŒ" - } - b.WriteString(detailStyle.Render(fmt.Sprintf(" %s %s", stepStatus, step.Name))) - b.WriteString("\n") - } - } - - return b.String() -} - -// renderOperationsSummary renders a summary of all operations -func (m MenuModel) renderOperationsSummary() string { - if len(m.allOperations) == 0 { - return "" - } - - completed := 0 - failed := 0 - running := 0 - - for _, op := range m.allOperations { - switch op.Status { - case "completed": - completed++ - case "failed": - failed++ - case "running": - running++ - } - } - - summary := fmt.Sprintf("šŸ“Š Operations: %d total | %d completed | %d failed | %d running", - len(m.allOperations), completed, failed, running) - - return infoStyle.Render(summary) -} - -// Enhanced backup handlers with progress tracking - -// Handle single database backup with progress +// handleSingleBackup opens database selector for single backup func (m MenuModel) handleSingleBackup() (tea.Model, tea.Cmd) { - if m.config.Database == "" { - m.message = errorStyle.Render("āŒ No database specified. Use --database flag or set in config.") - return m, nil - } - - m.message = progressStyle.Render(fmt.Sprintf("šŸ”„ Starting single backup for: %s", m.config.Database)) - m.showProgress = true - m.showCompletion = false - - // Start backup and return polling command - go func() { - err := RunBackupInTUI(m.ctx, m.config, m.logger, "single", m.config.Database, m.progressReporter) - // The completion will be handled by the progress reporter callback system - _ = err // Handle error in the progress reporter - }() - - return m, m.pollOperations() + selector := NewDatabaseSelector(m.config, m.logger, m, "šŸ—„ļø Single Database Backup", "single") + return selector, selector.Init() } -// Handle sample backup with progress +// handleSampleBackup opens database selector for sample backup func (m MenuModel) handleSampleBackup() (tea.Model, tea.Cmd) { - m.message = progressStyle.Render("šŸ”„ Starting sample backup...") - m.showProgress = true - m.showCompletion = false - m.completionDismissed = false // Reset for new operation - - // Start backup and return polling command - go func() { - err := RunBackupInTUI(m.ctx, m.config, m.logger, "sample", "", m.progressReporter) - // The completion will be handled by the progress reporter callback system - _ = err // Handle error in the progress reporter - }() - - return m, m.pollOperations() + selector := NewDatabaseSelector(m.config, m.logger, m, "šŸ“Š Sample Database Backup", "sample") + return selector, selector.Init() } -// Handle cluster backup with progress +// handleClusterBackup shows confirmation and executes cluster backup func (m MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) { - m.message = progressStyle.Render("šŸ”„ Starting cluster backup (all databases)...") - m.showProgress = true - m.showCompletion = false - m.completionDismissed = false // Reset for new operation - - // Start backup and return polling command - go func() { - err := RunBackupInTUI(m.ctx, m.config, m.logger, "cluster", "", m.progressReporter) - // The completion will be handled by the progress reporter callback system - _ = err // Handle error in the progress reporter - }() - - return m, m.pollOperations() + confirm := NewConfirmationModel(m.config, m.logger, m, + "šŸ—„ļø Cluster Backup", + "This will backup ALL databases in the cluster. Continue?") + return confirm, nil } -// Handle viewing active operations +// handleViewOperations shows active operations func (m MenuModel) handleViewOperations() (tea.Model, tea.Cmd) { - if len(m.allOperations) == 0 { - m.message = infoStyle.Render("ā„¹ļø No operations currently running or completed") - return m, nil - } - - var activeOps []progress.OperationStatus - for _, op := range m.allOperations { - if op.Status == "running" { - activeOps = append(activeOps, op) - } - } - - if len(activeOps) == 0 { - m.message = infoStyle.Render("ā„¹ļø No operations currently running") - } else { - m.message = progressStyle.Render(fmt.Sprintf("šŸ”„ %d active operations", len(activeOps))) - } - - return m, nil + ops := NewOperationsView(m.config, m.logger, m) + return ops, nil } -// Handle showing operation history +// handleOperationHistory shows operation history func (m MenuModel) handleOperationHistory() (tea.Model, tea.Cmd) { - if len(m.allOperations) == 0 { - m.message = infoStyle.Render("ā„¹ļø No operation history available") - return m, nil - } - - var history strings.Builder - history.WriteString("šŸ“‹ Operation History:\n") - - for i, op := range m.allOperations { - if i >= 5 { // Show last 5 operations - break - } - - status := "šŸ”„" - if op.Status == "completed" { - status = "āœ…" - } else if op.Status == "failed" { - status = "āŒ" - } - - history.WriteString(fmt.Sprintf("%s %s - %s (%s)\n", - status, op.Name, op.Type, op.StartTime.Format("15:04:05"))) - } - - m.message = history.String() - return m, nil + history := NewHistoryView(m.config, m.logger, m) + return history, nil } -// Handle status check +// handleStatus shows database status func (m MenuModel) handleStatus() (tea.Model, tea.Cmd) { - db, err := database.New(m.config, m.logger) - if err != nil { - m.message = errorStyle.Render(fmt.Sprintf("āŒ Connection failed: %v", err)) - return m, nil - } - defer db.Close() - - err = db.Connect(m.ctx) - if err != nil { - m.message = errorStyle.Render(fmt.Sprintf("āŒ Connection failed: %v", err)) - return m, nil - } - - err = db.Ping(m.ctx) - if err != nil { - m.message = errorStyle.Render(fmt.Sprintf("āŒ Ping failed: %v", err)) - return m, nil - } - - version, err := db.GetVersion(m.ctx) - if err != nil { - m.message = errorStyle.Render(fmt.Sprintf("āŒ Failed to get version: %v", err)) - return m, nil - } - - m.message = successStyle.Render(fmt.Sprintf("āœ… Connected successfully!\nVersion: %s", version)) - return m, nil + status := NewStatusView(m.config, m.logger, m) + return status, status.Init() } -// Handle settings display +// handleSettings opens settings func (m MenuModel) handleSettings() (tea.Model, tea.Cmd) { - // Create and switch to settings model + // Create and return the settings model settingsModel := NewSettingsModel(m.config, m.logger, m) - return settingsModel, settingsModel.Init() + return settingsModel, nil } -// Handle clearing operation history -func (m MenuModel) handleClearHistory() (tea.Model, tea.Cmd) { - m.allOperations = []progress.OperationStatus{} - m.currentOperation = nil - m.showProgress = false - m.message = successStyle.Render("āœ… Operation history cleared") - return m, nil -} - -// Utility functions - -// formatDuration formats a duration in a human-readable way -func formatDuration(d time.Duration) string { - if d < time.Minute { - return fmt.Sprintf("%.1fs", d.Seconds()) - } else if d < time.Hour { - return fmt.Sprintf("%.1fm", d.Minutes()) - } - return fmt.Sprintf("%.1fh", d.Hours()) -} - -// formatBytes formats byte count in human-readable format -func formatBytes(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%d B", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) -} - -// RunInteractiveMenu starts the enhanced TUI with progress tracking +// RunInteractiveMenu starts the simple TUI func RunInteractiveMenu(cfg *config.Config, log logger.Logger) error { m := NewMenuModel(cfg, log) - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tea.NewProgram(m) if _, err := p.Run(); err != nil { return fmt.Errorf("error running interactive menu: %w", err) diff --git a/internal/tui/operations.go b/internal/tui/operations.go new file mode 100644 index 0000000..05ef175 --- /dev/null +++ b/internal/tui/operations.go @@ -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() +} diff --git a/internal/tui/progress.go b/internal/tui/progress.go index 0c53648..c899ea8 100644 --- a/internal/tui/progress.go +++ b/internal/tui/progress.go @@ -15,9 +15,10 @@ import ( // TUIProgressReporter is a progress reporter that integrates with the TUI type TUIProgressReporter struct { - mu sync.RWMutex - operations map[string]*progress.OperationStatus - callbacks []func([]progress.OperationStatus) + mu sync.RWMutex + operations map[string]*progress.OperationStatus + callbacks []func([]progress.OperationStatus) + defaultOperationID string } // NewTUIProgressReporter creates a new TUI-compatible progress reporter @@ -41,17 +42,123 @@ func (t *TUIProgressReporter) notifyCallbacks() { for _, op := range t.operations { operations = append(operations, *op) } - + for _, callback := range t.callbacks { go callback(operations) } } +func (t *TUIProgressReporter) ensureDefaultOperationLocked(message string) *progress.OperationStatus { + if t.defaultOperationID == "" { + t.defaultOperationID = fmt.Sprintf("tui-progress-%d", time.Now().UnixNano()) + } + + op, exists := t.operations[t.defaultOperationID] + if !exists { + op = &progress.OperationStatus{ + ID: t.defaultOperationID, + Name: "Backup Progress", + Type: "indicator", + Status: "running", + StartTime: time.Now(), + Message: message, + Progress: 0, + Details: make(map[string]string), + Steps: make([]progress.StepStatus, 0), + } + t.operations[t.defaultOperationID] = op + } + + if message != "" { + op.Message = message + } + return op +} + +func (t *TUIProgressReporter) Start(message string) { + t.mu.Lock() + defer t.mu.Unlock() + + op := t.ensureDefaultOperationLocked(message) + now := time.Now() + op.Status = "running" + op.StartTime = now + op.EndTime = nil + op.Progress = 0 + op.Message = message + t.notifyCallbacks() +} + +func (t *TUIProgressReporter) Update(message string) { + t.mu.Lock() + defer t.mu.Unlock() + + op := t.ensureDefaultOperationLocked(message) + if op.Progress < 95 { + op.Progress += 5 + } + op.Message = message + t.notifyCallbacks() +} + +func (t *TUIProgressReporter) Complete(message string) { + t.mu.Lock() + defer t.mu.Unlock() + + if t.defaultOperationID == "" { + return + } + if op, exists := t.operations[t.defaultOperationID]; exists { + now := time.Now() + op.Status = "completed" + op.Message = message + op.Progress = 100 + op.EndTime = &now + op.Duration = now.Sub(op.StartTime) + t.notifyCallbacks() + } +} + +func (t *TUIProgressReporter) Fail(message string) { + t.mu.Lock() + defer t.mu.Unlock() + + if t.defaultOperationID == "" { + return + } + if op, exists := t.operations[t.defaultOperationID]; exists { + now := time.Now() + op.Status = "failed" + op.Message = message + op.EndTime = &now + op.Duration = now.Sub(op.StartTime) + t.notifyCallbacks() + } +} + +func (t *TUIProgressReporter) Stop() { + t.mu.Lock() + defer t.mu.Unlock() + + if t.defaultOperationID == "" { + return + } + if op, exists := t.operations[t.defaultOperationID]; exists { + if op.Status == "running" { + now := time.Now() + op.Status = "stopped" + op.EndTime = &now + op.Duration = now.Sub(op.StartTime) + } + t.notifyCallbacks() + } +} + // StartOperation starts tracking a new operation func (t *TUIProgressReporter) StartOperation(id, name, opType string) *TUIOperationTracker { t.mu.Lock() defer t.mu.Unlock() - + operation := &progress.OperationStatus{ ID: id, Name: name, @@ -63,10 +170,10 @@ func (t *TUIProgressReporter) StartOperation(id, name, opType string) *TUIOperat Details: make(map[string]string), Steps: make([]progress.StepStatus, 0), } - + t.operations[id] = operation t.notifyCallbacks() - + return &TUIOperationTracker{ reporter: t, operationID: id, @@ -83,7 +190,7 @@ type TUIOperationTracker struct { func (t *TUIOperationTracker) UpdateProgress(progress int, message string) { t.reporter.mu.Lock() defer t.reporter.mu.Unlock() - + if op, exists := t.reporter.operations[t.operationID]; exists { op.Progress = progress op.Message = message @@ -95,7 +202,7 @@ func (t *TUIOperationTracker) UpdateProgress(progress int, message string) { func (t *TUIOperationTracker) Complete(message string) { t.reporter.mu.Lock() defer t.reporter.mu.Unlock() - + if op, exists := t.reporter.operations[t.operationID]; exists { now := time.Now() op.Status = "completed" @@ -111,7 +218,7 @@ func (t *TUIOperationTracker) Complete(message string) { func (t *TUIOperationTracker) Fail(message string) { t.reporter.mu.Lock() defer t.reporter.mu.Unlock() - + if op, exists := t.reporter.operations[t.operationID]; exists { now := time.Now() op.Status = "failed" @@ -126,7 +233,7 @@ func (t *TUIOperationTracker) Fail(message string) { func (t *TUIProgressReporter) GetOperations() []progress.OperationStatus { t.mu.RLock() defer t.mu.RUnlock() - + operations := make([]progress.OperationStatus, 0, len(t.operations)) for _, op := range t.operations { operations = append(operations, *op) @@ -137,11 +244,11 @@ func (t *TUIProgressReporter) GetOperations() []progress.OperationStatus { // SilentLogger implements logger.Logger but doesn't output anything type SilentLogger struct{} -func (s *SilentLogger) Info(msg string, args ...any) {} -func (s *SilentLogger) Warn(msg string, args ...any) {} -func (s *SilentLogger) Error(msg string, args ...any) {} -func (s *SilentLogger) Debug(msg string, args ...any) {} -func (s *SilentLogger) Time(msg string, args ...any) {} +func (s *SilentLogger) Info(msg string, args ...any) {} +func (s *SilentLogger) Warn(msg string, args ...any) {} +func (s *SilentLogger) Error(msg string, args ...any) {} +func (s *SilentLogger) Debug(msg string, args ...any) {} +func (s *SilentLogger) Time(msg string, args ...any) {} func (s *SilentLogger) StartOperation(name string) logger.OperationLogger { return &SilentOperation{} } @@ -149,9 +256,9 @@ func (s *SilentLogger) StartOperation(name string) logger.OperationLogger { // SilentOperation implements logger.OperationLogger but doesn't output anything type SilentOperation struct{} -func (s *SilentOperation) Update(message string, args ...any) {} +func (s *SilentOperation) Update(message string, args ...any) {} func (s *SilentOperation) Complete(message string, args ...any) {} -func (s *SilentOperation) Fail(message string, args ...any) {} +func (s *SilentOperation) Fail(message string, args ...any) {} // SilentProgressIndicator implements progress.Indicator but doesn't output anything type SilentProgressIndicator struct{} @@ -163,9 +270,9 @@ func (s *SilentProgressIndicator) Fail(message string) {} func (s *SilentProgressIndicator) Stop() {} // RunBackupInTUI runs a backup operation with TUI-compatible progress reporting -func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger, +func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger, backupType string, databaseName string, reporter *TUIProgressReporter) error { - + // Create database connection db, err := database.New(cfg, &SilentLogger{}) // Use silent logger if err != nil { @@ -181,11 +288,11 @@ func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger, // Create backup engine with silent progress indicator and logger silentProgress := &SilentProgressIndicator{} engine := backup.NewSilent(cfg, &SilentLogger{}, db, silentProgress) - + // Start operation tracking operationID := fmt.Sprintf("%s_%d", backupType, time.Now().Unix()) tracker := reporter.StartOperation(operationID, databaseName, backupType) - + // Run the appropriate backup type switch backupType { case "single": @@ -200,7 +307,7 @@ func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger, default: err = fmt.Errorf("unknown backup type: %s", backupType) } - + // Update final status if err != nil { tracker.Fail(fmt.Sprintf("Backup failed: %v", err)) @@ -209,4 +316,4 @@ func RunBackupInTUI(ctx context.Context, cfg *config.Config, log logger.Logger, tracker.Complete(fmt.Sprintf("%s backup completed successfully", backupType)) return nil } -} \ No newline at end of file +} diff --git a/internal/tui/settings.go b/internal/tui/settings.go index a74a386..daea7d1 100644 --- a/internal/tui/settings.go +++ b/internal/tui/settings.go @@ -7,23 +7,34 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "dbbackup/internal/config" "dbbackup/internal/logger" ) +var ( + headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99")).Padding(1, 2) + inputStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")) + buttonStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("15")).Background(lipgloss.Color("57")).Padding(0, 2) + selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Background(lipgloss.Color("57")).Bold(true) + detailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Italic(true) +) + // SettingsModel represents the settings configuration state type SettingsModel struct { - config *config.Config - logger logger.Logger - cursor int - editing bool + config *config.Config + logger logger.Logger + cursor int + editing bool editingField string editingValue string - settings []SettingItem - quitting bool - message string - parent tea.Model + settings []SettingItem + quitting bool + message string + parent tea.Model + dirBrowser *DirectoryBrowser + browsingDir bool } // SettingItem represents a configurable setting @@ -217,6 +228,53 @@ func (m SettingsModel) Init() tea.Cmd { func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + // Handle directory browsing mode + if m.browsingDir && m.dirBrowser != nil { + switch msg.String() { + case "esc": + m.browsingDir = false + m.dirBrowser.Hide() + return m, nil + case "up", "k": + m.dirBrowser.Navigate(-1) + return m, nil + case "down", "j": + m.dirBrowser.Navigate(1) + return m, nil + case "enter", "right", "l": + m.dirBrowser.Enter() + return m, nil + case "left", "h": + // Go up one level (same as selecting ".." and entering) + parentPath := filepath.Dir(m.dirBrowser.CurrentPath) + if parentPath != m.dirBrowser.CurrentPath { // Avoid infinite loop at root + m.dirBrowser.CurrentPath = parentPath + m.dirBrowser.LoadItems() + } + return m, nil + case " ": + // Select current directory + selectedPath := m.dirBrowser.Select() + if m.cursor < len(m.settings) { + setting := m.settings[m.cursor] + if err := setting.Update(m.config, selectedPath); err != nil { + m.message = "āŒ Error: " + err.Error() + } else { + m.message = "āœ… Directory updated: " + selectedPath + } + } + m.browsingDir = false + m.dirBrowser.Hide() + return m, nil + case "tab": + // Toggle back to settings + m.browsingDir = false + m.dirBrowser.Hide() + return m, nil + } + return m, nil + } + if m.editing { return m.handleEditingInput(msg) } @@ -239,6 +297,20 @@ func (m SettingsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter", " ": return m.startEditing() + case "tab": + // Directory browser for path fields + if m.cursor >= 0 && m.cursor < len(m.settings) { + if m.settings[m.cursor].Type == "path" { + return m.openDirectoryBrowser() + } else { + m.message = "āŒ Tab key only works on directory path fields" + return m, nil + } + } else { + m.message = "āŒ Invalid selection" + return m, nil + } + case "r": return m.resetToDefaults() @@ -412,6 +484,14 @@ func (m SettingsModel) View() string { b.WriteString(desc) b.WriteString("\n") } + + // Show directory browser for current path field + if m.cursor == i && m.browsingDir && m.dirBrowser != nil && setting.Type == "path" { + b.WriteString("\n") + browserView := m.dirBrowser.Render() + b.WriteString(browserView) + b.WriteString("\n") + } } // Message area @@ -445,13 +525,47 @@ func (m SettingsModel) View() string { if m.editing { footer = infoStyle.Render("\nāŒØļø Type new value • Enter to save • Esc to cancel") } else { - footer = infoStyle.Render("\nāŒØļø ↑/↓ navigate • Enter to edit • 's' save • 'r' reset • 'q' back to menu") + if m.browsingDir { + footer = infoStyle.Render("\nāŒØļø ↑/↓ navigate directories • Enter open • Space select • Tab/Esc back to settings") + } else { + // Show different help based on current selection + if m.cursor >= 0 && m.cursor < len(m.settings) && m.settings[m.cursor].Type == "path" { + footer = infoStyle.Render("\nāŒØļø ↑/↓ navigate • Enter edit • Tab browse directories • 's' save • 'r' reset • 'q' menu") + } else { + footer = infoStyle.Render("\nāŒØļø ↑/↓ navigate • Enter edit • 's' save • 'r' reset • 'q' menu • Tab=dirs on path fields only") + } + } } b.WriteString(footer) return b.String() } +func (m SettingsModel) openDirectoryBrowser() (tea.Model, tea.Cmd) { + if m.cursor >= len(m.settings) { + return m, nil + } + + setting := m.settings[m.cursor] + currentValue := setting.Value(m.config) + if currentValue == "" { + currentValue = "/tmp" + } + + if m.dirBrowser == nil { + m.dirBrowser = NewDirectoryBrowser(currentValue) + } else { + // Update the browser to start from the current value + m.dirBrowser.CurrentPath = currentValue + m.dirBrowser.LoadItems() + } + + m.dirBrowser.Show() + m.browsingDir = true + + return m, nil +} + // RunSettingsMenu starts the settings configuration interface func RunSettingsMenu(cfg *config.Config, log logger.Logger, parent tea.Model) error { m := NewSettingsModel(cfg, log, parent) diff --git a/internal/tui/status.go b/internal/tui/status.go new file mode 100644 index 0000000..961078d --- /dev/null +++ b/internal/tui/status.go @@ -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() +} diff --git a/postgres-backup b/postgres-backup deleted file mode 100755 index 064c63b..0000000 --- a/postgres-backup +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash -# Simple wrapper for dbbackup with postgres defaults - -cd /root/dbbackup -exec ./dbbackup-simple interactive --user postgres "$@" \ No newline at end of file diff --git a/test_all_functions.sh b/test_all_functions.sh new file mode 100644 index 0000000..af07b41 --- /dev/null +++ b/test_all_functions.sh @@ -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 "===================================" \ No newline at end of file