3eee330ed5
- Monitoring page: "Távoli monitoring" section showing healthcheck ping UUID configuration status (configured/not configured) for each of the 5 pings - Alert manager: persistent dashboard banners on all pages generated from health check results, missing pings, and backup status - Notification system: controller-side notifier sends events to hub relay, with cooldown tracking and event-type filtering - Notification preferences UI: email, event checkboxes, cooldown settings on the settings page with test email functionality - Settings refactored: shared settingsData() helper, NotificationPrefs struct with getter/setter and defaults New files: - controller/internal/web/alerts.go (AlertManager) - controller/internal/notify/notifier.go (hub notification client) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
173 lines
4.6 KiB
Go
173 lines
4.6 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"`
|
|
}
|
|
|
|
// 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()
|
|
}
|