Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.
ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ
0058 подієві алерти: syslog, ncm, compliance спрацьовують у мить
події; правило з нереалізованим джерелом більше не зберігається
мовчки
0059 snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
описуються шаблоном, а не Go
0060 відкат конфігу: план як різниця, маскування паролів із підписом
плану, обов'язковий контрольний збір, verifying при обриві
0061 кнопки Telegram: довге опитування, авторизація не з callback_data
0062 аудит і архів хостів; тест на AST, що падає на ключі без назви
0063 RLS: три ролі, окремий пул для фонових тактів
0064 строки зберігання даних і сторінка сховища
0065 приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
переклад v1→v2 за RFC 3584 дає правильний OID
0066 ескалації сповіщень
0067 алерт про вичерпання диска
0068 поля заливки конфігу переїхали в каталог профілів
Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.
ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ
netpulse установник: одна команда замість 18 змінних і
593 рядків інструкції
RLS з першого запуску нова інсталяція під політиками одразу;
RLS-EXISTING-INSTALL.md лишається тільки для
старих інсталяцій
.forgejo + CI раннер не зареєстрований
Ці три перевірені компіляцією й міркуванням, але не виконанням.
ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ
Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.
Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.
Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
538 lines
20 KiB
Go
538 lines
20 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"`
|
||
// Фільтр складу портів для snmp.if. Приходить тим самим
|
||
// документом, що й решта шаблону: у формі це сусідня вкладка.
|
||
IfFilter *store.InterfaceFilter `json:"if_filter"`
|
||
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"`
|
||
// Прототипи їдуть тим самим документом, що й елементи: у формі
|
||
// це сусідня вкладка, і окрема ручка означала б, що шаблон
|
||
// можна зберегти наполовину — з новими метриками й старим
|
||
// правилом виявлення рядків.
|
||
Prototypes []store.TemplatePrototype `json:"prototypes"`
|
||
}
|
||
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
|
||
}
|
||
|
||
// Зразок перевіряємо тут, а не там, де він застосується: криву
|
||
// дужку показують тому, хто її щойно надрукував, а не через добу
|
||
// в журналі сервера рядком «фільтр не компілюється».
|
||
if in.IfFilter != nil && !in.IfFilter.IsEmpty() {
|
||
if err := store.ValidateInterfaceFilter(*in.IfFilter); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_filter", "фільтр портів: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
|
||
// Прототипи перевіряємо тут із тих самих міркувань, що й фільтр
|
||
// портів: криву дужку в зразку показують тому, хто її щойно
|
||
// надрукував. Прототип без OID виявлення мовчки не знайшов би
|
||
// жодного рядка — тобто виглядав би збереженим і не робив нічого.
|
||
for i := range in.Prototypes {
|
||
pr := &in.Prototypes[i]
|
||
pr.Key = strings.TrimSpace(pr.Key)
|
||
pr.Name = strings.TrimSpace(pr.Name)
|
||
if pr.Key == "" {
|
||
pr.Key = slugify(pr.Name)
|
||
}
|
||
if pr.Key == "" {
|
||
pr.Key = fmt.Sprintf("proto-%d", i+1)
|
||
}
|
||
if strings.TrimSpace(pr.DiscoverOID) == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"прототип «"+pr.Name+"» без OID виявлення рядків")
|
||
return
|
||
}
|
||
if pr.RowFilter != nil && !pr.RowFilter.IsEmpty() {
|
||
if err := store.ValidateRowFilter(*pr.RowFilter); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_filter",
|
||
"фільтр рядків прототипу «"+pr.Name+"»: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
for _, m := range pr.Metrics {
|
||
// Метрика без OID або без ключа не збирає нічого й нікуди:
|
||
// зберегти її означало б показати прототип, який виглядає
|
||
// робочим. Те саме правило, що для елементів snmp.get.
|
||
if strings.TrimSpace(m.OID) == "" || strings.TrimSpace(m.MetricKey) == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"метрика прототипу «"+pr.Name+"» потребує OID і ключ метрики")
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
t := store.Template{
|
||
ID: r.PathValue("id"),
|
||
Key: in.Key,
|
||
Name: in.Name,
|
||
Description: strings.TrimSpace(in.Description),
|
||
Vendor: strings.TrimSpace(in.Vendor),
|
||
IfFilter: in.IfFilter,
|
||
Prototypes: in.Prototypes,
|
||
}
|
||
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
|
||
}
|
||
// Зайнятий ключ — помилка людини, а не сервера. «Внутрішня
|
||
// помилка» у відповідь на цілком зрозумілу ситуацію лишає
|
||
// людину гадати, що зламалось, замість виправити одне поле.
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate_key",
|
||
"шаблон із ключем «"+t.Key+"» уже є — виберіть інший ключ")
|
||
return
|
||
}
|
||
// Фільтр, який зануляє склад портів на живому хості, — це не
|
||
// «внутрішня помилка», а рішення, яке треба переграти. Шаблон
|
||
// при цьому лишився таким, яким був: перевірка стоїть усередині
|
||
// тієї самої транзакції.
|
||
if errors.Is(err, store.ErrInterfaceFilterEmpty) {
|
||
writeError(w, http.StatusConflict, "filter_empty", err.Error())
|
||
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)
|
||
}
|
||
|
||
// handleSaveTemplateTriggers — тригери шаблону цілим набором.
|
||
//
|
||
// Окремим запитом, як і графіки: три вкладки форми зберігаються
|
||
// незалежно, і правка порога не має вимагати надіслати назад усі
|
||
// перевірки шаблону разом із нею.
|
||
func (s *Server) handleSaveTemplateTriggers(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "devices:write") {
|
||
return
|
||
}
|
||
var in struct {
|
||
Triggers []store.TemplateTrigger `json:"triggers"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||
return
|
||
}
|
||
|
||
for i := range in.Triggers {
|
||
t := &in.Triggers[i]
|
||
t.Name = strings.TrimSpace(t.Name)
|
||
t.Key = strings.TrimSpace(t.Key)
|
||
if t.Name == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "тригер без назви")
|
||
return
|
||
}
|
||
if t.Key == "" {
|
||
t.Key = slugify(t.Name)
|
||
}
|
||
if t.Key == "" {
|
||
t.Key = fmt.Sprintf("trigger-%d", i+1)
|
||
}
|
||
if t.Source == "" {
|
||
t.Source = "metric"
|
||
}
|
||
// Відмова стосується лише ввімкнених тригерів.
|
||
//
|
||
// Шаблон — документ, який редагують цілком: якби нереалізоване
|
||
// джерело блокувало збереження, людина з одним старим тригером
|
||
// на трапи не могла б виправити жоден інший. Вимкнений тригер
|
||
// нічого не обіцяє — він не розгортається в правило взагалі.
|
||
if reason := store.UnsupportedSourceReason(t.Source); reason != "" && t.Enabled {
|
||
writeError(w, http.StatusBadRequest, "bad_source",
|
||
"тригер «"+t.Name+"»: "+reason)
|
||
return
|
||
}
|
||
if t.Severity == "" {
|
||
t.Severity = "warning"
|
||
}
|
||
if !validSeverities[t.Severity] {
|
||
writeError(w, http.StatusBadRequest, "bad_severity", "невідома важливість: "+t.Severity)
|
||
return
|
||
}
|
||
if len(t.Condition) == 0 || string(t.Condition) == "{}" {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"тригер «"+t.Name+"» без умови")
|
||
return
|
||
}
|
||
if t.Enabled {
|
||
if err := store.ValidateRuleCondition(t.Source, t.Condition); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_condition",
|
||
"тригер «"+t.Name+"»: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
if t.ForSeconds < 0 || t.ForSeconds > 86400 {
|
||
writeError(w, http.StatusBadRequest, "bad_request",
|
||
"тригер «"+t.Name+"»: витримка поза межами 0…86400 с")
|
||
return
|
||
}
|
||
}
|
||
|
||
err := s.store.SaveTemplateTriggers(r.Context(), p.TenantID, r.PathValue("id"), in.Triggers)
|
||
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})
|
||
}
|