Метрики збиралися в ts.samples і не показувалися ніде — побачити
зібране можна було лише через psql. Додано GET /devices/{id}/series і
/metrics: джерело (сирі дані, 5m, 1h) обирається за потрібним кроком,
бакетизація в БД, пропуск у даних лишається пропуском, а не лінією
через діру.
Знайдено живим прогоном: зонд працює рівно годину. CredentialTTL —
година, Credentials() свідомо не віддає прострочені (щоб не блокувати
облікові записи на пристроях), а поновлення не просив ніхто:
CredentialRequest є в контракті з Етапу 2, сервер його обробляє, агент
не надсилає. Будь-яка інсталяція припиняла збирати SNMP через годину
після старту й мовчала про це.
Шаблон описує перевірки будь-якого типу, не лише OID. Пачкою в один PDU
збираються тільки snmp.get; решта — елемент на чек, слід у
core.checks.template_item_key. Вбудований шаблон «Доступність (ICMP)».
Імпорт/експорт глобальний і поштучний, свій формат замість Zabbix-YAML.
Спільний розклад бекапів із перевизначенням на хості: прапорець
follows_default, а не порівняння значень — власний розклад може
випадково збігтися зі спільним.
Правило саме каже, куди йде його алерт: канали, тихі години, групи
хостів, повідомлення про відновлення. Канали правила перекривають
маршрути повністю.
Доступи до обладнання отримали свою сторінку: SSH-паролі й
SNMP-community заводяться, змінюються й видаляються з вебу. Секрет
назовні не повертається ніколи.
Дрібниці за скаргами: відступи в картках шаблонів, українська множина,
ручний ввід інтервалу опитування, підтвердження видалення з описом
наслідків замість «Ви впевнені?», помітні кнопки видалення замість
сірого ✕ у кутку.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
495 lines
17 KiB
Go
495 lines
17 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"),
|
||
Scope: p.Scope(),
|
||
}
|
||
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, p.Scope())
|
||
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
|
||
}
|
||
|
||
if !p.Scope().CanWrite(req.DeviceID) {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||
return
|
||
}
|
||
|
||
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"`
|
||
// Куди слати. Порожньо — за загальними маршрутами тенанта.
|
||
ChannelIDs []string `json:"channel_ids"`
|
||
NotifySchedule json.RawMessage `json:"notify_schedule"`
|
||
NotifyOnResolve *bool `json:"notify_on_resolve"`
|
||
}
|
||
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,
|
||
ChannelIDs: req.ChannelIDs,
|
||
NotifySchedule: string(req.NotifySchedule),
|
||
NotifyOnResolve: req.NotifyOnResolve == nil || *req.NotifyOnResolve,
|
||
}
|
||
if in.ChannelIDs == nil {
|
||
in.ChannelIDs = []string{}
|
||
}
|
||
if in.NotifySchedule == "null" {
|
||
in.NotifySchedule = ""
|
||
}
|
||
|
||
// Оновлення наявного правила приходить сюди ж: форма однакова, і
|
||
// розділяти її на дві гілки на клієнті означало б розійтися в
|
||
// перевірках рівно там, де вони найпотрібніші.
|
||
if id := r.PathValue("id"); id != "" {
|
||
if err := s.store.UpdateRule(r.Context(), p.TenantID, id, in); err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "правило не знайдено")
|
||
return
|
||
}
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate", "правило з такою назвою вже є")
|
||
return
|
||
}
|
||
s.log.Error("оновлення правила", "err", err)
|
||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||
return
|
||
}
|
||
|
||
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")
|
||
}
|