Add unified notification center

This commit is contained in:
maxim
2026-07-31 21:17:48 +03:00
parent 030e1fdc7f
commit ec20550c3b
4 changed files with 193 additions and 6 deletions

147
alerts.go Normal file
View File

@@ -0,0 +1,147 @@
package main
import (
"fmt"
"sort"
"strings"
"time"
)
type Alert struct {
ID string `json:"id"`
Severity string `json:"severity"`
Source string `json:"source"`
Title string `json:"title"`
Message string `json:"message"`
}
func evaluateAlerts(metrics dashboardMetrics) []Alert {
var alerts []Alert
add := func(id, severity, source, title, message string) {
alerts = append(alerts, Alert{ID: id, Severity: severity, Source: source, Title: title, Message: message})
}
if metrics.CPU.UsagePercent >= 95 {
add("cpu-usage", "critical", "CPU", "Очень высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent))
} else if metrics.CPU.UsagePercent >= 85 {
add("cpu-usage", "warning", "CPU", "Высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent))
}
for _, temperature := range metrics.CPU.Temperatures {
if temperature.Celsius >= 90 {
add("cpu-temp-"+temperature.Label, "critical", "CPU", "Критическая температура CPU", fmt.Sprintf("%s: %.1f °C.", temperature.Label, temperature.Celsius))
} else if temperature.Celsius >= 80 {
add("cpu-temp-"+temperature.Label, "warning", "CPU", "Высокая температура CPU", fmt.Sprintf("%s: %.1f °C.", temperature.Label, temperature.Celsius))
}
}
if metrics.Memory.UsagePercent >= 95 {
add("memory", "critical", "Память", "Почти закончилась оперативная память", fmt.Sprintf("Используется %.1f%% RAM.", metrics.Memory.UsagePercent))
} else if metrics.Memory.UsagePercent >= 85 {
add("memory", "warning", "Память", "Высокое использование памяти", fmt.Sprintf("Используется %.1f%% RAM.", metrics.Memory.UsagePercent))
}
if metrics.Memory.SwapTotalBytes > 0 && metrics.Memory.SwapPercent >= 50 {
add("swap", "warning", "Память", "Активно используется swap", fmt.Sprintf("Занято %.1f%% swap.", metrics.Memory.SwapPercent))
}
if metrics.Storage.UsagePercent >= 95 {
add("root-storage", "critical", "Storage", "Системный раздел почти заполнен", fmt.Sprintf("Раздел / заполнен на %.1f%%.", metrics.Storage.UsagePercent))
} else if metrics.Storage.UsagePercent >= 85 {
add("root-storage", "warning", "Storage", "Мало места на системном разделе", fmt.Sprintf("Раздел / заполнен на %.1f%%.", metrics.Storage.UsagePercent))
}
for _, disk := range metrics.Disks {
if disk.SMARTStatus == "Ошибка" {
add("disk-smart-"+disk.Name, "critical", "Диски", disk.Model+": ошибка SMART", joinReasons(disk.AttentionReasons, "SMART сообщает о неисправности диска."))
} else if disk.SMARTStatus == "Требует внимания" {
add("disk-smart-"+disk.Name, "warning", "Диски", disk.Model+": SMART требует внимания", joinReasons(disk.AttentionReasons, "Проверьте SMART-показатели диска."))
}
if disk.Temperature != nil {
limit := 60.0
if strings.Contains(disk.Type, "NVMe") {
limit = 75
}
if *disk.Temperature >= limit {
add("disk-temp-"+disk.Name, "warning", "Диски", disk.Model+": высокая температура", fmt.Sprintf("Температура %.1f °C.", *disk.Temperature))
}
}
}
for _, pool := range metrics.ZFS.Pools {
if pool.Health != "ONLINE" {
add("zfs-health-"+pool.Name, "critical", "ZFS", pool.Name+": пул не ONLINE", joinReasons(pool.AttentionReasons, "Текущее состояние: "+pool.Health+"."))
}
if pool.CapacityPercent >= 90 {
add("zfs-capacity-"+pool.Name, "critical", "ZFS", pool.Name+": пул почти заполнен", fmt.Sprintf("Заполнение %.1f%%.", pool.CapacityPercent))
} else if pool.CapacityPercent >= 80 {
add("zfs-capacity-"+pool.Name, "warning", "ZFS", pool.Name+": мало свободного места", fmt.Sprintf("Заполнение %.1f%%.", pool.CapacityPercent))
}
if strings.Contains(strings.ToLower(pool.Scan), "in progress") {
add("zfs-scan-"+pool.Name, "info", "ZFS", pool.Name+": выполняется scrub/resilver", pool.Scan)
}
}
if metrics.UPS.Available {
status := metrics.UPS.Status
if strings.Contains(status, "LB") {
add("ups-low", "critical", "UPS", "Низкий заряд UPS", metrics.UPS.StatusLabel+".")
} else if strings.Contains(status, "OB") || status == "ONBATT" {
add("ups-battery", "warning", "UPS", "Сервер работает от UPS", "Основное питание отсутствует, используется батарея.")
}
if metrics.UPS.ChargePercent != nil && *metrics.UPS.ChargePercent < 30 {
add("ups-charge", "critical", "UPS", "Низкий заряд батареи", fmt.Sprintf("Осталось %.1f%%.", *metrics.UPS.ChargePercent))
}
}
if metrics.Backups.Available && metrics.Guests.Available {
latest := make(map[int]BackupItem)
for _, backup := range metrics.Backups.Items {
latest[backup.VMID] = backup
}
now := time.Now()
for _, guest := range metrics.Guests.Guests {
if guest.Template != 0 {
continue
}
name := guest.Name
if name == "" {
name = fmt.Sprintf("VMID %d", guest.VMID)
}
backup, ok := latest[guest.VMID]
if !ok {
add(fmt.Sprintf("backup-missing-%d", guest.VMID), "critical", "Backups", name+": резервной копии нет", fmt.Sprintf("Для VMID %d не найдена резервная копия.", guest.VMID))
continue
}
age := now.Sub(time.Unix(backup.CreatedAt, 0))
if age > 48*time.Hour {
add(fmt.Sprintf("backup-old-%d", guest.VMID), "warning", "Backups", name+": копия старше 48 часов", fmt.Sprintf("Последняя копия создана %.0f часов назад на %s.", age.Hours(), backup.Storage))
}
}
}
if metrics.Guests.Available {
stopped := 0
for _, guest := range metrics.Guests.Guests {
if guest.Template == 0 && guest.Status != "running" {
stopped++
}
}
if stopped > 0 {
add("guests-stopped", "info", "VM/LXC", "Есть остановленные гости", fmt.Sprintf("Остановлено VM/LXC: %d. Это уведомление информационное.", stopped))
}
}
priority := map[string]int{"critical": 0, "warning": 1, "info": 2}
sort.SliceStable(alerts, func(i, j int) bool {
if priority[alerts[i].Severity] != priority[alerts[j].Severity] {
return priority[alerts[i].Severity] < priority[alerts[j].Severity]
}
return alerts[i].Title < alerts[j].Title
})
return alerts
}
func joinReasons(reasons []string, fallback string) string {
if len(reasons) == 0 {
return fallback
}
return strings.Join(reasons, " · ")
}