Схема tpl.* (шаблон → елементи → прив'язка до хоста), реконсиляція в core.checks, REST, редактор у вебі, чотири вбудовані шаблони SNMP. Елемент шаблону — одна метрика; у чеки вони групуються за (шаблон, тип, інтервал) в один snmp.get. Сотня окремих чеків замість однієї пачки — це сотня SNMP-сесій там, де досить кількох PDU. Позначка template_id у core.checks дає реконсиляції право власності: без неї відв'язування шаблону не знало б, що прибирати. Звірка планів раз на 5 секунд — те, чого бракувало весь час. Чеки міняє REST-процес, живу сесію зонда тримає AgentService; досі будь-яка зміна доїжджала до зонда лише при обриві зв'язку, тобто ніколи. Знайдено живими прогонами й виправлено: - креденшели не їхали разом із планом, і хост, приписаний зонду після його підключення, падав на кожній задачі з «немає SNMP-креденшелів»; - форма хоста відв'язувала зонд: поле починалося порожнім, підпис обіцяв «не змінювати», сервер трактував порожнє буквально; - hrProcessorLoad.1 у базовому шаблоні — здогадка, а не адреса: на net-snmp «No Such Instance». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
12 KiB
Go
373 lines
12 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Групи пристроїв
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleListDeviceGroups(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:read") {
|
||
return
|
||
}
|
||
groups, err := s.store.ListDeviceGroups(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "перелік груп", err)
|
||
return
|
||
}
|
||
if groups == nil {
|
||
groups = []store.DeviceGroup{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||
}
|
||
|
||
func (s *Server) handleCreateDeviceGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
var in struct {
|
||
Name string `json:"name"`
|
||
Color string `json:"color"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
if strings.TrimSpace(in.Name) == "" {
|
||
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва групи")
|
||
return
|
||
}
|
||
id, err := s.store.CreateDeviceGroup(r.Context(), p.TenantID, strings.TrimSpace(in.Name), in.Color)
|
||
if err != nil {
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate", "група з такою назвою вже є")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "створення групи", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) handleDeleteDeviceGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
err := s.store.DeleteDeviceGroup(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if errors.Is(err, store.ErrGroupNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.writeStoreError(w, "видалення групи", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Хости
|
||
// ---------------------------------------------------------------------
|
||
|
||
var validDeviceKinds = map[string]bool{
|
||
"router": true, "switch": true, "firewall": true, "server": true,
|
||
"ap": true, "olt": true, "onu": true, "ups": true, "printer": true,
|
||
"camera": true, "sensor": true, "vm": true, "container": true, "other": true,
|
||
}
|
||
|
||
func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
in, body, ok := decodeDevice(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
id, err := s.store.CreateDevice(r.Context(), p.TenantID, in)
|
||
if err != nil {
|
||
if isPlanLimit(err) {
|
||
writeError(w, http.StatusPaymentRequired, "plan_limit",
|
||
"вичерпано ліміт пристроїв тарифу")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "створення хоста", err)
|
||
return
|
||
}
|
||
|
||
// Перевірки й доступи — частина створення, а не окремий крок.
|
||
// Хост без жодної перевірки просто лежить у списку й ніколи не
|
||
// опитується; вимагати від людини другого запиту означало б робити
|
||
// такий стан типовим.
|
||
if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, id, body.checkInputs()); err != nil {
|
||
s.writeStoreError(w, "перевірки хоста", err)
|
||
return
|
||
}
|
||
if body.CredentialIDs != nil {
|
||
if err := s.store.SetDeviceCredentials(r.Context(), p.TenantID, id, body.CredentialIDs); err != nil {
|
||
s.writeStoreError(w, "доступи хоста", err)
|
||
return
|
||
}
|
||
}
|
||
if body.TemplateIDs != nil {
|
||
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, id, body.TemplateIDs); err != nil {
|
||
s.writeStoreError(w, "шаблони хоста", err)
|
||
return
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) handleUpdateDevice(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
|
||
}
|
||
|
||
in, body, ok := decodeDevice(w, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := s.store.UpdateDevice(r.Context(), p.TenantID, deviceID, in); err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "оновлення хоста", err)
|
||
return
|
||
}
|
||
if err := s.store.SetDeviceGroups(r.Context(), p.TenantID, deviceID, in.GroupIDs); err != nil {
|
||
s.writeStoreError(w, "групи хоста", err)
|
||
return
|
||
}
|
||
// nil означає «форма про це не говорила» — тоді не чіпаємо. Порожній
|
||
// масив означає «прибрати все», і це різні наміри.
|
||
if body.Checks != nil {
|
||
if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, deviceID, body.checkInputs()); err != nil {
|
||
s.writeStoreError(w, "перевірки хоста", err)
|
||
return
|
||
}
|
||
}
|
||
if body.CredentialIDs != nil {
|
||
if err := s.store.SetDeviceCredentials(r.Context(), p.TenantID, deviceID, body.CredentialIDs); err != nil {
|
||
s.writeStoreError(w, "доступи хоста", err)
|
||
return
|
||
}
|
||
}
|
||
if body.TemplateIDs != nil {
|
||
if err := s.store.SetDeviceTemplates(r.Context(), p.TenantID, deviceID, body.TemplateIDs); err != nil {
|
||
s.writeStoreError(w, "шаблони хоста", err)
|
||
return
|
||
}
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (s *Server) handleDeleteDevice(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
|
||
}
|
||
if err := s.store.DeleteDevice(r.Context(), p.TenantID, deviceID); err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "видалення хоста", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// deviceBody — усе, що приходить із форми хоста, разом із тим, ЯК його
|
||
// опитувати. Хост без перевірок не опитується взагалі й лише займає
|
||
// місце в списку, тому форма має задавати їх одразу.
|
||
type deviceBody struct {
|
||
Name string `json:"name"`
|
||
Address string `json:"address"`
|
||
Kind string `json:"kind"`
|
||
Vendor string `json:"vendor"`
|
||
AgentID string `json:"agent_id"`
|
||
Enabled *bool `json:"enabled"`
|
||
GroupIDs []string `json:"group_ids"`
|
||
Notes string `json:"notes"`
|
||
|
||
Checks []struct {
|
||
ID string `json:"id"`
|
||
CheckType string `json:"check_type"`
|
||
Params json.RawMessage `json:"params"`
|
||
IntervalSec int `json:"interval_sec"`
|
||
TimeoutMs int `json:"timeout_ms"`
|
||
Retries int `json:"retries"`
|
||
Enabled *bool `json:"enabled"`
|
||
} `json:"checks"`
|
||
CredentialIDs []string `json:"credential_ids"`
|
||
TemplateIDs []string `json:"template_ids"`
|
||
}
|
||
|
||
func (b deviceBody) checkInputs() []store.CheckInput {
|
||
out := make([]store.CheckInput, 0, len(b.Checks))
|
||
for _, c := range b.Checks {
|
||
out = append(out, store.CheckInput{
|
||
ID: c.ID,
|
||
CheckType: c.CheckType,
|
||
Params: jsonOr(c.Params, "{}"),
|
||
IntervalSec: c.IntervalSec,
|
||
TimeoutMs: c.TimeoutMs,
|
||
Retries: c.Retries,
|
||
Enabled: c.Enabled == nil || *c.Enabled,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func decodeDevice(w http.ResponseWriter, r *http.Request) (store.DeviceInput, deviceBody, bool) {
|
||
var in deviceBody
|
||
if !decodeBody(w, r, &in) {
|
||
return store.DeviceInput{}, in, false
|
||
}
|
||
if strings.TrimSpace(in.Name) == "" {
|
||
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва хоста")
|
||
return store.DeviceInput{}, in, false
|
||
}
|
||
if in.Kind == "" {
|
||
in.Kind = "other"
|
||
}
|
||
if !validDeviceKinds[in.Kind] {
|
||
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий тип пристрою")
|
||
return store.DeviceInput{}, in, false
|
||
}
|
||
return store.DeviceInput{
|
||
Name: strings.TrimSpace(in.Name),
|
||
Address: strings.TrimSpace(in.Address),
|
||
Kind: in.Kind,
|
||
Vendor: in.Vendor,
|
||
AgentID: in.AgentID,
|
||
Enabled: in.Enabled == nil || *in.Enabled,
|
||
GroupIDs: in.GroupIDs,
|
||
Notes: in.Notes,
|
||
}, in, true
|
||
}
|
||
|
||
func isPlanLimit(err error) bool {
|
||
return err != nil && strings.Contains(err.Error(), "ліміт")
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Групи користувачів
|
||
// ---------------------------------------------------------------------
|
||
|
||
func (s *Server) handleListUserGroups(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "users:read") {
|
||
return
|
||
}
|
||
groups, err := s.store.ListUserGroups(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "перелік груп користувачів", err)
|
||
return
|
||
}
|
||
if groups == nil {
|
||
groups = []store.UserGroup{}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||
}
|
||
|
||
func (s *Server) handleCreateUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "users:write") {
|
||
return
|
||
}
|
||
var in struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
if strings.TrimSpace(in.Name) == "" {
|
||
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва групи")
|
||
return
|
||
}
|
||
id, err := s.store.CreateUserGroup(r.Context(), p.TenantID, strings.TrimSpace(in.Name), in.Description)
|
||
if err != nil {
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate", "група з такою назвою вже є")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "створення групи користувачів", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||
}
|
||
|
||
// handlePatchUserGroup міняє склад і права одним запитом.
|
||
//
|
||
// Разом, а не окремими ендпоїнтами: у формі це один екран і одна
|
||
// кнопка «Зберегти», і розбивати її на два запити означало б лишати
|
||
// групу в напівзбереженому стані, якщо другий не пройде.
|
||
func (s *Server) handlePatchUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "users:write") {
|
||
return
|
||
}
|
||
var in struct {
|
||
MemberIDs *[]string `json:"member_ids"`
|
||
Permissions *map[string]string `json:"permissions"`
|
||
}
|
||
if !decodeBody(w, r, &in) {
|
||
return
|
||
}
|
||
|
||
groupID := r.PathValue("id")
|
||
if in.MemberIDs != nil {
|
||
if err := s.store.SetUserGroupMembers(r.Context(), p.TenantID, groupID, *in.MemberIDs); err != nil {
|
||
if errors.Is(err, store.ErrGroupNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "склад групи", err)
|
||
return
|
||
}
|
||
}
|
||
if in.Permissions != nil {
|
||
if err := s.store.SetUserGroupPermissions(r.Context(), p.TenantID, groupID, *in.Permissions); err != nil {
|
||
if errors.Is(err, store.ErrGroupNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "права групи", err)
|
||
return
|
||
}
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (s *Server) handleDeleteUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "users:write") {
|
||
return
|
||
}
|
||
err := s.store.DeleteUserGroup(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if errors.Is(err, store.ErrGroupNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.writeStoreError(w, "видалення групи", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|