Files
felhom-controller/controller/internal/web/alerts.go
T
admin a277b18981 channelhealth: controller->agent channel health-check (periodic probe + classified operator alert) v0.90.0
New internal/channelhealth Checker: ~60s probe via the PRODUCTION memoized client
(Server.ProbeAgentChannel, GET /storage), classifies failures (spike Q1 map), debounces transient
reasons (N>=2; construction error latches distinctly), seeds first obs, alerts operator+dashboard on
transition. Notifier.NotifyAgentChannelDown/Recovered (English, operator-only), AlertManager dashboard
banner (Hungarian). No agent/hub change. Spike-proven.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EPZ4GJ8L5Jqf8UiPwbn1kt
2026-06-29 20:28:02 +02:00

269 lines
7.5 KiB
Go

package web
import (
"fmt"
"hash/crc32"
"log"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// 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
PageOnly []string // if non-empty, only show on these pages (e.g., ["dashboard", "monitoring"])
Inline bool // if true, rendered by page template inline, not in layout banner
}
// 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
hubPushStatusFn func() HubPushStatusData
// agentChannelAlert is set/cleared by the channel-health checker (out-of-band from Refresh, which
// is health-report-driven). nil = channel up. It is prepended in GetAlerts so a dead controller→
// agent link (disk/storage UI broken) is always visible regardless of the health-report cycle.
agentChannelAlert *Alert
}
// NewAlertManager creates a new AlertManager.
func NewAlertManager(logger *log.Logger) *AlertManager {
return &AlertManager{
logger: logger,
}
}
// SetHubPushStatus sets the hub push status callback for generating hub alerts.
func (am *AlertManager) SetHubPushStatus(fn func() HubPushStatusData) {
am.mu.Lock()
am.hubPushStatusFn = fn
am.mu.Unlock()
}
// SetAgentChannelAlert sets (down=true) or clears (down=false) the controller→agent channel-down
// dashboard banner. Called by the channel-health checker each probe (idempotent). msg is the short
// Hungarian display line.
func (am *AlertManager) SetAgentChannelAlert(down bool, msg string) {
am.mu.Lock()
defer am.mu.Unlock()
if !down {
am.agentChannelAlert = nil
return
}
am.agentChannelAlert = &Alert{
ID: "agent-channel-down",
Level: "error",
Message: msg,
Link: "/settings",
LinkText: "Beállítások",
}
}
// Refresh regenerates alerts from the latest health check report and config state.
// Called after each health check cycle (every 5 minutes) and on storage state changes.
func (am *AlertManager) Refresh(report *monitor.HealthReport, cfg *config.Config, backupMgr *backup.Manager, updateAvailable bool, latestVersion string, storagePaths ...[]settings.StoragePath) {
var alerts []Alert
// Disconnected storage alerts (top-level error banners on all pages)
if len(storagePaths) > 0 {
for _, sp := range storagePaths[0] {
if sp.Disconnected {
label := sp.Label
if label == "" {
label = sp.Path
}
alerts = append(alerts, Alert{
ID: "storage-disconnected-" + simpleHash(sp.Path),
Level: "error",
Message: fmt.Sprintf("Meghajtó leválasztva: %s (%s)", label, sp.Path),
Link: "/settings",
LinkText: "Beállítások",
})
}
}
}
// 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 {
alert := Alert{
ID: "health-" + simpleHash(w),
Level: "warning",
Message: w,
Link: "/monitoring",
LinkText: "Rendszermonitor",
}
// Disk-related warnings rendered inline under storage bars, not in top banner
if strings.Contains(w, "meghajtón") || strings.Contains(w, "adattároló") || strings.Contains(w, "meghajtó") {
alert.ID = "disk-not-separate"
alert.PageOnly = []string{"dashboard", "monitoring"}
alert.Inline = true
}
alerts = append(alerts, alert)
}
// Hub connection status
if !cfg.Hub.Enabled || cfg.Hub.URL == "" {
alerts = append(alerts, Alert{
ID: "hub-disabled",
Level: "warning",
Message: "Hub kapcsolat kikapcsolva — a központi monitoring nem aktív",
Link: "/monitoring",
LinkText: "Rendszermonitor",
})
} else if am.hubPushStatusFn != nil {
ps := am.hubPushStatusFn()
if ps.LastError != "" && (ps.LastSuccess.IsZero() || time.Since(ps.LastSuccess) > 30*time.Minute) {
alerts = append(alerts, Alert{
ID: "hub-unreachable",
Level: "error",
Message: fmt.Sprintf("Hub nem elérhető — utolsó hiba: %s", ps.LastError),
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",
})
}
// Update available
if updateAvailable && latestVersion != "" {
alerts = append(alerts, Alert{
ID: "update-available",
Level: "info",
Message: fmt.Sprintf("Új controller verzió elérhető: %s", latestVersion),
Link: "/settings",
LinkText: "Frissítés",
})
}
// Sort: errors first, then warnings, then info
sortAlerts(alerts)
am.mu.Lock()
am.alerts = alerts
am.mu.Unlock()
}
// 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 && am.agentChannelAlert == nil {
return nil
}
exclude := make(map[string]bool, len(excludeIDs))
for _, id := range excludeIDs {
exclude[id] = true
}
var result []Alert
// Channel-down is prepended (highest priority — the agent link being dead breaks disk/storage UI).
if am.agentChannelAlert != nil && !exclude[am.agentChannelAlert.ID] {
result = append(result, *am.agentChannelAlert)
}
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
}
// GetInlineAlerts returns alerts marked as Inline for a specific page.
func (am *AlertManager) GetInlineAlerts(page string) []Alert {
am.mu.RLock()
defer am.mu.RUnlock()
var result []Alert
for _, a := range am.alerts {
if !a.Inline {
continue
}
if len(a.PageOnly) == 0 {
result = append(result, a)
continue
}
for _, p := range a.PageOnly {
if p == page {
result = append(result, a)
break
}
}
}
return result
}
// simpleHash returns a short deterministic hash for deduplication.
func simpleHash(s string) string {
return fmt.Sprintf("%08x", crc32.ChecksumIEEE([]byte(s)))
}
// 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
}