Netpulse_SasS/server/internal/httpapi/alerts.go
byrsapty 215bf8c4db Етап 9: алерти й сповіщення
Система вміла малювати мапу, але мовчала, коли щось падало. Схема alr.*
лежала готовою з Етапу 1 і повністю порожньою.

Движок:
- обчислює правила з icmp / interface / metric / no_data і не тримає
  стану між тіками: вікно for_seconds — це запит по часу до TSDB, тож
  перезапуск нічого не збиває
- дві семантики вікна: без agg умова має триматися всі виміри (антифлап),
  з agg порівнюється агрегат
- кореляція за топологією: причина аварії — той, у кого лишився живий
  сусід; хто оточений мертвими, той наслідок. Заради цього й будувалась
  topo.links: інакше падіння маршрутизатора дає сорок сповіщень
- вікна обслуговування, ручне заглушення зі стелею в тиждень
- advisory-блокування на тік: кілька API за балансувальником безпечні

Доставка:
- Telegram, webhook, SMTP; токени в core.secrets тим самим кільцем, що
  й паролі від обладнання
- тенант без маршрутів отримує все в усі придатні канали — підключили
  Telegram, має працювати
- тиха година не глушить disaster
- вебхуки на внутрішні адреси заблоковано; для self-hosted знімається
  прапорцем процесу, бо там ця мережа своя

UI: індикатор у шапці, панель зі списком, ack/mute/close, окремий фільтр
придушених, спільна шина подій замість другого WebSocket.

Знайдено живою роботою й виправлено:
- вимкнене або видалене правило лишало алерти сиротами назавжди
- перехід у suppressed не публікував події — UI дізнавався лише після
  перезавантаження сторінки
- кнопки дій на телефоні були 26 px

20 нових тестів, go vet і tsc чисто. Живий прогін: поріг посередині
розділив два справжні пристрої, 5 доставок на 5 подій без повторів.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:09:19 +03:00

455 lines
15 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 httpapi
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/store"
)
// maxMute — стеля ручного заглушення.
//
// Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути
// моніторинг назавжди: людина глушить на час аварії й забуває. Тиждень
// достатньо для будь-яких робіт і замало, щоб забути назовсім.
const maxMute = 7 * 24 * time.Hour
func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:read") {
return
}
f := store.AlertFilter{
MinSeverity: r.URL.Query().Get("min_severity"),
DeviceID: r.URL.Query().Get("device_id"),
}
if v := r.URL.Query().Get("state"); v != "" {
f.States = strings.Split(v, ",")
}
if v := r.URL.Query().Get("limit"); v != "" {
f.Limit, _ = strconv.Atoi(v)
}
alerts, err := s.store.ListAlerts(r.Context(), p.TenantID, f)
if err != nil {
s.log.Error("перелік алертів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if alerts == nil {
alerts = []store.Alert{}
}
counts, err := s.store.CountAlerts(r.Context(), p.TenantID)
if err != nil {
s.log.Error("лічильники алертів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, map[string]any{"alerts": alerts, "counts": counts})
}
func (s *Server) handleAckAlert(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:ack") {
return
}
var req struct {
Comment string `json:"comment"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
a, err := s.store.AckAlert(r.Context(), p.TenantID, r.PathValue("id"), p.UserID, req.Comment)
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "алерт не знайдено або вже закритий")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("підтвердження алерту", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, a)
}
func (s *Server) handleCloseAlert(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:ack") {
return
}
err := s.store.CloseAlert(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "алерт не знайдено або вже закритий")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("закриття алерту", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleMuteDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:ack") {
return
}
var req struct {
DeviceID string `json:"device_id"`
Minutes int `json:"minutes"`
Reason string `json:"reason"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
return
}
if req.DeviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібен device_id")
return
}
if req.Minutes <= 0 {
req.Minutes = 60
}
until := time.Now().Add(time.Duration(req.Minutes) * time.Minute)
if max := time.Now().Add(maxMute); until.After(max) {
until = max
}
if err := s.store.MuteDevice(r.Context(), p.TenantID, req.DeviceID, p.UserID, req.Reason, until); err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор пристрою")
return
}
s.log.Error("заглушення пристрою", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, map[string]any{"until": until})
}
// ---------------------------------------------------------------------
// Правила
// ---------------------------------------------------------------------
func (s *Server) handleListAlertRules(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:read") {
return
}
rules, err := s.store.ListRules(r.Context(), p.TenantID)
if err != nil {
s.log.Error("перелік правил", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if rules == nil {
rules = []map[string]any{}
}
writeJSON(w, http.StatusOK, map[string]any{"rules": rules})
}
// validSources і validSeverities дублюють ENUM у БД навмисно: помилка
// в назві джерела має давати 400 з зрозумілим текстом, а не 500 від
// невдалого приведення типу в глибині запиту.
var validSources = map[string]bool{
"metric": true, "icmp": true, "interface": true, "link": true,
"syslog": true, "trap": true, "ncm": true, "agent": true, "compliance": true,
}
var validSeverities = map[string]bool{
"info": true, "warning": true, "average": true, "high": true, "disaster": true,
}
func (s *Server) handleCreateAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
Severity string `json:"severity"`
Selector json.RawMessage `json:"selector"`
Condition json.RawMessage `json:"condition"`
ForSeconds int `json:"for_seconds"`
DependsOnTopology *bool `json:"depends_on_topology"`
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва правила")
return
}
if !validSources[req.Source] {
writeError(w, http.StatusBadRequest, "bad_source", "невідоме джерело правила")
return
}
if req.Severity == "" {
req.Severity = "warning"
}
if !validSeverities[req.Severity] {
writeError(w, http.StatusBadRequest, "bad_severity", "невідома серйозність")
return
}
if len(req.Condition) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна умова")
return
}
if req.ForSeconds <= 0 {
req.ForSeconds = 60
}
in := store.RuleInput{
Name: req.Name,
Description: req.Description,
Source: req.Source,
Severity: req.Severity,
Selector: jsonOr(req.Selector, "{}"),
Condition: string(req.Condition),
ForSeconds: req.ForSeconds,
DependsOnTopology: req.DependsOnTopology == nil || *req.DependsOnTopology,
Enabled: req.Enabled == nil || *req.Enabled,
}
id, err := s.store.CreateRule(r.Context(), p.TenantID, p.UserID, in)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "правило з такою назвою вже є")
return
}
s.log.Error("створення правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handlePatchAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
var req struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Enabled == nil {
writeError(w, http.StatusBadRequest, "bad_request", "очікується поле enabled")
return
}
err := s.store.SetRuleEnabled(r.Context(), p.TenantID, r.PathValue("id"), *req.Enabled)
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("зміна правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleDeleteAlertRule(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
err := s.store.DeleteRule(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("видалення правила", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------
// Канали
// ---------------------------------------------------------------------
func (s *Server) handleListChannels(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:read") {
return
}
// Секрети не розшифровуються: у переліку вони не потрібні, а
// найнадійніший спосіб не віддати токен назовні — не діставати його
// з бази взагалі.
channels, err := s.store.LoadChannels(r.Context(), p.TenantID, nil)
if err != nil {
s.log.Error("перелік каналів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if channels == nil {
channels = []store.Channel{}
}
writeJSON(w, http.StatusOK, map[string]any{"channels": channels})
}
var validChannelKinds = map[string]bool{
"email": true, "telegram": true, "webhook": true, "slack": true,
"discord": true, "mattermost": true,
}
func (s *Server) handleCreateChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
if s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_keyring",
"сервер запущено без ключа шифрування")
return
}
var req struct {
Kind string `json:"kind"`
Name string `json:"name"`
Config json.RawMessage `json:"config"`
Template string `json:"template"`
MinSeverity string `json:"min_severity"`
Secret string `json:"secret"`
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "некоректний JSON")
return
}
if strings.TrimSpace(req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва каналу")
return
}
if !validChannelKinds[req.Kind] {
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий вид каналу")
return
}
if req.MinSeverity == "" {
req.MinSeverity = "warning"
}
if !validSeverities[req.MinSeverity] {
writeError(w, http.StatusBadRequest, "bad_severity", "невідома серйозність")
return
}
id, err := s.store.CreateChannel(r.Context(), p.TenantID, store.ChannelInput{
Kind: req.Kind,
Name: req.Name,
Config: jsonOr(req.Config, "{}"),
Template: req.Template,
MinSeverity: req.MinSeverity,
Secret: req.Secret,
Enabled: req.Enabled == nil || *req.Enabled,
}, s.keyring)
if err != nil {
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate", "канал із такою назвою вже є")
return
}
s.log.Error("створення каналу", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handleDeleteChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
err := s.store.DeleteChannel(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrAlertNotFound) {
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
return
}
if err != nil {
if isBadUUID(err) {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор")
return
}
s.log.Error("видалення каналу", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleTestChannel надсилає пробне повідомлення.
func (s *Server) handleTestChannel(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "alerts:write") {
return
}
if s.notifier == nil || s.keyring == nil {
writeError(w, http.StatusServiceUnavailable, "no_notifier",
"доставку сповіщень не налаштовано в цьому процесі")
return
}
channels, err := s.store.LoadChannels(r.Context(), p.TenantID, s.keyring)
if err != nil {
s.log.Error("читання каналів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
id := r.PathValue("id")
for _, c := range channels {
if c.ID != id {
continue
}
if err := s.notifier.TestChannel(r.Context(), p.TenantID, c); err != nil {
// Помилка каналу — це відповідь на питання «чи працює», а не
// збій сервера: 200 з описом кращий за 500 без нього.
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
writeError(w, http.StatusNotFound, "not_found", "канал не знайдено")
}
// ---------------------------------------------------------------------
func jsonOr(raw json.RawMessage, def string) string {
if len(raw) == 0 {
return def
}
return string(raw)
}
func isBadUUID(err error) bool {
return err != nil && strings.Contains(err.Error(), "invalid input syntax for type uuid")
}
func isUniqueViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "SQLSTATE 23505")
}