Netpulse_SasS/server/internal/httpapi/templates.go
byrsapty edc6209c98 Вигляд мапи, редаговані вбудовані шаблони, мобільні картки
Мапа: підпис ребра більше не друкує «? → ?» і «— з 10.0 Гбіт/с» —
невідоме краще не обіцяти. Вузол став карткою з градієнтом, кольоровою
смугою стану зліва й крапкою стану праворуч; смуга несе стан навіть
тоді, коли рамку перефарбували під майданчик. Пульсація лише за
обривом, порти — на наведення. Фон: радіальна підсвітка й дві сітки.

Вбудовані шаблони редагуються: правка створює власну копію тенанта з
тим самим ключем, вона замінює оригінал у списку, оригінал лишається
недоторканим для решти. Плюс окрема кнопка «Копія» з суфіксом у ключі.
Ключі елементів і графіків тепер виводяться на сервері — прямий виклик
API без key давав 500 від core.slug.

Вибір шаблонів у формі хоста став картками з описом і лічильником
метрик; обрані спливають угору.

Мобільна сітка: min-w-0 на картці. Елемент грід-сітки типово має
min-width:auto й не стискається нижче ширини вмісту — картка на 410 px
обрізалась у 375 px екрані.

Межа помилок навколо полотна: цикл рендеру в React Flow одного разу вже
поклав увесь застосунок, тепер падатиме лише мапа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:53:58 +03:00

381 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"
"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
}
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)
}
// 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})
}