bdbe170a54
New storage watchdog monitors registered storage paths every 5s. On disconnect (3 consecutive probe failures), auto-stops affected apps, lazy-unmounts stale VFS entries, fires alerts/notifications/hub report. On reconnect (UUID detected), auto-remounts via fstab, cleans stale restic locks, offers app restart. Safe disconnect UI for USB drives: confirmation dialog, stop apps, sync, unmount. Disconnected state visible across all pages (dashboard, settings, backups, monitoring) with hatched red bars and badges. Backup guards skip disconnected drives. 22 files changed (1 new: monitor/watchdog.go), ~1500 lines added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
250 lines
6.4 KiB
Go
250 lines
6.4 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"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"
|
|
"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
|
|
}
|
|
|
|
// 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) 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)
|
|
}
|
|
|
|
// 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",
|
|
})
|
|
}
|
|
|
|
// 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()
|
|
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|