Netpulse_SasS/server/internal/httpapi/groups.go
byrsapty 2de1894fd5 Етап 6: шаблони опитування + звірка планів зонда
Схема 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>
2026-08-24 14:07:54 +03:00

373 lines
12 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"
"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)
}