7d801d1094
- Storage overview on backup page (SSD/HDD bars, repo stats) - Restic password visibility + hub sync for disaster recovery - App data discovery (HDD bind mounts, Docker volumes) - Per-app backup toggle checkboxes with settings persistence - Dynamic backup paths: enabled app HDD data included in restic snapshots - Limited app restore from snapshots (self-service recovery) - Snapshots API endpoint for restore dropdown - Version bump to 0.8.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
227 lines
6.1 KiB
Go
227 lines
6.1 KiB
Go
package settings
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
)
|
|
|
|
// Settings holds customer-modifiable overrides and cached state.
|
|
// Persisted as a single JSON file (settings.json) in the data directory.
|
|
type Settings struct {
|
|
mu sync.RWMutex `json:"-"`
|
|
path string `json:"-"`
|
|
log *log.Logger `json:"-"`
|
|
|
|
// Auth
|
|
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
|
|
|
|
// Notification preferences (Phase 2 — define struct now, leave empty)
|
|
Notifications *NotificationPrefs `json:"notifications,omitempty"`
|
|
|
|
// Cached state
|
|
DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`
|
|
|
|
// Per-app backup preferences
|
|
AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
|
|
}
|
|
|
|
// AppBackupPrefs holds per-app backup toggle state.
|
|
type AppBackupPrefs struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// NotificationPrefs holds customer notification preferences.
|
|
type NotificationPrefs struct {
|
|
Email string `json:"email,omitempty"`
|
|
EnabledEvents []string `json:"enabled_events,omitempty"`
|
|
CooldownHours int `json:"cooldown_hours,omitempty"` // default: 6
|
|
}
|
|
|
|
// DefaultEnabledEvents are the events enabled by default for new customers.
|
|
var DefaultEnabledEvents = []string{
|
|
"disk_warning",
|
|
"backup_failed",
|
|
"update_available",
|
|
}
|
|
|
|
// DBValidationCache holds cached DB dump validation results.
|
|
type DBValidationCache struct {
|
|
ValidatedAt string `json:"validated_at"` // RFC3339
|
|
TableCount int `json:"table_count"`
|
|
HasHeader bool `json:"has_header"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Load reads settings from the given file path.
|
|
// Returns empty Settings if the file doesn't exist (not an error).
|
|
func Load(path string, logger *log.Logger) (*Settings, error) {
|
|
s := &Settings{
|
|
path: path,
|
|
log: logger,
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
logger.Printf("[INFO] No settings.json found, using defaults")
|
|
return s, nil
|
|
}
|
|
return nil, fmt.Errorf("reading settings file: %w", err)
|
|
}
|
|
|
|
if err := json.Unmarshal(data, s); err != nil {
|
|
return nil, fmt.Errorf("parsing settings file: %w", err)
|
|
}
|
|
|
|
logger.Printf("[DEBUG] Settings loaded from %s", path)
|
|
return s, nil
|
|
}
|
|
|
|
// Save writes settings to disk atomically (write to .tmp, rename).
|
|
// Caller must hold the write lock or call this from a method that does.
|
|
func (s *Settings) save() error {
|
|
data, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling settings: %w", err)
|
|
}
|
|
|
|
tmpPath := s.path + ".tmp"
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
|
return fmt.Errorf("creating settings dir: %w", err)
|
|
}
|
|
|
|
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
|
return fmt.Errorf("writing tmp settings: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tmpPath, s.path); err != nil {
|
|
os.Remove(tmpPath)
|
|
return fmt.Errorf("renaming settings file: %w", err)
|
|
}
|
|
|
|
s.log.Printf("[DEBUG] Settings saved to %s", s.path)
|
|
return nil
|
|
}
|
|
|
|
// GetPasswordHash returns the stored password hash (thread-safe).
|
|
func (s *Settings) GetPasswordHash() string {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.PasswordHash
|
|
}
|
|
|
|
// SetPasswordHash updates the password hash and saves to disk.
|
|
func (s *Settings) SetPasswordHash(hash string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.PasswordHash = hash
|
|
return s.save()
|
|
}
|
|
|
|
// GetDBValidations returns a copy of the cached DB validations.
|
|
func (s *Settings) GetDBValidations() map[string]DBValidationCache {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.DBValidations == nil {
|
|
return nil
|
|
}
|
|
result := make(map[string]DBValidationCache, len(s.DBValidations))
|
|
for k, v := range s.DBValidations {
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SetDBValidation saves a validation result for a dump file and persists to disk.
|
|
func (s *Settings) SetDBValidation(filename string, cache DBValidationCache) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.DBValidations == nil {
|
|
s.DBValidations = make(map[string]DBValidationCache)
|
|
}
|
|
s.DBValidations[filename] = cache
|
|
return s.save()
|
|
}
|
|
|
|
// GetNotificationPrefs returns a copy of the notification preferences.
|
|
func (s *Settings) GetNotificationPrefs() *NotificationPrefs {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.Notifications == nil {
|
|
return &NotificationPrefs{
|
|
EnabledEvents: DefaultEnabledEvents,
|
|
CooldownHours: 6,
|
|
}
|
|
}
|
|
prefs := *s.Notifications
|
|
if prefs.CooldownHours == 0 {
|
|
prefs.CooldownHours = 6
|
|
}
|
|
if prefs.EnabledEvents == nil {
|
|
prefs.EnabledEvents = DefaultEnabledEvents
|
|
}
|
|
// Return a copy of the slice
|
|
events := make([]string, len(prefs.EnabledEvents))
|
|
copy(events, prefs.EnabledEvents)
|
|
prefs.EnabledEvents = events
|
|
return &prefs
|
|
}
|
|
|
|
// SetNotificationPrefs updates notification preferences and saves to disk.
|
|
func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.Notifications = prefs
|
|
return s.save()
|
|
}
|
|
|
|
// IsAppBackupEnabled returns whether backup is enabled for the given stack.
|
|
func (s *Settings) IsAppBackupEnabled(stackName string) bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.AppBackup == nil {
|
|
return false
|
|
}
|
|
return s.AppBackup[stackName].Enabled
|
|
}
|
|
|
|
// SetAppBackup enables or disables backup for a stack and saves to disk.
|
|
func (s *Settings) SetAppBackup(stackName string, enabled bool) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.AppBackup == nil {
|
|
s.AppBackup = make(map[string]AppBackupPrefs)
|
|
}
|
|
s.AppBackup[stackName] = AppBackupPrefs{Enabled: enabled}
|
|
return s.save()
|
|
}
|
|
|
|
// GetAppBackupMap returns a map of stack_name -> enabled for all app backup prefs.
|
|
func (s *Settings) GetAppBackupMap() map[string]bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if s.AppBackup == nil {
|
|
return nil
|
|
}
|
|
result := make(map[string]bool, len(s.AppBackup))
|
|
for k, v := range s.AppBackup {
|
|
result[k] = v.Enabled
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SetAppBackupBulk updates backup prefs for all stacks at once and saves to disk.
|
|
func (s *Settings) SetAppBackupBulk(prefs map[string]bool) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.AppBackup = make(map[string]AppBackupPrefs, len(prefs))
|
|
for name, enabled := range prefs {
|
|
s.AppBackup[name] = AppBackupPrefs{Enabled: enabled}
|
|
}
|
|
return s.save()
|
|
}
|