Some checks failed
CI/CD / Test (push) Successful in 3m10s
CI/CD / Lint (push) Successful in 1m31s
CI/CD / Integration Tests (push) Successful in 1m9s
CI/CD / Native Engine Tests (push) Successful in 1m2s
CI/CD / Build Binary (push) Successful in 54s
CI/CD / Test Release Build (push) Successful in 1m46s
CI/CD / Release Binaries (push) Failing after 11m4s
- Fix menu.go case 10/11 mismatch (separator vs profile item) - Add tea.InterruptMsg handlers for Bubbletea v1.3+ SIGINT handling: - archive_browser.go - restore_preview.go - confirmation.go - dbselector.go - cluster_db_selector.go - profile.go - Add missing ctrl+c key handlers to cluster_db_selector and profile - Fix ConfirmationModel fallback to use context.Background() if nil
306 lines
8.4 KiB
Go
306 lines
8.4 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"dbbackup/internal/config"
|
|
"dbbackup/internal/logger"
|
|
"dbbackup/internal/restore"
|
|
)
|
|
|
|
// ClusterDatabaseSelectorModel for selecting databases from a cluster backup
|
|
type ClusterDatabaseSelectorModel struct {
|
|
config *config.Config
|
|
logger logger.Logger
|
|
parent tea.Model
|
|
ctx context.Context
|
|
archive ArchiveInfo
|
|
databases []restore.DatabaseInfo
|
|
cursor int
|
|
selected map[int]bool // Track multiple selections
|
|
loading bool
|
|
err error
|
|
title string
|
|
mode string // "single" or "multiple"
|
|
extractOnly bool // If true, extract without restoring
|
|
extractedDir string // Pre-extracted cluster directory (optimization)
|
|
}
|
|
|
|
func NewClusterDatabaseSelector(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, archive ArchiveInfo, mode string, extractOnly bool) ClusterDatabaseSelectorModel {
|
|
return ClusterDatabaseSelectorModel{
|
|
config: cfg,
|
|
logger: log,
|
|
parent: parent,
|
|
ctx: ctx,
|
|
archive: archive,
|
|
databases: nil,
|
|
selected: make(map[int]bool),
|
|
title: "Select Database(s) from Cluster Backup",
|
|
loading: true,
|
|
mode: mode,
|
|
extractOnly: extractOnly,
|
|
}
|
|
}
|
|
|
|
func (m ClusterDatabaseSelectorModel) Init() tea.Cmd {
|
|
return fetchClusterDatabases(m.ctx, m.archive, m.config, m.logger)
|
|
}
|
|
|
|
type clusterDatabaseListMsg struct {
|
|
databases []restore.DatabaseInfo
|
|
err error
|
|
extractedDir string // Path to extracted directory (for reuse)
|
|
}
|
|
|
|
func fetchClusterDatabases(ctx context.Context, archive ArchiveInfo, cfg *config.Config, log logger.Logger) tea.Cmd {
|
|
return func() tea.Msg {
|
|
// OPTIMIZATION: Extract archive ONCE, then list databases from disk
|
|
// This eliminates double-extraction (scan + restore)
|
|
log.Info("Pre-extracting cluster archive for database listing")
|
|
safety := restore.NewSafety(cfg, log)
|
|
extractedDir, err := safety.ValidateAndExtractCluster(ctx, archive.Path)
|
|
if err != nil {
|
|
// Fallback to direct tar scan if extraction fails
|
|
log.Warn("Pre-extraction failed, falling back to tar scan", "error", err)
|
|
databases, err := restore.ListDatabasesInCluster(ctx, archive.Path, log)
|
|
if err != nil {
|
|
return clusterDatabaseListMsg{databases: nil, err: fmt.Errorf("failed to list databases: %w", err), extractedDir: ""}
|
|
}
|
|
return clusterDatabaseListMsg{databases: databases, err: nil, extractedDir: ""}
|
|
}
|
|
|
|
// List databases from extracted directory (fast!)
|
|
databases, err := restore.ListDatabasesFromExtractedDir(ctx, extractedDir, log)
|
|
if err != nil {
|
|
return clusterDatabaseListMsg{databases: nil, err: fmt.Errorf("failed to list databases from extracted dir: %w", err), extractedDir: extractedDir}
|
|
}
|
|
return clusterDatabaseListMsg{databases: databases, err: nil, extractedDir: extractedDir}
|
|
}
|
|
}
|
|
|
|
func (m ClusterDatabaseSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case clusterDatabaseListMsg:
|
|
m.loading = false
|
|
if msg.err != nil {
|
|
m.err = msg.err
|
|
} else {
|
|
m.databases = msg.databases
|
|
m.extractedDir = msg.extractedDir // Store for later reuse
|
|
if len(m.databases) > 0 && m.mode == "single" {
|
|
m.selected[0] = true // Pre-select first database in single mode
|
|
}
|
|
}
|
|
return m, nil
|
|
|
|
case tea.InterruptMsg:
|
|
// Handle Ctrl+C signal (SIGINT) - Bubbletea v1.3+ sends this instead of KeyMsg for ctrl+c
|
|
return m.parent, nil
|
|
|
|
case tea.KeyMsg:
|
|
if m.loading {
|
|
return m, nil
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c", "q", "esc":
|
|
// Return to parent
|
|
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 " ": // Space to toggle selection (multiple mode)
|
|
if m.mode == "multiple" {
|
|
m.selected[m.cursor] = !m.selected[m.cursor]
|
|
} else {
|
|
// Single mode: clear all and select current
|
|
m.selected = make(map[int]bool)
|
|
m.selected[m.cursor] = true
|
|
}
|
|
|
|
case "enter":
|
|
if m.err != nil {
|
|
return m.parent, nil
|
|
}
|
|
|
|
if len(m.databases) == 0 {
|
|
return m.parent, nil
|
|
}
|
|
|
|
// Get selected database(s)
|
|
var selectedDBs []restore.DatabaseInfo
|
|
for i, selected := range m.selected {
|
|
if selected && i < len(m.databases) {
|
|
selectedDBs = append(selectedDBs, m.databases[i])
|
|
}
|
|
}
|
|
|
|
if len(selectedDBs) == 0 {
|
|
// No selection, use cursor position
|
|
selectedDBs = []restore.DatabaseInfo{m.databases[m.cursor]}
|
|
}
|
|
|
|
if m.extractOnly {
|
|
// TODO: Implement extraction flow
|
|
m.logger.Info("Extract-only mode not yet implemented in TUI")
|
|
return m.parent, nil
|
|
}
|
|
|
|
// For restore: proceed to restore preview/confirmation
|
|
if len(selectedDBs) == 1 {
|
|
// Single database restore from cluster
|
|
// Create a temporary archive info for the selected database
|
|
dbArchive := ArchiveInfo{
|
|
Name: selectedDBs[0].Filename,
|
|
Path: m.archive.Path, // Still use cluster archive path
|
|
Format: m.archive.Format,
|
|
Size: selectedDBs[0].Size,
|
|
Modified: m.archive.Modified,
|
|
DatabaseName: selectedDBs[0].Name,
|
|
ExtractedDir: m.extractedDir, // Pass pre-extracted directory
|
|
}
|
|
|
|
preview := NewRestorePreview(m.config, m.logger, m.parent, m.ctx, dbArchive, "restore-cluster-single")
|
|
return preview, preview.Init()
|
|
} else {
|
|
// Multiple database restore - not yet implemented
|
|
m.logger.Info("Multiple database restore not yet implemented in TUI")
|
|
return m.parent, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m ClusterDatabaseSelectorModel) View() string {
|
|
if m.loading {
|
|
return TitleStyle.Render("Loading databases from cluster backup...") + "\n\nPlease wait..."
|
|
}
|
|
|
|
if m.err != nil {
|
|
var s strings.Builder
|
|
s.WriteString(TitleStyle.Render("Error"))
|
|
s.WriteString("\n\n")
|
|
s.WriteString(StatusErrorStyle.Render("Failed to list databases"))
|
|
s.WriteString("\n\n")
|
|
s.WriteString(m.err.Error())
|
|
s.WriteString("\n\n")
|
|
s.WriteString(StatusReadyStyle.Render("Press any key to go back"))
|
|
return s.String()
|
|
}
|
|
|
|
if len(m.databases) == 0 {
|
|
var s strings.Builder
|
|
s.WriteString(TitleStyle.Render("No Databases Found"))
|
|
s.WriteString("\n\n")
|
|
s.WriteString(StatusWarningStyle.Render("The cluster backup appears to be empty or invalid."))
|
|
s.WriteString("\n\n")
|
|
s.WriteString(StatusReadyStyle.Render("Press any key to go back"))
|
|
return s.String()
|
|
}
|
|
|
|
var s strings.Builder
|
|
|
|
// Title
|
|
s.WriteString(TitleStyle.Render(m.title))
|
|
s.WriteString("\n\n")
|
|
|
|
// Archive info
|
|
s.WriteString(LabelStyle.Render("Archive: "))
|
|
s.WriteString(m.archive.Name)
|
|
s.WriteString("\n")
|
|
s.WriteString(LabelStyle.Render("Databases: "))
|
|
s.WriteString(fmt.Sprintf("%d", len(m.databases)))
|
|
s.WriteString("\n\n")
|
|
|
|
// Instructions
|
|
if m.mode == "multiple" {
|
|
s.WriteString(StatusReadyStyle.Render("↑/↓: navigate • space: select/deselect • enter: confirm • q/esc: back"))
|
|
} else {
|
|
s.WriteString(StatusReadyStyle.Render("↑/↓: navigate • enter: select • q/esc: back"))
|
|
}
|
|
s.WriteString("\n\n")
|
|
|
|
// Database list
|
|
s.WriteString(ListHeaderStyle.Render("Available Databases:"))
|
|
s.WriteString("\n\n")
|
|
|
|
for i, db := range m.databases {
|
|
cursor := " "
|
|
if m.cursor == i {
|
|
cursor = "▶ "
|
|
}
|
|
|
|
checkbox := ""
|
|
if m.mode == "multiple" {
|
|
if m.selected[i] {
|
|
checkbox = "[✓] "
|
|
} else {
|
|
checkbox = "[ ] "
|
|
}
|
|
} else {
|
|
if m.selected[i] {
|
|
checkbox = "● "
|
|
} else {
|
|
checkbox = "○ "
|
|
}
|
|
}
|
|
|
|
sizeStr := formatBytes(db.Size)
|
|
line := fmt.Sprintf("%s%s%-40s %10s", cursor, checkbox, db.Name, sizeStr)
|
|
|
|
if m.cursor == i {
|
|
s.WriteString(ListSelectedStyle.Render(line))
|
|
} else {
|
|
s.WriteString(ListNormalStyle.Render(line))
|
|
}
|
|
s.WriteString("\n")
|
|
}
|
|
|
|
s.WriteString("\n")
|
|
|
|
// Selection summary
|
|
selectedCount := 0
|
|
var totalSize int64
|
|
for i, selected := range m.selected {
|
|
if selected && i < len(m.databases) {
|
|
selectedCount++
|
|
totalSize += m.databases[i].Size
|
|
}
|
|
}
|
|
|
|
if selectedCount > 0 {
|
|
s.WriteString(StatusSuccessStyle.Render(fmt.Sprintf("Selected: %d database(s), Total size: %s", selectedCount, formatBytes(totalSize))))
|
|
s.WriteString("\n")
|
|
}
|
|
|
|
return s.String()
|
|
}
|
|
|
|
// formatBytes formats byte count as human-readable string
|
|
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])
|
|
}
|