diff --git a/README.md b/README.md index d36bf3f..2e09b7b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ Через кнопку настроек можно скрывать неиспользуемые карточки и переключать светлую или тёмную тему. Выбор сохраняется локально в браузере. +Центр уведомлений объединяет критические события, предупреждения и +информационные сообщения по CPU, памяти, storage, SMART, ZFS, UPS, VM/LXC и +резервным копиям. Критические события всегда отображаются первыми. + На главном экране находятся компактные карточки CPU, памяти, диска и сети. Нажатие на карточку открывает подробные показатели. diff --git a/alerts.go b/alerts.go new file mode 100644 index 0000000..526d3a8 --- /dev/null +++ b/alerts.go @@ -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, " · ") +} diff --git a/alerts_test.go b/alerts_test.go new file mode 100644 index 0000000..74f6951 --- /dev/null +++ b/alerts_test.go @@ -0,0 +1,18 @@ +package main + +import "testing" + +func TestEvaluateAlerts(t *testing.T) { + metrics := dashboardMetrics{ + Memory: MemoryMetrics{UsagePercent: 91}, + Storage: StorageMetrics{UsagePercent: 96}, + Backups: BackupMetrics{}, Guests: GuestsMetrics{}, + } + alerts := evaluateAlerts(metrics) + if len(alerts) != 2 { + t.Fatalf("ожидалось два уведомления, получено: %+v", alerts) + } + if alerts[0].Severity != "critical" || alerts[1].Severity != "warning" { + t.Fatalf("неверная сортировка уведомлений: %+v", alerts) + } +} diff --git a/web.go b/web.go index eb82fd1..3be8dc8 100644 --- a/web.go +++ b/web.go @@ -17,6 +17,7 @@ type dashboardMetrics struct { UPS UPSMetrics `json:"ups"` Guests GuestsMetrics `json:"guests"` Backups BackupMetrics `json:"backups"` + Alerts []Alert `json:"alerts"` Timestamp time.Time `json:"timestamp"` } @@ -37,12 +38,14 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle if err != nil { return dashboardMetrics{}, err } - return dashboardMetrics{ + metrics := dashboardMetrics{ CPU: cpu, Memory: memory, Storage: storage, Disks: diskCollector.collect(), Network: network, ZFS: zfsCollector.collect(), UPS: upsCollector.collect(), Guests: guestCollector.collect(), Backups: backupCollector.collect(), Timestamp: time.Now(), - }, nil + } + metrics.Alerts = evaluateAlerts(metrics) + return metrics, nil } func routes(collector *cpuCollector) http.Handler { @@ -131,13 +134,14 @@ h1{font-size:clamp(28px,5vw,44px);letter-spacing:-.045em;margin:9px 0 0}.header- .cards{display:grid;grid-template-columns:1fr 1fr;grid-auto-rows:1fr;gap:16px}.card{appearance:none;display:flex;flex-direction:column;text-align:left;color:inherit;width:100%;padding:0;background:linear-gradient(145deg,rgba(18,28,36,.98),rgba(12,19,25,.98));border:1px solid var(--line);border-radius:20px;overflow:hidden;cursor:pointer;transition:transform .18s,border-color .18s,box-shadow .18s}.card:hover{transform:translateY(-2px);border-color:#3d4e5c;box-shadow:0 20px 55px rgba(0,0,0,.22)}.card:focus-visible{outline:2px solid var(--green);outline-offset:3px} .card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;min-height:106px;padding:23px 24px 18px}.card-head>div:first-child{min-width:0}.label{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.1em}.name{font-size:17px;font-weight:650;margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:350px}.percent{flex:0 0 auto;font:700 35px/.95 ui-monospace,monospace;letter-spacing:-.07em}.percent small{font-size:16px;color:var(--green)} .bar{height:5px;background:#222e37;margin:0 24px;overflow:hidden;border-radius:20px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--green),#afe36d);transition:width .5s ease}.memory .fill{background:linear-gradient(90deg,var(--violet),#72b9ff)} -.storage .fill{background:linear-gradient(90deg,#ffb35c,#ff755c)}.network .fill{background:linear-gradient(90deg,#43c6d9,#65ddaa)}.zfs .fill{background:linear-gradient(90deg,#46c0ff,#6577ff)}.ups .fill{background:linear-gradient(90deg,#d5e85a,#55d58a)}.guests .fill{background:linear-gradient(90deg,#ff78b8,#9d8cff)}.backups .fill{background:linear-gradient(90deg,#ffcf5c,#55d58a)} +.storage .fill{background:linear-gradient(90deg,#ffb35c,#ff755c)}.network .fill{background:linear-gradient(90deg,#43c6d9,#65ddaa)}.zfs .fill{background:linear-gradient(90deg,#46c0ff,#6577ff)}.ups .fill{background:linear-gradient(90deg,#d5e85a,#55d58a)}.guests .fill{background:linear-gradient(90deg,#ff78b8,#9d8cff)}.backups .fill{background:linear-gradient(90deg,#ffcf5c,#55d58a)}.alert-center{grid-column:1/-1}.alert-center .fill{background:linear-gradient(90deg,#ffb35c,#ff665c)} .quick{display:grid;grid-template-columns:1fr 1fr;margin-top:19px;border-top:1px solid var(--line)}.quick div{padding:16px 24px}.quick div+div{border-left:1px solid var(--line)}.quick strong{display:block;font:650 20px/1.2 ui-monospace,monospace;margin-top:5px}.hint{color:#657581;font-size:11px;margin:auto 24px 18px;padding-top:14px} dialog{width:min(760px,calc(100% - 28px));max-height:calc(100vh - 32px);padding:0;color:var(--text);background:#101820;border:1px solid #34434f;border-radius:22px;box-shadow:0 30px 100px #000b;overflow:auto}dialog::backdrop{background:rgba(3,7,10,.75);backdrop-filter:blur(6px)} .modal-head{position:sticky;top:0;z-index:2;display:flex;align-items:flex-start;justify-content:space-between;padding:26px 28px;background:rgba(16,24,32,.94);backdrop-filter:blur(14px);border-bottom:1px solid var(--line)}.modal-head h2{font-size:24px;letter-spacing:-.03em;margin:5px 0 0}.close{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:#17222b;color:var(--text);font-size:22px;cursor:pointer}.detail-grid{display:grid;grid-template-columns:repeat(3,1fr)}.detail{padding:22px 27px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.detail:nth-child(3n){border-right:0}.detail strong{display:block;font:650 24px/1.2 ui-monospace,monospace;margin-top:7px}.wide{padding:24px 28px}.temps{display:flex;flex-wrap:wrap;gap:9px;margin-top:12px}.temp{padding:7px 11px;border:1px solid var(--line);border-radius:99px}.temp b{color:var(--green);margin-left:7px}.empty{color:var(--muted);font-size:13px} .disk-list{display:grid;gap:12px;padding:16px}.disk-item{padding:19px;border:1px solid var(--line);border-radius:15px;background:#0c141a}.disk-top{display:flex;justify-content:space-between;gap:20px}.disk-model{font-size:17px;font-weight:650}.disk-meta{color:var(--muted);font:12px/1.5 ui-monospace,monospace;margin-top:4px;overflow-wrap:anywhere}.badge{position:relative;height:max-content;padding:5px 9px;border:1px solid #2d6245;border-radius:99px;color:var(--green);font-size:11px;white-space:nowrap}.badge.warn{color:#ffb35c;border-color:#6d4c2c}.badge.bad{color:#ff755c;border-color:#71362f}.badge[data-reason]:hover::after,.badge[data-reason]:focus::after{content:attr(data-reason);position:absolute;z-index:5;right:0;bottom:calc(100% + 10px);width:280px;padding:11px 13px;border:1px solid #4c5963;border-radius:10px;background:#1a242c;color:var(--text);font:12px/1.45 Inter,system-ui,sans-serif;white-space:normal;box-shadow:0 14px 35px #0008}.disk-stats{display:grid;grid-template-columns:repeat(6,1fr);gap:14px;margin-top:18px}.disk-stat span{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.disk-stat b{display:block;margin-top:5px;font:600 14px ui-monospace,monospace} .settings-body{padding:22px 26px 28px}.settings-group+.settings-group{margin-top:25px}.theme-options{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:11px}.theme-option{padding:13px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--text);cursor:pointer}.theme-option.active{border-color:var(--green);box-shadow:inset 0 0 0 1px var(--green)}.setting-list{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:11px}.setting-item{display:flex;align-items:center;gap:10px;padding:11px 12px;border:1px solid var(--line);border-radius:11px}.setting-item input{accent-color:var(--green);width:17px;height:17px}.card[hidden]{display:none} -html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9da 0,transparent 34%),var(--bg)}html[data-theme="light"] .card{background:linear-gradient(145deg,#fff,#f7f9fa)}html[data-theme="light"] dialog{background:#fff;box-shadow:0 30px 80px #4e64752b}html[data-theme="light"] .modal-head{background:rgba(255,255,255,.94)}html[data-theme="light"] .close{background:#f1f4f6}html[data-theme="light"] .disk-item{background:#f7f9fa}html[data-theme="light"] .bar{background:#e2e8ec}html[data-theme="light"] dialog::backdrop{background:rgba(30,45,55,.35)}html[data-theme="light"] .badge[data-reason]:hover::after,html[data-theme="light"] .badge[data-reason]:focus::after{background:#fff} +.alert-list{display:grid;gap:10px;padding:16px}.alert-item{display:grid;grid-template-columns:auto 1fr;gap:13px;padding:17px 18px;border:1px solid var(--line);border-radius:14px;background:#0c141a}.alert-icon{width:10px;height:10px;border-radius:50%;margin-top:6px;background:#6ba8ff;box-shadow:0 0 14px currentColor}.alert-item.warning .alert-icon{background:#ffb35c}.alert-item.critical .alert-icon{background:#ff665c}.alert-title{font-weight:650}.alert-source{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:4px}.alert-message{color:var(--muted);font-size:13px;margin-top:4px}.all-good{padding:45px 24px;text-align:center}.all-good strong{display:block;font-size:22px;margin-bottom:7px}.all-good span{color:var(--muted)} +html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9da 0,transparent 34%),var(--bg)}html[data-theme="light"] .card{background:linear-gradient(145deg,#fff,#f7f9fa)}html[data-theme="light"] dialog{background:#fff;box-shadow:0 30px 80px #4e64752b}html[data-theme="light"] .modal-head{background:rgba(255,255,255,.94)}html[data-theme="light"] .close{background:#f1f4f6}html[data-theme="light"] .disk-item,html[data-theme="light"] .alert-item{background:#f7f9fa}html[data-theme="light"] .bar{background:#e2e8ec}html[data-theme="light"] dialog::backdrop{background:rgba(30,45,55,.35)}html[data-theme="light"] .badge[data-reason]:hover::after,html[data-theme="light"] .badge[data-reason]:focus::after{background:#fff} footer{color:#657581;font-size:12px;margin-top:17px} @media(max-width:700px){main{padding:28px 0}.cards{grid-template-columns:1fr}header{align-items:flex-start;flex-direction:column;gap:14px}.detail-grid{grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n){border-right:1px solid var(--line)}.detail:nth-child(2n){border-right:0}.name{max-width:220px}.disk-stats,.setting-list{grid-template-columns:repeat(2,1fr)}} @@ -145,6 +149,12 @@ footer{color:#657581;font-size:12px;margin-top:17px}