Netpulse_SasS/server/internal/httpapi/templates.go
byrsapty 2fe562936d
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Тригери описуються в шаблоні; форма шаблону — на вкладках
Правило «процесор вище 85% — це проблема» описує клас пристроїв, а не
окремий хост. Тепер воно живе поруч із перевірками, які дають йому
дані, а не окремою сторінкою, де його доводилось повторювати руками
для кожного комутатора.

Тригер розгортається в ОДНЕ правило alr.rules із селектором за
шаблоном, а не в правило на кожен хост: призначили шаблон новому
пристрою — він одразу під правилом, без перегенерації.

Форма шаблону розкладена на вкладки Загальне/Перевірки/Графіки/Тригери
з лічильниками; смуга вкладок винесена у спільний компонент. Умова
тригера редагується полями, JSON лишився запасним виходом.

Правила з шаблону в списку правил помічені й не редагуються там.
Клон копіює тригери вимкненими, щоб копія не подвоїла сповіщення.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:45:49 +03:00

459 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"
"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})
}