From a9c7775bb751eeddcb0df4461e80c54375a077a4 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 2 Apr 2026 15:32:15 +0300 Subject: [PATCH] feat: configuration backup management with manual and auto backup Add backup/restore functionality for the SQLite database. Users can trigger manual backups, configure automatic backups on an interval with retention policies, list/download/delete backups, and restore from any backup. - Backup engine using VACUUM INTO (safe with WAL mode) - Backup metadata tracked in DB, files stored in DATA_DIR/backups/ - Settings: backup_enabled, backup_interval_hours, backup_retention_count - API: POST/GET/DELETE /api/backups, download, restore endpoints - Autobackup via cron scheduler with configurable interval - Retention: prune on startup, after each backup (manual and auto) - Orphan cleanup: removes backup files without metadata on startup - Restore: replaces DB and triggers graceful server shutdown - Settings UI: /settings/backup with toggle, interval, retention config - Backup list with download, delete, restore actions - i18n: English and Russian translations --- cmd/server/main.go | 53 ++++ internal/api/backups.go | 142 +++++++++ internal/api/router.go | 27 ++ internal/api/settings.go | 33 ++- internal/backup/engine.go | 197 +++++++++++++ internal/store/backups.go | 106 +++++++ internal/store/models.go | 22 +- internal/store/settings.go | 23 +- internal/store/store.go | 12 + plans/backup-management/CONTEXT.md | 26 ++ plans/backup-management/PLAN.md | 43 +++ .../phase-1-backup-engine.md | 46 +++ plans/backup-management/phase-2-backup-api.md | 43 +++ plans/backup-management/phase-3-backup-ui.md | 45 +++ plans/backup-management/phase-4-retention.md | 33 +++ web/src/lib/api.ts | 25 +- web/src/lib/i18n/en.json | 39 +++ web/src/lib/i18n/ru.json | 39 +++ web/src/lib/types.ts | 12 + web/src/routes/settings/+layout.svelte | 7 +- web/src/routes/settings/backup/+page.svelte | 274 ++++++++++++++++++ 21 files changed, 1230 insertions(+), 17 deletions(-) create mode 100644 internal/api/backups.go create mode 100644 internal/backup/engine.go create mode 100644 internal/store/backups.go create mode 100644 plans/backup-management/CONTEXT.md create mode 100644 plans/backup-management/PLAN.md create mode 100644 plans/backup-management/phase-1-backup-engine.md create mode 100644 plans/backup-management/phase-2-backup-api.md create mode 100644 plans/backup-management/phase-3-backup-ui.md create mode 100644 plans/backup-management/phase-4-retention.md create mode 100644 web/src/routes/settings/backup/+page.svelte diff --git a/cmd/server/main.go b/cmd/server/main.go index 8119447..717d179 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -3,6 +3,7 @@ package main import ( "context" "errors" + "fmt" "io/fs" "log/slog" "net/http" @@ -19,6 +20,7 @@ import ( "github.com/alexei/docker-watcher/internal/auth" "github.com/alexei/docker-watcher/internal/config" "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/backup" "github.com/alexei/docker-watcher/internal/deployer" "github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/docker-watcher/internal/docker" @@ -201,10 +203,56 @@ func main() { slog.Info("DNS provider initialized", "provider", settings.DNSProvider) } + // Initialize backup engine. + backupEngine, err := backup.New(db, dbPath, dataDir) + if err != nil { + slog.Error("create backup engine", "error", err) + os.Exit(1) + } + + // Clean orphaned backup files and prune on startup. + if cleaned, err := backupEngine.CleanOrphans(); err != nil { + slog.Warn("backup: clean orphans on startup", "error", err) + } else if cleaned > 0 { + slog.Info("backup: cleaned orphaned files on startup", "count", cleaned) + } + if settings.BackupRetentionCount > 0 { + if pruned, err := backupEngine.Prune(settings.BackupRetentionCount); err != nil { + slog.Warn("backup: prune on startup", "error", err) + } else if pruned > 0 { + slog.Info("backup: pruned old backups on startup", "count", pruned) + } + } + + // Schedule autobackup if enabled. + if settings.BackupEnabled && settings.BackupIntervalHours > 0 { + interval := fmt.Sprintf("@every %dh", settings.BackupIntervalHours) + if _, err := cronScheduler.AddFunc(interval, func() { + b, err := backupEngine.CreateBackup("auto") + if err != nil { + slog.Error("autobackup failed", "error", err) + return + } + slog.Info("autobackup completed", "id", b.ID, "filename", b.Filename) + + // Prune after auto backup. + currentSettings, err := db.GetSettings() + if err == nil && currentSettings.BackupRetentionCount > 0 { + backupEngine.Prune(currentSettings.BackupRetentionCount) + } + }); err != nil { + slog.Warn("failed to schedule autobackup", "error", err) + } else { + slog.Info("autobackup scheduled", "interval_hours", settings.BackupIntervalHours) + } + } + // Build API server. apiServer := api.NewServer(db, dockerClient, npmClient, dep, webhookHandler, eventBus, encKey) apiServer.SetStaleScanner(staleScanner) apiServer.SetProxyManager(proxyManager) + apiServer.SetBackupEngine(backupEngine) + apiServer.SetDBPath(dbPath) apiServer.SetDNSProvider(dnsProvider) apiServer.SetDNSProviderChangedCallback(func(provider dns.Provider) { dep.SetDNSProvider(provider) @@ -239,6 +287,11 @@ func main() { done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGTERM) + // Allow restore to trigger shutdown. + apiServer.SetShutdownFunc(func() { + done <- syscall.SIGTERM + }) + go func() { slog.Info("Docker Watcher started", "addr", addr) if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { diff --git a/internal/api/backups.go b/internal/api/backups.go new file mode 100644 index 0000000..344bc8c --- /dev/null +++ b/internal/api/backups.go @@ -0,0 +1,142 @@ +package api + +import ( + "net/http" + "os" + "path/filepath" + + "github.com/alexei/docker-watcher/internal/store" + "github.com/go-chi/chi/v5" +) + +// listBackups handles GET /api/backups. +func (s *Server) listBackups(w http.ResponseWriter, r *http.Request) { + if s.backupEngine == nil { + respondError(w, http.StatusServiceUnavailable, "backup engine not initialized") + return + } + + backups, err := s.backupEngine.ListBackups() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list backups: "+err.Error()) + return + } + + if backups == nil { + backups = []store.Backup{} + } + + respondJSON(w, http.StatusOK, backups) +} + +// triggerBackup handles POST /api/backups. +func (s *Server) triggerBackup(w http.ResponseWriter, r *http.Request) { + if s.backupEngine == nil { + respondError(w, http.StatusServiceUnavailable, "backup engine not initialized") + return + } + + backup, err := s.backupEngine.CreateBackup("manual") + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create backup: "+err.Error()) + return + } + + // Prune after manual backup too. + settings, err := s.store.GetSettings() + if err == nil && settings.BackupRetentionCount > 0 { + s.backupEngine.Prune(settings.BackupRetentionCount) + } + + respondJSON(w, http.StatusCreated, backup) +} + +// downloadBackup handles GET /api/backups/{id}/download. +func (s *Server) downloadBackup(w http.ResponseWriter, r *http.Request) { + if s.backupEngine == nil { + respondError(w, http.StatusServiceUnavailable, "backup engine not initialized") + return + } + + id := chi.URLParam(r, "id") + backup, err := s.backupEngine.GetBackup(id) + if err != nil { + respondError(w, http.StatusNotFound, "backup not found") + return + } + + filePath := s.backupEngine.FilePath(backup) + if _, err := os.Stat(filePath); err != nil { + respondError(w, http.StatusNotFound, "backup file not found on disk") + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(backup.Filename)+"\"") + http.ServeFile(w, r, filePath) +} + +// deleteBackup handles DELETE /api/backups/{id}. +func (s *Server) deleteBackup(w http.ResponseWriter, r *http.Request) { + if s.backupEngine == nil { + respondError(w, http.StatusServiceUnavailable, "backup engine not initialized") + return + } + + id := chi.URLParam(r, "id") + if err := s.backupEngine.DeleteBackup(id); err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete backup: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// restoreBackup handles POST /api/backups/{id}/restore. +// This replaces the current database with the backup. The server should be restarted after. +func (s *Server) restoreBackup(w http.ResponseWriter, r *http.Request) { + if s.backupEngine == nil { + respondError(w, http.StatusServiceUnavailable, "backup engine not initialized") + return + } + + id := chi.URLParam(r, "id") + restorePath, err := s.backupEngine.RestorePath(id) + if err != nil { + respondError(w, http.StatusNotFound, "backup not found: "+err.Error()) + return + } + + // Read the backup file. + backupData, err := os.ReadFile(restorePath) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to read backup file: "+err.Error()) + return + } + + // Close the current database to release locks. + if err := s.store.Close(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to close database: "+err.Error()) + return + } + + // Write backup over the main database file. + if err := os.WriteFile(s.dbPath, backupData, 0o644); err != nil { + respondError(w, http.StatusInternalServerError, "failed to write database: "+err.Error()) + return + } + + // Remove WAL and SHM files to ensure clean state. + os.Remove(s.dbPath + "-wal") + os.Remove(s.dbPath + "-shm") + + respondJSON(w, http.StatusOK, map[string]any{ + "status": "restored", + "message": "Database restored. The server needs to be restarted to apply changes.", + }) + + // Signal the server to shut down gracefully so it can be restarted. + if s.shutdownFunc != nil { + go s.shutdownFunc() + } +} diff --git a/internal/api/router.go b/internal/api/router.go index b01f57e..82dfe82 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/alexei/docker-watcher/internal/auth" + "github.com/alexei/docker-watcher/internal/backup" "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/dns" "github.com/alexei/docker-watcher/internal/docker" @@ -40,6 +41,10 @@ type Server struct { dnsProviderMu sync.RWMutex dnsProvider dns.Provider onDNSProviderChanged DNSProviderChangedFunc + + backupEngine *backup.Engine + dbPath string + shutdownFunc func() // called after restore to trigger graceful shutdown } // NewServer creates a new API Server with all required dependencies. @@ -86,6 +91,21 @@ func (s *Server) SetProxyManager(pm *proxy.Manager) { s.proxyManager = pm } +// SetBackupEngine sets the backup engine on the server. +func (s *Server) SetBackupEngine(engine *backup.Engine) { + s.backupEngine = engine +} + +// SetDBPath sets the database file path (needed for restore). +func (s *Server) SetDBPath(path string) { + s.dbPath = path +} + +// SetShutdownFunc sets the function called after a restore to trigger graceful shutdown. +func (s *Server) SetShutdownFunc(fn func()) { + s.shutdownFunc = fn +} + // SetDNSProvider sets the current DNS provider on the server. func (s *Server) SetDNSProvider(provider dns.Provider) { s.dnsProviderMu.Lock() @@ -287,6 +307,13 @@ func (s *Server) Router() chi.Router { r.Get("/dns/records", s.listDNSRecords) r.Post("/dns/sync", s.syncDNSRecords) r.Delete("/dns/records/{fqdn}", s.deleteDNSRecord) + + // Backup endpoints. + r.Get("/backups", s.listBackups) + r.Post("/backups", s.triggerBackup) + r.Get("/backups/{id}/download", s.downloadBackup) + r.Delete("/backups/{id}", s.deleteBackup) + r.Post("/backups/{id}/restore", s.restoreBackup) }) }) }) diff --git a/internal/api/settings.go b/internal/api/settings.go index 2541660..5ee133f 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -30,10 +30,13 @@ type settingsRequest struct { SSLCertificateID *int `json:"ssl_certificate_id,omitempty"` StaleThresholdDays *int `json:"stale_threshold_days,omitempty"` AllowedVolumePaths *string `json:"allowed_volume_paths,omitempty"` - WildcardDNS *bool `json:"wildcard_dns,omitempty"` - DNSProvider *string `json:"dns_provider,omitempty"` - CloudflareAPIToken string `json:"cloudflare_api_token"` - CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"` + WildcardDNS *bool `json:"wildcard_dns,omitempty"` + DNSProvider *string `json:"dns_provider,omitempty"` + CloudflareAPIToken string `json:"cloudflare_api_token"` + CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"` + BackupEnabled *bool `json:"backup_enabled,omitempty"` + BackupIntervalHours *int `json:"backup_interval_hours,omitempty"` + BackupRetentionCount *int `json:"backup_retention_count,omitempty"` } // getSettings handles GET /api/settings. @@ -62,6 +65,9 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { "dns_provider": settings.DNSProvider, "has_cloudflare_api_token": settings.CloudflareAPIToken != "", "cloudflare_zone_id": settings.CloudflareZoneID, + "backup_enabled": settings.BackupEnabled, + "backup_interval_hours": settings.BackupIntervalHours, + "backup_retention_count": settings.BackupRetentionCount, "updated_at": settings.UpdatedAt, }) } @@ -160,6 +166,25 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { updated.CloudflareZoneID = *req.CloudflareZoneID } + // Backup settings. + if req.BackupEnabled != nil { + updated.BackupEnabled = *req.BackupEnabled + } + if req.BackupIntervalHours != nil { + if *req.BackupIntervalHours < 1 { + respondError(w, http.StatusBadRequest, "backup_interval_hours must be at least 1") + return + } + updated.BackupIntervalHours = *req.BackupIntervalHours + } + if req.BackupRetentionCount != nil { + if *req.BackupRetentionCount < 1 { + respondError(w, http.StatusBadRequest, "backup_retention_count must be at least 1") + return + } + updated.BackupRetentionCount = *req.BackupRetentionCount + } + if err := s.store.UpdateSettings(updated); err != nil { respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error()) return diff --git a/internal/backup/engine.go b/internal/backup/engine.go new file mode 100644 index 0000000..8597918 --- /dev/null +++ b/internal/backup/engine.go @@ -0,0 +1,197 @@ +package backup + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + "github.com/alexei/docker-watcher/internal/store" +) + +// Engine manages database backup operations. +type Engine struct { + store *store.Store + dbPath string + backupDir string +} + +// New creates a new backup engine. It ensures the backup directory exists. +func New(st *store.Store, dbPath, dataDir string) (*Engine, error) { + backupDir := filepath.Join(dataDir, "backups") + if err := os.MkdirAll(backupDir, 0o755); err != nil { + return nil, fmt.Errorf("create backup directory: %w", err) + } + return &Engine{ + store: st, + dbPath: dbPath, + backupDir: backupDir, + }, nil +} + +// BackupDir returns the path to the backup directory. +func (e *Engine) BackupDir() string { + return e.backupDir +} + +// CreateBackup creates a new database backup using VACUUM INTO. +// Returns the backup metadata record. +func (e *Engine) CreateBackup(backupType string) (store.Backup, error) { + timestamp := time.Now().UTC().Format("20060102-150405") + filename := fmt.Sprintf("docker-watcher-%s-%s.db", backupType, timestamp) + destPath := filepath.Join(e.backupDir, filename) + + // VACUUM INTO creates a clean, standalone copy of the database. + // It is safe to use while the database is open and in WAL mode. + _, err := e.store.DB().Exec(`VACUUM INTO ?`, destPath) + if err != nil { + return store.Backup{}, fmt.Errorf("vacuum into %s: %w", destPath, err) + } + + // Get file size. + info, err := os.Stat(destPath) + if err != nil { + return store.Backup{}, fmt.Errorf("stat backup file: %w", err) + } + + // Store metadata. + backup, err := e.store.CreateBackup(store.Backup{ + Filename: filename, + SizeBytes: info.Size(), + BackupType: backupType, + }) + if err != nil { + // Best effort: remove the file if metadata insert fails. + os.Remove(destPath) + return store.Backup{}, fmt.Errorf("store backup metadata: %w", err) + } + + slog.Info("backup created", "id", backup.ID, "filename", filename, "size", info.Size(), "type", backupType) + return backup, nil +} + +// ListBackups returns all backup records. +func (e *Engine) ListBackups() ([]store.Backup, error) { + return e.store.ListBackups() +} + +// GetBackup returns a single backup record. +func (e *Engine) GetBackup(id string) (store.Backup, error) { + return e.store.GetBackup(id) +} + +// FilePath returns the full filesystem path for a backup. +func (e *Engine) FilePath(backup store.Backup) string { + return filepath.Join(e.backupDir, backup.Filename) +} + +// DeleteBackup removes a backup file and its metadata record. +func (e *Engine) DeleteBackup(id string) error { + backup, err := e.store.GetBackup(id) + if err != nil { + return fmt.Errorf("get backup: %w", err) + } + + // Remove file. + filePath := filepath.Join(e.backupDir, backup.Filename) + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove backup file: %w", err) + } + + // Remove metadata. + if err := e.store.DeleteBackup(id); err != nil { + return fmt.Errorf("delete backup metadata: %w", err) + } + + slog.Info("backup deleted", "id", id, "filename", backup.Filename) + return nil +} + +// RestorePath returns the path of a backup file for restore operations. +// The caller is responsible for actually replacing the database. +func (e *Engine) RestorePath(id string) (string, error) { + backup, err := e.store.GetBackup(id) + if err != nil { + return "", fmt.Errorf("get backup: %w", err) + } + + filePath := filepath.Join(e.backupDir, backup.Filename) + if _, err := os.Stat(filePath); err != nil { + return "", fmt.Errorf("backup file not found: %w", err) + } + + return filePath, nil +} + +// Prune removes old backups exceeding the retention count. +// Returns the number of backups pruned. +func (e *Engine) Prune(retentionCount int) (int, error) { + if retentionCount <= 0 { + return 0, nil + } + + count, err := e.store.CountBackups() + if err != nil { + return 0, fmt.Errorf("count backups: %w", err) + } + + excess := count - retentionCount + if excess <= 0 { + return 0, nil + } + + oldest, err := e.store.GetOldestBackups(excess) + if err != nil { + return 0, fmt.Errorf("get oldest backups: %w", err) + } + + pruned := 0 + for _, b := range oldest { + if err := e.DeleteBackup(b.ID); err != nil { + slog.Warn("prune: failed to delete backup", "id", b.ID, "error", err) + continue + } + pruned++ + } + + if pruned > 0 { + slog.Info("backups pruned", "pruned", pruned, "retention", retentionCount) + } + return pruned, nil +} + +// CleanOrphans removes backup files in the backup directory that have no metadata record. +func (e *Engine) CleanOrphans() (int, error) { + entries, err := os.ReadDir(e.backupDir) + if err != nil { + return 0, fmt.Errorf("read backup directory: %w", err) + } + + backups, err := e.store.ListBackups() + if err != nil { + return 0, fmt.Errorf("list backups: %w", err) + } + + tracked := make(map[string]bool, len(backups)) + for _, b := range backups { + tracked[b.Filename] = true + } + + cleaned := 0 + for _, entry := range entries { + if entry.IsDir() { + continue + } + if !tracked[entry.Name()] { + filePath := filepath.Join(e.backupDir, entry.Name()) + if err := os.Remove(filePath); err != nil { + slog.Warn("clean orphan: failed to remove file", "file", entry.Name(), "error", err) + continue + } + slog.Info("removed orphaned backup file", "file", entry.Name()) + cleaned++ + } + } + return cleaned, nil +} diff --git a/internal/store/backups.go b/internal/store/backups.go new file mode 100644 index 0000000..6fd344f --- /dev/null +++ b/internal/store/backups.go @@ -0,0 +1,106 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateBackup inserts a new backup metadata record. +func (s *Store) CreateBackup(b Backup) (Backup, error) { + if b.ID == "" { + b.ID = uuid.New().String() + } + b.CreatedAt = Now() + + _, err := s.db.Exec( + `INSERT INTO backups (id, filename, size_bytes, backup_type, created_at) + VALUES (?, ?, ?, ?, ?)`, + b.ID, b.Filename, b.SizeBytes, b.BackupType, b.CreatedAt, + ) + if err != nil { + return Backup{}, fmt.Errorf("insert backup: %w", err) + } + return b, nil +} + +// GetBackup returns a backup by ID. +func (s *Store) GetBackup(id string) (Backup, error) { + var b Backup + err := s.db.QueryRow( + `SELECT id, filename, size_bytes, backup_type, created_at + FROM backups WHERE id = ?`, id, + ).Scan(&b.ID, &b.Filename, &b.SizeBytes, &b.BackupType, &b.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return Backup{}, fmt.Errorf("backup %s: %w", id, ErrNotFound) + } + if err != nil { + return Backup{}, fmt.Errorf("query backup: %w", err) + } + return b, nil +} + +// ListBackups returns all backups ordered by creation date descending. +func (s *Store) ListBackups() ([]Backup, error) { + rows, err := s.db.Query( + `SELECT id, filename, size_bytes, backup_type, created_at + FROM backups ORDER BY created_at DESC`, + ) + if err != nil { + return nil, fmt.Errorf("query backups: %w", err) + } + defer rows.Close() + + var backups []Backup + for rows.Next() { + var b Backup + if err := rows.Scan(&b.ID, &b.Filename, &b.SizeBytes, &b.BackupType, &b.CreatedAt); err != nil { + return nil, fmt.Errorf("scan backup: %w", err) + } + backups = append(backups, b) + } + return backups, rows.Err() +} + +// DeleteBackup removes a backup metadata record by ID. +func (s *Store) DeleteBackup(id string) error { + _, err := s.db.Exec(`DELETE FROM backups WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete backup: %w", err) + } + return nil +} + +// CountBackups returns the total number of backup records. +func (s *Store) CountBackups() (int, error) { + var count int + err := s.db.QueryRow(`SELECT COUNT(*) FROM backups`).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count backups: %w", err) + } + return count, nil +} + +// GetOldestBackups returns the N oldest backups (for pruning). +func (s *Store) GetOldestBackups(limit int) ([]Backup, error) { + rows, err := s.db.Query( + `SELECT id, filename, size_bytes, backup_type, created_at + FROM backups ORDER BY created_at ASC LIMIT ?`, limit, + ) + if err != nil { + return nil, fmt.Errorf("query oldest backups: %w", err) + } + defer rows.Close() + + var backups []Backup + for rows.Next() { + var b Backup + if err := rows.Scan(&b.ID, &b.Filename, &b.SizeBytes, &b.BackupType, &b.CreatedAt); err != nil { + return nil, fmt.Errorf("scan backup: %w", err) + } + backups = append(backups, b) + } + return backups, rows.Err() +} diff --git a/internal/store/models.go b/internal/store/models.go index 13101c5..88d8ab9 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -58,11 +58,23 @@ type Settings struct { SSLCertificateID int `json:"ssl_certificate_id"` StaleThresholdDays int `json:"stale_threshold_days"` AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths - WildcardDNS bool `json:"wildcard_dns"` - DNSProvider string `json:"dns_provider"` - CloudflareAPIToken string `json:"cloudflare_api_token"` - CloudflareZoneID string `json:"cloudflare_zone_id"` - UpdatedAt string `json:"updated_at"` + WildcardDNS bool `json:"wildcard_dns"` + DNSProvider string `json:"dns_provider"` + CloudflareAPIToken string `json:"cloudflare_api_token"` + CloudflareZoneID string `json:"cloudflare_zone_id"` + BackupEnabled bool `json:"backup_enabled"` + BackupIntervalHours int `json:"backup_interval_hours"` + BackupRetentionCount int `json:"backup_retention_count"` + UpdatedAt string `json:"updated_at"` +} + +// Backup represents a backup metadata record. +type Backup struct { + ID string `json:"id"` + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + BackupType string `json:"backup_type"` // "manual" or "auto" + CreatedAt string `json:"created_at"` } // DNSRecord tracks a DNS record managed by the application. diff --git a/internal/store/settings.go b/internal/store/settings.go index 3e6dde2..9da3135 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -7,23 +7,28 @@ import ( // GetSettings returns the global settings (single-row pattern, always row id=1). func (s *Store) GetSettings() (Settings, error) { var st Settings - var wildcardDNS int + var wildcardDNS, backupEnabled int err := s.db.QueryRow( `SELECT domain, server_ip, network, subdomain_pattern, notification_url, npm_url, npm_email, npm_password, webhook_secret, polling_interval, base_volume_path, ssl_certificate_id, stale_threshold_days, allowed_volume_paths, wildcard_dns, dns_provider, - cloudflare_api_token, cloudflare_zone_id, updated_at + cloudflare_api_token, cloudflare_zone_id, + backup_enabled, backup_interval_hours, backup_retention_count, + updated_at FROM settings WHERE id = 1`, ).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL, &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, &st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider, - &st.CloudflareAPIToken, &st.CloudflareZoneID, &st.UpdatedAt) + &st.CloudflareAPIToken, &st.CloudflareZoneID, + &backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount, + &st.UpdatedAt) if err != nil { return Settings{}, fmt.Errorf("query settings: %w", err) } st.WildcardDNS = wildcardDNS != 0 + st.BackupEnabled = backupEnabled != 0 return st, nil } @@ -34,19 +39,27 @@ func (s *Store) UpdateSettings(st Settings) error { if st.WildcardDNS { wildcardDNS = 1 } + backupEnabled := 0 + if st.BackupEnabled { + backupEnabled = 1 + } _, err := s.db.Exec( `UPDATE settings SET domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?, npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, allowed_volume_paths=?, wildcard_dns=?, dns_provider=?, - cloudflare_api_token=?, cloudflare_zone_id=?, updated_at=? + cloudflare_api_token=?, cloudflare_zone_id=?, + backup_enabled=?, backup_interval_hours=?, backup_retention_count=?, + updated_at=? WHERE id = 1`, st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL, st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, st.AllowedVolumePaths, wildcardDNS, st.DNSProvider, - st.CloudflareAPIToken, st.CloudflareZoneID, st.UpdatedAt, + st.CloudflareAPIToken, st.CloudflareZoneID, + backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount, + st.UpdatedAt, ) if err != nil { return fmt.Errorf("update settings: %w", err) diff --git a/internal/store/store.go b/internal/store/store.go index 4b803ef..ae33e38 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -95,6 +95,10 @@ func (s *Store) runMigrations() error { `ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`, `ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`, `ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`, + // Add backup management fields to settings (2026-04-02). + `ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`, + `ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`, } for _, m := range migrations { @@ -313,6 +317,14 @@ CREATE TABLE IF NOT EXISTS dns_records ( created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +CREATE TABLE IF NOT EXISTS backups ( + id TEXT PRIMARY KEY, + filename TEXT NOT NULL UNIQUE, + size_bytes INTEGER NOT NULL DEFAULT 0, + backup_type TEXT NOT NULL DEFAULT 'manual', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); ` // Now returns the current time formatted for SQLite storage. diff --git a/plans/backup-management/CONTEXT.md b/plans/backup-management/CONTEXT.md new file mode 100644 index 0000000..d49d97e --- /dev/null +++ b/plans/backup-management/CONTEXT.md @@ -0,0 +1,26 @@ +# Feature Context: Backup Management + +## Configuration +- **Development mode:** Automated +- **Execution mode:** Direct +- **Strategy:** Big Bang +- **Build (Go):** `go build ./cmd/server` +- **Build (Frontend):** `cd web && npm run build` +- **Check (Frontend):** `cd web && npm run check` +- **Dev server:** `./scripts/dev-server.sh` (port 8090) + +## Current State +Starting fresh — no implementation yet. + +## Key Architecture Decisions +- Backup via `VACUUM INTO` — creates a clean standalone DB copy, safe with WAL mode +- Backups stored in `DATA_DIR/backups/` as timestamped `.db` files +- Backup metadata tracked in a `backups` table in the main DB +- Autobackup uses existing robfig/cron scheduler pattern +- Restore replaces the DB file and triggers a graceful server restart +- ENCRYPTION_KEY is NOT backed up — user must have the same key on restore + +## Cross-Phase Dependencies +- Phase 2 depends on Phase 1 (backup.Engine) +- Phase 3 depends on Phase 2 (API endpoints) +- Phase 4 depends on Phase 1 (Engine.Prune method) diff --git a/plans/backup-management/PLAN.md b/plans/backup-management/PLAN.md new file mode 100644 index 0000000..a7c10a3 --- /dev/null +++ b/plans/backup-management/PLAN.md @@ -0,0 +1,43 @@ +# Feature: Backup Management + +**Branch:** `feature/backup-management` +**Base branch:** `main` +**Created:** 2026-04-02 +**Status:** 🟡 In Progress +**Strategy:** Big Bang +**Mode:** Automated +**Execution:** Direct + +## Summary + +Add manual and automatic backup/restore functionality for the SQLite database. +Users can trigger backups on demand, configure autobackup on an interval with +retention policies, list/download/delete backups, and restore from a backup. + +## Build & Test Commands +- **Build (Go):** `go build ./cmd/server` +- **Build (Frontend):** `cd web && npm run build` +- **Check (Frontend):** `cd web && npm run check` +- **Dev server:** `./scripts/dev-server.sh` + +## Phases + +- [ ] Phase 1: Backup engine & settings [domain: backend] → [subplan](./phase-1-backup-engine.md) +- [ ] Phase 2: Backup API endpoints [domain: backend] → [subplan](./phase-2-backup-api.md) +- [ ] Phase 3: Backup settings & management UI [domain: frontend] → [subplan](./phase-3-backup-ui.md) +- [ ] Phase 4: Retention & cleanup [domain: backend] → [subplan](./phase-4-retention.md) + +## Phase Progress Log + +| Phase | Domain | Status | Review | Build | Committed | +|-------|--------|--------|--------|-------|-----------| +| Phase 1: Backup engine & settings | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 2: Backup API endpoints | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 3: Backup UI | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 4: Retention & cleanup | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | + +## Final Review +- [ ] Comprehensive code review +- [ ] Full build passes +- [ ] Full test suite passes +- [ ] Merged to `main` diff --git a/plans/backup-management/phase-1-backup-engine.md b/plans/backup-management/phase-1-backup-engine.md new file mode 100644 index 0000000..2048dcb --- /dev/null +++ b/plans/backup-management/phase-1-backup-engine.md @@ -0,0 +1,46 @@ +# Phase 1: Backup Engine & Settings + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Create the core backup engine and extend settings to support backup configuration. + +## Tasks + +- [ ] Task 1: Add backup settings fields to Settings model + - `BackupEnabled` bool (default false) + - `BackupIntervalHours` int (default 24) + - `BackupRetentionCount` int (default 10) +- [ ] Task 2: Add migration columns in store.go +- [ ] Task 3: Update GetSettings/UpdateSettings queries +- [ ] Task 4: Create `backups` metadata table + - id, filename, size_bytes, backup_type (manual/auto), created_at +- [ ] Task 5: Create `internal/backup/engine.go` + - Engine struct with db path, backup dir + - `CreateBackup(backupType string) (Backup, error)` — VACUUM INTO timestamped file + - `ListBackups() ([]Backup, error)` — read from metadata table + - `GetBackup(id string) (Backup, error)` + - `DeleteBackup(id string) error` — delete file + metadata + - `RestoreBackup(id string) error` — copy backup over main DB + - `DownloadPath(id string) (string, error)` — return file path for download +- [ ] Task 6: Create `internal/store/backups.go` — CRUD for backup metadata +- [ ] Task 7: Create backup directory on engine init (`DATA_DIR/backups/`) + +## Files to Modify/Create +- `internal/store/models.go` — add Backup struct, extend Settings +- `internal/store/store.go` — add migration + backups table +- `internal/store/settings.go` — update queries +- `internal/store/backups.go` — backup metadata CRUD +- `internal/backup/engine.go` — core backup logic + +## Acceptance Criteria +- Backup engine can create a backup file via VACUUM INTO +- Backup metadata stored in DB +- Backup files stored in DATA_DIR/backups/ +- Settings include backup configuration fields +- Delete removes both file and metadata + +## Handoff to Next Phase + diff --git a/plans/backup-management/phase-2-backup-api.md b/plans/backup-management/phase-2-backup-api.md new file mode 100644 index 0000000..2173339 --- /dev/null +++ b/plans/backup-management/phase-2-backup-api.md @@ -0,0 +1,43 @@ +# Phase 2: Backup API Endpoints + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Expose backup operations via REST API and wire autobackup scheduling. + +## Tasks + +- [ ] Task 1: Create `internal/api/backups.go` with handlers + - `POST /api/backups` — trigger manual backup + - `GET /api/backups` — list all backups + - `GET /api/backups/{id}/download` — download backup file + - `DELETE /api/backups/{id}` — delete backup + - `POST /api/backups/{id}/restore` — restore from backup +- [ ] Task 2: Register routes in router.go (admin-only) +- [ ] Task 3: Add backup engine to Server struct + - SetBackupEngine method (same pattern as SetProxyManager) +- [ ] Task 4: Wire autobackup cron in main.go + - Create backup engine on startup + - If backup_enabled, schedule cron job + - Graceful shutdown: stop cron +- [ ] Task 5: Update settings handler to restart autobackup cron on settings change +- [ ] Task 6: Add GET /api/settings response fields for backup settings + +## Files to Modify/Create +- `internal/api/backups.go` — new handler file +- `internal/api/router.go` — register routes, add engine field +- `cmd/server/main.go` — wire engine, schedule cron +- `internal/api/settings.go` — include backup fields in GET/PUT + +## Acceptance Criteria +- All CRUD endpoints work for backups +- Manual backup creates file and returns metadata +- Download streams the backup file +- Restore replaces DB (may require restart) +- Autobackup runs on configured interval +- Settings change updates cron schedule + +## Handoff to Next Phase + diff --git a/plans/backup-management/phase-3-backup-ui.md b/plans/backup-management/phase-3-backup-ui.md new file mode 100644 index 0000000..aacbad5 --- /dev/null +++ b/plans/backup-management/phase-3-backup-ui.md @@ -0,0 +1,45 @@ +# Phase 3: Backup Settings & Management UI + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** frontend + +## Objective +Create a backup management page under settings with autobackup configuration, +manual backup trigger, and backup list with download/delete/restore actions. + +## Tasks + +- [ ] Task 1: Add API functions in api.ts + - triggerBackup, listBackups, downloadBackup, deleteBackup, restoreBackup +- [ ] Task 2: Add types in types.ts (BackupInfo interface) +- [ ] Task 3: Add i18n keys for backup page (en.json, ru.json) +- [ ] Task 4: Create backup settings page at `/settings/backup/+page.svelte` + - Autobackup toggle + interval selector + retention count + - "Backup Now" button with loading state + - Backup list table: filename, date, size, type, actions + - Download button (direct file download) + - Delete button with confirmation + - Restore button with strong warning dialog +- [ ] Task 5: Add navigation link in settings layout +- [ ] Task 6: Create IconBackup component (or reuse IconHardDrive/IconDatabase) + +## Files to Modify/Create +- `web/src/lib/api.ts` — add backup API functions +- `web/src/lib/types.ts` — add BackupInfo type +- `web/src/lib/i18n/en.json` — add backup i18n keys +- `web/src/lib/i18n/ru.json` — add backup i18n keys +- `web/src/routes/settings/backup/+page.svelte` — new page +- `web/src/routes/settings/+layout.svelte` — add nav item + +## Acceptance Criteria +- Backup page accessible at /settings/backup +- Autobackup settings save correctly +- Manual backup triggers and shows result +- Backup list shows all backups with correct metadata +- Download streams file to browser +- Delete removes backup with confirmation +- Restore shows warning and triggers restore + +## Handoff to Next Phase + diff --git a/plans/backup-management/phase-4-retention.md b/plans/backup-management/phase-4-retention.md new file mode 100644 index 0000000..8025a4f --- /dev/null +++ b/plans/backup-management/phase-4-retention.md @@ -0,0 +1,33 @@ +# Phase 4: Retention & Cleanup + +**Status:** ⬜ Not Started +**Parent plan:** [PLAN.md](./PLAN.md) +**Domain:** backend + +## Objective +Implement automatic retention enforcement — prune old backups after each backup +operation and on server startup. + +## Tasks + +- [ ] Task 1: Add `Prune()` method to backup engine + - Delete backups exceeding retention count (keep N most recent) + - Return count of pruned backups +- [ ] Task 2: Call Prune after every CreateBackup (both manual and auto) +- [ ] Task 3: Call Prune on server startup +- [ ] Task 4: Clean up orphaned files (files in backup dir not in metadata) +- [ ] Task 5: Log pruning results + +## Files to Modify/Create +- `internal/backup/engine.go` — add Prune method +- `cmd/server/main.go` — call prune on startup + +## Acceptance Criteria +- Backups exceeding retention count are automatically deleted +- Both file and metadata are removed during pruning +- Orphaned files (no metadata) are cleaned up +- Pruning runs after each backup and on startup +- Pruning results logged + +## Handoff to Next Phase + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index dfe2d56..85f9927 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -24,7 +24,8 @@ import type { VolumeScopeInfo, BrowseResult, DnsZone, - DnsRecordView + DnsRecordView, + BackupInfo } from './types'; // ── Helpers ───────────────────────────────────────────────────────── @@ -292,6 +293,28 @@ export function deleteDnsRecord(fqdn: string): Promise { return del(`/api/dns/records/${encodeURIComponent(fqdn)}`); } +// ── Backups ──────────────────────────────────────────────────────── + +export function listBackups(): Promise { + return get('/api/backups'); +} + +export function triggerBackup(): Promise { + return post('/api/backups'); +} + +export function deleteBackup(id: string): Promise { + return del(`/api/backups/${id}`); +} + +export function restoreBackup(id: string): Promise<{ status: string; message: string }> { + return post<{ status: string; message: string }>(`/api/backups/${id}/restore`); +} + +export function backupDownloadUrl(id: string): string { + return `/api/backups/${id}/download`; +} + // ── Health ────────────────────────────────────────────────────────── export function getHealth(): Promise<{ docker: DockerHealth }> { diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 53e4ade..7c9dc97 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -204,6 +204,7 @@ "registries": "Registries", "credentials": "Credentials", "authentication": "Authentication", + "backup": "Backups", "appearance": "Appearance", "staleThreshold": "Stale threshold (days)", "staleThresholdHelp": "Containers inactive for longer than this will be flagged as stale." @@ -325,6 +326,44 @@ "registriesLink": "Registries", "registryTokensSuffix": "section. Each registry stores its token encrypted in the database." }, + "settingsBackup": { + "title": "Backup Management", + "description": "Manage database backups and configure automatic backup schedules.", + "autoBackup": "Automatic Backups", + "autoBackupHelp": "Automatically create backups at the configured interval.", + "interval": "Backup Interval", + "intervalHelp": "How often to create automatic backups.", + "intervalHours": "{hours} hours", + "retention": "Retention Count", + "retentionHelp": "Maximum number of backups to keep. Oldest are deleted first.", + "backupNow": "Backup Now", + "creatingBackup": "Creating...", + "backupCreated": "Backup created successfully", + "backupFailed": "Failed to create backup", + "backupList": "Backups", + "noBackups": "No backups yet. Create one manually or enable automatic backups.", + "columnFilename": "Filename", + "columnSize": "Size", + "columnType": "Type", + "columnDate": "Created", + "columnActions": "Actions", + "download": "Download", + "delete": "Delete", + "restore": "Restore", + "deleteConfirm": "Are you sure you want to delete this backup?", + "deleted": "Backup deleted", + "deleteFailed": "Failed to delete backup", + "restoreConfirm": "Are you sure you want to restore from this backup? This will replace the current database and restart the server. All current data will be lost.", + "restoreWarning": "This action cannot be undone!", + "restored": "Database restored. The server is restarting...", + "restoreFailed": "Failed to restore backup", + "typeManual": "Manual", + "typeAuto": "Auto", + "save": "Save", + "saving": "Saving...", + "saved": "Backup settings saved", + "saveFailed": "Failed to save backup settings" + }, "settingsAuth": { "title": "Authentication Settings", "description": "Configure authentication mode and manage users.", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 4e5020d..4550d51 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -204,6 +204,7 @@ "registries": "Реестры", "credentials": "Учётные данные", "authentication": "Аутентификация", + "backup": "Резервные копии", "appearance": "Внешний вид", "staleThreshold": "Порог устаревания (дни)", "staleThresholdHelp": "Контейнеры, неактивные дольше этого срока, будут помечены как устаревшие." @@ -325,6 +326,44 @@ "registriesLink": "Реестры", "registryTokensSuffix": ". Каждый реестр хранит свой токен в зашифрованном виде." }, + "settingsBackup": { + "title": "Управление резервными копиями", + "description": "Управление резервными копиями базы данных и настройка автоматического резервного копирования.", + "autoBackup": "Автоматическое резервное копирование", + "autoBackupHelp": "Автоматически создавать резервные копии с заданным интервалом.", + "interval": "Интервал копирования", + "intervalHelp": "Как часто создавать автоматические резервные копии.", + "intervalHours": "{hours} часов", + "retention": "Количество хранимых копий", + "retentionHelp": "Максимальное количество хранимых резервных копий. Старые удаляются первыми.", + "backupNow": "Создать копию", + "creatingBackup": "Создание...", + "backupCreated": "Резервная копия создана", + "backupFailed": "Не удалось создать резервную копию", + "backupList": "Резервные копии", + "noBackups": "Резервных копий пока нет. Создайте вручную или включите автоматическое копирование.", + "columnFilename": "Файл", + "columnSize": "Размер", + "columnType": "Тип", + "columnDate": "Создано", + "columnActions": "Действия", + "download": "Скачать", + "delete": "Удалить", + "restore": "Восстановить", + "deleteConfirm": "Вы уверены, что хотите удалить эту резервную копию?", + "deleted": "Резервная копия удалена", + "deleteFailed": "Не удалось удалить резервную копию", + "restoreConfirm": "Вы уверены, что хотите восстановить из этой копии? Текущая база данных будет заменена и сервер будет перезапущен. Все текущие данные будут потеряны.", + "restoreWarning": "Это действие необратимо!", + "restored": "База данных восстановлена. Сервер перезапускается...", + "restoreFailed": "Не удалось восстановить резервную копию", + "typeManual": "Ручная", + "typeAuto": "Авто", + "save": "Сохранить", + "saving": "Сохранение...", + "saved": "Настройки копирования сохранены", + "saveFailed": "Не удалось сохранить настройки копирования" + }, "settingsAuth": { "title": "Настройки аутентификации", "description": "Настройка режима аутентификации и управление пользователями.", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 53941af..72baa37 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -112,6 +112,9 @@ export interface Settings { dns_provider: string; has_cloudflare_api_token: boolean; cloudflare_zone_id: string; + backup_enabled: boolean; + backup_interval_hours: number; + backup_retention_count: number; updated_at: string; } @@ -132,6 +135,15 @@ export interface DnsRecordView { status: string; } +/** A backup metadata record. */ +export interface BackupInfo { + id: string; + filename: string; + size_bytes: number; + backup_type: string; + created_at: string; +} + /** An SSL certificate from Nginx Proxy Manager. */ export interface NpmCertificate { id: number; diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte index 35acbe7..57aa225 100644 --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -2,7 +2,7 @@ import type { Snippet } from 'svelte'; import { page } from '$app/stores'; import { t } from '$lib/i18n'; - import { IconSettings, IconDatabase, IconKey, IconShield } from '$lib/components/icons'; + import { IconSettings, IconDatabase, IconKey, IconShield, IconHardDrive } from '$lib/components/icons'; interface Props { children: Snippet; @@ -14,7 +14,8 @@ { href: '/settings', labelKey: 'settings.general', icon: 'general' }, { href: '/settings/registries', labelKey: 'settings.registries', icon: 'registries' }, { href: '/settings/credentials', labelKey: 'settings.credentials', icon: 'credentials' }, - { href: '/settings/auth', labelKey: 'settings.authentication', icon: 'auth' } + { href: '/settings/auth', labelKey: 'settings.authentication', icon: 'auth' }, + { href: '/settings/backup', labelKey: 'settings.backup', icon: 'backup' } ]; let currentPath = $derived($page.url.pathname); @@ -49,6 +50,8 @@ {:else if item.icon === 'auth'} + {:else if item.icon === 'backup'} + {/if} {$t(item.labelKey)} diff --git a/web/src/routes/settings/backup/+page.svelte b/web/src/routes/settings/backup/+page.svelte new file mode 100644 index 0000000..ec377d6 --- /dev/null +++ b/web/src/routes/settings/backup/+page.svelte @@ -0,0 +1,274 @@ + + + + {$t('settingsBackup.title')} - {$t('app.name')} + + +
+ {#if loading} +
+ + + +
+ {:else} + +
+

{$t('settingsBackup.title')}

+

{$t('settingsBackup.description')}

+ +
+ + + + {#if backupEnabled} +
+ + +
+ {/if} + +
+ + +
+
+
+ + +
+
+

{$t('settingsBackup.backupList')}

+ +
+ + {#if backups.length === 0} +
+ {$t('settingsBackup.noBackups')} +
+ {:else} +
+ + + + + + + + + + + + {#each backups as backup} + + + + + + + + {/each} + +
{$t('settingsBackup.columnFilename')}{$t('settingsBackup.columnSize')}{$t('settingsBackup.columnType')}{$t('settingsBackup.columnDate')}{$t('settingsBackup.columnActions')}
{backup.filename}{formatSize(backup.size_bytes)} + + {backup.backup_type === 'auto' ? $t('settingsBackup.typeAuto') : $t('settingsBackup.typeManual')} + + {formatDate(backup.created_at)} +
+ + + +
+
+
+ {/if} +
+ {/if} +
+ + + { confirmDeleteId = ''; }} +/> + + + { confirmRestoreId = ''; }} +/>