Правило «процесор вище 85% — це проблема» описує клас пристроїв, а не окремий хост. Тепер воно живе поруч із перевірками, які дають йому дані, а не окремою сторінкою, де його доводилось повторювати руками для кожного комутатора. Тригер розгортається в ОДНЕ правило alr.rules із селектором за шаблоном, а не в правило на кожен хост: призначили шаблон новому пристрою — він одразу під правилом, без перегенерації. Форма шаблону розкладена на вкладки Загальне/Перевірки/Графіки/Тригери з лічильниками; смуга вкладок винесена у спільний компонент. Умова тригера редагується полями, JSON лишився запасним виходом. Правила з шаблону в списку правил помічені й не редагуються там. Клон копіює тригери вимкненими, щоб копія не подвоїла сповіщення. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
459 lines
15 KiB
Go
459 lines
15 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
func (s *Server) handleListTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:read") {
|
||
return
|
||
}
|
||
list, err := s.store.ListTemplates(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "шаблони", err)
|
||
return
|
||
}
|
||
if list == nil {
|
||
list = []store.Template{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"templates": list})
|
||
}
|
||
|
||
func (s *Server) handleGetTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:read") {
|
||
return
|
||
}
|
||
t, err := s.store.GetTemplate(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if err != nil {
|
||
s.writeStoreError(w, "шаблон", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, t)
|
||
}
|
||
|
||
// handleSaveTemplate створює новий шаблон (POST) або замінює наявний (PUT).
|
||
//
|
||
// Елементи приходять повним списком: форма редагує шаблон як єдиний
|
||
// документ, і часткові оновлення тут лише додали б способів отримати
|
||
// стан, якого людина не просила.
|
||
func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
|
||
var in struct {
|
||
Key string `json:"key"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
Vendor string `json:"vendor"`
|
||
Items []struct {
|
||
Key string `json:"key"`
|
||
Name string `json:"name"`
|
||
CheckType string `json:"check_type"`
|
||
OID string `json:"oid"`
|
||
MetricKey string `json:"metric_key"`
|
||
Unit string `json:"unit"`
|
||
Scale float64 `json:"scale"`
|
||
IntervalSec int `json:"interval_sec"`
|
||
Enabled *bool `json:"enabled"`
|
||
Params json.RawMessage `json:"params"`
|
||
} `json:"items"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||
return
|
||
}
|
||
|
||
in.Key = strings.TrimSpace(in.Key)
|
||
in.Name = strings.TrimSpace(in.Name)
|
||
if in.Key == "" || in.Name == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "ключ і назва обов'язкові")
|
||
return
|
||
}
|
||
|
||
t := store.Template{
|
||
ID: r.PathValue("id"),
|
||
Key: in.Key,
|
||
Name: in.Name,
|
||
Description: strings.TrimSpace(in.Description),
|
||
Vendor: strings.TrimSpace(in.Vendor),
|
||
}
|
||
for _, i := range in.Items {
|
||
checkType := i.CheckType
|
||
if checkType == "" {
|
||
checkType = "snmp.get"
|
||
}
|
||
oid := strings.TrimSpace(i.OID)
|
||
metric := strings.TrimSpace(i.MetricKey)
|
||
|
||
// OID і ключ метрики потрібні лише там, де перевірка справді
|
||
// адресується OID-ом. У пінга немає ані того, ані іншого — його
|
||
// метрики визначає сам тип перевірки.
|
||
if checkType == "snmp.get" {
|
||
if oid == "" || metric == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"метрика SNMP потребує OID і ключ метрики")
|
||
return
|
||
}
|
||
// Крапка на початку — те, як OID пишуть у документації
|
||
// вендорів і як його чекає gosnmp. Дописати її самим
|
||
// дешевше, ніж відмовляти людині через символ, який нічого
|
||
// не означає.
|
||
if !strings.HasPrefix(oid, ".") {
|
||
oid = "." + oid
|
||
}
|
||
}
|
||
if i.IntervalSec < 5 || i.IntervalSec > 86400 {
|
||
if i.IntervalSec != 0 {
|
||
writeError(w, http.StatusBadRequest, "bad_interval",
|
||
"інтервал задається в межах від 5 секунд до доби")
|
||
return
|
||
}
|
||
}
|
||
enabled := true
|
||
if i.Enabled != nil {
|
||
enabled = *i.Enabled
|
||
}
|
||
// Ключ елемента виводимо самі, якщо його не прислали: він
|
||
// потрібен базі, але жодного рішення не несе, і вимагати його
|
||
// від клієнта означало б 500 замість збереження.
|
||
key := strings.TrimSpace(i.Key)
|
||
if key == "" {
|
||
key = slugify(metric)
|
||
}
|
||
if key == "" {
|
||
key = slugify(strings.TrimSpace(i.Name))
|
||
}
|
||
if key == "" {
|
||
key = fmt.Sprintf("item-%d", len(t.Items)+1)
|
||
}
|
||
|
||
t.Items = append(t.Items, store.TemplateItem{
|
||
Key: key,
|
||
Name: strings.TrimSpace(i.Name),
|
||
CheckType: checkType,
|
||
OID: oid,
|
||
MetricKey: metric,
|
||
Unit: strings.TrimSpace(i.Unit),
|
||
Scale: i.Scale,
|
||
IntervalSec: i.IntervalSec,
|
||
Enabled: enabled,
|
||
Params: i.Params,
|
||
})
|
||
}
|
||
|
||
id, err := s.store.SaveTemplate(r.Context(), p.TenantID, t)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrBuiltin) {
|
||
writeError(w, http.StatusForbidden, "builtin",
|
||
"вбудований шаблон не редагується — зробіть копію")
|
||
return
|
||
}
|
||
// Зайнятий ключ — помилка людини, а не сервера. «Внутрішня
|
||
// помилка» у відповідь на цілком зрозумілу ситуацію лишає
|
||
// людину гадати, що зламалось, замість виправити одне поле.
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate_key",
|
||
"шаблон із ключем «"+t.Key+"» уже є — виберіть інший ключ")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "збереження шаблону", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) handleDeleteTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
err := s.store.DeleteTemplate(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrBuiltin) {
|
||
writeError(w, http.StatusForbidden, "builtin", "вбудований шаблон не видаляється")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "видалення шаблону", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Прив'язка до хоста
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleGetDeviceTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:read") {
|
||
return
|
||
}
|
||
deviceID := r.PathValue("id")
|
||
if !p.Scope().CanRead(deviceID) {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||
return
|
||
}
|
||
ids, err := s.store.DeviceTemplateIDs(r.Context(), p.TenantID, deviceID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "шаблони хоста", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"template_ids": ids})
|
||
}
|
||
|
||
func (s *Server) handleSetDeviceTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
deviceID := r.PathValue("id")
|
||
if !p.Scope().CanWrite(deviceID) {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста")
|
||
return
|
||
}
|
||
|
||
var in struct {
|
||
TemplateIDs []string `json:"template_ids"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||
return
|
||
}
|
||
|
||
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, deviceID, in.TemplateIDs); err != nil {
|
||
s.writeStoreError(w, "прив'язка шаблонів", err)
|
||
return
|
||
}
|
||
|
||
ids, err := s.store.DeviceTemplateIDs(r.Context(), p.TenantID, deviceID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "шаблони хоста", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"template_ids": ids})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Обмін
|
||
// ---------------------------------------------------------------------
|
||
|
||
// handleExportTemplates віддає документ як файл.
|
||
//
|
||
// `?ids=a,b` — вибрані, без параметра — усі. Content-Disposition
|
||
// обов'язковий: без нього браузер покаже JSON у вкладці, а людина
|
||
// прийшла по файл.
|
||
func (s *Server) handleExportTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:read") {
|
||
return
|
||
}
|
||
var ids []string
|
||
for _, v := range strings.Split(r.URL.Query().Get("ids"), ",") {
|
||
if v = strings.TrimSpace(v); v != "" {
|
||
ids = append(ids, v)
|
||
}
|
||
}
|
||
doc, err := s.store.ExportTemplates(r.Context(), p.TenantID, ids)
|
||
if err != nil {
|
||
s.writeStoreError(w, "експорт шаблонів", err)
|
||
return
|
||
}
|
||
name := fmt.Sprintf("netpulse-templates-%s.json", time.Now().Format("2006-01-02"))
|
||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||
writeJSON(w, http.StatusOK, doc)
|
||
}
|
||
|
||
func (s *Server) handleImportTemplates(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
|
||
var in struct {
|
||
store.TemplateDoc
|
||
// Типово вимкнено: мовчазне перезаписування — найшвидший спосіб
|
||
// втратити локальні правки.
|
||
UpdateExisting bool `json:"update_existing"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_json",
|
||
"не вдалося прочитати документ: "+err.Error())
|
||
return
|
||
}
|
||
if len(in.Templates) == 0 {
|
||
writeError(w, http.StatusBadRequest, "empty", "у документі немає жодного шаблону")
|
||
return
|
||
}
|
||
|
||
res, err := s.store.ImportTemplates(r.Context(), p.TenantID, in.TemplateDoc, in.UpdateExisting)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "import_failed", err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, res)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Графіки
|
||
// ---------------------------------------------------------------------
|
||
|
||
var validGraphKinds = map[string]bool{
|
||
"line": true, "area": true, "stacked": true,
|
||
"bar": true, "gauge": true, "stat": true,
|
||
}
|
||
|
||
func (s *Server) handleSaveTemplateGraphs(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
var in struct {
|
||
Graphs []store.TemplateGraph `json:"graphs"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||
return
|
||
}
|
||
for i := range in.Graphs {
|
||
g := &in.Graphs[i]
|
||
g.Name = strings.TrimSpace(g.Name)
|
||
g.Key = strings.TrimSpace(g.Key)
|
||
if g.Name == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "графік без назви")
|
||
return
|
||
}
|
||
if g.Key == "" {
|
||
g.Key = slugify(g.Name)
|
||
}
|
||
if g.Key == "" {
|
||
g.Key = fmt.Sprintf("graph-%d", i+1)
|
||
}
|
||
if g.Kind == "" {
|
||
g.Kind = "line"
|
||
}
|
||
if !validGraphKinds[g.Kind] {
|
||
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий вид графіка: "+g.Kind)
|
||
return
|
||
}
|
||
if len(g.MetricKeys) == 0 {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"графік «"+g.Name+"» не містить жодної метрики")
|
||
return
|
||
}
|
||
}
|
||
|
||
err := s.store.SaveTemplateGraphs(r.Context(), p.TenantID, r.PathValue("id"), in.Graphs)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrBuiltin) {
|
||
writeError(w, http.StatusForbidden, "builtin",
|
||
"графіки вбудованого шаблону не редагуються — зробіть копію")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "графіки шаблону", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// handleSaveTemplateTriggers — тригери шаблону цілим набором.
|
||
//
|
||
// Окремим запитом, як і графіки: три вкладки форми зберігаються
|
||
// незалежно, і правка порога не має вимагати надіслати назад усі
|
||
// перевірки шаблону разом із нею.
|
||
func (s *Server) handleSaveTemplateTriggers(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
var in struct {
|
||
Triggers []store.TemplateTrigger `json:"triggers"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||
return
|
||
}
|
||
|
||
for i := range in.Triggers {
|
||
t := &in.Triggers[i]
|
||
t.Name = strings.TrimSpace(t.Name)
|
||
t.Key = strings.TrimSpace(t.Key)
|
||
if t.Name == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "тригер без назви")
|
||
return
|
||
}
|
||
if t.Key == "" {
|
||
t.Key = slugify(t.Name)
|
||
}
|
||
if t.Key == "" {
|
||
t.Key = fmt.Sprintf("trigger-%d", i+1)
|
||
}
|
||
if t.Source == "" {
|
||
t.Source = "metric"
|
||
}
|
||
if !validSources[t.Source] {
|
||
writeError(w, http.StatusBadRequest, "bad_source", "невідоме джерело тригера: "+t.Source)
|
||
return
|
||
}
|
||
if t.Severity == "" {
|
||
t.Severity = "warning"
|
||
}
|
||
if !validSeverities[t.Severity] {
|
||
writeError(w, http.StatusBadRequest, "bad_severity", "невідома важливість: "+t.Severity)
|
||
return
|
||
}
|
||
if len(t.Condition) == 0 || string(t.Condition) == "{}" {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"тригер «"+t.Name+"» без умови")
|
||
return
|
||
}
|
||
if t.ForSeconds < 0 || t.ForSeconds > 86400 {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"тригер «"+t.Name+"»: витримка поза межами 0…86400 с")
|
||
return
|
||
}
|
||
}
|
||
|
||
err := s.store.SaveTemplateTriggers(r.Context(), p.TenantID, r.PathValue("id"), in.Triggers)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrBuiltin) {
|
||
writeError(w, http.StatusForbidden, "builtin",
|
||
"тригери вбудованого шаблону не редагуються — зробіть копію")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "тригери шаблону", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// handleDeviceGraphs — графіки, які дають шаблони цього хоста.
|
||
func (s *Server) handleDeviceGraphs(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:read") {
|
||
return
|
||
}
|
||
deviceID := r.PathValue("id")
|
||
if !p.Scope().CanRead(deviceID) {
|
||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||
return
|
||
}
|
||
list, err := s.store.DeviceGraphs(r.Context(), p.TenantID, deviceID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "графіки хоста", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"graphs": list})
|
||
}
|
||
|
||
func (s *Server) handleCloneTemplate(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
id, err := s.store.CloneTemplate(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if err != nil {
|
||
s.writeStoreError(w, "клонування шаблону", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||
}
|
||
|