389 lines
23 KiB
Go
389 lines
23 KiB
Go
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"`
|
||
FirstSeen int64 `json:"firstSeen,omitempty"`
|
||
LastSeen int64 `json:"lastSeen,omitempty"`
|
||
}
|
||
|
||
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 {
|
||
diskExcluded := false
|
||
for _, value := range strings.Split(thresholds.DiskIOExcluded, ",") {
|
||
value = strings.TrimSpace(value)
|
||
if value != "" && (strings.EqualFold(value, disk.Name) || strings.EqualFold(value, disk.Serial)) {
|
||
diskExcluded = true
|
||
break
|
||
}
|
||
}
|
||
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))
|
||
}
|
||
}
|
||
if len(disk.CounterChanges) > 0 {
|
||
add("disk-growth-"+disk.Name, "critical", "Диски", disk.Model+": ухудшились SMART-счётчики", strings.Join(disk.CounterChanges, " · "))
|
||
}
|
||
if !diskExcluded && disk.IOPressureSeconds >= thresholds.DiskIODuration {
|
||
severity := "warning"
|
||
if disk.Utilization >= 98 || disk.LatencyMs >= 100 {
|
||
severity = "critical"
|
||
}
|
||
cause := disk.IOCause
|
||
if cause == "" {
|
||
cause = "источник пока не определён"
|
||
}
|
||
add("disk-io-pressure-"+disk.Name, severity, "Диски", disk.Model+": длительная I/O-нагрузка", fmt.Sprintf("%s занят на %.1f%%; вероятная причина: %s (уверенность %d%%). %.0f IOPS, %.1f MB/s, latency %.1f ms, очередь %.1f.", disk.Name, disk.Utilization, cause, disk.IOConfidence, disk.ReadIOPS+disk.WriteIOPS, (disk.ReadBytesPerSec+disk.WriteBytesPerSec)/1048576, disk.LatencyMs, disk.AverageQueue))
|
||
}
|
||
if disk.SelfTestNeverRun {
|
||
add("disk-selftest-"+disk.Name, "warning", "Диски", disk.Model+": нет завершённого SMART self-test", "Короткий тест будет автоматически запущен; проверьте его результат после завершения.")
|
||
}
|
||
}
|
||
for _, missing := range metrics.MissingDisks {
|
||
add("disk-missing-"+missing, "critical", "Диски", "Физический диск исчез", missing+" больше не обнаруживается в /sys/block.")
|
||
}
|
||
|
||
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 pool.ReadErrors+pool.WriteErrors+pool.ChecksumErrors > 0 {
|
||
add("zfs-vdev-errors-"+pool.Name, "critical", "ZFS", pool.Name+": ошибки чтения/записи/checksum", fmt.Sprintf("read=%d, write=%d, checksum=%d.", pool.ReadErrors, pool.WriteErrors, pool.ChecksumErrors))
|
||
}
|
||
if len(pool.ErrorChanges) > 0 {
|
||
add("zfs-new-errors-"+pool.Name, "critical", "ZFS", pool.Name+": появились новые ошибки", strings.Join(pool.ErrorChanges, " · "))
|
||
}
|
||
if pool.ScrubTooOld {
|
||
add("zfs-scrub-old-"+pool.Name, "warning", "ZFS", pool.Name+": scrub просрочен", "Scrub не выполнялся более 35 дней либо ни разу не запускался.")
|
||
}
|
||
}
|
||
for _, dataset := range metrics.ZFS.Datasets {
|
||
if dataset.CapacityPercent >= 90 {
|
||
add("zfs-dataset-"+dataset.Name, "critical", "ZFS", dataset.Name+": dataset почти заполнен", fmt.Sprintf("Заполнение %.1f%%, доступно %.1f GB.", dataset.CapacityPercent, float64(dataset.AvailableBytes)/1073741824))
|
||
} else if dataset.CapacityPercent >= 80 {
|
||
add("zfs-dataset-"+dataset.Name, "warning", "ZFS", dataset.Name+": мало свободного места", fmt.Sprintf("Заполнение %.1f%%.", dataset.CapacityPercent))
|
||
}
|
||
}
|
||
if metrics.ZFS.ARC.Available && metrics.ZFS.ARC.Hits+metrics.ZFS.ARC.Misses > 10000 && metrics.ZFS.ARC.HitRatio < 70 {
|
||
add("zfs-arc-hit", "warning", "ZFS", "Низкий ARC hit ratio", fmt.Sprintf("Попадания ARC: %.1f%%.", metrics.ZFS.ARC.HitRatio))
|
||
}
|
||
|
||
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.UPS.ReplaceBattery {
|
||
add("ups-replace-battery", "critical", "UPS", "UPS требует заменить батарею", "NUT/APC сообщает флаг Replace Battery.")
|
||
}
|
||
if metrics.UPS.BatteryAgeDays > 3*365 {
|
||
add("ups-battery-age", "warning", "UPS", "Батарея UPS старше трёх лет", fmt.Sprintf("Возраст батареи примерно %d дней.", metrics.UPS.BatteryAgeDays))
|
||
}
|
||
} else if metrics.UPS.Source != "" {
|
||
add("ups-communication", "warning", "UPS", "Нет связи с UPS", "NUT и apcupsd не вернули данные. Проверьте USB/сетевое подключение и службу UPS.")
|
||
}
|
||
|
||
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 _, agent := range metrics.Agents {
|
||
vmSuffix := fmt.Sprintf("-%d", agent.VMID)
|
||
if !agent.Online {
|
||
add("agent-offline-"+agent.ID+vmSuffix, "critical", "Агенты", agent.Name+": агент не отвечает", fmt.Sprintf("Последний heartbeat: %s назад. VMID %d.", time.Since(time.Unix(agent.LastSeen, 0)).Round(time.Second), agent.VMID))
|
||
continue
|
||
}
|
||
if agent.Report.DockerAvailable {
|
||
for _, container := range agent.Report.Containers {
|
||
if container.State != "running" {
|
||
add("agent-container-stopped-"+agent.ID+"-"+container.ID+vmSuffix, "warning", "Docker", agent.Name+": контейнер "+container.Name+" остановлен", fmt.Sprintf("%s · VMID %d.", container.Status, agent.VMID))
|
||
} else if container.Health == "unhealthy" {
|
||
add("agent-container-health-"+agent.ID+"-"+container.ID+vmSuffix, "critical", "Docker", agent.Name+": контейнер "+container.Name+" unhealthy", fmt.Sprintf("Docker health-check не проходит · VMID %d.", agent.VMID))
|
||
}
|
||
}
|
||
}
|
||
for _, service := range agent.Report.Services {
|
||
if service.State == "failed" || service.SubState == "failed" {
|
||
add("agent-systemd-"+agent.ID+"-"+service.Name+vmSuffix, "critical", "Агенты", agent.Name+": упала служба "+service.Name, fmt.Sprintf("%s · VMID %d.", firstNonEmpty(service.Description, service.SubState), agent.VMID))
|
||
} else if service.Flapping {
|
||
add("agent-flapping-"+agent.ID+"-"+service.Name+vmSuffix, "warning", "Агенты", agent.Name+": служба постоянно перезапускается", fmt.Sprintf("%s: %d рестартов, последний запуск %s · VMID %d.", service.Name, service.Restarts, time.Unix(service.StartedAt, 0).Format("15:04:05"), agent.VMID))
|
||
}
|
||
}
|
||
for _, filesystem := range agent.Report.Filesystems {
|
||
if filesystem.UsedPercent >= 90 {
|
||
add("agent-filesystem-"+agent.ID+"-"+filesystem.Mountpoint+vmSuffix, "critical", "Агенты", agent.Name+": заканчивается место "+filesystem.Mountpoint, fmt.Sprintf("Заполнено %.1f%%, свободно %.1f GB · VMID %d.", filesystem.UsedPercent, float64(filesystem.TotalBytes-filesystem.UsedBytes)/1073741824, agent.VMID))
|
||
}
|
||
if filesystem.InodesPercent >= 90 {
|
||
add("agent-inodes-"+agent.ID+"-"+filesystem.Mountpoint+vmSuffix, "critical", "Агенты", agent.Name+": заканчиваются inode "+filesystem.Mountpoint, fmt.Sprintf("Использовано %.1f%% inode · VMID %d.", filesystem.InodesPercent, agent.VMID))
|
||
}
|
||
}
|
||
if len(agent.Report.OOMKills) > 0 {
|
||
latest := agent.Report.OOMKills[0]
|
||
add("agent-oom-"+agent.ID+vmSuffix, "critical", "Агенты", agent.Name+": ядро завершало процессы из-за нехватки памяти", fmt.Sprintf("%s · %s · VMID %d.", time.Unix(latest.Time, 0).Format("02.01 15:04"), latest.Message, agent.VMID))
|
||
}
|
||
if agent.Report.RebootRequired {
|
||
add("agent-reboot-"+agent.ID+vmSuffix, "info", "Агенты", agent.Name+": требуется перезагрузка", fmt.Sprintf("Обновления системы запросили перезагрузку · VMID %d.", agent.VMID))
|
||
}
|
||
if agent.Report.MemoryTotal > 0 && float64(agent.Report.MemoryUsed)/float64(agent.Report.MemoryTotal)*100 >= 95 {
|
||
add("agent-memory-"+agent.ID+vmSuffix, "warning", "Агенты", agent.Name+": почти закончилась память", fmt.Sprintf("Используется %.1f%% RAM · VMID %d.", float64(agent.Report.MemoryUsed)/float64(agent.Report.MemoryTotal)*100, agent.VMID))
|
||
}
|
||
if agent.Report.RootTotal > 0 && float64(agent.Report.RootUsed)/float64(agent.Report.RootTotal)*100 >= 90 {
|
||
add("agent-storage-"+agent.ID+vmSuffix, "critical", "Агенты", agent.Name+": мало места на системном разделе", fmt.Sprintf("Раздел / заполнен на %.1f%% · VMID %d.", float64(agent.Report.RootUsed)/float64(agent.Report.RootTotal)*100, agent.VMID))
|
||
}
|
||
}
|
||
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))
|
||
}
|
||
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.GatewayUp {
|
||
add("network-gateway", "critical", "Сеть", "Недоступен локальный шлюз", firstNonEmpty(metrics.Network.Health.Gateway, "Default gateway не определён."))
|
||
}
|
||
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.DNSUp {
|
||
add("network-dns", "critical", "Сеть", "Не работает DNS", "Не удалось разрешить deb.debian.org.")
|
||
}
|
||
if !metrics.Network.Health.CheckedAt.IsZero() && !metrics.Network.Health.InternetUp {
|
||
add("network-internet", "warning", "Сеть", "Нет доступа в интернет", "Внешняя проверка IP не выполнена.")
|
||
}
|
||
if metrics.Network.Health.PacketLoss > 0 {
|
||
add("network-loss", "warning", "Сеть", "Потери пакетов до шлюза", fmt.Sprintf("Потери %.1f%%, задержка %.1f ms.", metrics.Network.Health.PacketLoss, metrics.Network.Health.LatencyMs))
|
||
}
|
||
if metrics.Network.Health.ExternalIPChanged {
|
||
add("network-public-ip", "info", "Сеть", "Изменился внешний IP", "Новый адрес: "+metrics.Network.Health.ExternalIP)
|
||
}
|
||
for _, name := range metrics.Network.Health.DisappearedInterfaces {
|
||
add("network-interface-missing-"+name, "warning", "Сеть", "Исчез сетевой интерфейс", name+" больше не присутствует в /proc/net/dev.")
|
||
}
|
||
for _, iface := range metrics.Network.Interfaces {
|
||
if iface.Kind == "Physical" && !iface.Carrier && (iface.Master != "" || iface.ReceiveBytes+iface.TransmitBytes > 0) {
|
||
add("network-link-"+iface.Name, "warning", "Сеть", iface.Name+": нет carrier", "Физический линк не поднят.")
|
||
}
|
||
if (iface.Kind == "Bridge" || iface.Kind == "Bond" || iface.Kind == "VLAN") && iface.State == "down" {
|
||
add("network-state-"+iface.Name, "warning", "Сеть", iface.Name+": интерфейс выключен", iface.Kind+" находится в состоянии down.")
|
||
}
|
||
}
|
||
if !metrics.Monitor.LastSuccessfulCollection.IsZero() && time.Since(metrics.Monitor.LastSuccessfulCollection) > 30*time.Second {
|
||
add("monitor-stale", "critical", "Dashboard", "Данные мониторинга устарели", "Последний успешный сбор был "+time.Since(metrics.Monitor.LastSuccessfulCollection).Round(time.Second).String()+" назад.")
|
||
}
|
||
if metrics.Monitor.HistoryWriteError != "" {
|
||
add("monitor-history", "critical", "Dashboard", "Ошибка записи истории", metrics.Monitor.HistoryWriteError)
|
||
}
|
||
if metrics.Monitor.DatabaseFreeBytes > 0 && metrics.Monitor.DatabaseFreeBytes < 1024*1024*1024 {
|
||
add("monitor-db-space", "warning", "Dashboard", "Мало места для базы истории", fmt.Sprintf("На разделе базы свободно %.1f GB.", float64(metrics.Monitor.DatabaseFreeBytes)/1073741824))
|
||
}
|
||
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))
|
||
}
|
||
}
|
||
for _, trend := range metrics.Trends {
|
||
add(trend.ID, trend.Severity, trend.Source, trend.Title, trend.Message)
|
||
}
|
||
|
||
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 correlateAlerts(metrics dashboardMetrics, alerts []Alert) []Alert {
|
||
pressureAlerts := map[string]bool{}
|
||
for _, a := range alerts {
|
||
if strings.HasPrefix(a.ID, "disk-io-pressure-") {
|
||
pressureAlerts[strings.TrimPrefix(a.ID, "disk-io-pressure-")] = true
|
||
}
|
||
}
|
||
var pressure []DiskMetrics
|
||
for _, d := range metrics.Disks {
|
||
if pressureAlerts[d.Name] && d.IOCause != "" {
|
||
pressure = append(pressure, d)
|
||
}
|
||
}
|
||
if len(pressure) == 0 {
|
||
return alerts
|
||
}
|
||
var down []string
|
||
for _, service := range metrics.Services.Services {
|
||
for _, endpoint := range service.Endpoints {
|
||
if !endpoint.Up {
|
||
down = append(down, service.Name)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
d := pressure[0]
|
||
chain := d.IOCause + " → " + d.Name + fmt.Sprintf(" util %.1f%% → latency %.1f ms", d.Utilization, d.LatencyMs)
|
||
if len(down) > 0 {
|
||
chain += " → недоступны/медленны сервисы: " + strings.Join(down, ", ")
|
||
}
|
||
severity := "warning"
|
||
if len(down) > 0 || d.Utilization >= 98 {
|
||
severity = "critical"
|
||
}
|
||
correlated := Alert{ID: "correlation-io-" + d.Name, Severity: severity, Source: "Корреляция", Title: "Обнаружена связанная цепочка I/O-событий", Message: chain + fmt.Sprintf(". Уверенность причины %d%%.", d.IOConfidence)}
|
||
filtered := []Alert{correlated}
|
||
for _, a := range alerts {
|
||
if strings.HasPrefix(a.ID, "disk-io-pressure-") || strings.HasPrefix(a.ID, "service-") {
|
||
continue
|
||
}
|
||
filtered = append(filtered, a)
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
func joinReasons(reasons []string, fallback string) string {
|
||
if len(reasons) == 0 {
|
||
return fallback
|
||
}
|
||
return strings.Join(reasons, " · ")
|
||
}
|