9056f01fae
Source: felhom.eu/documentation/audits/DIAG-agent-channel-2026-07-26.md bootstrap.DetectEndpointDrift names a controller.yaml vs bootstrap.json local_api.endpoint divergence -- one ERROR carrying BOTH values and BOTH paths, its own event type local_api_endpoint_drift, and its own Hungarian banner shown ABOVE the channel banner because drift is the cause and "agent unreachable" the symptom. It writes NOTHING: reconciling from bootstrap.json would clobber a correct controller.yaml on any half-provisioned or hand-repaired guest, so the authority ruling is deferred to R-78. Fail-safe silent on absent/unparseable/ incomplete bootstrap and on an empty endpoint (ensureLocalAPI's fill-if-missing path is untouched). Fingerprint compared as a BOOLEAN only; token never compared, logged or exposed. EffectiveProtected now gates samba on Enabled && UserSet, mirroring BOTH of reconcileSambaAt's early returns, and the doc comment is corrected in the same change -- it claimed "detection and deployment agree in both directions" while citing only !smb.Enabled, an assertion that went false when !smb.UserSet was added. Not over-suppressed: sharing on WITH a password and a dead container still alarms. Channel log: the debounce placeholder is stateUnconfirmed (rendered "unseeded") instead of "up", so a born-down channel no longer logs "up->down" and orUnseeded stops being dead code. Logging only -- the placeholder is still matched in the re-arm condition, so F2 born-down alerting is byte-for-byte unchanged and all nine pre-existing channelhealth tests pass. Tests 951 -> 959, all green. Red-proofs A (both directions), E and F. MinAgent unchanged; felhom-agent untouched.
370 lines
12 KiB
Go
370 lines
12 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
|
|
// deadAppAlerts (fix-3, CAMPAIGN-3) is set each cycle by the health loop from the deployed-app
|
|
// running-state view (which is NOT in the health report — it comes from the stack manager). Same
|
|
// out-of-band, state-based, self-clearing model as agentChannelAlert: passing an empty slice when
|
|
// every deployed app is running clears the banner with no manual dismissal.
|
|
deadAppAlerts []Alert
|
|
// endpointDriftAlert (R-77) is set/cleared at startup by the local_api drift check. Separate from
|
|
// agentChannelAlert on purpose: drift is usually the CAUSE and "agent unreachable" the SYMPTOM,
|
|
// and during the 2026-07-25 outage only the symptom was visible.
|
|
endpointDriftAlert *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",
|
|
}
|
|
}
|
|
|
|
// SetEndpointDriftAlert sets (drift=true) or clears the local_api endpoint-drift banner (R-77).
|
|
//
|
|
// It is deliberately a SEPARATE alert from SetAgentChannelAlert: during the 2026-07-25 outage the
|
|
// generic "agent unreachable" banner was the ONLY signal, and it looked like a dead agent. The two
|
|
// can also be true at once — a drifted endpoint usually CAUSES the channel to be down — so folding
|
|
// them together would hide the actionable one behind the symptom.
|
|
func (am *AlertManager) SetEndpointDriftAlert(drift bool, msg string) {
|
|
am.mu.Lock()
|
|
defer am.mu.Unlock()
|
|
if !drift {
|
|
am.endpointDriftAlert = nil
|
|
return
|
|
}
|
|
am.endpointDriftAlert = &Alert{
|
|
ID: "local-api-endpoint-drift",
|
|
Level: "error",
|
|
Message: msg,
|
|
Link: "/settings",
|
|
LinkText: "Beállítások",
|
|
}
|
|
}
|
|
|
|
// DeadApp is a deployed app the health loop found not-running (fix-3). State is the container-state
|
|
// string for the display (e.g. "stopped"/"exited").
|
|
type DeadApp struct {
|
|
Name string
|
|
DisplayName string
|
|
State string
|
|
}
|
|
|
|
// deadAppGroupThreshold: above this many dead apps, collapse to ONE grouped alert (a reboot storm
|
|
// with many NAS apps down should not paper the dashboard with a wall of banners — fix-3).
|
|
const deadAppGroupThreshold = 3
|
|
|
|
// buildDeadAppAlerts turns the dead-app list into dashboard alerts (WARN). ≤ threshold → one per app;
|
|
// more → a single grouped alert. Pure → unit-tested. Empty list → nil (clears the banner).
|
|
func buildDeadAppAlerts(dead []DeadApp) []Alert {
|
|
if len(dead) == 0 {
|
|
return nil
|
|
}
|
|
if len(dead) > deadAppGroupThreshold {
|
|
return []Alert{{
|
|
ID: "deadapp-group",
|
|
Level: "warning",
|
|
Message: fmt.Sprintf("%d telepített alkalmazás nem fut — nézze meg a rendszermonitort", len(dead)),
|
|
Link: "/monitoring",
|
|
LinkText: "Rendszermonitor",
|
|
}}
|
|
}
|
|
alerts := make([]Alert, 0, len(dead))
|
|
for _, d := range dead {
|
|
name := d.DisplayName
|
|
if name == "" {
|
|
name = d.Name
|
|
}
|
|
msg := "Telepített alkalmazás nem fut: " + name
|
|
if d.State != "" {
|
|
msg += " (" + d.State + ")"
|
|
}
|
|
alerts = append(alerts, Alert{
|
|
ID: "deadapp-" + simpleHash(d.Name),
|
|
Level: "warning",
|
|
Message: msg,
|
|
Link: "/monitoring",
|
|
LinkText: "Rendszermonitor",
|
|
})
|
|
}
|
|
return alerts
|
|
}
|
|
|
|
// SetDeadAppAlerts stores the current deployed-not-running banner set (fix-3). State-based: called
|
|
// each health cycle with the live dead-app list (empty clears it). Included in GetAlerts.
|
|
func (am *AlertManager) SetDeadAppAlerts(dead []DeadApp) {
|
|
alerts := buildDeadAppAlerts(dead)
|
|
am.mu.Lock()
|
|
am.deadAppAlerts = alerts
|
|
am.mu.Unlock()
|
|
}
|
|
|
|
// 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 && am.endpointDriftAlert == nil && len(am.deadAppAlerts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
exclude := make(map[string]bool, len(excludeIDs))
|
|
for _, id := range excludeIDs {
|
|
exclude[id] = true
|
|
}
|
|
|
|
var result []Alert
|
|
// Endpoint drift first: it is the actionable CAUSE, and the channel-down banner below is usually
|
|
// just its symptom. Showing the symptom above the cause is what made the 2026-07-25 outage read
|
|
// as an infrastructure blip for 17.5 h.
|
|
if am.endpointDriftAlert != nil && !exclude[am.endpointDriftAlert.ID] {
|
|
result = append(result, *am.endpointDriftAlert)
|
|
}
|
|
// 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)
|
|
}
|
|
// Dead-app banners (fix-3) — prepended after the channel alert: a deployed app being down is a
|
|
// high-signal state the operator/customer must see immediately.
|
|
for _, a := range am.deadAppAlerts {
|
|
if !exclude[a.ID] {
|
|
result = append(result, a)
|
|
}
|
|
}
|
|
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
|
|
}
|