Метрики збиралися в 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>
275 lines
9.1 KiB
Go
275 lines
9.1 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
|
||
}
|
||
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)
|
||
}
|