Files
deploy-felhom-compose/controller/internal/settings/settings.go
T
admin 69698a89e8 v0.10.0: Phase B — Storage Management UI Polish & Health Severity Fix
- Health severity fix: mount-point check downgraded from issue (FAIL) to warning (WARN)
- All storage health messages translated to Hungarian
- Success flash messages for all storage operations
- Edit storage path labels (inline edit UI + backend)
- App details per storage path on settings page (expandable list with names + sizes)
- Storage badge on stacks page showing which storage each app uses
- Deploy dropdown with free space display and low-space warning (<20%)
- Filesystem & disk info on settings page (ext4/btrfs, device, model via findmnt)
- Backup page storage context with per-app storage label badges

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:48:51 +01:00

412 lines
11 KiB
Go

package settings
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// 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"`
// Storage paths registry
StoragePaths []StoragePath `json:"storage_paths,omitempty"`
}
// AppBackupPrefs holds per-app backup toggle state.
type AppBackupPrefs struct {
Enabled bool `json:"enabled"`
}
// StoragePath represents a registered external storage location.
type StoragePath struct {
Path string `json:"path"` // e.g., "/mnt/hdd_1"
Label string `json:"label,omitempty"` // e.g., "Külső HDD 1TB"
IsDefault bool `json:"is_default,omitempty"` // new apps use this by default
Schedulable bool `json:"schedulable"` // whether new apps can be deployed here
AddedAt string `json:"added_at"` // RFC3339
}
// 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()
}
// --- Storage Paths ---
// GetStoragePaths returns a copy of all registered storage paths.
func (s *Settings) GetStoragePaths() []StoragePath {
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.StoragePaths) == 0 {
return nil
}
result := make([]StoragePath, len(s.StoragePaths))
copy(result, s.StoragePaths)
return result
}
// GetDefaultStoragePath returns the default storage path string, or "".
func (s *Settings) GetDefaultStoragePath() string {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sp := range s.StoragePaths {
if sp.IsDefault {
return sp.Path
}
}
return ""
}
// GetSchedulableStoragePaths returns paths available for new deployments.
func (s *Settings) GetSchedulableStoragePaths() []StoragePath {
s.mu.RLock()
defer s.mu.RUnlock()
var result []StoragePath
for _, sp := range s.StoragePaths {
if sp.Schedulable {
result = append(result, sp)
}
}
return result
}
// AddStoragePath registers a new storage path. Validation is done by caller.
func (s *Settings) AddStoragePath(sp StoragePath) error {
s.mu.Lock()
defer s.mu.Unlock()
if sp.IsDefault {
for i := range s.StoragePaths {
s.StoragePaths[i].IsDefault = false
}
}
s.StoragePaths = append(s.StoragePaths, sp)
return s.save()
}
// RemoveStoragePath removes a path by its path string.
func (s *Settings) RemoveStoragePath(path string) error {
s.mu.Lock()
defer s.mu.Unlock()
var kept []StoragePath
for _, sp := range s.StoragePaths {
if sp.Path != path {
kept = append(kept, sp)
}
}
s.StoragePaths = kept
return s.save()
}
// SetDefaultStoragePath changes which path is the default.
func (s *Settings) SetDefaultStoragePath(path string) error {
s.mu.Lock()
defer s.mu.Unlock()
found := false
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].IsDefault = true
found = true
} else {
s.StoragePaths[i].IsDefault = false
}
}
if !found {
return fmt.Errorf("storage path %q not found", path)
}
return s.save()
}
// SetSchedulable enables/disables a path for new deployments.
func (s *Settings) SetSchedulable(path string, schedulable bool) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Schedulable = schedulable
return s.save()
}
}
return fmt.Errorf("storage path %q not found", path)
}
// SetStorageLabel updates the label for a storage path.
func (s *Settings) SetStorageLabel(path, label string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.StoragePaths {
if s.StoragePaths[i].Path == path {
s.StoragePaths[i].Label = label
return s.save()
}
}
return fmt.Errorf("storage path %q not found", path)
}
// AutoDiscoverStoragePaths scans for HDD_PATH values and registers them if none exist.
// discoveredPaths are pre-scanned HDD_PATH values from deployed apps' app.yaml.
// fallbackHDDPath is the legacy controller.yaml paths.hdd_path (may be empty).
func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHDDPath string, logger *log.Logger) {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.StoragePaths) > 0 {
return // already configured
}
seen := make(map[string]bool)
var ordered []string
for _, p := range discoveredPaths {
cleaned := filepath.Clean(p)
if cleaned != "" && !seen[cleaned] {
seen[cleaned] = true
ordered = append(ordered, cleaned)
}
}
if fallbackHDDPath != "" {
cleaned := filepath.Clean(fallbackHDDPath)
if !seen[cleaned] {
seen[cleaned] = true
ordered = append(ordered, cleaned)
}
}
for i, path := range ordered {
sp := StoragePath{
Path: path,
Label: InferStorageLabel(path),
IsDefault: i == 0,
Schedulable: true,
AddedAt: time.Now().UTC().Format(time.RFC3339),
}
s.StoragePaths = append(s.StoragePaths, sp)
}
if len(s.StoragePaths) > 0 {
if err := s.save(); err != nil {
logger.Printf("[ERROR] Failed to save auto-discovered storage paths: %v", err)
return
}
logger.Printf("[INFO] Auto-discovered %d storage path(s)", len(s.StoragePaths))
for _, sp := range s.StoragePaths {
logger.Printf("[INFO] %s (%s) default=%v", sp.Path, sp.Label, sp.IsDefault)
}
}
}
// InferStorageLabel generates a human-readable label for a storage path.
func InferStorageLabel(path string) string {
base := filepath.Base(path)
if strings.HasPrefix(base, "hdd") || strings.HasPrefix(base, "ssd") || strings.HasPrefix(base, "usb") {
return fmt.Sprintf("Külső tárhely (%s)", base)
}
return fmt.Sprintf("Tárhely (%s)", base)
}