Files
ProxmoxDash/alerts.go

205 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"sort"
"strings"
"time"
)
type Alert struct {
EventID int64 `json:"eventId,omitempty"`
ID string `json:"id"`
Severity string `json:"severity"`
Source string `json:"source"`
Title string `json:"title"`
Message string `json:"message"`
}
func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []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.Update.State == "available" {
add("dashboard-update", "info", "Dashboard", "Доступно обновление "+metrics.Update.AvailableVersion, "Режим обновлений: только уведомлять. Установите обновление с сервера или включите автоматический режим.")
} else if metrics.Update.State == "error" {
add("dashboard-update-error", "warning", "Dashboard", "Не удалось проверить обновления", metrics.Update.Message)
}
if metrics.Maintenance.RebootRequired || metrics.Maintenance.NewKernelAvailable {
add("maintenance-reboot", "warning", "Proxmox", "Требуется перезагрузка хоста", "Установлено новое ядро или система запросила перезагрузку.")
}
if metrics.Maintenance.AvailableUpdates > 0 {
add("maintenance-updates", "info", "Proxmox", "Доступны обновления системы", fmt.Sprintf("Пакетов для обновления: %d.", metrics.Maintenance.AvailableUpdates))
}
if metrics.CPU.UsagePercent >= thresholds.CPUCritical {
add("cpu-usage", "critical", "CPU", "Очень высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent))
} else if metrics.CPU.UsagePercent >= thresholds.CPUWarning {
add("cpu-usage", "warning", "CPU", "Высокая загрузка CPU", fmt.Sprintf("Текущая загрузка %.1f%%.", metrics.CPU.UsagePercent))
}
for _, temperature := range metrics.CPU.Temperatures {
if temperature.Celsius >= thresholds.CPUTempCritical {
add("cpu-temp-"+temperature.Label, "critical", "CPU", "Критическая температура CPU", fmt.Sprintf("%s: %.1f °C.", temperature.Label, temperature.Celsius))
} else if temperature.Celsius >= thresholds.CPUTempWarning {
add("cpu-temp-"+temperature.Label, "warning", "CPU", "Высокая температура CPU", fmt.Sprintf("%s: %.1f °C.", temperature.Label, temperature.Celsius))
}
}
if metrics.Memory.UsagePercent >= thresholds.MemoryCritical {
add("memory", "critical", "Память", "Почти закончилась оперативная память", fmt.Sprintf("Используется %.1f%% RAM.", metrics.Memory.UsagePercent))
} else if metrics.Memory.UsagePercent >= thresholds.MemoryWarning {
add("memory", "warning", "Память", "Высокое использование памяти", fmt.Sprintf("Используется %.1f%% RAM.", metrics.Memory.UsagePercent))
}
if metrics.Memory.SwapTotalBytes > 0 && metrics.Memory.SwapPercent >= thresholds.SwapWarning {
add("swap", "warning", "Память", "Активно используется swap", fmt.Sprintf("Занято %.1f%% swap.", metrics.Memory.SwapPercent))
}
if metrics.Storage.UsagePercent >= thresholds.StorageCritical {
add("root-storage", "critical", "Storage", "Системный раздел почти заполнен", fmt.Sprintf("Раздел / заполнен на %.1f%%.", metrics.Storage.UsagePercent))
} else if metrics.Storage.UsagePercent >= thresholds.StorageWarning {
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 := thresholds.DiskTempWarning
if strings.Contains(disk.Type, "NVMe") {
limit = thresholds.NVMeTempWarning
}
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 >= thresholds.ZFSCritical {
add("zfs-capacity-"+pool.Name, "critical", "ZFS", pool.Name+": пул почти заполнен", fmt.Sprintf("Заполнение %.1f%%.", pool.CapacityPercent))
} else if pool.CapacityPercent >= thresholds.ZFSWarning {
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 < thresholds.UPSChargeCritical {
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 > time.Duration(thresholds.BackupMaxAgeHours*float64(time.Hour)) {
add(fmt.Sprintf("backup-old-%d", guest.VMID), "warning", "Backups", fmt.Sprintf("%s: копия старше %.0f часов", name, thresholds.BackupMaxAgeHours), 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))
}
}
for _, service := range metrics.Services.Services {
for _, endpoint := range service.Endpoints {
if !endpoint.Up {
add(fmt.Sprintf("service-%d-%s", service.ID, endpoint.Kind), "critical", "Сервисы", service.Name+": недоступен "+strings.ToLower(endpoint.Label), endpoint.Error)
} else if endpoint.TLSDays != nil && *endpoint.TLSDays < 0 {
add(fmt.Sprintf("service-tls-%d-%s", service.ID, endpoint.Kind), "critical", "Сервисы", service.Name+": сертификат истёк", fmt.Sprintf("%s: истёк %d дн. назад.", endpoint.Label, -*endpoint.TLSDays))
} else if endpoint.TLSDays != nil && *endpoint.TLSDays < 14 {
add(fmt.Sprintf("service-tls-%d-%s", service.ID, endpoint.Kind), "warning", "Сервисы", service.Name+": сертификат скоро истечёт", fmt.Sprintf("%s: осталось %d дн.", endpoint.Label, *endpoint.TLSDays))
}
}
}
if metrics.SystemHealth.FailedUnits > 0 {
add("systemd-failed", "critical", "Система", "Есть упавшие systemd-службы", fmt.Sprintf("Не запущено служб: %d.", metrics.SystemHealth.FailedUnits))
}
if metrics.SystemHealth.KernelErrors > 0 {
add("kernel-errors", "warning", "Система", "Ошибки ядра за 24 часа", fmt.Sprintf("Найдено сообщений: %d.", metrics.SystemHealth.KernelErrors))
}
var networkErrors uint64
for _, iface := range metrics.Network.Interfaces {
networkErrors += iface.ReceiveErrors + iface.TransmitErrors + iface.ReceiveDrops + iface.TransmitDrops
}
if networkErrors > 0 {
add("network-errors", "warning", "Сеть", "Сетевые ошибки и потери пакетов", fmt.Sprintf("Суммарный счётчик интерфейсов: %d.", networkErrors))
}
oldSnapshots := 0
for _, snapshot := range metrics.Activity.Snapshots {
if snapshot.AgeSeconds > 30*24*3600 {
oldSnapshots++
}
}
if oldSnapshots > 0 {
add("old-snapshots", "warning", "VM/LXC", "Есть старые snapshots", fmt.Sprintf("Снимков старше 30 дней: %d.", oldSnapshots))
}
for _, task := range metrics.Activity.Tasks {
if task.StartTime < time.Now().Add(-24*time.Hour).Unix() {
continue
}
if task.Status != "" && task.Status != "OK" {
severity := "warning"
if task.Type == "vzdump" {
severity = "critical"
}
add("task-failed-"+task.UPID, severity, "Proxmox", task.Type+": задача завершилась ошибкой", fmt.Sprintf("VMID %s, пользователь %s, статус %s.", task.ID, task.User, task.Status))
}
}
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, " · ")
}