Netpulse_SasS/server/internal/httpapi/templates.go
byrsapty cd8d4c62ed Історія метрик, шаблони будь-яких перевірок, маршрути правил
Метрики збиралися в 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>
2026-08-24 17:21:50 +03:00

275 lines
9.1 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
}
t.Items = append(t.Items, store.TemplateItem{
Key: strings.TrimSpace(i.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
}
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)
}