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, " · ") }