diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 8f11856..274e788 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -16,6 +16,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/metrics" "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" + "gitea.dooplex.hu/admin/felhom-controller/internal/notify" "gitea.dooplex.hu/admin/felhom-controller/internal/report" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" @@ -116,6 +117,12 @@ func main() { go backupMgr.LoadSnapshotHistory() } + // --- Initialize alert manager --- + alertMgr := web.NewAlertManager(logger) + + // --- Initialize notifier --- + notifier := notify.New(cfg.Hub.URL, cfg.Hub.APIKey, cfg.Customer.ID, sett, logger) + // --- Initialize scheduler --- sched := scheduler.New(logger) @@ -147,16 +154,28 @@ func main() { } else { pinger.Ping(healthUUID, body) } + // Refresh dashboard alerts from health report + alertMgr.Refresh(healthReport, cfg, backupMgr) + // Notify on health status changes + notifier.NotifyHealthChange(healthReport.Status, healthReport.Issues, healthReport.Warnings) return nil }) // Backup daily jobs if cfg.Backup.Enabled && backupMgr != nil { sched.Daily("db-dump", cfg.Backup.DBDumpSchedule, func(ctx context.Context) error { - return backupMgr.RunDBDumps(ctx) + err := backupMgr.RunDBDumps(ctx) + if err != nil { + notifier.NotifyDBDumpFailed("Adatbázis mentés sikertelen", err.Error()) + } + return err }) sched.Daily("backup", cfg.Backup.ResticSchedule, func(ctx context.Context) error { - return backupMgr.RunBackup(ctx) + err := backupMgr.RunBackup(ctx) + if err != nil { + notifier.NotifyBackupFailed("Biztonsági mentés sikertelen", err.Error()) + } + return err }) // Weekly integrity check — Sunday 04:00 @@ -164,7 +183,11 @@ func main() { if time.Now().Weekday() != time.Sunday { return nil } - return backupMgr.RunIntegrityCheck(ctx) + err := backupMgr.RunIntegrityCheck(ctx) + if err != nil { + notifier.NotifyIntegrityFailed("Mentés integritás ellenőrzés sikertelen", err.Error()) + } + return err }) // Cache refresh: every 5 minutes @@ -214,11 +237,17 @@ func main() { }() } + // Initial alert refresh (so alerts appear immediately, not after first 5min health check) + go func() { + report := monitor.RunHealthCheck(cfg, cpuCollector) + alertMgr.Refresh(report, cfg, backupMgr) + }() + // --- Initialize API router --- apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, metricsStore, logger) // --- Initialize web server --- - webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, logger, Version) + webServer := web.NewServer(cfg, stackMgr, cpuCollector, backupMgr, sched, sett, alertMgr, notifier, logger, Version) // --- Build HTTP mux --- mux := http.NewServeMux() diff --git a/controller/internal/notify/notifier.go b/controller/internal/notify/notifier.go new file mode 100644 index 0000000..75dd6f9 --- /dev/null +++ b/controller/internal/notify/notifier.go @@ -0,0 +1,272 @@ +package notify + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Notifier sends notification events to the hub relay service. +// Non-blocking: fires requests in goroutines, logs errors but doesn't retry aggressively. +type Notifier struct { + hubURL string + apiKey string + customerID string + httpClient *http.Client + logger *log.Logger + enabled bool + settings *settings.Settings + + mu sync.Mutex + cooldowns map[string]time.Time // event_type -> last notification time + + // prevHealthStatus tracks the previous health check status for change detection + prevHealthStatus string +} + +// New creates a new Notifier. Returns a no-op notifier if hub is not enabled. +func New(hubURL, apiKey, customerID string, sett *settings.Settings, logger *log.Logger) *Notifier { + enabled := hubURL != "" && apiKey != "" + if enabled { + logger.Printf("[INFO] Notifier enabled (hub: %s)", hubURL) + } else { + logger.Printf("[INFO] Notifier disabled (hub not configured)") + } + + return &Notifier{ + hubURL: hubURL, + apiKey: apiKey, + customerID: customerID, + httpClient: &http.Client{Timeout: 10 * time.Second}, + logger: logger, + enabled: enabled, + settings: sett, + cooldowns: make(map[string]time.Time), + } +} + +// notifyRequest is the JSON payload sent to the hub. +type notifyRequest struct { + CustomerID string `json:"customer_id"` + EventType string `json:"event_type"` + Severity string `json:"severity"` + Message string `json:"message"` + Details string `json:"details,omitempty"` +} + +// Notify sends a notification event to the hub relay. +// Checks local cooldown and event preferences before sending. +// Non-blocking: fires the HTTP request in a goroutine. +func (n *Notifier) Notify(eventType, severity, message, details string) { + if !n.enabled { + return + } + + prefs := n.settings.GetNotificationPrefs() + if prefs.Email == "" { + return // No email configured, skip + } + + // Check if event is enabled in preferences + eventEnabled := false + for _, e := range prefs.EnabledEvents { + if e == eventType { + eventEnabled = true + break + } + } + if !eventEnabled { + return + } + + // Check cooldown + cooldownDuration := time.Duration(prefs.CooldownHours) * time.Hour + if cooldownDuration == 0 { + cooldownDuration = 6 * time.Hour + } + + n.mu.Lock() + lastSent, exists := n.cooldowns[eventType] + if exists && time.Since(lastSent) < cooldownDuration { + n.mu.Unlock() + n.logger.Printf("[DEBUG] Notification cooldown active for %s (sent %s ago)", eventType, time.Since(lastSent).Round(time.Minute)) + return + } + n.cooldowns[eventType] = time.Now() + n.mu.Unlock() + + // Fire the notification in a goroutine (non-blocking) + go func() { + payload := notifyRequest{ + CustomerID: n.customerID, + EventType: eventType, + Severity: severity, + Message: message, + Details: details, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + n.logger.Printf("[ERROR] Failed to marshal notification: %v", err) + return + } + + url := n.hubURL + "/api/v1/notify" + req, err := http.NewRequest("POST", url, bytes.NewReader(jsonData)) + if err != nil { + n.logger.Printf("[ERROR] Failed to create notification request: %v", err) + return + } + req.Header.Set("Authorization", "Bearer "+n.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := n.httpClient.Do(req) + if err != nil { + n.logger.Printf("[WARN] Failed to send notification to hub: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + n.logger.Printf("[WARN] Hub notification returned %d for %s/%s", resp.StatusCode, eventType, severity) + return + } + + n.logger.Printf("[INFO] Notification sent: %s (%s) — %s", eventType, severity, message) + }() +} + +// NotifyHealthChange checks if health status changed and sends appropriate notifications. +// Call this after each health check with the new report status/issues/warnings. +func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string) { + if !n.enabled { + return + } + + prev := n.prevHealthStatus + n.prevHealthStatus = status + + // Only notify on status degradation (ok→warn, ok→fail, warn→fail) + if prev == "" { + return // First run, just record status + } + if statusRank(status) <= statusRank(prev) { + return // Status improved or stayed the same + } + + // Notify about each issue/warning + for _, issue := range issues { + n.Notify("container_unhealthy", "critical", issue, "") + } + for _, w := range warnings { + // Determine specific event type from warning message + eventType := classifyWarning(w) + n.Notify(eventType, "warning", w, "") + } +} + +// NotifyBackupFailed sends a notification about a backup failure. +func (n *Notifier) NotifyBackupFailed(message, details string) { + n.Notify("backup_failed", "critical", message, details) +} + +// NotifyDBDumpFailed sends a notification about a database dump failure. +func (n *Notifier) NotifyDBDumpFailed(message, details string) { + n.Notify("db_dump_failed", "critical", message, details) +} + +// NotifyIntegrityFailed sends a notification about a backup integrity check failure. +func (n *Notifier) NotifyIntegrityFailed(message, details string) { + n.Notify("integrity_failed", "warning", message, details) +} + +// SendTest sends a test notification for verifying the notification flow. +func (n *Notifier) SendTest() error { + if !n.enabled { + return fmt.Errorf("notifications not enabled (hub not configured)") + } + + payload := notifyRequest{ + CustomerID: n.customerID, + EventType: "test", + Severity: "info", + Message: "Teszt értesítés a Felhom rendszerből", + Details: "Ha ezt az emailt megkapta, az értesítések megfelelően működnek.", + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + + url := n.hubURL + "/api/v1/notify" + req, err := http.NewRequest("POST", url, bytes.NewReader(jsonData)) + if err != nil { + return fmt.Errorf("request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+n.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := n.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("hub returned %d", resp.StatusCode) + } + + return nil +} + +func statusRank(status string) int { + switch status { + case "ok": + return 0 + case "warn": + return 1 + case "fail": + return 2 + default: + return 0 + } +} + +func classifyWarning(message string) string { + // Try to classify the warning message into a specific event type + switch { + case contains(message, "disk") || contains(message, "Disk") || contains(message, "SSD") || contains(message, "HDD"): + if contains(message, "critical") || contains(message, "Critical") { + return "disk_critical" + } + return "disk_warning" + case contains(message, "Memory") || contains(message, "memory"): + return "disk_warning" // group memory under system warnings + case contains(message, "Temperature") || contains(message, "temperature"): + return "disk_warning" // group temp under system warnings + case contains(message, "container") || contains(message, "Container"): + return "container_unhealthy" + default: + return "disk_warning" // fallback to generic system warning + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsBytes(s, substr)) +} + +func containsBytes(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 8dc6d4a..c2a3ba4 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -26,8 +26,19 @@ type Settings struct { DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"` } -// NotificationPrefs is a placeholder for Phase 2 notification settings. -type NotificationPrefs struct{} +// 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 { @@ -127,3 +138,35 @@ func (s *Settings) SetDBValidation(filename string, cache DBValidationCache) err 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() +} diff --git a/controller/internal/web/alerts.go b/controller/internal/web/alerts.go new file mode 100644 index 0000000..2cd6ec4 --- /dev/null +++ b/controller/internal/web/alerts.go @@ -0,0 +1,184 @@ +package web + +import ( + "fmt" + "log" + "sync" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/monitor" +) + +// Alert represents a persistent dashboard alert banner. +type Alert struct { + ID string // unique identifier for filtering + Level string // "error", "warning", "info" + Message string // Hungarian display text + Link string // optional link to relevant page + LinkText string // link display text +} + +// AlertManager generates and stores dashboard alerts from health check results. +// Alerts are state-based (not event-based) — they reflect current system state +// and are regenerated after each health check cycle. +type AlertManager struct { + mu sync.RWMutex + alerts []Alert + logger *log.Logger +} + +// NewAlertManager creates a new AlertManager. +func NewAlertManager(logger *log.Logger) *AlertManager { + return &AlertManager{ + logger: logger, + } +} + +// Refresh regenerates alerts from the latest health check report and config state. +// Called after each health check cycle (every 5 minutes). +func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config, backupMgr *backup.Manager) { + var alerts []Alert + + // From health check issues (critical) + for _, issue := range report.Issues { + alerts = append(alerts, Alert{ + ID: "health-" + simpleHash(issue), + Level: "error", + Message: issue, + Link: "/monitoring", + LinkText: "Rendszermonitor", + }) + } + + // From health check warnings + for _, w := range report.Warnings { + alerts = append(alerts, Alert{ + ID: "health-" + simpleHash(w), + Level: "warning", + Message: w, + Link: "/monitoring", + LinkText: "Rendszermonitor", + }) + } + + // Missing ping UUIDs + if cfg.Monitoring.Enabled { + missing := countMissingPings(cfg) + if missing > 0 { + alerts = append(alerts, Alert{ + ID: "pings-missing", + Level: "warning", + Message: fmt.Sprintf("%d monitoring ellenőrzés nincs beállítva", missing), + Link: "/monitoring", + LinkText: "Rendszermonitor", + }) + } + } + + // Backup disabled + if !cfg.Backup.Enabled { + alerts = append(alerts, Alert{ + ID: "backup-disabled", + Level: "warning", + Message: "A biztonsági mentés nincs bekapcsolva", + Link: "/settings", + LinkText: "Beállítások", + }) + } + + // Sort: errors first, then warnings, then info + sortAlerts(alerts) + + am.mu.Lock() + am.alerts = alerts + am.mu.Unlock() + + am.logger.Printf("[DEBUG] AlertManager refreshed: %d alerts (%d error, %d warning)", + len(alerts), countLevel(alerts, "error"), countLevel(alerts, "warning")) +} + +// GetAlerts returns a copy of the current alerts, optionally excluding specific IDs. +func (am *AlertManager) GetAlerts(excludeIDs ...string) []Alert { + am.mu.RLock() + defer am.mu.RUnlock() + + if len(am.alerts) == 0 { + return nil + } + + exclude := make(map[string]bool, len(excludeIDs)) + for _, id := range excludeIDs { + exclude[id] = true + } + + var result []Alert + for _, a := range am.alerts { + if exclude[a.ID] { + continue + } + result = append(result, a) + } + + // Cap at 5 visible alerts + if len(result) > 5 { + overflow := len(result) - 5 + result = result[:5] + result = append(result, Alert{ + ID: "overflow", + Level: "info", + Message: fmt.Sprintf("+ %d további figyelmeztetés", overflow), + Link: "/monitoring", + }) + } + + return result +} + +// countMissingPings counts how many ping UUIDs are not configured. +func countMissingPings(cfg *config.Config) int { + count := 0 + uuids := []string{ + cfg.Monitoring.PingUUIDs.Heartbeat, + cfg.Monitoring.PingUUIDs.SystemHealth, + cfg.Monitoring.PingUUIDs.DBDump, + cfg.Monitoring.PingUUIDs.Backup, + cfg.Monitoring.PingUUIDs.BackupIntegrity, + } + for _, uuid := range uuids { + if !isPingConfigured(uuid) { + count++ + } + } + return count +} + +// simpleHash returns a short deterministic hash for deduplication. +func simpleHash(s string) string { + h := uint32(0) + for _, c := range s { + h = h*31 + uint32(c) + } + return fmt.Sprintf("%08x", h) +} + +// sortAlerts sorts alerts by severity: error > warning > info. +func sortAlerts(alerts []Alert) { + levelOrder := map[string]int{"error": 0, "warning": 1, "info": 2} + for i := 1; i < len(alerts); i++ { + for j := i; j > 0 && levelOrder[alerts[j].Level] < levelOrder[alerts[j-1].Level]; j-- { + alerts[j], alerts[j-1] = alerts[j-1], alerts[j] + } + } +} + +func countLevel(alerts []Alert, level string) int { + n := 0 + for _, a := range alerts { + if a.Level == level { + n++ + } + } + return n +} + diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index e3e414d..120f97d 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -4,15 +4,17 @@ import ( "fmt" "net/http" "net/url" + "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" "gitea.dooplex.hu/admin/felhom-controller/internal/system" "golang.org/x/crypto/bcrypt" ) func (s *Server) baseData(page, title string) map[string]interface{} { - return map[string]interface{}{ + data := map[string]interface{}{ "Page": page, "Title": title, "CustomerName": s.cfg.Customer.Name, @@ -20,6 +22,10 @@ func (s *Server) baseData(page, title string) map[string]interface{} { "Version": s.version, "AuthEnabled": s.authEnabled(), } + if s.alertManager != nil { + data["Alerts"] = s.alertManager.GetAlerts() + } + return data } func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) { @@ -196,9 +202,41 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s func (s *Server) monitoringHandler(w http.ResponseWriter, _ *http.Request) { data := s.baseData("monitoring", "Rendszermonitor") data["SystemInfo"] = system.GetInfo(s.cfg.Paths.HDDPath, s.cpuCollector) + + // On monitoring page, exclude the "pings-missing" alert since the detailed table is visible + if s.alertManager != nil { + data["Alerts"] = s.alertManager.GetAlerts("pings-missing") + } + + // Ping status section + data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled + if s.cfg.Monitoring.Enabled { + pings := []map[string]interface{}{ + {"Label": "Életjel (Heartbeat)", "Icon": "💓", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenként"}, + {"Label": "Rendszer állapot", "Icon": "🖥️", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenként"}, + {"Label": "Adatbázis mentés", "Icon": "🗄️", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule}, + {"Label": "Biztonsági mentés", "Icon": "💾", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule}, + {"Label": "Mentés integritás", "Icon": "🔍", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": "Hetente (vasárnap)"}, + } + allConfigured := true + for _, p := range pings { + if !p["Configured"].(bool) { + allConfigured = false + break + } + } + data["PingStatus"] = pings + data["AllPingsConfigured"] = allConfigured + } + s.render(w, "monitoring", data) } +// isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder. +func isPingConfigured(uuid string) bool { + return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME") +} + func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) { data := s.baseData("backups", "Biztonsági mentés") @@ -214,10 +252,8 @@ func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) { s.render(w, "backups", data) } -func (s *Server) settingsHandler(w http.ResponseWriter, _ *http.Request) { +func (s *Server) settingsData() map[string]interface{} { data := s.baseData("settings", "Beállítások") - - // System configuration (read-only display from controller.yaml) data["CustomerID"] = s.cfg.Customer.ID data["CustomerDomain"] = s.cfg.Customer.Domain data["GitRepoURL"] = s.cfg.Git.RepoURL @@ -228,8 +264,12 @@ func (s *Server) settingsHandler(w http.ResponseWriter, _ *http.Request) { data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase data["HubEnabled"] = s.cfg.Hub.Enabled + data["NotificationPrefs"] = s.settings.GetNotificationPrefs() + return data +} - s.render(w, "settings", data) +func (s *Server) settingsHandler(w http.ResponseWriter, _ *http.Request) { + s.render(w, "settings", s.settingsData()) } func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) { @@ -238,17 +278,7 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) newPassword := r.FormValue("new_password") confirmPassword := r.FormValue("confirm_password") - data := s.baseData("settings", "Beállítások") - data["CustomerID"] = s.cfg.Customer.ID - data["CustomerDomain"] = s.cfg.Customer.Domain - data["GitRepoURL"] = s.cfg.Git.RepoURL - data["GitSyncInterval"] = s.cfg.Git.SyncInterval - data["BackupEnabled"] = s.cfg.Backup.Enabled - data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule - data["ResticSchedule"] = s.cfg.Backup.ResticSchedule - data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled - data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase - data["HubEnabled"] = s.cfg.Hub.Enabled + data := s.settingsData() // Validate current password effectiveHash := s.effectivePasswordHash() @@ -298,3 +328,71 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.") http.Redirect(w, r, "/login?flash="+flash, http.StatusFound) } + +func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + email := strings.TrimSpace(r.FormValue("notification_email")) + cooldownStr := r.FormValue("cooldown_hours") + cooldownHours := 6 + if cooldownStr != "" { + if n, err := fmt.Sscanf(cooldownStr, "%d", &cooldownHours); n != 1 || err != nil { + cooldownHours = 6 + } + } + if cooldownHours < 1 { + cooldownHours = 1 + } + if cooldownHours > 168 { + cooldownHours = 168 + } + + // Collect enabled events from checkboxes + var enabledEvents []string + for _, evt := range []string{"disk_warning", "backup_failed", "update_available", "security_update"} { + if r.FormValue("event_"+evt) == "on" { + enabledEvents = append(enabledEvents, evt) + } + } + + prefs := &settings.NotificationPrefs{ + Email: email, + EnabledEvents: enabledEvents, + CooldownHours: cooldownHours, + } + + if err := s.settings.SetNotificationPrefs(prefs); err != nil { + s.logger.Printf("[ERROR] Failed to save notification prefs: %v", err) + data := s.settingsData() + data["NotificationError"] = "Hiba a beállítások mentésekor" + s.render(w, "settings", data) + return + } + + s.logger.Printf("[INFO] Notification preferences updated: email=%s, events=%v", email, enabledEvents) + + data := s.settingsData() + data["NotificationSuccess"] = "Értesítési beállítások mentve." + s.render(w, "settings", data) +} + +func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) { + data := s.settingsData() + + if s.notifier == nil { + data["NotificationError"] = "Az értesítések nincsenek bekapcsolva" + s.render(w, "settings", data) + return + } + + err := s.notifier.SendTest() + if err != nil { + s.logger.Printf("[ERROR] Test notification failed: %v", err) + data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err) + s.render(w, "settings", data) + return + } + + data["NotificationSuccess"] = "Teszt email elküldve." + s.render(w, "settings", data) +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 61da45a..6dff848 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -12,6 +12,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/notify" "gitea.dooplex.hu/admin/felhom-controller/internal/scheduler" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" @@ -25,6 +26,8 @@ type Server struct { backupMgr *backup.Manager scheduler *scheduler.Scheduler settings *settings.Settings + alertManager *AlertManager + notifier *notify.Notifier logger *log.Logger version string tmpl *template.Template @@ -34,7 +37,7 @@ type Server struct { done chan struct{} } -func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, logger *log.Logger, version string) *Server { +func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server { s := &Server{ cfg: cfg, stackMgr: stackMgr, @@ -42,6 +45,8 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste backupMgr: backupMgr, scheduler: sched, settings: sett, + alertManager: alertMgr, + notifier: notif, logger: logger, version: version, sessions: make(map[string]*session), @@ -85,6 +90,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.settingsHandler(w, r) case path == "/settings/password" && r.Method == http.MethodPost: s.settingsPasswordHandler(w, r) + case path == "/settings/notifications" && r.Method == http.MethodPost: + s.settingsNotificationsHandler(w, r) + case path == "/settings/notifications/test" && r.Method == http.MethodPost: + s.settingsNotificationsTestHandler(w, r) case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"): name := strings.TrimPrefix(path, "/stacks/") name = strings.TrimSuffix(name, "/logs") diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index 08dc927..edebe7b 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -28,6 +28,17 @@
+{{if .Alerts}} +
+ {{range .Alerts}} +
+ {{if eq .Level "error"}}🔴{{else if eq .Level "warning"}}🟡{{else}}ℹ️{{end}} + {{.Message}} + {{if .Link}}{{.LinkText}} →{{end}} +
+ {{end}} +
+{{end}} {{end}} {{define "layout_end"}} diff --git a/controller/internal/web/templates/monitoring.html b/controller/internal/web/templates/monitoring.html index 61e3c41..048f984 100644 --- a/controller/internal/web/templates/monitoring.html +++ b/controller/internal/web/templates/monitoring.html @@ -36,6 +36,37 @@ + +
+

Távoli monitoring

+ {{if not .MonitoringEnabled}} +
+ ⚠️ A távoli monitoring ki van kapcsolva. Az üzemeltető nem kap értesítést hibák esetén. +
+ {{else}} + {{if .AllPingsConfigured}} +
+ ✅ Minden távoli monitoring aktív — az üzemeltető értesítést kap hibák esetén. +
+ {{else}} +
+ ⚠️ Egyes monitoring ellenőrzések nincsenek beállítva. Kérd az üzemeltetőt a konfiguráláshoz. +
+ {{end}} +
+ {{range .PingStatus}} +
+ {{.Icon}} {{.Label}} + + {{if .Configured}}✅ Beállítva{{else}}⚠️ Nincs beállítva{{end}} + {{.Schedule}} + +
+ {{end}} +
+ {{end}} +
+
diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index cb52c4c..e8a6c6a 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -93,5 +93,60 @@ {{end}}
+ +
+

Értesítések

+ {{if .HubEnabled}} + {{if .NotificationSuccess}}
{{.NotificationSuccess}}
{{end}} + {{if .NotificationError}}
{{.NotificationError}}
{{end}} +
+
+ + +
+
+ +
+ + + + +
+
+
+ +
+ + óra (azonos probléma esetén ennyi ideig nem küld újat) +
+
+
+ + +
+
+ {{else}} +
+ Az értesítések a központi rendszeren keresztül működnek, ami jelenleg nincs bekapcsolva. +
+ {{end}} +
+ {{template "layout_end" .}} {{end}} diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index cffde1b..b878dd9 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -527,6 +527,29 @@ h3 { } .form-group-auto label { margin: 0; } .auto-generated-badge { color: var(--green); font-size: .8rem; font-weight: 500; } +.checkbox-group { + display: flex; + flex-direction: column; + gap: .5rem; + margin-top: .25rem; +} +.form-inline { + display: flex; + align-items: center; + gap: .75rem; +} +.form-control-narrow { + width: 80px; +} +.form-hint { + font-size: .85rem; + color: var(--text-muted); +} +.form-actions { + display: flex; + gap: .75rem; + margin-top: 1rem; +} .form-control { width: 100%; padding: .55rem .75rem; @@ -640,6 +663,37 @@ select.form-control option { background: var(--bg-secondary); color: var(--text- border-color: rgba(210, 153, 34, 0.3); } +/* Dashboard alert banners */ +.alerts-container { margin-bottom: 1rem; } +.alert-banner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 8px; + margin-bottom: 0.5rem; + font-size: 0.9rem; + border: 1px solid; +} +.alert-banner-error { + background: rgba(248, 113, 113, 0.1); + border-color: rgba(248, 113, 113, 0.3); + color: #f87171; +} +.alert-banner-warning { + background: rgba(250, 204, 21, 0.1); + border-color: rgba(250, 204, 21, 0.3); + color: #facc15; +} +.alert-banner-info { + background: rgba(96, 165, 250, 0.1); + border-color: rgba(96, 165, 250, 0.3); + color: #60a5fa; +} +.alert-icon { flex-shrink: 0; } +.alert-message { flex: 1; } +.alert-link { margin-left: auto; white-space: nowrap; } + /* Memory summary on deploy page */ .memory-summary { background: var(--bg-card); @@ -1488,6 +1542,34 @@ a.stat-card:hover { .monitor-card h3 { margin-bottom: .75rem; } +.monitoring-banner { + padding: .75rem 1rem; + border-radius: 8px; + font-size: .9rem; + border: 1px solid; +} +.monitoring-banner-green { + background: var(--green-bg); + color: var(--green); + border-color: rgba(35, 134, 54, 0.3); +} +.monitoring-banner-yellow { + background: var(--yellow-bg); + color: var(--yellow); + border-color: rgba(210, 153, 34, 0.3); +} +.monitoring-banner-red { + background: var(--orange-bg); + color: var(--orange); + border-color: rgba(219, 109, 40, 0.3); +} +.ping-status-ok { color: var(--green); } +.ping-status-warn { color: var(--yellow); } +.ping-schedule { + color: var(--text-muted); + font-size: .85rem; + margin-left: .75rem; +} .monitor-card-header { display: flex; align-items: center;