Phase 2: monitoring warnings, dashboard alerts & notification system
- 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>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -28,6 +28,17 @@
|
||||
</div>
|
||||
</nav>
|
||||
<main class="content">
|
||||
{{if .Alerts}}
|
||||
<div class="alerts-container">
|
||||
{{range .Alerts}}
|
||||
<div class="alert-banner alert-banner-{{.Level}}">
|
||||
<span class="alert-icon">{{if eq .Level "error"}}🔴{{else if eq .Level "warning"}}🟡{{else}}ℹ️{{end}}</span>
|
||||
<span class="alert-message">{{.Message}}</span>
|
||||
{{if .Link}}<a href="{{.Link}}" class="alert-link">{{.LinkText}} →</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "layout_end"}}
|
||||
|
||||
@@ -36,6 +36,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 1.5: Remote Monitoring Status -->
|
||||
<div class="monitor-card">
|
||||
<h3>Távoli monitoring</h3>
|
||||
{{if not .MonitoringEnabled}}
|
||||
<div class="monitoring-banner monitoring-banner-red">
|
||||
⚠️ A távoli monitoring ki van kapcsolva. Az üzemeltető nem kap értesítést hibák esetén.
|
||||
</div>
|
||||
{{else}}
|
||||
{{if .AllPingsConfigured}}
|
||||
<div class="monitoring-banner monitoring-banner-green">
|
||||
✅ Minden távoli monitoring aktív — az üzemeltető értesítést kap hibák esetén.
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="monitoring-banner monitoring-banner-yellow">
|
||||
⚠️ Egyes monitoring ellenőrzések nincsenek beállítva. Kérd az üzemeltetőt a konfiguráláshoz.
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="sysinfo-grid" style="margin-top: 0.75rem">
|
||||
{{range .PingStatus}}
|
||||
<div class="sysinfo-row">
|
||||
<span class="sysinfo-label">{{.Icon}} {{.Label}}</span>
|
||||
<span class="sysinfo-value">
|
||||
{{if .Configured}}<span class="ping-status-ok">✅ Beállítva</span>{{else}}<span class="ping-status-warn">⚠️ Nincs beállítva</span>{{end}}
|
||||
<span class="ping-schedule">{{.Schedule}}</span>
|
||||
</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Section 2: System Metrics Charts -->
|
||||
<div class="monitor-card">
|
||||
<div class="monitor-card-header">
|
||||
|
||||
@@ -93,5 +93,60 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Section C: Notification Preferences -->
|
||||
<div class="settings-card">
|
||||
<h3>Értesítések</h3>
|
||||
{{if .HubEnabled}}
|
||||
{{if .NotificationSuccess}}<div class="alert alert-info">{{.NotificationSuccess}}</div>{{end}}
|
||||
{{if .NotificationError}}<div class="alert alert-error">{{.NotificationError}}</div>{{end}}
|
||||
<form method="POST" action="/settings/notifications">
|
||||
<div class="form-group">
|
||||
<label for="notification_email">E-mail cím</label>
|
||||
<input type="email" id="notification_email" name="notification_email"
|
||||
value="{{with .NotificationPrefs}}{{.Email}}{{end}}"
|
||||
placeholder="pelda@email.hu" class="form-control">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Az alábbi eseményekről kapjon értesítést:</label>
|
||||
<div class="checkbox-group">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="event_disk_warning" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "disk_warning"}}checked{{end}}{{end}}{{end}}>
|
||||
<span class="toggle-label">Lemez figyelmeztetés (80%+)</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="event_backup_failed" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "backup_failed"}}checked{{end}}{{end}}{{end}}>
|
||||
<span class="toggle-label">Biztonsági mentés sikertelen</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="event_update_available" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "update_available"}}checked{{end}}{{end}}{{end}}>
|
||||
<span class="toggle-label">Frissítés elérhető</span>
|
||||
</label>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" name="event_security_update" {{with .NotificationPrefs}}{{range .EnabledEvents}}{{if eq . "security_update"}}checked{{end}}{{end}}{{end}}>
|
||||
<span class="toggle-label">Biztonsági frissítés</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cooldown_hours">Értesítési szünet</label>
|
||||
<div class="form-inline">
|
||||
<input type="number" id="cooldown_hours" name="cooldown_hours" min="1" max="168"
|
||||
value="{{with .NotificationPrefs}}{{.CooldownHours}}{{end}}"
|
||||
class="form-control form-control-narrow">
|
||||
<span class="form-hint">óra (azonos probléma esetén ennyi ideig nem küld újat)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Mentés</button>
|
||||
<button type="submit" formaction="/settings/notifications/test" class="btn btn-outline">Teszt email küldése</button>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="alert alert-info">
|
||||
Az értesítések a központi rendszeren keresztül működnek, ami jelenleg nincs bekapcsolva.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user