Система вміла малювати мапу, але мовчала, коли щось падало. Схема 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>
455 lines
15 KiB
Go
455 lines
15 KiB
Go
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")
|
||
}
|