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
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
@@ -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
|
||||
<!-- Filled in after completion -->
|
||||
+24
-1
@@ -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<void> {
|
||||
return del<void>(`/api/dns/records/${encodeURIComponent(fqdn)}`);
|
||||
}
|
||||
|
||||
// ── Backups ────────────────────────────────────────────────────────
|
||||
|
||||
export function listBackups(): Promise<BackupInfo[]> {
|
||||
return get<BackupInfo[]>('/api/backups');
|
||||
}
|
||||
|
||||
export function triggerBackup(): Promise<BackupInfo> {
|
||||
return post<BackupInfo>('/api/backups');
|
||||
}
|
||||
|
||||
export function deleteBackup(id: string): Promise<void> {
|
||||
return del<void>(`/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 }> {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Настройка режима аутентификации и управление пользователями.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 @@
|
||||
<IconKey size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{:else if item.icon === 'auth'}
|
||||
<IconShield size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{:else if item.icon === 'backup'}
|
||||
<IconHardDrive size={16} class="{isActive(item.href) ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)]'}" />
|
||||
{/if}
|
||||
{$t(item.labelKey)}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { getSettings, updateSettings, listBackups, triggerBackup, deleteBackup, restoreBackup, backupDownloadUrl } from '$lib/api';
|
||||
import type { BackupInfo } from '$lib/types';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconTrash, IconRefresh } from '$lib/components/icons';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { getAuthToken } from '$lib/auth';
|
||||
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let creatingBackup = $state(false);
|
||||
|
||||
let backupEnabled = $state(false);
|
||||
let backupIntervalHours = $state('24');
|
||||
let backupRetentionCount = $state('10');
|
||||
let backups = $state<BackupInfo[]>([]);
|
||||
|
||||
let confirmDeleteId = $state('');
|
||||
let confirmRestoreId = $state('');
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [settings, backupList] = await Promise.all([
|
||||
getSettings(),
|
||||
listBackups()
|
||||
]);
|
||||
backupEnabled = settings.backup_enabled ?? false;
|
||||
backupIntervalHours = String(settings.backup_interval_hours ?? 24);
|
||||
backupRetentionCount = String(settings.backup_retention_count ?? 10);
|
||||
backups = backupList ?? [];
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : 'Failed to load backup settings');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
await updateSettings({
|
||||
backup_enabled: backupEnabled,
|
||||
backup_interval_hours: Math.max(1, parseInt(backupIntervalHours, 10) || 24),
|
||||
backup_retention_count: Math.max(1, parseInt(backupRetentionCount, 10) || 10)
|
||||
} as any);
|
||||
toasts.success($t('settingsBackup.saved'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.saveFailed'));
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBackupNow() {
|
||||
creatingBackup = true;
|
||||
try {
|
||||
const backup = await triggerBackup();
|
||||
backups = [backup, ...backups];
|
||||
toasts.success($t('settingsBackup.backupCreated'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.backupFailed'));
|
||||
} finally {
|
||||
creatingBackup = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const id = confirmDeleteId;
|
||||
confirmDeleteId = '';
|
||||
try {
|
||||
await deleteBackup(id);
|
||||
backups = backups.filter(b => b.id !== id);
|
||||
toasts.success($t('settingsBackup.deleted'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.deleteFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestore() {
|
||||
const id = confirmRestoreId;
|
||||
confirmRestoreId = '';
|
||||
try {
|
||||
await restoreBackup(id);
|
||||
toasts.success($t('settingsBackup.restored'));
|
||||
} catch (err) {
|
||||
toasts.error(err instanceof Error ? err.message : $t('settingsBackup.restoreFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(id: string) {
|
||||
const token = getAuthToken();
|
||||
const url = backupDownloadUrl(id);
|
||||
// Open download in new tab with auth header via fetch+blob
|
||||
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Download failed');
|
||||
return r.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = '';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
})
|
||||
.catch(err => toasts.error(err instanceof Error ? err.message : 'Download failed'));
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr + 'Z');
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
$effect(() => { loadData(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('settingsBackup.title')} - {$t('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
{#if loading}
|
||||
<div class="space-y-4">
|
||||
<Skeleton height="2rem" width="12rem" />
|
||||
<Skeleton height="10rem" />
|
||||
<Skeleton height="15rem" />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Backup Settings -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
<h2 class="mb-1 text-lg font-semibold text-[var(--text-primary)]">{$t('settingsBackup.title')}</h2>
|
||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('settingsBackup.description')}</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Auto backup toggle -->
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" bind:checked={backupEnabled}
|
||||
class="h-4 w-4 rounded border-[var(--border-primary)] text-[var(--color-brand-600)] focus:ring-[var(--color-brand-500)]" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsBackup.autoBackup')}</span>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsBackup.autoBackupHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if backupEnabled}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 ml-7">
|
||||
<FormField
|
||||
label={$t('settingsBackup.interval')}
|
||||
name="backupIntervalHours"
|
||||
type="number"
|
||||
bind:value={backupIntervalHours}
|
||||
placeholder="24"
|
||||
helpText={$t('settingsBackup.intervalHelp')}
|
||||
/>
|
||||
<FormField
|
||||
label={$t('settingsBackup.retention')}
|
||||
name="backupRetentionCount"
|
||||
type="number"
|
||||
bind:value={backupRetentionCount}
|
||||
placeholder="10"
|
||||
helpText={$t('settingsBackup.retentionHelp')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick={handleSave} disabled={saving}
|
||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press">
|
||||
{#if saving}<IconLoader size={16} />{/if}
|
||||
{saving ? $t('settingsBackup.saving') : $t('settingsBackup.save')}
|
||||
</button>
|
||||
<button onclick={handleBackupNow} disabled={creatingBackup}
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-[var(--color-brand-600)] hover:bg-[var(--color-brand-50)] disabled:opacity-50 transition-colors active:animate-press">
|
||||
{#if creatingBackup}<IconLoader size={16} />{/if}
|
||||
{creatingBackup ? $t('settingsBackup.creatingBackup') : $t('settingsBackup.backupNow')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup List -->
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)] overflow-hidden">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-primary)]">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsBackup.backupList')}</h2>
|
||||
<button onclick={() => loadData()}
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<IconRefresh size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if backups.length === 0}
|
||||
<div class="p-8 text-center text-sm text-[var(--text-tertiary)]">
|
||||
{$t('settingsBackup.noBackups')}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--border-primary)] bg-[var(--surface-card-hover)]">
|
||||
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('settingsBackup.columnFilename')}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('settingsBackup.columnSize')}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('settingsBackup.columnType')}</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[var(--text-secondary)]">{$t('settingsBackup.columnDate')}</th>
|
||||
<th class="px-4 py-3 text-right font-medium text-[var(--text-secondary)]">{$t('settingsBackup.columnActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each backups as backup}
|
||||
<tr class="border-b border-[var(--border-primary)] last:border-b-0 hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
<td class="px-4 py-3 font-mono text-xs text-[var(--text-primary)]">{backup.filename}</td>
|
||||
<td class="px-4 py-3 text-[var(--text-secondary)]">{formatSize(backup.size_bytes)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{backup.backup_type === 'auto'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'}">
|
||||
{backup.backup_type === 'auto' ? $t('settingsBackup.typeAuto') : $t('settingsBackup.typeManual')}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-[var(--text-secondary)]">{formatDate(backup.created_at)}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button onclick={() => handleDownload(backup.id)}
|
||||
class="rounded-lg px-2 py-1 text-xs text-[var(--color-brand-600)] hover:bg-[var(--color-brand-50)] transition-colors">
|
||||
{$t('settingsBackup.download')}
|
||||
</button>
|
||||
<button onclick={() => { confirmRestoreId = backup.id; }}
|
||||
class="rounded-lg px-2 py-1 text-xs text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||
{$t('settingsBackup.restore')}
|
||||
</button>
|
||||
<button onclick={() => { confirmDeleteId = backup.id; }}
|
||||
class="rounded-lg px-2 py-1 text-xs text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors">
|
||||
<IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteId !== ''}
|
||||
title={$t('settingsBackup.delete')}
|
||||
message={$t('settingsBackup.deleteConfirm')}
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => { confirmDeleteId = ''; }}
|
||||
/>
|
||||
|
||||
<!-- Restore confirmation -->
|
||||
<ConfirmDialog
|
||||
open={confirmRestoreId !== ''}
|
||||
title={$t('settingsBackup.restore')}
|
||||
message={$t('settingsBackup.restoreConfirm') + '\n\n' + $t('settingsBackup.restoreWarning')}
|
||||
onconfirm={handleRestore}
|
||||
oncancel={() => { confirmRestoreId = ''; }}
|
||||
/>
|
||||
Reference in New Issue
Block a user