Compare commits

...

7 Commits

Author SHA1 Message Date
dc12a8e4b0 feat: add config validate command (Quick Win #3)
All checks were successful
CI/CD / Test (push) Successful in 1m18s
CI/CD / Lint (push) Successful in 1m9s
CI/CD / Integration Tests (push) Successful in 52s
CI/CD / Native Engine Tests (push) Successful in 52s
CI/CD / Build Binary (push) Successful in 44s
CI/CD / Test Release Build (push) Successful in 1m19s
CI/CD / Release Binaries (push) Successful in 11m5s
Added 'dbbackup validate' command for comprehensive configuration validation:

Features:
- Configuration file syntax validation
- Database connection parameters check
- Directory paths and permissions validation
- External tool availability checks (pg_dump, mysqldump, etc.)
- Cloud storage credentials validation
- Encryption setup verification
- Resource limits validation (CPU cores, parallel jobs)
- Database connectivity tests (TCP port check)

Validation Categories:
- [PASS] All checks passed
- [WARN] Non-critical issues (config missing, directories to be created)
- [FAIL] Critical issues preventing operation

Output Formats:
- Table: Human-readable report with categorized issues
- JSON: Machine-readable output for automation/CI

Usage Examples:
  dbbackup validate                    # Full validation
  dbbackup validate --quick            # Skip connectivity tests
  dbbackup validate --format json      # JSON output
  dbbackup validate --native           # Validate for native mode

Validates:
✓ Database type (postgres/mysql/mariadb)
✓ Host and port configuration
✓ Backup directory writability
✓ Required external tools (or native mode)
✓ Cloud provider settings
✓ Encryption tools (openssl)
✓ CPU/job configuration
✓ Network connectivity

Helps identify configuration issues before running backups, preventing
runtime failures and reducing troubleshooting time.

Quick Win #3: Config Validate - 20 min implementation
2026-01-31 06:12:36 +01:00
f69a8e374b feat: add space forecast command (Quick Win #9)
All checks were successful
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m12s
CI/CD / Integration Tests (push) Successful in 53s
CI/CD / Native Engine Tests (push) Successful in 49s
CI/CD / Build Binary (push) Successful in 44s
CI/CD / Test Release Build (push) Successful in 1m16s
CI/CD / Release Binaries (push) Successful in 10m59s
Added 'dbbackup forecast' command for capacity planning and growth prediction:

Features:
- Analyzes historical backup growth patterns from catalog
- Calculates daily/weekly/monthly/annual growth rates
- Projects future space requirements (7, 30, 60, 90, 180, 365 days)
- Confidence scoring based on sample size and variance
- Capacity limit alerts (warn when approaching threshold)
- Calculates time until space limit reached

Usage Examples:
  dbbackup forecast mydb                    # Basic forecast
  dbbackup forecast --all                   # All databases
  dbbackup forecast mydb --days 180         # 6-month projection
  dbbackup forecast mydb --limit 500GB      # Set capacity limit
  dbbackup forecast mydb --format json      # JSON output

Key Metrics:
- Daily growth rate (bytes/day and percentage)
- Current utilization vs capacity limit
- Growth confidence (high/medium/low)
- Time to capacity limit (with critical/warning alerts)

Helps answer:
- When will we run out of space?
- How much storage to provision?
- Is growth accelerating?
- When to add capacity?

Quick Win #9: Space Forecast - 15 min implementation
2026-01-31 06:09:04 +01:00
a525ce0167 feat: integrate schedule and chain commands into TUI menu
All checks were successful
CI/CD / Test (push) Successful in 1m20s
CI/CD / Lint (push) Successful in 1m13s
CI/CD / Integration Tests (push) Successful in 52s
CI/CD / Native Engine Tests (push) Successful in 48s
CI/CD / Build Binary (push) Successful in 44s
CI/CD / Test Release Build (push) Successful in 1m18s
CI/CD / Release Binaries (push) Successful in 11m3s
Added interactive menu options for viewing backup schedules and chains:

- internal/tui/schedule.go: New TUI view for systemd timer schedules
  * Parses systemctl output to show backup timers
  * Displays status, next run time, and last run info
  * Filters backup-related timers (dbbackup, backup)

- internal/tui/chain.go: New TUI view for backup chain relationships
  * Shows full backups and their incremental children
  * Detects incomplete chains (incrementals without full backup)
  * Displays chain statistics (total size, count, timespan)
  * Loads from catalog database

- internal/tui/menu.go: Added menu items #8 and #9
  * View Backup Schedule (systemd timers)
  * View Backup Chain (full → incremental)
  * Connected handleSchedule() and handleChain() methods

This completes TUI integration for Quick Win features #1 and #8.
2026-01-31 06:05:52 +01:00
405b7fbf79 feat: chain command - show backup chain relationships
All checks were successful
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m12s
CI/CD / Integration Tests (push) Successful in 50s
CI/CD / Native Engine Tests (push) Successful in 50s
CI/CD / Build Binary (push) Successful in 47s
CI/CD / Test Release Build (push) Successful in 1m16s
CI/CD / Release Binaries (push) Has been skipped
- Add 'dbbackup chain' command to visualize backup dependencies
- Display full backup → incremental backup relationships
- Show backup sequence and timeline
- Calculate total chain size and duration
- Detect incomplete chains (incrementals without full backup)
- Support --all flag to show all database chains
- Support --verbose for detailed metadata
- Support --format json for automation
- Provides restore guidance (which backups are needed)
- Warns about orphaned incremental backups

Quick Win #8 from TODO list
2026-01-31 05:58:47 +01:00
767c1cafa1 feat: schedule command - show systemd timer schedules
Some checks failed
CI/CD / Test (push) Successful in 1m17s
CI/CD / Lint (push) Successful in 1m12s
CI/CD / Native Engine Tests (push) Has been cancelled
CI/CD / Build Binary (push) Has been cancelled
CI/CD / Test Release Build (push) Has been cancelled
CI/CD / Release Binaries (push) Has been cancelled
CI/CD / Integration Tests (push) Has been cancelled
- Add 'dbbackup schedule' command to show backup schedules
- Query systemd timers for next run times
- Display last run time and duration
- Show time remaining until next backup
- Support --timer flag to show specific timer
- Support --all flag to show all system timers
- Support --format json for automation
- Automatically filters backup-related timers
- Works with dbbackup-databases, etc-backup, and custom timers

Quick Win #1 from TODO list - verified on production (mysql01)
2026-01-31 05:56:13 +01:00
b1eb8fe294 refactor: clean up version output - remove ASCII boxes
All checks were successful
CI/CD / Test (push) Successful in 1m18s
CI/CD / Lint (push) Successful in 1m12s
CI/CD / Integration Tests (push) Successful in 50s
CI/CD / Native Engine Tests (push) Successful in 47s
CI/CD / Build Binary (push) Successful in 45s
CI/CD / Test Release Build (push) Successful in 1m16s
CI/CD / Release Binaries (push) Has been skipped
Replace fancy box characters with clean line separators for better readability and terminal compatibility
2026-01-31 05:48:17 +01:00
f3a339d517 feat: catalog prune command - remove old/missing/failed entries
Some checks failed
CI/CD / Test (push) Has been cancelled
CI/CD / Integration Tests (push) Has been cancelled
CI/CD / Native Engine Tests (push) Has been cancelled
CI/CD / Lint (push) Has been cancelled
CI/CD / Build Binary (push) Has been cancelled
CI/CD / Test Release Build (push) Has been cancelled
CI/CD / Release Binaries (push) Has been cancelled
- Add 'dbbackup catalog prune' subcommand
- Support --missing flag to remove entries for deleted backup files
- Support --older-than duration (90d, 6m, 1y) for retention cleanup
- Support --status flag to remove failed/corrupted entries
- Add --dry-run flag to preview changes without deleting
- Add --database filter for targeted pruning
- Display detailed results with space freed estimates
- Implement PruneAdvanced() in catalog package
- Add parseDuration() helper for flexible time parsing

Quick Win #2 from TODO list
2026-01-31 05:45:23 +01:00
11 changed files with 2485 additions and 49 deletions

View File

@ -178,6 +178,35 @@ Examples:
RunE: runCatalogInfo,
}
var catalogPruneCmd = &cobra.Command{
Use: "prune",
Short: "Remove old or invalid entries from catalog",
Long: `Clean up the catalog by removing entries that meet specified criteria.
This command can remove:
- Entries for backups that no longer exist on disk
- Entries older than a specified retention period
- Failed or corrupted backups
- Entries marked as deleted
Examples:
# Remove entries for missing backup files
dbbackup catalog prune --missing
# Remove entries older than 90 days
dbbackup catalog prune --older-than 90d
# Remove failed backups
dbbackup catalog prune --status failed
# Dry run (preview without deleting)
dbbackup catalog prune --missing --dry-run
# Combined: remove missing and old entries
dbbackup catalog prune --missing --older-than 30d`,
RunE: runCatalogPrune,
}
func init() {
rootCmd.AddCommand(catalogCmd)
@ -197,6 +226,7 @@ func init() {
catalogCmd.AddCommand(catalogGapsCmd)
catalogCmd.AddCommand(catalogSearchCmd)
catalogCmd.AddCommand(catalogInfoCmd)
catalogCmd.AddCommand(catalogPruneCmd)
// Sync flags
catalogSyncCmd.Flags().BoolVarP(&catalogVerbose, "verbose", "v", false, "Show detailed output")
@ -221,6 +251,13 @@ func init() {
catalogSearchCmd.Flags().Bool("verified", false, "Only verified backups")
catalogSearchCmd.Flags().Bool("encrypted", false, "Only encrypted backups")
catalogSearchCmd.Flags().Bool("drill-tested", false, "Only drill-tested backups")
// Prune flags
catalogPruneCmd.Flags().Bool("missing", false, "Remove entries for missing backup files")
catalogPruneCmd.Flags().String("older-than", "", "Remove entries older than duration (e.g., 90d, 6m, 1y)")
catalogPruneCmd.Flags().String("status", "", "Remove entries with specific status (failed, corrupted, deleted)")
catalogPruneCmd.Flags().Bool("dry-run", false, "Preview changes without actually deleting")
catalogPruneCmd.Flags().StringVar(&catalogDatabase, "database", "", "Only prune entries for specific database")
}
func getDefaultConfigDir() string {
@ -725,6 +762,146 @@ func runCatalogInfo(cmd *cobra.Command, args []string) error {
return nil
}
func runCatalogPrune(cmd *cobra.Command, args []string) error {
cat, err := openCatalog()
if err != nil {
return err
}
defer cat.Close()
ctx := context.Background()
// Parse flags
missing, _ := cmd.Flags().GetBool("missing")
olderThan, _ := cmd.Flags().GetString("older-than")
status, _ := cmd.Flags().GetString("status")
dryRun, _ := cmd.Flags().GetBool("dry-run")
// Validate that at least one criterion is specified
if !missing && olderThan == "" && status == "" {
return fmt.Errorf("at least one prune criterion must be specified (--missing, --older-than, or --status)")
}
// Parse olderThan duration
var cutoffTime *time.Time
if olderThan != "" {
duration, err := parseDuration(olderThan)
if err != nil {
return fmt.Errorf("invalid duration: %w", err)
}
t := time.Now().Add(-duration)
cutoffTime = &t
}
// Validate status
if status != "" && status != "failed" && status != "corrupted" && status != "deleted" {
return fmt.Errorf("invalid status: %s (must be: failed, corrupted, or deleted)", status)
}
pruneConfig := &catalog.PruneConfig{
CheckMissing: missing,
OlderThan: cutoffTime,
Status: status,
Database: catalogDatabase,
DryRun: dryRun,
}
fmt.Printf("=====================================================\n")
if dryRun {
fmt.Printf(" Catalog Prune (DRY RUN)\n")
} else {
fmt.Printf(" Catalog Prune\n")
}
fmt.Printf("=====================================================\n\n")
if catalogDatabase != "" {
fmt.Printf("[DIR] Database filter: %s\n", catalogDatabase)
}
if missing {
fmt.Printf("[CHK] Checking for missing backup files...\n")
}
if cutoffTime != nil {
fmt.Printf("[TIME] Removing entries older than: %s (%s)\n", cutoffTime.Format("2006-01-02"), olderThan)
}
if status != "" {
fmt.Printf("[LOG] Removing entries with status: %s\n", status)
}
fmt.Println()
result, err := cat.PruneAdvanced(ctx, pruneConfig)
if err != nil {
return err
}
if result.TotalChecked == 0 {
fmt.Printf("[INFO] No entries found matching criteria\n")
return nil
}
// Show results
fmt.Printf("=====================================================\n")
fmt.Printf(" Prune Results\n")
fmt.Printf("=====================================================\n")
fmt.Printf(" [CHK] Checked: %d entries\n", result.TotalChecked)
if dryRun {
fmt.Printf(" [WAIT] Would remove: %d entries\n", result.Removed)
} else {
fmt.Printf(" [DEL] Removed: %d entries\n", result.Removed)
}
fmt.Printf(" [TIME] Duration: %.2fs\n", result.Duration)
fmt.Printf("=====================================================\n")
if len(result.Details) > 0 {
fmt.Printf("\nRemoved entries:\n")
for _, detail := range result.Details {
fmt.Printf(" • %s\n", detail)
}
}
if result.SpaceFreed > 0 {
fmt.Printf("\n[SAVE] Estimated space freed: %s\n", catalog.FormatSize(result.SpaceFreed))
}
if dryRun {
fmt.Printf("\n[INFO] This was a dry run. Run without --dry-run to actually delete entries.\n")
}
return nil
}
// parseDuration extends time.ParseDuration to support days, months, years
func parseDuration(s string) (time.Duration, error) {
if len(s) < 2 {
return 0, fmt.Errorf("invalid duration: %s", s)
}
unit := s[len(s)-1]
value := s[:len(s)-1]
var multiplier time.Duration
switch unit {
case 'd': // days
multiplier = 24 * time.Hour
case 'w': // weeks
multiplier = 7 * 24 * time.Hour
case 'm': // months (approximate)
multiplier = 30 * 24 * time.Hour
case 'y': // years (approximate)
multiplier = 365 * 24 * time.Hour
default:
// Try standard time.ParseDuration
return time.ParseDuration(s)
}
var num int
_, err := fmt.Sscanf(value, "%d", &num)
if err != nil {
return 0, fmt.Errorf("invalid duration value: %s", value)
}
return time.Duration(num) * multiplier, nil
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s

298
cmd/chain.go Normal file
View File

@ -0,0 +1,298 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"dbbackup/internal/catalog"
"github.com/spf13/cobra"
)
var chainCmd = &cobra.Command{
Use: "chain [database]",
Short: "Show backup chain (full → incremental)",
Long: `Display the backup chain showing the relationship between full and incremental backups.
This command helps understand:
- Which incremental backups depend on which full backup
- Backup sequence and timeline
- Gaps in the backup chain
- Total size of backup chain
The backup chain is crucial for:
- Point-in-Time Recovery (PITR)
- Understanding restore dependencies
- Identifying orphaned incremental backups
- Planning backup retention
Examples:
# Show chain for specific database
dbbackup chain mydb
# Show all backup chains
dbbackup chain --all
# JSON output for automation
dbbackup chain mydb --format json
# Show detailed chain with metadata
dbbackup chain mydb --verbose`,
Args: cobra.MaximumNArgs(1),
RunE: runChain,
}
var (
chainFormat string
chainAll bool
chainVerbose bool
)
func init() {
rootCmd.AddCommand(chainCmd)
chainCmd.Flags().StringVar(&chainFormat, "format", "table", "Output format (table, json)")
chainCmd.Flags().BoolVar(&chainAll, "all", false, "Show chains for all databases")
chainCmd.Flags().BoolVar(&chainVerbose, "verbose", false, "Show detailed information")
}
type BackupChain struct {
Database string `json:"database"`
FullBackup *catalog.Entry `json:"full_backup"`
Incrementals []*catalog.Entry `json:"incrementals"`
TotalSize int64 `json:"total_size"`
TotalBackups int `json:"total_backups"`
OldestBackup time.Time `json:"oldest_backup"`
NewestBackup time.Time `json:"newest_backup"`
ChainDuration time.Duration `json:"chain_duration"`
Incomplete bool `json:"incomplete"` // true if incrementals without full backup
}
func runChain(cmd *cobra.Command, args []string) error {
cat, err := openCatalog()
if err != nil {
return err
}
defer cat.Close()
ctx := context.Background()
var chains []*BackupChain
if chainAll || len(args) == 0 {
// Get all databases
databases, err := cat.ListDatabases(ctx)
if err != nil {
return err
}
for _, db := range databases {
chain, err := buildBackupChain(ctx, cat, db)
if err != nil {
return err
}
if chain != nil && chain.TotalBackups > 0 {
chains = append(chains, chain)
}
}
if len(chains) == 0 {
fmt.Println("No backup chains found.")
fmt.Println("\nRun 'dbbackup catalog sync <directory>' to import backups into catalog.")
return nil
}
} else {
// Specific database
database := args[0]
chain, err := buildBackupChain(ctx, cat, database)
if err != nil {
return err
}
if chain == nil || chain.TotalBackups == 0 {
fmt.Printf("No backups found for database: %s\n", database)
return nil
}
chains = append(chains, chain)
}
// Output based on format
if chainFormat == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(chains)
}
// Table format
outputChainTable(chains)
return nil
}
func buildBackupChain(ctx context.Context, cat *catalog.SQLiteCatalog, database string) (*BackupChain, error) {
// Query all backups for this database, ordered by creation time
query := &catalog.SearchQuery{
Database: database,
Limit: 1000,
OrderBy: "created_at",
OrderDesc: false,
}
entries, err := cat.Search(ctx, query)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, nil
}
chain := &BackupChain{
Database: database,
Incrementals: []*catalog.Entry{},
}
var totalSize int64
var oldest, newest time.Time
// Find full backups and incrementals
for _, entry := range entries {
totalSize += entry.SizeBytes
if oldest.IsZero() || entry.CreatedAt.Before(oldest) {
oldest = entry.CreatedAt
}
if newest.IsZero() || entry.CreatedAt.After(newest) {
newest = entry.CreatedAt
}
// Check backup type
backupType := entry.BackupType
if backupType == "" {
backupType = "full" // default to full if not specified
}
if backupType == "full" {
// Use most recent full backup as base
if chain.FullBackup == nil || entry.CreatedAt.After(chain.FullBackup.CreatedAt) {
chain.FullBackup = entry
}
} else if backupType == "incremental" {
chain.Incrementals = append(chain.Incrementals, entry)
}
}
chain.TotalSize = totalSize
chain.TotalBackups = len(entries)
chain.OldestBackup = oldest
chain.NewestBackup = newest
if !oldest.IsZero() && !newest.IsZero() {
chain.ChainDuration = newest.Sub(oldest)
}
// Check if incomplete (incrementals without full backup)
if len(chain.Incrementals) > 0 && chain.FullBackup == nil {
chain.Incomplete = true
}
return chain, nil
}
func outputChainTable(chains []*BackupChain) {
fmt.Println()
fmt.Println("Backup Chains")
fmt.Println("=====================================================")
for _, chain := range chains {
fmt.Printf("\n[DIR] %s\n", chain.Database)
if chain.Incomplete {
fmt.Println(" [WARN] INCOMPLETE CHAIN - No full backup found!")
}
if chain.FullBackup != nil {
fmt.Printf(" [BASE] Full Backup:\n")
fmt.Printf(" Created: %s\n", chain.FullBackup.CreatedAt.Format("2006-01-02 15:04:05"))
fmt.Printf(" Size: %s\n", catalog.FormatSize(chain.FullBackup.SizeBytes))
if chainVerbose {
fmt.Printf(" Path: %s\n", chain.FullBackup.BackupPath)
if chain.FullBackup.SHA256 != "" {
fmt.Printf(" SHA256: %s\n", chain.FullBackup.SHA256[:16]+"...")
}
}
}
if len(chain.Incrementals) > 0 {
fmt.Printf("\n [CHAIN] Incremental Backups: %d\n", len(chain.Incrementals))
for i, inc := range chain.Incrementals {
if chainVerbose || i < 5 {
fmt.Printf(" %d. %s - %s\n",
i+1,
inc.CreatedAt.Format("2006-01-02 15:04"),
catalog.FormatSize(inc.SizeBytes))
if chainVerbose && inc.BackupPath != "" {
fmt.Printf(" Path: %s\n", inc.BackupPath)
}
} else if i == 5 {
fmt.Printf(" ... and %d more (use --verbose to show all)\n", len(chain.Incrementals)-5)
break
}
}
} else if chain.FullBackup != nil {
fmt.Printf("\n [INFO] No incremental backups (full backup only)\n")
}
// Summary
fmt.Printf("\n [STATS] Chain Summary:\n")
fmt.Printf(" Total Backups: %d\n", chain.TotalBackups)
fmt.Printf(" Total Size: %s\n", catalog.FormatSize(chain.TotalSize))
if chain.ChainDuration > 0 {
fmt.Printf(" Span: %s (oldest: %s, newest: %s)\n",
formatChainDuration(chain.ChainDuration),
chain.OldestBackup.Format("2006-01-02"),
chain.NewestBackup.Format("2006-01-02"))
}
// Restore info
if chain.FullBackup != nil && len(chain.Incrementals) > 0 {
fmt.Printf("\n [INFO] To restore, you need:\n")
fmt.Printf(" 1. Full backup from %s\n", chain.FullBackup.CreatedAt.Format("2006-01-02"))
fmt.Printf(" 2. All %d incremental backup(s)\n", len(chain.Incrementals))
fmt.Printf(" (Apply in chronological order)\n")
}
}
fmt.Println()
fmt.Println("=====================================================")
fmt.Printf("Total: %d database chain(s)\n", len(chains))
fmt.Println()
// Warnings
incompleteCount := 0
for _, chain := range chains {
if chain.Incomplete {
incompleteCount++
}
}
if incompleteCount > 0 {
fmt.Printf("\n[WARN] %d incomplete chain(s) detected!\n", incompleteCount)
fmt.Println("Incremental backups without a full backup cannot be restored.")
fmt.Println("Run a full backup to establish a new base.")
}
}
func formatChainDuration(d time.Duration) string {
if d < time.Hour {
return fmt.Sprintf("%.0f minutes", d.Minutes())
}
if d < 24*time.Hour {
return fmt.Sprintf("%.1f hours", d.Hours())
}
days := int(d.Hours() / 24)
if days == 1 {
return "1 day"
}
return fmt.Sprintf("%d days", days)
}

443
cmd/forecast.go Normal file
View File

@ -0,0 +1,443 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"math"
"os"
"strings"
"text/tabwriter"
"time"
"dbbackup/internal/catalog"
"github.com/spf13/cobra"
)
var forecastCmd = &cobra.Command{
Use: "forecast [database]",
Short: "Predict future disk space requirements",
Long: `Analyze backup growth patterns and predict future disk space needs.
This command helps with:
- Capacity planning (when will we run out of space?)
- Budget forecasting (how much storage to provision?)
- Growth trend analysis (is growth accelerating?)
- Alert thresholds (when to add capacity?)
Uses historical backup data to calculate:
- Average daily growth rate
- Growth acceleration/deceleration
- Time until space limit reached
- Projected size at future dates
Examples:
# Forecast for specific database
dbbackup forecast mydb
# Forecast all databases
dbbackup forecast --all
# Show projection for 90 days
dbbackup forecast mydb --days 90
# Set capacity limit (alert when approaching)
dbbackup forecast mydb --limit 100GB
# JSON output for automation
dbbackup forecast mydb --format json`,
Args: cobra.MaximumNArgs(1),
RunE: runForecast,
}
var (
forecastFormat string
forecastAll bool
forecastDays int
forecastLimitSize string
)
type ForecastResult struct {
Database string `json:"database"`
CurrentSize int64 `json:"current_size_bytes"`
TotalBackups int `json:"total_backups"`
OldestBackup time.Time `json:"oldest_backup"`
NewestBackup time.Time `json:"newest_backup"`
ObservationPeriod time.Duration `json:"observation_period_seconds"`
DailyGrowthRate float64 `json:"daily_growth_bytes"`
DailyGrowthPct float64 `json:"daily_growth_percent"`
Projections []ForecastProjection `json:"projections"`
TimeToLimit *time.Duration `json:"time_to_limit_seconds,omitempty"`
SizeAtLimit *time.Time `json:"date_reaching_limit,omitempty"`
Confidence string `json:"confidence"` // "high", "medium", "low"
}
type ForecastProjection struct {
Days int `json:"days_from_now"`
Date time.Time `json:"date"`
PredictedSize int64 `json:"predicted_size_bytes"`
Confidence float64 `json:"confidence_percent"`
}
func init() {
rootCmd.AddCommand(forecastCmd)
forecastCmd.Flags().StringVar(&forecastFormat, "format", "table", "Output format (table, json)")
forecastCmd.Flags().BoolVar(&forecastAll, "all", false, "Show forecast for all databases")
forecastCmd.Flags().IntVar(&forecastDays, "days", 90, "Days to project into future")
forecastCmd.Flags().StringVar(&forecastLimitSize, "limit", "", "Capacity limit (e.g., '100GB', '1TB')")
}
func runForecast(cmd *cobra.Command, args []string) error {
cat, err := openCatalog()
if err != nil {
return err
}
defer cat.Close()
ctx := context.Background()
var forecasts []*ForecastResult
if forecastAll || len(args) == 0 {
// Get all databases
databases, err := cat.ListDatabases(ctx)
if err != nil {
return err
}
for _, db := range databases {
forecast, err := calculateForecast(ctx, cat, db)
if err != nil {
return err
}
if forecast != nil {
forecasts = append(forecasts, forecast)
}
}
} else {
database := args[0]
forecast, err := calculateForecast(ctx, cat, database)
if err != nil {
return err
}
if forecast != nil {
forecasts = append(forecasts, forecast)
}
}
if len(forecasts) == 0 {
fmt.Println("No forecast data available.")
fmt.Println("\nRun 'dbbackup catalog sync <directory>' to import backups.")
return nil
}
// Parse limit if provided
var limitBytes int64
if forecastLimitSize != "" {
limitBytes, err = parseSize(forecastLimitSize)
if err != nil {
return fmt.Errorf("invalid limit size: %w", err)
}
}
// Output results
if forecastFormat == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(forecasts)
}
// Table output
for i, forecast := range forecasts {
if i > 0 {
fmt.Println()
}
printForecast(forecast, limitBytes)
}
return nil
}
func calculateForecast(ctx context.Context, cat *catalog.SQLiteCatalog, database string) (*ForecastResult, error) {
// Get all backups for this database
query := &catalog.SearchQuery{
Database: database,
Limit: 1000,
OrderBy: "created_at",
OrderDesc: false,
}
entries, err := cat.Search(ctx, query)
if err != nil {
return nil, err
}
if len(entries) < 2 {
return nil, nil // Need at least 2 backups for growth rate
}
// Calculate metrics
var totalSize int64
oldest := entries[0].CreatedAt
newest := entries[len(entries)-1].CreatedAt
for _, entry := range entries {
totalSize += entry.SizeBytes
}
// Calculate observation period
observationPeriod := newest.Sub(oldest)
if observationPeriod == 0 {
return nil, nil
}
// Calculate daily growth rate
firstSize := entries[0].SizeBytes
lastSize := entries[len(entries)-1].SizeBytes
sizeDelta := float64(lastSize - firstSize)
daysObserved := observationPeriod.Hours() / 24
dailyGrowthRate := sizeDelta / daysObserved
// Calculate daily growth percentage
var dailyGrowthPct float64
if firstSize > 0 {
dailyGrowthPct = (dailyGrowthRate / float64(firstSize)) * 100
}
// Determine confidence based on sample size and consistency
confidence := determineConfidence(entries, dailyGrowthRate)
// Generate projections
projections := make([]ForecastProjection, 0)
projectionDates := []int{7, 30, 60, 90, 180, 365}
if forecastDays > 0 {
// Use user-specified days
projectionDates = []int{forecastDays}
if forecastDays > 30 {
projectionDates = []int{7, 30, forecastDays}
}
}
for _, days := range projectionDates {
if days > 365 && forecastDays == 90 {
continue // Skip longer projections unless explicitly requested
}
predictedSize := lastSize + int64(dailyGrowthRate*float64(days))
if predictedSize < 0 {
predictedSize = 0
}
// Confidence decreases with time
confidencePct := calculateConfidence(days, confidence)
projections = append(projections, ForecastProjection{
Days: days,
Date: newest.Add(time.Duration(days) * 24 * time.Hour),
PredictedSize: predictedSize,
Confidence: confidencePct,
})
}
result := &ForecastResult{
Database: database,
CurrentSize: lastSize,
TotalBackups: len(entries),
OldestBackup: oldest,
NewestBackup: newest,
ObservationPeriod: observationPeriod,
DailyGrowthRate: dailyGrowthRate,
DailyGrowthPct: dailyGrowthPct,
Projections: projections,
Confidence: confidence,
}
return result, nil
}
func determineConfidence(entries []*catalog.Entry, avgGrowth float64) string {
if len(entries) < 5 {
return "low"
}
if len(entries) < 15 {
return "medium"
}
// Calculate variance in growth rates
var variance float64
for i := 1; i < len(entries); i++ {
timeDiff := entries[i].CreatedAt.Sub(entries[i-1].CreatedAt).Hours() / 24
if timeDiff == 0 {
continue
}
sizeDiff := float64(entries[i].SizeBytes - entries[i-1].SizeBytes)
growthRate := sizeDiff / timeDiff
variance += math.Pow(growthRate-avgGrowth, 2)
}
variance /= float64(len(entries) - 1)
stdDev := math.Sqrt(variance)
// If standard deviation is more than 50% of average growth, confidence is low
if stdDev > math.Abs(avgGrowth)*0.5 {
return "medium"
}
return "high"
}
func calculateConfidence(daysAhead int, baseConfidence string) float64 {
var base float64
switch baseConfidence {
case "high":
base = 95.0
case "medium":
base = 75.0
case "low":
base = 50.0
}
// Decay confidence over time (10% per 30 days)
decay := float64(daysAhead) / 30.0 * 10.0
confidence := base - decay
if confidence < 30 {
confidence = 30
}
return confidence
}
func printForecast(f *ForecastResult, limitBytes int64) {
fmt.Printf("[FORECAST] %s\n", f.Database)
fmt.Println(strings.Repeat("=", 60))
fmt.Printf("\n[CURRENT STATE]\n")
fmt.Printf(" Size: %s\n", catalog.FormatSize(f.CurrentSize))
fmt.Printf(" Backups: %d backups\n", f.TotalBackups)
fmt.Printf(" Observed: %s (%.0f days)\n",
formatForecastDuration(f.ObservationPeriod),
f.ObservationPeriod.Hours()/24)
fmt.Printf("\n[GROWTH RATE]\n")
if f.DailyGrowthRate > 0 {
fmt.Printf(" Daily: +%s/day (%.2f%%/day)\n",
catalog.FormatSize(int64(f.DailyGrowthRate)), f.DailyGrowthPct)
fmt.Printf(" Weekly: +%s/week\n", catalog.FormatSize(int64(f.DailyGrowthRate*7)))
fmt.Printf(" Monthly: +%s/month\n", catalog.FormatSize(int64(f.DailyGrowthRate*30)))
fmt.Printf(" Annual: +%s/year\n", catalog.FormatSize(int64(f.DailyGrowthRate*365)))
} else if f.DailyGrowthRate < 0 {
fmt.Printf(" Daily: %s/day (shrinking)\n", catalog.FormatSize(int64(f.DailyGrowthRate)))
} else {
fmt.Printf(" Daily: No growth detected\n")
}
fmt.Printf(" Confidence: %s (%d samples)\n", f.Confidence, f.TotalBackups)
if len(f.Projections) > 0 {
fmt.Printf("\n[PROJECTIONS]\n")
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(w, " Days\tDate\tPredicted Size\tConfidence\n")
fmt.Fprintf(w, " ----\t----\t--------------\t----------\n")
for _, proj := range f.Projections {
fmt.Fprintf(w, " %d\t%s\t%s\t%.0f%%\n",
proj.Days,
proj.Date.Format("2006-01-02"),
catalog.FormatSize(proj.PredictedSize),
proj.Confidence)
}
w.Flush()
}
// Check against limit
if limitBytes > 0 {
fmt.Printf("\n[CAPACITY LIMIT]\n")
fmt.Printf(" Limit: %s\n", catalog.FormatSize(limitBytes))
currentPct := float64(f.CurrentSize) / float64(limitBytes) * 100
fmt.Printf(" Current: %.1f%% used\n", currentPct)
if f.CurrentSize >= limitBytes {
fmt.Printf(" Status: [WARN] LIMIT EXCEEDED\n")
} else if currentPct >= 80 {
fmt.Printf(" Status: [WARN] Approaching limit\n")
} else {
fmt.Printf(" Status: [OK] Within limit\n")
}
// Calculate when we'll hit the limit
if f.DailyGrowthRate > 0 {
remaining := limitBytes - f.CurrentSize
daysToLimit := float64(remaining) / f.DailyGrowthRate
if daysToLimit > 0 && daysToLimit < 1000 {
dateAtLimit := f.NewestBackup.Add(time.Duration(daysToLimit*24) * time.Hour)
fmt.Printf(" Estimated: Limit reached in %.0f days (%s)\n",
daysToLimit, dateAtLimit.Format("2006-01-02"))
if daysToLimit < 30 {
fmt.Printf(" Alert: [CRITICAL] Less than 30 days remaining!\n")
} else if daysToLimit < 90 {
fmt.Printf(" Alert: [WARN] Less than 90 days remaining\n")
}
}
}
}
fmt.Println()
}
func formatForecastDuration(d time.Duration) string {
hours := d.Hours()
if hours < 24 {
return fmt.Sprintf("%.1f hours", hours)
}
days := hours / 24
if days < 7 {
return fmt.Sprintf("%.1f days", days)
}
weeks := days / 7
if weeks < 4 {
return fmt.Sprintf("%.1f weeks", weeks)
}
months := days / 30
if months < 12 {
return fmt.Sprintf("%.1f months", months)
}
years := days / 365
return fmt.Sprintf("%.1f years", years)
}
func parseSize(s string) (int64, error) {
// Simple size parser (supports KB, MB, GB, TB)
s = strings.ToUpper(strings.TrimSpace(s))
var multiplier int64 = 1
var numStr string
if strings.HasSuffix(s, "TB") {
multiplier = 1024 * 1024 * 1024 * 1024
numStr = strings.TrimSuffix(s, "TB")
} else if strings.HasSuffix(s, "GB") {
multiplier = 1024 * 1024 * 1024
numStr = strings.TrimSuffix(s, "GB")
} else if strings.HasSuffix(s, "MB") {
multiplier = 1024 * 1024
numStr = strings.TrimSuffix(s, "MB")
} else if strings.HasSuffix(s, "KB") {
multiplier = 1024
numStr = strings.TrimSuffix(s, "KB")
} else {
numStr = s
}
var num float64
_, err := fmt.Sscanf(numStr, "%f", &num)
if err != nil {
return 0, fmt.Errorf("invalid size format: %s", s)
}
return int64(num * float64(multiplier)), nil
}

278
cmd/schedule.go Normal file
View File

@ -0,0 +1,278 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
)
var scheduleFormat string
var scheduleCmd = &cobra.Command{
Use: "schedule",
Short: "Show scheduled backup times",
Long: `Display information about scheduled backups from systemd timers.
This command queries systemd to show:
- Next scheduled backup time
- Last run time and duration
- Timer status (active/inactive)
- Calendar schedule configuration
Useful for:
- Verifying backup schedules
- Troubleshooting missed backups
- Planning maintenance windows
Examples:
# Show all backup schedules
dbbackup schedule
# JSON output for automation
dbbackup schedule --format json
# Show specific timer
dbbackup schedule --timer dbbackup-databases`,
RunE: runSchedule,
}
var (
scheduleTimer string
scheduleAll bool
)
func init() {
rootCmd.AddCommand(scheduleCmd)
scheduleCmd.Flags().StringVar(&scheduleFormat, "format", "table", "Output format (table, json)")
scheduleCmd.Flags().StringVar(&scheduleTimer, "timer", "", "Show specific timer only")
scheduleCmd.Flags().BoolVar(&scheduleAll, "all", false, "Show all timers (not just dbbackup)")
}
type TimerInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
NextRun string `json:"next_run"`
NextRunTime time.Time `json:"next_run_time,omitempty"`
LastRun string `json:"last_run,omitempty"`
LastRunTime time.Time `json:"last_run_time,omitempty"`
Passed string `json:"passed,omitempty"`
Left string `json:"left,omitempty"`
Active string `json:"active"`
Unit string `json:"unit,omitempty"`
}
func runSchedule(cmd *cobra.Command, args []string) error {
// Check if systemd is available
if runtime.GOOS == "windows" {
return fmt.Errorf("schedule command is only supported on Linux with systemd")
}
// Check if systemctl is available
if _, err := exec.LookPath("systemctl"); err != nil {
return fmt.Errorf("systemctl not found - this command requires systemd")
}
timers, err := getSystemdTimers()
if err != nil {
return err
}
// Filter timers
filtered := filterTimers(timers)
if len(filtered) == 0 {
fmt.Println("No backup timers found.")
fmt.Println("\nTo install dbbackup as a systemd service:")
fmt.Println(" sudo dbbackup install")
return nil
}
// Output based on format
if scheduleFormat == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(filtered)
}
// Table format
outputTimerTable(filtered)
return nil
}
func getSystemdTimers() ([]TimerInfo, error) {
// Run systemctl list-timers --all --no-pager
cmdArgs := []string{"list-timers", "--all", "--no-pager"}
output, err := exec.Command("systemctl", cmdArgs...).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to list timers: %w\nOutput: %s", err, string(output))
}
return parseTimerList(string(output)), nil
}
func parseTimerList(output string) []TimerInfo {
var timers []TimerInfo
lines := strings.Split(output, "\n")
// Skip header and footer
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "NEXT") || strings.HasPrefix(line, "---") {
continue
}
// Parse timer line format:
// NEXT LEFT LAST PASSED UNIT ACTIVATES
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
// Extract timer info
timer := TimerInfo{}
// Check if NEXT field is "n/a" (inactive timer)
if fields[0] == "n/a" {
timer.NextRun = "n/a"
timer.Left = "n/a"
// Shift indices
if len(fields) >= 3 {
timer.Unit = fields[len(fields)-2]
timer.Active = "inactive"
}
} else {
// Active timer - parse dates
nextIdx := 0
unitIdx := -1
// Find indices by looking for recognizable patterns
for i, field := range fields {
if strings.Contains(field, ":") && nextIdx == 0 {
nextIdx = i
} else if strings.HasSuffix(field, ".timer") || strings.HasSuffix(field, ".service") {
unitIdx = i
}
}
// Build timer info
if nextIdx > 0 {
// Combine date and time for NEXT
timer.NextRun = strings.Join(fields[0:nextIdx+1], " ")
}
// Find LEFT (time until next)
var leftIdx int
for i := nextIdx + 1; i < len(fields); i++ {
if fields[i] == "left" {
if i > 0 {
timer.Left = strings.Join(fields[nextIdx+1:i], " ")
}
leftIdx = i
break
}
}
// Find LAST (last run time)
if leftIdx > 0 {
for i := leftIdx + 1; i < len(fields); i++ {
if fields[i] == "ago" {
timer.LastRun = strings.Join(fields[leftIdx+1:i+1], " ")
break
}
}
}
// Unit is usually second to last
if unitIdx > 0 {
timer.Unit = fields[unitIdx]
} else if len(fields) >= 2 {
timer.Unit = fields[len(fields)-2]
}
timer.Active = "active"
}
if timer.Unit != "" {
timers = append(timers, timer)
}
}
return timers
}
func filterTimers(timers []TimerInfo) []TimerInfo {
var filtered []TimerInfo
for _, timer := range timers {
// If specific timer requested
if scheduleTimer != "" {
if strings.Contains(timer.Unit, scheduleTimer) {
filtered = append(filtered, timer)
}
continue
}
// If --all flag, return all
if scheduleAll {
filtered = append(filtered, timer)
continue
}
// Default: filter for backup-related timers
name := strings.ToLower(timer.Unit)
if strings.Contains(name, "backup") ||
strings.Contains(name, "dbbackup") ||
strings.Contains(name, "postgres") ||
strings.Contains(name, "mysql") ||
strings.Contains(name, "mariadb") {
filtered = append(filtered, timer)
}
}
return filtered
}
func outputTimerTable(timers []TimerInfo) {
fmt.Println()
fmt.Println("Scheduled Backups")
fmt.Println("=====================================================")
for _, timer := range timers {
name := timer.Unit
if strings.HasSuffix(name, ".timer") {
name = strings.TrimSuffix(name, ".timer")
}
fmt.Printf("\n[TIMER] %s\n", name)
fmt.Printf(" Status: %s\n", timer.Active)
if timer.Active == "active" && timer.NextRun != "" && timer.NextRun != "n/a" {
fmt.Printf(" Next Run: %s\n", timer.NextRun)
if timer.Left != "" {
fmt.Printf(" Due In: %s\n", timer.Left)
}
} else {
fmt.Printf(" Next Run: Not scheduled (timer inactive)\n")
}
if timer.LastRun != "" && timer.LastRun != "n/a" {
fmt.Printf(" Last Run: %s\n", timer.LastRun)
}
}
fmt.Println()
fmt.Println("=====================================================")
fmt.Printf("Total: %d timer(s)\n", len(timers))
fmt.Println()
if !scheduleAll {
fmt.Println("Tip: Use --all to show all system timers")
}
}

540
cmd/validate.go Normal file
View File

@ -0,0 +1,540 @@
package cmd
import (
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"dbbackup/internal/config"
"github.com/spf13/cobra"
)
var validateCmd = &cobra.Command{
Use: "validate",
Short: "Validate configuration and environment",
Long: `Validate dbbackup configuration file and runtime environment.
This command performs comprehensive validation:
- Configuration file syntax and structure
- Database connection parameters
- Directory paths and permissions
- External tool availability (pg_dump, mysqldump)
- Cloud storage credentials (if configured)
- Encryption setup (if enabled)
- Resource limits and system requirements
- Port accessibility
Helps identify configuration issues before running backups.
Examples:
# Validate default config (.dbbackup.conf)
dbbackup validate
# Validate specific config file
dbbackup validate --config /etc/dbbackup/prod.conf
# Quick validation (skip connectivity tests)
dbbackup validate --quick
# JSON output for automation
dbbackup validate --format json`,
RunE: runValidate,
}
var (
validateFormat string
validateQuick bool
)
type ValidationResult struct {
Valid bool `json:"valid"`
Issues []ValidationIssue `json:"issues"`
Warnings []ValidationIssue `json:"warnings"`
Checks []ValidationCheck `json:"checks"`
Summary string `json:"summary"`
}
type ValidationIssue struct {
Category string `json:"category"`
Description string `json:"description"`
Suggestion string `json:"suggestion,omitempty"`
}
type ValidationCheck struct {
Name string `json:"name"`
Status string `json:"status"` // "pass", "warn", "fail"
Message string `json:"message,omitempty"`
}
func init() {
rootCmd.AddCommand(validateCmd)
validateCmd.Flags().StringVar(&validateFormat, "format", "table", "Output format (table, json)")
validateCmd.Flags().BoolVar(&validateQuick, "quick", false, "Quick validation (skip connectivity tests)")
}
func runValidate(cmd *cobra.Command, args []string) error {
result := &ValidationResult{
Valid: true,
Issues: []ValidationIssue{},
Warnings: []ValidationIssue{},
Checks: []ValidationCheck{},
}
// Validate configuration file
validateConfigFile(cfg, result)
// Validate database settings
validateDatabase(cfg, result)
// Validate paths
validatePaths(cfg, result)
// Validate external tools
validateTools(cfg, result)
// Validate cloud storage (if enabled)
if cfg.CloudEnabled {
validateCloud(cfg, result)
}
// Validate encryption (if enabled)
if cfg.PITREnabled && cfg.WALEncryption {
validateEncryption(cfg, result)
}
// Validate resource limits
validateResources(cfg, result)
// Connectivity tests (unless --quick)
if !validateQuick {
validateConnectivity(cfg, result)
}
// Determine overall validity
result.Valid = len(result.Issues) == 0
// Generate summary
if result.Valid {
if len(result.Warnings) > 0 {
result.Summary = fmt.Sprintf("Configuration valid with %d warning(s)", len(result.Warnings))
} else {
result.Summary = "Configuration valid - all checks passed"
}
} else {
result.Summary = fmt.Sprintf("Configuration invalid - %d issue(s) found", len(result.Issues))
}
// Output results
if validateFormat == "json" {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}
printValidationResult(result)
if !result.Valid {
return fmt.Errorf("validation failed")
}
return nil
}
func validateConfigFile(cfg *config.Config, result *ValidationResult) {
check := ValidationCheck{Name: "Configuration File"}
if cfg.ConfigPath == "" {
check.Status = "warn"
check.Message = "No config file specified (using defaults)"
result.Warnings = append(result.Warnings, ValidationIssue{
Category: "config",
Description: "No configuration file found",
Suggestion: "Run 'dbbackup backup' to create .dbbackup.conf",
})
} else {
if _, err := os.Stat(cfg.ConfigPath); err != nil {
check.Status = "warn"
check.Message = "Config file not found"
result.Warnings = append(result.Warnings, ValidationIssue{
Category: "config",
Description: fmt.Sprintf("Config file not accessible: %s", cfg.ConfigPath),
Suggestion: "Check file path and permissions",
})
} else {
check.Status = "pass"
check.Message = fmt.Sprintf("Loaded from %s", cfg.ConfigPath)
}
}
result.Checks = append(result.Checks, check)
}
func validateDatabase(cfg *config.Config, result *ValidationResult) {
// Database type
check := ValidationCheck{Name: "Database Type"}
if cfg.DatabaseType != "postgres" && cfg.DatabaseType != "mysql" && cfg.DatabaseType != "mariadb" {
check.Status = "fail"
check.Message = fmt.Sprintf("Invalid: %s", cfg.DatabaseType)
result.Issues = append(result.Issues, ValidationIssue{
Category: "database",
Description: fmt.Sprintf("Invalid database type: %s", cfg.DatabaseType),
Suggestion: "Use 'postgres', 'mysql', or 'mariadb'",
})
} else {
check.Status = "pass"
check.Message = cfg.DatabaseType
}
result.Checks = append(result.Checks, check)
// Host
check = ValidationCheck{Name: "Database Host"}
if cfg.Host == "" {
check.Status = "fail"
check.Message = "Not configured"
result.Issues = append(result.Issues, ValidationIssue{
Category: "database",
Description: "Database host not specified",
Suggestion: "Set --host flag or host in config file",
})
} else {
check.Status = "pass"
check.Message = cfg.Host
}
result.Checks = append(result.Checks, check)
// Port
check = ValidationCheck{Name: "Database Port"}
if cfg.Port <= 0 || cfg.Port > 65535 {
check.Status = "fail"
check.Message = fmt.Sprintf("Invalid: %d", cfg.Port)
result.Issues = append(result.Issues, ValidationIssue{
Category: "database",
Description: fmt.Sprintf("Invalid port number: %d", cfg.Port),
Suggestion: "Use valid port (1-65535)",
})
} else {
check.Status = "pass"
check.Message = strconv.Itoa(cfg.Port)
}
result.Checks = append(result.Checks, check)
// User
check = ValidationCheck{Name: "Database User"}
if cfg.User == "" {
check.Status = "warn"
check.Message = "Not configured (using current user)"
result.Warnings = append(result.Warnings, ValidationIssue{
Category: "database",
Description: "Database user not specified",
Suggestion: "Set --user flag or user in config file",
})
} else {
check.Status = "pass"
check.Message = cfg.User
}
result.Checks = append(result.Checks, check)
}
func validatePaths(cfg *config.Config, result *ValidationResult) {
// Backup directory
check := ValidationCheck{Name: "Backup Directory"}
if cfg.BackupDir == "" {
check.Status = "fail"
check.Message = "Not configured"
result.Issues = append(result.Issues, ValidationIssue{
Category: "paths",
Description: "Backup directory not specified",
Suggestion: "Set --backup-dir flag or backup_dir in config",
})
} else {
info, err := os.Stat(cfg.BackupDir)
if err != nil {
check.Status = "warn"
check.Message = "Does not exist (will be created)"
result.Warnings = append(result.Warnings, ValidationIssue{
Category: "paths",
Description: fmt.Sprintf("Backup directory does not exist: %s", cfg.BackupDir),
Suggestion: "Directory will be created automatically",
})
} else if !info.IsDir() {
check.Status = "fail"
check.Message = "Not a directory"
result.Issues = append(result.Issues, ValidationIssue{
Category: "paths",
Description: fmt.Sprintf("Backup path is not a directory: %s", cfg.BackupDir),
Suggestion: "Specify a valid directory path",
})
} else {
// Check write permissions
testFile := filepath.Join(cfg.BackupDir, ".dbbackup-test")
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
check.Status = "fail"
check.Message = "Not writable"
result.Issues = append(result.Issues, ValidationIssue{
Category: "paths",
Description: fmt.Sprintf("Cannot write to backup directory: %s", cfg.BackupDir),
Suggestion: "Check directory permissions",
})
} else {
os.Remove(testFile)
check.Status = "pass"
check.Message = cfg.BackupDir
}
}
}
result.Checks = append(result.Checks, check)
// WAL archive directory (if PITR enabled)
if cfg.PITREnabled {
check = ValidationCheck{Name: "WAL Archive Directory"}
if cfg.WALArchiveDir == "" {
check.Status = "warn"
check.Message = "Not configured"
result.Warnings = append(result.Warnings, ValidationIssue{
Category: "pitr",
Description: "PITR enabled but WAL archive directory not set",
Suggestion: "Set --wal-archive-dir for PITR functionality",
})
} else {
check.Status = "pass"
check.Message = cfg.WALArchiveDir
}
result.Checks = append(result.Checks, check)
}
}
func validateTools(cfg *config.Config, result *ValidationResult) {
// Skip if using native engine
if cfg.UseNativeEngine {
check := ValidationCheck{
Name: "External Tools",
Status: "pass",
Message: "Using native Go engine (no external tools required)",
}
result.Checks = append(result.Checks, check)
return
}
// Check for database tools
var requiredTools []string
if cfg.DatabaseType == "postgres" {
requiredTools = []string{"pg_dump", "pg_restore", "psql"}
} else if cfg.DatabaseType == "mysql" || cfg.DatabaseType == "mariadb" {
requiredTools = []string{"mysqldump", "mysql"}
}
for _, tool := range requiredTools {
check := ValidationCheck{Name: fmt.Sprintf("Tool: %s", tool)}
path, err := exec.LookPath(tool)
if err != nil {
check.Status = "fail"
check.Message = "Not found in PATH"
result.Issues = append(result.Issues, ValidationIssue{
Category: "tools",
Description: fmt.Sprintf("Required tool not found: %s", tool),
Suggestion: fmt.Sprintf("Install %s or use --native flag", tool),
})
} else {
check.Status = "pass"
check.Message = path
}
result.Checks = append(result.Checks, check)
}
}
func validateCloud(cfg *config.Config, result *ValidationResult) {
check := ValidationCheck{Name: "Cloud Storage"}
if cfg.CloudProvider == "" {
check.Status = "fail"
check.Message = "Provider not configured"
result.Issues = append(result.Issues, ValidationIssue{
Category: "cloud",
Description: "Cloud enabled but provider not specified",
Suggestion: "Set --cloud-provider (s3, gcs, azure, minio, b2)",
})
} else {
check.Status = "pass"
check.Message = cfg.CloudProvider
}
result.Checks = append(result.Checks, check)
// Bucket
check = ValidationCheck{Name: "Cloud Bucket"}
if cfg.CloudBucket == "" {
check.Status = "fail"
check.Message = "Not configured"
result.Issues = append(result.Issues, ValidationIssue{
Category: "cloud",
Description: "Cloud bucket/container not specified",
Suggestion: "Set --cloud-bucket",
})
} else {
check.Status = "pass"
check.Message = cfg.CloudBucket
}
result.Checks = append(result.Checks, check)
// Credentials
check = ValidationCheck{Name: "Cloud Credentials"}
if cfg.CloudAccessKey == "" || cfg.CloudSecretKey == "" {
check.Status = "warn"
check.Message = "Credentials not in config (may use env vars)"
result.Warnings = append(result.Warnings, ValidationIssue{
Category: "cloud",
Description: "Cloud credentials not in config file",
Suggestion: "Ensure AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY or similar env vars are set",
})
} else {
check.Status = "pass"
check.Message = "Configured"
}
result.Checks = append(result.Checks, check)
}
func validateEncryption(cfg *config.Config, result *ValidationResult) {
check := ValidationCheck{Name: "Encryption"}
// Check for openssl
if _, err := exec.LookPath("openssl"); err != nil {
check.Status = "fail"
check.Message = "openssl not found"
result.Issues = append(result.Issues, ValidationIssue{
Category: "encryption",
Description: "Encryption enabled but openssl not available",
Suggestion: "Install openssl or disable WAL encryption",
})
} else {
check.Status = "pass"
check.Message = "openssl available"
}
result.Checks = append(result.Checks, check)
}
func validateResources(cfg *config.Config, result *ValidationResult) {
// CPU cores
check := ValidationCheck{Name: "CPU Cores"}
if cfg.MaxCores < 1 {
check.Status = "fail"
check.Message = "Invalid core count"
result.Issues = append(result.Issues, ValidationIssue{
Category: "resources",
Description: "Invalid max cores setting",
Suggestion: "Set --max-cores to positive value",
})
} else {
check.Status = "pass"
check.Message = fmt.Sprintf("%d cores", cfg.MaxCores)
}
result.Checks = append(result.Checks, check)
// Jobs
check = ValidationCheck{Name: "Parallel Jobs"}
if cfg.Jobs < 1 {
check.Status = "fail"
check.Message = "Invalid job count"
result.Issues = append(result.Issues, ValidationIssue{
Category: "resources",
Description: "Invalid jobs setting",
Suggestion: "Set --jobs to positive value",
})
} else if cfg.Jobs > cfg.MaxCores*2 {
check.Status = "warn"
check.Message = fmt.Sprintf("%d jobs (high)", cfg.Jobs)
result.Warnings = append(result.Warnings, ValidationIssue{
Category: "resources",
Description: "Jobs count higher than CPU cores",
Suggestion: "Consider reducing --jobs for better performance",
})
} else {
check.Status = "pass"
check.Message = fmt.Sprintf("%d jobs", cfg.Jobs)
}
result.Checks = append(result.Checks, check)
}
func validateConnectivity(cfg *config.Config, result *ValidationResult) {
check := ValidationCheck{Name: "Database Connectivity"}
// Try to connect to database port
address := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
conn, err := net.DialTimeout("tcp", address, 5*1000000000) // 5 seconds
if err != nil {
check.Status = "fail"
check.Message = fmt.Sprintf("Cannot connect to %s", address)
result.Issues = append(result.Issues, ValidationIssue{
Category: "connectivity",
Description: fmt.Sprintf("Cannot connect to database: %v", err),
Suggestion: "Check host, port, and network connectivity",
})
} else {
conn.Close()
check.Status = "pass"
check.Message = fmt.Sprintf("Connected to %s", address)
}
result.Checks = append(result.Checks, check)
}
func printValidationResult(result *ValidationResult) {
fmt.Println("\n[VALIDATION REPORT]")
fmt.Println(strings.Repeat("=", 60))
// Print checks
fmt.Println("\n[CHECKS]")
for _, check := range result.Checks {
var status string
switch check.Status {
case "pass":
status = "[PASS]"
case "warn":
status = "[WARN]"
case "fail":
status = "[FAIL]"
}
fmt.Printf(" %-25s %s", check.Name+":", status)
if check.Message != "" {
fmt.Printf(" %s", check.Message)
}
fmt.Println()
}
// Print issues
if len(result.Issues) > 0 {
fmt.Println("\n[ISSUES]")
for i, issue := range result.Issues {
fmt.Printf(" %d. [%s] %s\n", i+1, strings.ToUpper(issue.Category), issue.Description)
if issue.Suggestion != "" {
fmt.Printf(" → %s\n", issue.Suggestion)
}
}
}
// Print warnings
if len(result.Warnings) > 0 {
fmt.Println("\n[WARNINGS]")
for i, warning := range result.Warnings {
fmt.Printf(" %d. [%s] %s\n", i+1, strings.ToUpper(warning.Category), warning.Description)
if warning.Suggestion != "" {
fmt.Printf(" → %s\n", warning.Suggestion)
}
}
}
// Print summary
fmt.Println("\n" + strings.Repeat("=", 60))
if result.Valid {
fmt.Printf("[OK] %s\n\n", result.Summary)
} else {
fmt.Printf("[FAIL] %s\n\n", result.Summary)
}
}

View File

@ -45,19 +45,19 @@ func init() {
}
type versionInfo struct {
Version string `json:"version"`
BuildTime string `json:"build_time"`
GitCommit string `json:"git_commit"`
GoVersion string `json:"go_version"`
OS string `json:"os"`
Arch string `json:"arch"`
NumCPU int `json:"num_cpu"`
Version string `json:"version"`
BuildTime string `json:"build_time"`
GitCommit string `json:"git_commit"`
GoVersion string `json:"go_version"`
OS string `json:"os"`
Arch string `json:"arch"`
NumCPU int `json:"num_cpu"`
DatabaseTools map[string]string `json:"database_tools"`
}
func runVersionCmd(cmd *cobra.Command, args []string) {
info := collectVersionInfo()
switch versionOutputFormat {
case "json":
outputVersionJSON(info)
@ -79,7 +79,7 @@ func collectVersionInfo() versionInfo {
NumCPU: runtime.NumCPU(),
DatabaseTools: make(map[string]string),
}
// Check database tools
tools := []struct {
name string
@ -93,14 +93,14 @@ func collectVersionInfo() versionInfo {
{"mysql", "mysql", []string{"--version"}},
{"mariadb-dump", "mariadb-dump", []string{"--version"}},
}
for _, tool := range tools {
version := getToolVersion(tool.command, tool.args)
if version != "" {
info.DatabaseTools[tool.name] = version
}
}
return info
}
@ -110,11 +110,11 @@ func getToolVersion(command string, args []string) string {
if err != nil {
return ""
}
// Parse first line and extract version
line := strings.Split(string(output), "\n")[0]
line = strings.TrimSpace(line)
// Try to extract just the version number
// e.g., "pg_dump (PostgreSQL) 16.1" -> "16.1"
// e.g., "mysqldump Ver 8.0.35" -> "8.0.35"
@ -123,7 +123,7 @@ func getToolVersion(command string, args []string) string {
// Return last part which is usually the version
return parts[len(parts)-1]
}
return line
}
@ -135,34 +135,25 @@ func outputVersionJSON(info versionInfo) {
func outputTable(info versionInfo) {
fmt.Println()
fmt.Println("╔═══════════════════════════════════════════════════════════════╗")
fmt.Println("║ dbbackup Version Info ║")
fmt.Println("╠═══════════════════════════════════════════════════════════════╣")
fmt.Printf("║ %-20s %-40s ║\n", "Version:", info.Version)
fmt.Printf(" %-20s %-40s ║\n", "Build Time:", info.BuildTime)
// Truncate commit if too long
commit := info.GitCommit
if len(commit) > 40 {
commit = commit[:40]
}
fmt.Printf("║ %-20s %-40s ║\n", "Git Commit:", commit)
fmt.Println("╠═══════════════════════════════════════════════════════════════╣")
fmt.Printf("║ %-20s %-40s ║\n", "Go Version:", info.GoVersion)
fmt.Printf("║ %-20s %-40s ║\n", "OS/Arch:", fmt.Sprintf("%s/%s", info.OS, info.Arch))
fmt.Printf("║ %-20s %-40d ║\n", "CPU Cores:", info.NumCPU)
fmt.Println("╠═══════════════════════════════════════════════════════════════╣")
fmt.Println("║ Database Tools ║")
fmt.Println("╟───────────────────────────────────────────────────────────────╢")
if len(info.DatabaseTools) == 0 {
fmt.Println("║ (none detected) ║")
} else {
fmt.Println("dbbackup Version Info")
fmt.Println("=====================================================")
fmt.Printf(" Version: %s\n", info.Version)
fmt.Printf(" Build Time: %s\n", info.BuildTime)
fmt.Printf(" Git Commit: %s\n", info.GitCommit)
fmt.Println()
fmt.Printf(" Go Version: %s\n", info.GoVersion)
fmt.Printf(" OS/Arch: %s/%s\n", info.OS, info.Arch)
fmt.Printf(" CPU Cores: %d\n", info.NumCPU)
if len(info.DatabaseTools) > 0 {
fmt.Println()
fmt.Println("Database Tools")
fmt.Println("-----------------------------------------------------")
for tool, version := range info.DatabaseTools {
fmt.Printf(" %-18s %-41s ║\n", tool+":", version)
fmt.Printf(" %-18s %s\n", tool+":", version)
}
}
fmt.Println("╚═══════════════════════════════════════════════════════════════╝")
fmt.Println("=====================================================")
fmt.Println()
}

153
internal/catalog/prune.go Normal file
View File

@ -0,0 +1,153 @@
package catalog
import (
"context"
"fmt"
"os"
"time"
)
// PruneConfig defines criteria for pruning catalog entries
type PruneConfig struct {
CheckMissing bool // Remove entries for missing backup files
OlderThan *time.Time // Remove entries older than this time
Status string // Remove entries with specific status
Database string // Only prune entries for this database
DryRun bool // Preview without actually deleting
}
// PruneResult contains the results of a prune operation
type PruneResult struct {
TotalChecked int // Total entries checked
Removed int // Number of entries removed
SpaceFreed int64 // Estimated disk space freed (bytes)
Duration float64 // Operation duration in seconds
Details []string // Details of removed entries
}
// PruneAdvanced removes catalog entries matching the specified criteria
func (c *SQLiteCatalog) PruneAdvanced(ctx context.Context, config *PruneConfig) (*PruneResult, error) {
startTime := time.Now()
result := &PruneResult{
Details: []string{},
}
// Build query to find matching entries
query := "SELECT id, database, backup_path, size_bytes, created_at, status FROM backups WHERE 1=1"
args := []interface{}{}
if config.Database != "" {
query += " AND database = ?"
args = append(args, config.Database)
}
if config.Status != "" {
query += " AND status = ?"
args = append(args, config.Status)
}
if config.OlderThan != nil {
query += " AND created_at < ?"
args = append(args, config.OlderThan.Unix())
}
query += " ORDER BY created_at ASC"
rows, err := c.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
idsToRemove := []int64{}
spaceToFree := int64(0)
for rows.Next() {
var id int64
var database, backupPath, status string
var sizeBytes int64
var createdAt int64
if err := rows.Scan(&id, &database, &backupPath, &sizeBytes, &createdAt, &status); err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
result.TotalChecked++
shouldRemove := false
reason := ""
// Check if file is missing (if requested)
if config.CheckMissing {
if _, err := os.Stat(backupPath); os.IsNotExist(err) {
shouldRemove = true
reason = "missing file"
}
}
// Check if older than cutoff (already filtered in query, but double-check)
if config.OlderThan != nil && time.Unix(createdAt, 0).Before(*config.OlderThan) {
if !shouldRemove {
shouldRemove = true
reason = fmt.Sprintf("older than %s", config.OlderThan.Format("2006-01-02"))
}
}
// Check status (already filtered in query)
if config.Status != "" && status == config.Status {
if !shouldRemove {
shouldRemove = true
reason = fmt.Sprintf("status: %s", status)
}
}
if shouldRemove {
idsToRemove = append(idsToRemove, id)
spaceToFree += sizeBytes
createdTime := time.Unix(createdAt, 0)
detail := fmt.Sprintf("%s - %s (created %s) - %s",
database,
backupPath,
createdTime.Format("2006-01-02"),
reason)
result.Details = append(result.Details, detail)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("row iteration failed: %w", err)
}
// Actually delete entries if not dry run
if !config.DryRun && len(idsToRemove) > 0 {
// Use transaction for safety
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin transaction failed: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, "DELETE FROM backups WHERE id = ?")
if err != nil {
return nil, fmt.Errorf("prepare delete statement failed: %w", err)
}
defer stmt.Close()
for _, id := range idsToRemove {
if _, err := stmt.ExecContext(ctx, id); err != nil {
return nil, fmt.Errorf("delete failed for id %d: %w", id, err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit transaction failed: %w", err)
}
}
result.Removed = len(idsToRemove)
result.SpaceFreed = spaceToFree
result.Duration = time.Since(startTime).Seconds()
return result, nil
}

278
internal/tui/chain.go Normal file
View File

@ -0,0 +1,278 @@
package tui
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"dbbackup/internal/catalog"
"dbbackup/internal/config"
"dbbackup/internal/logger"
)
// ChainView displays backup chain relationships
type ChainView struct {
config *config.Config
logger logger.Logger
parent tea.Model
chains []*BackupChain
loading bool
error string
quitting bool
}
type BackupChain struct {
Database string
FullBackup *catalog.Entry
Incrementals []*catalog.Entry
TotalSize int64
TotalBackups int
OldestBackup time.Time
NewestBackup time.Time
ChainDuration time.Duration
Incomplete bool
}
func NewChainView(cfg *config.Config, log logger.Logger, parent tea.Model) *ChainView {
return &ChainView{
config: cfg,
logger: log,
parent: parent,
loading: true,
}
}
type chainLoadedMsg struct {
chains []*BackupChain
err error
}
func (c *ChainView) Init() tea.Cmd {
return c.loadChains
}
func (c *ChainView) loadChains() tea.Msg {
ctx := context.Background()
// Open catalog - use default path
home, _ := os.UserHomeDir()
catalogPath := filepath.Join(home, ".dbbackup", "catalog.db")
cat, err := catalog.NewSQLiteCatalog(catalogPath)
if err != nil {
return chainLoadedMsg{err: fmt.Errorf("failed to open catalog: %w", err)}
}
defer cat.Close()
// Get all databases
databases, err := cat.ListDatabases(ctx)
if err != nil {
return chainLoadedMsg{err: fmt.Errorf("failed to list databases: %w", err)}
}
var chains []*BackupChain
for _, db := range databases {
chain, err := buildBackupChain(ctx, cat, db)
if err != nil {
return chainLoadedMsg{err: fmt.Errorf("failed to build chain: %w", err)}
}
if chain != nil && chain.TotalBackups > 0 {
chains = append(chains, chain)
}
}
return chainLoadedMsg{chains: chains}
}
func buildBackupChain(ctx context.Context, cat *catalog.SQLiteCatalog, database string) (*BackupChain, error) {
// Query all backups for this database
query := &catalog.SearchQuery{
Database: database,
Limit: 1000,
OrderBy: "created_at",
OrderDesc: false,
}
entries, err := cat.Search(ctx, query)
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, nil
}
chain := &BackupChain{
Database: database,
Incrementals: []*catalog.Entry{},
}
var totalSize int64
var oldest, newest time.Time
for _, entry := range entries {
totalSize += entry.SizeBytes
if oldest.IsZero() || entry.CreatedAt.Before(oldest) {
oldest = entry.CreatedAt
}
if newest.IsZero() || entry.CreatedAt.After(newest) {
newest = entry.CreatedAt
}
backupType := entry.BackupType
if backupType == "" {
backupType = "full"
}
if backupType == "full" {
if chain.FullBackup == nil || entry.CreatedAt.After(chain.FullBackup.CreatedAt) {
chain.FullBackup = entry
}
} else if backupType == "incremental" {
chain.Incrementals = append(chain.Incrementals, entry)
}
}
chain.TotalSize = totalSize
chain.TotalBackups = len(entries)
chain.OldestBackup = oldest
chain.NewestBackup = newest
if !oldest.IsZero() && !newest.IsZero() {
chain.ChainDuration = newest.Sub(oldest)
}
if len(chain.Incrementals) > 0 && chain.FullBackup == nil {
chain.Incomplete = true
}
return chain, nil
}
func (c *ChainView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case chainLoadedMsg:
c.loading = false
if msg.err != nil {
c.error = msg.err.Error()
} else {
c.chains = msg.chains
}
return c, nil
case tea.KeyMsg:
switch msg.String() {
case "q", "esc":
return c.parent, nil
}
}
return c, nil
}
func (c *ChainView) View() string {
if c.quitting {
return ""
}
var b strings.Builder
b.WriteString(titleStyle.Render("Backup Chain"))
b.WriteString("\n\n")
if c.loading {
b.WriteString(infoStyle.Render("Loading backup chains..."))
b.WriteString("\n")
return b.String()
}
if c.error != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("[FAIL] %s", c.error)))
b.WriteString("\n\n")
b.WriteString(infoStyle.Render("Run 'dbbackup catalog sync <directory>' to import backups"))
b.WriteString("\n")
return b.String()
}
if len(c.chains) == 0 {
b.WriteString(infoStyle.Render("No backup chains found"))
b.WriteString("\n\n")
b.WriteString(infoStyle.Render("Run 'dbbackup catalog sync <directory>' to import backups"))
b.WriteString("\n")
return b.String()
}
// Display chains
for i, chain := range c.chains {
if i > 0 {
b.WriteString("\n")
}
b.WriteString(successStyle.Render(fmt.Sprintf("[DIR] %s", chain.Database)))
b.WriteString("\n")
if chain.Incomplete {
b.WriteString(errorStyle.Render(" [WARN] INCOMPLETE - No full backup!"))
b.WriteString("\n")
}
if chain.FullBackup != nil {
b.WriteString(fmt.Sprintf(" [BASE] Full: %s (%s)\n",
chain.FullBackup.CreatedAt.Format("2006-01-02 15:04"),
catalog.FormatSize(chain.FullBackup.SizeBytes)))
}
if len(chain.Incrementals) > 0 {
b.WriteString(fmt.Sprintf(" [CHAIN] %d Incremental(s)\n", len(chain.Incrementals)))
// Show first few
limit := 3
for i, inc := range chain.Incrementals {
if i >= limit {
b.WriteString(fmt.Sprintf(" ... and %d more\n", len(chain.Incrementals)-limit))
break
}
b.WriteString(fmt.Sprintf(" %d. %s (%s)\n",
i+1,
inc.CreatedAt.Format("2006-01-02 15:04"),
catalog.FormatSize(inc.SizeBytes)))
}
}
b.WriteString(fmt.Sprintf(" [STATS] Total: %d backups, %s\n",
chain.TotalBackups,
catalog.FormatSize(chain.TotalSize)))
if chain.ChainDuration > 0 {
b.WriteString(fmt.Sprintf(" [TIME] Span: %s\n", formatChainDuration(chain.ChainDuration)))
}
}
b.WriteString("\n")
b.WriteString(infoStyle.Render(fmt.Sprintf("Total: %d database chain(s)", len(c.chains))))
b.WriteString("\n\n")
b.WriteString(infoStyle.Render("[KEYS] Press q or ESC to return"))
b.WriteString("\n")
return b.String()
}
func formatChainDuration(d time.Duration) string {
if d < time.Hour {
return fmt.Sprintf("%.0f minutes", d.Minutes())
}
if d < 24*time.Hour {
return fmt.Sprintf("%.1f hours", d.Hours())
}
days := int(d.Hours() / 24)
if days == 1 {
return "1 day"
}
return fmt.Sprintf("%d days", days)
}

View File

@ -102,6 +102,8 @@ func NewMenuModel(cfg *config.Config, log logger.Logger) *MenuModel {
"Restore Cluster Backup",
"Diagnose Backup File",
"List & Manage Backups",
"View Backup Schedule",
"View Backup Chain",
"--------------------------------",
"Tools",
"View Active Operations",
@ -277,21 +279,25 @@ func (m *MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleDiagnoseBackup()
case 7: // List & Manage Backups
return m.handleBackupManager()
case 8: // Separator
case 8: // View Backup Schedule
return m.handleSchedule()
case 9: // View Backup Chain
return m.handleChain()
case 10: // Separator
// Do nothing
case 9: // Tools
case 11: // Tools
return m.handleTools()
case 10: // View Active Operations
case 12: // View Active Operations
return m.handleViewOperations()
case 11: // Show Operation History
case 13: // Show Operation History
return m.handleOperationHistory()
case 12: // Database Status
case 14: // Database Status
return m.handleStatus()
case 13: // Settings
case 15: // Settings
return m.handleSettings()
case 14: // Clear History
case 16: // Clear History
m.message = "[DEL] History cleared"
case 15: // Quit
case 17: // Quit
if m.cancel != nil {
m.cancel()
}
@ -449,7 +455,17 @@ func (m *MenuModel) handleDiagnoseBackup() (tea.Model, tea.Cmd) {
browser := NewArchiveBrowser(m.config, m.logger, m, m.ctx, "diagnose")
return browser, browser.Init()
}
// handleSchedule shows backup schedule
func (m *MenuModel) handleSchedule() (tea.Model, tea.Cmd) {
schedule := NewScheduleView(m.config, m.logger, m)
return schedule, schedule.Init()
}
// handleChain shows backup chain
func (m *MenuModel) handleChain() (tea.Model, tea.Cmd) {
chain := NewChainView(m.config, m.logger, m)
return chain, chain.Init()
}
// handleTools opens the tools submenu
func (m *MenuModel) handleTools() (tea.Model, tea.Cmd) {
tools := NewToolsMenu(m.config, m.logger, m, m.ctx)

262
internal/tui/schedule.go Normal file
View File

@ -0,0 +1,262 @@
package tui
import (
"fmt"
"os/exec"
"runtime"
"strings"
tea "github.com/charmbracelet/bubbletea"
"dbbackup/internal/config"
"dbbackup/internal/logger"
)
// ScheduleView displays systemd timer schedules
type ScheduleView struct {
config *config.Config
logger logger.Logger
parent tea.Model
timers []TimerInfo
loading bool
error string
quitting bool
}
type TimerInfo struct {
Name string
NextRun string
Left string
LastRun string
Active string
}
func NewScheduleView(cfg *config.Config, log logger.Logger, parent tea.Model) *ScheduleView {
return &ScheduleView{
config: cfg,
logger: log,
parent: parent,
loading: true,
}
}
type scheduleLoadedMsg struct {
timers []TimerInfo
err error
}
func (s *ScheduleView) Init() tea.Cmd {
return s.loadTimers
}
func (s *ScheduleView) loadTimers() tea.Msg {
// Check if systemd is available
if runtime.GOOS == "windows" {
return scheduleLoadedMsg{err: fmt.Errorf("systemd not available on Windows")}
}
if _, err := exec.LookPath("systemctl"); err != nil {
return scheduleLoadedMsg{err: fmt.Errorf("systemctl not found")}
}
// Run systemctl list-timers
output, err := exec.Command("systemctl", "list-timers", "--all", "--no-pager").CombinedOutput()
if err != nil {
return scheduleLoadedMsg{err: fmt.Errorf("failed to list timers: %w", err)}
}
timers := parseTimerList(string(output))
// Filter for backup-related timers
var filtered []TimerInfo
for _, timer := range timers {
name := strings.ToLower(timer.Name)
if strings.Contains(name, "backup") ||
strings.Contains(name, "dbbackup") ||
strings.Contains(name, "postgres") ||
strings.Contains(name, "mysql") ||
strings.Contains(name, "mariadb") {
filtered = append(filtered, timer)
}
}
return scheduleLoadedMsg{timers: filtered}
}
func parseTimerList(output string) []TimerInfo {
var timers []TimerInfo
lines := strings.Split(output, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "NEXT") || strings.HasPrefix(line, "---") {
continue
}
fields := strings.Fields(line)
if len(fields) < 5 {
continue
}
timer := TimerInfo{}
// Check if NEXT field is "n/a" (inactive timer)
if fields[0] == "n/a" {
timer.NextRun = "n/a"
timer.Left = "n/a"
timer.Active = "inactive"
if len(fields) >= 3 {
timer.Name = fields[len(fields)-2]
}
} else {
// Active timer - parse dates
nextIdx := 0
unitIdx := -1
for i, field := range fields {
if strings.Contains(field, ":") && nextIdx == 0 {
nextIdx = i
} else if strings.HasSuffix(field, ".timer") || strings.HasSuffix(field, ".service") {
unitIdx = i
}
}
if nextIdx > 0 {
timer.NextRun = strings.Join(fields[0:nextIdx+1], " ")
}
// Find LEFT
for i := nextIdx + 1; i < len(fields); i++ {
if fields[i] == "left" {
if i > 0 {
timer.Left = strings.Join(fields[nextIdx+1:i], " ")
}
break
}
}
// Find LAST
for i := 0; i < len(fields); i++ {
if fields[i] == "ago" && i > 0 {
// Reconstruct from fields before "ago"
for j := i - 1; j >= 0; j-- {
if strings.Contains(fields[j], ":") {
timer.LastRun = strings.Join(fields[j:i+1], " ")
break
}
}
break
}
}
if unitIdx > 0 {
timer.Name = fields[unitIdx]
} else if len(fields) >= 2 {
timer.Name = fields[len(fields)-2]
}
timer.Active = "active"
}
if timer.Name != "" {
timers = append(timers, timer)
}
}
return timers
}
func (s *ScheduleView) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case scheduleLoadedMsg:
s.loading = false
if msg.err != nil {
s.error = msg.err.Error()
} else {
s.timers = msg.timers
}
return s, nil
case tea.KeyMsg:
switch msg.String() {
case "q", "esc":
return s.parent, nil
}
}
return s, nil
}
func (s *ScheduleView) View() string {
if s.quitting {
return ""
}
var b strings.Builder
b.WriteString(titleStyle.Render("Backup Schedule"))
b.WriteString("\n\n")
if s.loading {
b.WriteString(infoStyle.Render("Loading systemd timers..."))
b.WriteString("\n")
return b.String()
}
if s.error != "" {
b.WriteString(errorStyle.Render(fmt.Sprintf("[FAIL] %s", s.error)))
b.WriteString("\n\n")
b.WriteString(infoStyle.Render("Note: Schedule feature requires systemd"))
b.WriteString("\n")
return b.String()
}
if len(s.timers) == 0 {
b.WriteString(infoStyle.Render("No backup timers found"))
b.WriteString("\n\n")
b.WriteString(infoStyle.Render("To install dbbackup as systemd service:"))
b.WriteString("\n")
b.WriteString(infoStyle.Render(" sudo dbbackup install"))
b.WriteString("\n")
return b.String()
}
// Display timers
for _, timer := range s.timers {
name := timer.Name
if strings.HasSuffix(name, ".timer") {
name = strings.TrimSuffix(name, ".timer")
}
b.WriteString(successStyle.Render(fmt.Sprintf("[TIMER] %s", name)))
b.WriteString("\n")
statusColor := successStyle
if timer.Active == "inactive" {
statusColor = errorStyle
}
b.WriteString(fmt.Sprintf(" Status: %s\n", statusColor.Render(timer.Active)))
if timer.Active == "active" && timer.NextRun != "" && timer.NextRun != "n/a" {
b.WriteString(fmt.Sprintf(" Next Run: %s\n", infoStyle.Render(timer.NextRun)))
if timer.Left != "" {
b.WriteString(fmt.Sprintf(" Due In: %s\n", infoStyle.Render(timer.Left)))
}
} else {
b.WriteString(fmt.Sprintf(" Next Run: %s\n", errorStyle.Render("Not scheduled (inactive)")))
}
if timer.LastRun != "" && timer.LastRun != "n/a" {
b.WriteString(fmt.Sprintf(" Last Run: %s\n", infoStyle.Render(timer.LastRun)))
}
b.WriteString("\n")
}
b.WriteString(infoStyle.Render(fmt.Sprintf("Total: %d timer(s)", len(s.timers))))
b.WriteString("\n\n")
b.WriteString(infoStyle.Render("[KEYS] Press q or ESC to return"))
b.WriteString("\n")
return b.String()
}

View File

@ -16,7 +16,7 @@ import (
// Build information (set by ldflags)
var (
version = "5.1.0"
version = "5.1.3"
buildTime = "unknown"
gitCommit = "unknown"
)