Netpulse_SasS/server/internal/store/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

917 lines
30 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 store
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"github.com/jackc/pgx/v5"
)
// ErrBuiltin — спроба змінити вбудований шаблон.
//
// Вбудовані спільні для всіх тенантів: правка одного мовчки змінила б
// опитування в чужих мережах. Хто хоче свій варіант — робить копію.
var ErrBuiltin = errors.New("вбудований шаблон не редагується")
// Template — шаблон опитування.
type Template struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"`
IsBuiltin bool `json:"is_builtin"`
Items []TemplateItem `json:"items,omitempty"`
Graphs []TemplateGraph `json:"graphs,omitempty"`
ItemCount int `json:"item_count"`
DeviceCount int `json:"device_count"`
}
// TemplateItem — одна перевірка шаблону.
//
// Для snmp.get це «один OID = одна метрика». Для решти типів (пінг,
// HTTP) OID немає взагалі, а поведінку задає Params — те саме, що
// лежить у core.checks.params.
type TemplateItem struct {
ID string `json:"id,omitempty"`
Key string `json:"key"`
Name string `json:"name"`
CheckType string `json:"check_type"`
OID string `json:"oid,omitempty"`
MetricKey string `json:"metric_key,omitempty"`
Unit string `json:"unit"`
Scale float64 `json:"scale"`
IntervalSec int `json:"interval_sec"`
Enabled bool `json:"enabled"`
Params json.RawMessage `json:"params,omitempty"`
}
// groupsByOID каже, чи збираються елементи цього типу в один чек.
//
// Пачкою ходять лише OID: агент питає їх однією PDU, і сотня окремих
// чеків замість однієї пачки — це сотня SNMP-сесій. Пінг у пачку не
// збирається: два пінги з різними параметрами — це просто два пінги.
func groupsByOID(checkType string) bool {
return checkType == "snmp.get"
}
// ---------------------------------------------------------------------
// Читання
// ---------------------------------------------------------------------
// ListTemplates — довідник шаблонів із лічильниками.
func (s *Store) ListTemplates(ctx context.Context, tenantID string) ([]Template, error) {
var out []Template
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
// Власна копія ховає вбудований оригінал.
//
// Так виглядає «редагування вбудованого»: правка не змінює
// спільний для всіх тенантів рядок, а створює власний із тим
// самим ключем. Показувати обидва означало б питати людину, який
// із двох однакових шаблонів вона щойно змінила.
rows, err := tx.Query(ctx, `
SELECT t.id::text, t.key, t.name, COALESCE(t.description,''),
COALESCE(t.vendor,''), t.is_builtin,
(SELECT count(*) FROM tpl.items i WHERE i.template_id = t.id),
(SELECT count(*) FROM tpl.device_templates dt WHERE dt.template_id = t.id)
FROM tpl.templates t
WHERE t.tenant_id IS NOT NULL
OR NOT EXISTS (
SELECT 1 FROM tpl.templates o
WHERE o.tenant_id = $1 AND o.key = t.key)
ORDER BY t.is_builtin DESC, t.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var t Template
if err := rows.Scan(&t.ID, &t.Key, &t.Name, &t.Description,
&t.Vendor, &t.IsBuiltin, &t.ItemCount, &t.DeviceCount); err != nil {
return err
}
out = append(out, t)
}
return rows.Err()
})
return out, err
}
// GetTemplate читає шаблон разом з елементами.
func (s *Store) GetTemplate(ctx context.Context, tenantID, id string) (*Template, error) {
var t Template
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
err := tx.QueryRow(ctx, `
SELECT id::text, key, name, COALESCE(description,''),
COALESCE(vendor,''), is_builtin
FROM tpl.templates WHERE id = $1
`, id).Scan(&t.ID, &t.Key, &t.Name, &t.Description, &t.Vendor, &t.IsBuiltin)
if err != nil {
return err
}
items, err := templateItems(ctx, tx, id)
if err != nil {
return err
}
t.Items = items
t.ItemCount = len(items)
graphs, err := templateGraphs(ctx, tx, id)
if err != nil {
return err
}
t.Graphs = graphs
return tx.QueryRow(ctx, `
SELECT count(*) FROM tpl.device_templates WHERE template_id = $1
`, id).Scan(&t.DeviceCount)
})
if err != nil {
if isNoRows(err) {
return nil, ErrNotFound
}
return nil, err
}
return &t, nil
}
func templateItems(ctx context.Context, tx pgx.Tx, templateID string) ([]TemplateItem, error) {
rows, err := tx.Query(ctx, `
SELECT id::text, key, name, check_type, COALESCE(oid,''),
COALESCE(metric_key,''), unit, scale, interval_sec, enabled,
params::text
FROM tpl.items WHERE template_id = $1 ORDER BY name
`, templateID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []TemplateItem{}
for rows.Next() {
var i TemplateItem
var params string
if err := rows.Scan(&i.ID, &i.Key, &i.Name, &i.CheckType, &i.OID,
&i.MetricKey, &i.Unit, &i.Scale, &i.IntervalSec, &i.Enabled,
&params); err != nil {
return nil, err
}
if params != "" && params != "{}" {
i.Params = json.RawMessage(params)
}
items = append(items, i)
}
return items, rows.Err()
}
// ---------------------------------------------------------------------
// Запис
// ---------------------------------------------------------------------
// SaveTemplate створює або оновлює власний шаблон разом з елементами.
//
// Елементи замінюються цілком, а не додаються: форма редагує шаблон як
// один документ, і «прибрати метрику» має бути таким самим звичайним
// рухом, як «додати». Ключ елемента при цьому стабільний, тож рядок,
// який не змінився, лишається тим самим рядком.
func (s *Store) SaveTemplate(ctx context.Context, tenantID string, t Template) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if t.ID != "" {
var builtin bool
var owner *string
if err := tx.QueryRow(ctx, `
SELECT is_builtin, tenant_id::text FROM tpl.templates WHERE id = $1
`, t.ID).Scan(&builtin, &owner); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
// Правка вбудованого не змінює спільний рядок, а створює
// власну копію тенанта з тим самим ключем. Оригінал лишається
// недоторканим для решти тенантів, а список показує копію
// замість нього.
//
// Це чесніше за «вбудований не редагується»: людина хоче
// підправити OID під своє залізо, а не дізнатися про межі
// нашої моделі даних.
if owner == nil {
t.ID = ""
}
}
if t.ID != "" {
if err := tx.QueryRow(ctx, `
UPDATE tpl.templates
SET key = $2, name = $3, description = NULLIF($4,''),
vendor = NULLIF($5,''), updated_at = now()
WHERE id = $1 AND tenant_id = $6
RETURNING id::text
`, t.ID, t.Key, t.Name, t.Description, t.Vendor, tenantID).Scan(&id); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM tpl.items WHERE template_id = $1`, id); err != nil {
return err
}
} else {
if err := tx.QueryRow(ctx, `
INSERT INTO tpl.templates (tenant_id, key, name, description, vendor, is_builtin)
VALUES ($1, $2, $3, NULLIF($4,''), NULLIF($5,''), false)
RETURNING id::text
`, tenantID, t.Key, t.Name, t.Description, t.Vendor).Scan(&id); err != nil {
return err
}
}
for _, i := range t.Items {
if i.Scale == 0 {
i.Scale = 1
}
if i.IntervalSec == 0 {
i.IntervalSec = 60
}
if i.CheckType == "" {
i.CheckType = "snmp.get"
}
params := "{}"
if len(i.Params) > 0 {
params = string(i.Params)
}
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.items
(template_id, key, name, check_type, oid, metric_key, unit,
scale, interval_sec, enabled, params)
VALUES ($1,$2,$3,$4,NULLIF($5,''),NULLIF($6,''),$7,$8,$9,$10,$11::jsonb)
`, id, i.Key, i.Name, i.CheckType, i.OID, i.MetricKey, i.Unit,
i.Scale, i.IntervalSec, i.Enabled, params); err != nil {
return err
}
}
return nil
})
if err != nil {
return "", err
}
// Чеки на всіх прив'язаних хостах мають наздогнати нову редакцію:
// сенс шаблону саме в тому, що виправлений OID доїжджає сам.
if err := s.reconcileTemplate(ctx, tenantID, id); err != nil {
return id, err
}
return id, nil
}
// DeleteTemplate прибирає власний шаблон.
//
// Породжені чеки зникають каскадом через core.checks.template_id — саме
// для цього колонка й існує.
func (s *Store) DeleteTemplate(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var builtin bool
if err := tx.QueryRow(ctx, `
SELECT is_builtin FROM tpl.templates WHERE id = $1
`, id).Scan(&builtin); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
if builtin {
return ErrBuiltin
}
_, err := tx.Exec(ctx, `
DELETE FROM tpl.templates WHERE id = $1 AND tenant_id = $2
`, id, tenantID)
return err
})
}
// ---------------------------------------------------------------------
// Прив'язка до хостів
// ---------------------------------------------------------------------
// DeviceTemplateIDs — які шаблони прив'язані до хоста.
func (s *Store) DeviceTemplateIDs(ctx context.Context, tenantID, deviceID string) ([]string, error) {
ids := []string{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT template_id::text FROM tpl.device_templates WHERE device_id = $1
`, deviceID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
ids = append(ids, id)
}
return rows.Err()
})
return ids, err
}
// SetDeviceTemplates замінює набір шаблонів хоста й перебудовує чеки.
func (s *Store) SetDeviceTemplates(ctx context.Context, tenantID, deviceID string, templateIDs []string) error {
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owned bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM inv.devices
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
`, deviceID, tenantID).Scan(&owned); err != nil {
return err
}
if !owned {
return ErrNotFound
}
if _, err := tx.Exec(ctx, `
DELETE FROM tpl.device_templates WHERE device_id = $1
`, deviceID); err != nil {
return err
}
for _, id := range templateIDs {
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.device_templates (device_id, template_id, tenant_id)
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING
`, deviceID, id, tenantID); err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
return s.ReconcileDeviceTemplates(ctx, tenantID, deviceID)
}
// ---------------------------------------------------------------------
// Реконсиляція: шаблони → core.checks
// ---------------------------------------------------------------------
// oidSpec — елемент params.oids для snmp.get. Імена полів мусять
// збігатися з agent/internal/modules/snmp.OIDSpec.
type oidSpec struct {
OID string `json:"oid"`
MetricKey string `json:"metric_key"`
Unit string `json:"unit,omitempty"`
Scale float64 `json:"scale"`
}
type checkGroup struct {
templateID string
checkType string
intervalSec int
// Порожній для згрупованого snmp.get, ключ елемента — для решти.
itemKey string
oids []oidSpec
params string
}
// ReconcileDeviceTemplates приводить чеки хоста у відповідність до його
// шаблонів.
//
// Елементи snmp.get групуються за (шаблон, інтервал) в один чек: агент
// уміє питати список OID однією пачкою, і сотня окремих чеків замість
// однієї пачки — це сотня SNMP-сесій там, де досить кількох PDU.
// Інтервал у ключі групування, бо пачка ходить цілком: змішавши
// хвилинну метрику з п'ятихвилинною, ми або опитували б рідкісну надто
// часто, або часту — надто рідко.
//
// Решта типів (пінг, HTTP) групування не знає: елемент — це чек.
func (s *Store) ReconcileDeviceTemplates(ctx context.Context, tenantID, deviceID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return reconcileDevice(ctx, tx, tenantID, deviceID)
})
}
func (s *Store) reconcileTemplate(ctx context.Context, tenantID, templateID string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT device_id::text FROM tpl.device_templates WHERE template_id = $1
`, templateID)
if err != nil {
return err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, id := range ids {
if err := reconcileDevice(ctx, tx, tenantID, id); err != nil {
return err
}
}
return nil
})
}
func reconcileDevice(ctx context.Context, tx pgx.Tx, tenantID, deviceID string) error {
rows, err := tx.Query(ctx, `
SELECT i.template_id::text, i.check_type, i.interval_sec, i.key,
COALESCE(i.oid,''), COALESCE(i.metric_key,''), i.unit, i.scale,
i.params::text
FROM tpl.device_templates dt
JOIN tpl.items i ON i.template_id = dt.template_id
WHERE dt.device_id = $1 AND i.enabled
ORDER BY i.template_id, i.check_type, i.interval_sec, i.key, i.oid
`, deviceID)
if err != nil {
return err
}
groups := map[string]*checkGroup{}
for rows.Next() {
var (
templateID, checkType, itemKey, params string
intervalSec int
o oidSpec
)
if err := rows.Scan(&templateID, &checkType, &intervalSec, &itemKey,
&o.OID, &o.MetricKey, &o.Unit, &o.Scale, &params); err != nil {
rows.Close()
return err
}
g := &checkGroup{
templateID: templateID,
checkType: checkType,
intervalSec: intervalSec,
params: params,
}
if !groupsByOID(checkType) {
g.itemKey = itemKey
}
k := fmt.Sprintf("%s|%s|%d|%s", g.templateID, g.checkType, g.intervalSec, g.itemKey)
if groups[k] == nil {
g.oids = []oidSpec{}
groups[k] = g
}
if groupsByOID(checkType) && o.OID != "" {
groups[k].oids = append(groups[k].oids, o)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
// Порядок ключів фіксуємо: params потрапляє в хеш плану, і
// «однаковий шаблон дав інший хеш» через порядок map — це
// перезалив плану на кожному тіку звірки.
keys := make([]string, 0, len(groups))
for k := range groups {
keys = append(keys, k)
}
sort.Strings(keys)
kept := make([]string, 0, len(keys))
for _, k := range keys {
g := groups[k]
var params string
if groupsByOID(g.checkType) {
if len(g.oids) == 0 {
continue
}
b, err := json.Marshal(map[string]any{"oids": g.oids})
if err != nil {
return err
}
params = string(b)
} else {
params = g.params
if params == "" {
params = "{}"
}
}
var id string
if err := tx.QueryRow(ctx, `
INSERT INTO core.checks
(tenant_id, device_id, template_id, template_item_key, check_type,
params, interval_sec, enabled)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, true)
ON CONFLICT (device_id, template_id, check_type, interval_sec, template_item_key)
WHERE template_id IS NOT NULL
DO UPDATE SET params = EXCLUDED.params, enabled = true, updated_at = now()
RETURNING id::text
`, tenantID, deviceID, g.templateID, g.itemKey, g.checkType,
params, g.intervalSec).Scan(&id); err != nil {
return err
}
kept = append(kept, id)
}
// Прибираємо те, що шаблони більше не породжують: відв'язаний
// шаблон, вимкнений елемент або змінений інтервал.
_, err = tx.Exec(ctx, `
DELETE FROM core.checks
WHERE device_id = $1 AND template_id IS NOT NULL
AND NOT (id = ANY($2::uuid[]))
`, deviceID, kept)
return err
}
// ---------------------------------------------------------------------
// Обмін шаблонами
// ---------------------------------------------------------------------
// TemplateDoc — документ обміну.
//
// Свій формат, а не Zabbix-YAML: там елемент описується ключем виду
// `snmp.get[...]`, до нього чіпляються препроцесинг, value maps і
// тригери — усього цього тут поки немає, і вдавати сумісність означало б
// мовчки втрачати половину імпортованого. Формат навмисно плаский і
// читається очима.
type TemplateDoc struct {
Format string `json:"format"`
Version int `json:"version"`
Exported time.Time `json:"exported_at"`
Templates []Template `json:"templates"`
}
const (
docFormat = "netpulse-templates"
docVersion = 1
)
// ExportTemplates збирає документ із заданих шаблонів.
//
// Порожній список — усі, включно з вбудованими: типовий сценарій
// «забрати все з одного стенду й перенести на інший».
func (s *Store) ExportTemplates(ctx context.Context, tenantID string, ids []string) (*TemplateDoc, error) {
doc := &TemplateDoc{
Format: docFormat,
Version: docVersion,
Exported: time.Now().UTC(),
Templates: []Template{},
}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var rows pgx.Rows
var err error
if len(ids) == 0 {
rows, err = tx.Query(ctx, `
SELECT id::text, key, name, COALESCE(description,''), COALESCE(vendor,'')
FROM tpl.templates ORDER BY name`)
} else {
rows, err = tx.Query(ctx, `
SELECT id::text, key, name, COALESCE(description,''), COALESCE(vendor,'')
FROM tpl.templates WHERE id = ANY($1::uuid[]) ORDER BY name`, ids)
}
if err != nil {
return err
}
var list []Template
for rows.Next() {
var t Template
if err := rows.Scan(&t.ID, &t.Key, &t.Name, &t.Description, &t.Vendor); err != nil {
rows.Close()
return err
}
list = append(list, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for i := range list {
items, err := templateItems(ctx, tx, list[i].ID)
if err != nil {
return err
}
// Ідентифікатори з документа прибираємо: на іншому стенді
// вони нічого не значать, а лишені — створюють ілюзію, що
// імпорт «відновить те саме».
list[i].ID = ""
for j := range items {
items[j].ID = ""
}
list[i].Items = items
doc.Templates = append(doc.Templates, list[i])
}
return nil
})
return doc, err
}
// ImportResult — що зробив імпорт.
type ImportResult struct {
Created []string `json:"created"`
Updated []string `json:"updated"`
Skipped []string `json:"skipped"`
}
// ImportTemplates заливає документ.
//
// updateExisting вирішує долю збігів за ключем: без нього наявний
// шаблон пропускається. Мовчазне перезаписування — найшвидший спосіб
// втратити локальні правки, тому типово вимкнене.
//
// Вбудований шаблон із таким самим ключем ніколи не чіпається: він
// спільний для всіх тенантів. Такий збіг завжди йде у Skipped.
func (s *Store) ImportTemplates(ctx context.Context, tenantID string, doc TemplateDoc, updateExisting bool) (*ImportResult, error) {
if doc.Format != "" && doc.Format != docFormat {
return nil, fmt.Errorf("чужий формат документа: %s", doc.Format)
}
res := &ImportResult{Created: []string{}, Updated: []string{}, Skipped: []string{}}
for _, t := range doc.Templates {
if t.Key == "" || t.Name == "" {
return nil, fmt.Errorf("шаблон без ключа або назви")
}
var existingID string
var builtin bool
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
return tx.QueryRow(ctx, `
SELECT id::text, is_builtin FROM tpl.templates
WHERE key = $1 AND (tenant_id IS NULL OR tenant_id = $2)
ORDER BY is_builtin DESC LIMIT 1
`, t.Key, tenantID).Scan(&existingID, &builtin)
})
if err != nil && !isNoRows(err) {
return nil, err
}
switch {
case existingID != "" && builtin:
res.Skipped = append(res.Skipped, t.Key)
continue
case existingID != "" && !updateExisting:
res.Skipped = append(res.Skipped, t.Key)
continue
case existingID != "":
t.ID = existingID
default:
t.ID = ""
}
if _, err := s.SaveTemplate(ctx, tenantID, t); err != nil {
return nil, fmt.Errorf("шаблон %s: %w", t.Key, err)
}
if existingID != "" {
res.Updated = append(res.Updated, t.Key)
} else {
res.Created = append(res.Created, t.Key)
}
}
return res, nil
}
// ---------------------------------------------------------------------
// Графіки шаблону
// ---------------------------------------------------------------------
// TemplateGraph — опис графіка.
type TemplateGraph struct {
ID string `json:"id,omitempty"`
Key string `json:"key"`
Name string `json:"name"`
Kind string `json:"kind"`
MetricKeys []string `json:"metric_keys"`
Options json.RawMessage `json:"options,omitempty"`
Position int `json:"position"`
}
// DeviceGraph — графік, розгорнутий під конкретний хост.
//
// Ключі метрик перетворені на справжні ряди: шаблон каже «покажи
// cpu.util_pct», а на хості це може бути один ряд або жоден.
type DeviceGraph struct {
TemplateGraph
TemplateName string `json:"template_name"`
SeriesIDs []int64 `json:"series_ids"`
}
func templateGraphs(ctx context.Context, tx pgx.Tx, templateID string) ([]TemplateGraph, error) {
rows, err := tx.Query(ctx, `
SELECT id::text, key, name, kind::text, metric_keys::text, options::text, position
FROM tpl.graphs WHERE template_id = $1 ORDER BY position, name
`, templateID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []TemplateGraph{}
for rows.Next() {
var g TemplateGraph
var keys, opts string
if err := rows.Scan(&g.ID, &g.Key, &g.Name, &g.Kind, &keys, &opts, &g.Position); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(keys), &g.MetricKeys); err != nil {
g.MetricKeys = []string{}
}
if opts != "" && opts != "{}" {
g.Options = json.RawMessage(opts)
}
out = append(out, g)
}
return out, rows.Err()
}
// SaveTemplateGraphs замінює графіки шаблону цілком.
func (s *Store) SaveTemplateGraphs(ctx context.Context, tenantID, templateID string, graphs []TemplateGraph) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owner *string
if err := tx.QueryRow(ctx,
`SELECT tenant_id::text FROM tpl.templates WHERE id = $1`, templateID).Scan(&owner); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
// Графіки вбудованого шаблону писати нікуди: сам шаблон при
// збереженні вже перетворився на власну копію, і клієнт присилає
// її id. Якщо все ж прийшов вбудований — це помилка виклику.
if owner == nil {
return ErrBuiltin
}
if _, err := tx.Exec(ctx, `DELETE FROM tpl.graphs WHERE template_id = $1`, templateID); err != nil {
return err
}
for i, g := range graphs {
keys, err := json.Marshal(g.MetricKeys)
if err != nil {
return err
}
opts := "{}"
if len(g.Options) > 0 {
opts = string(g.Options)
}
if g.Kind == "" {
g.Kind = "line"
}
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.graphs (template_id, key, name, kind, metric_keys, options, position)
VALUES ($1,$2,$3,$4::tpl.graph_kind,$5::jsonb,$6::jsonb,$7)
`, templateID, g.Key, g.Name, g.Kind, string(keys), opts, i); err != nil {
return err
}
}
return nil
})
}
// DeviceGraphs збирає графіки з усіх шаблонів хоста й підставляє ряди.
//
// Графік без жодного знайденого ряду не повертається: порожня рамка з
// підписом «Пам'ять» на хості, де пам'ять не збирається, — це обіцянка
// даних, яких немає.
func (s *Store) DeviceGraphs(ctx context.Context, tenantID, deviceID string) ([]DeviceGraph, error) {
out := []DeviceGraph{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT t.id::text, t.name
FROM tpl.device_templates dt
JOIN tpl.templates t ON t.id = dt.template_id
WHERE dt.device_id = $1
ORDER BY t.name
`, deviceID)
if err != nil {
return err
}
type tpl struct{ id, name string }
var tpls []tpl
for rows.Next() {
var t tpl
if err := rows.Scan(&t.id, &t.name); err != nil {
rows.Close()
return err
}
tpls = append(tpls, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
// Ряди хоста один раз: графіків може бути десяток, і питати
// однакове по разу на кожен — це десяток однакових запитів.
byKey := map[string][]int64{}
srows, err := tx.Query(ctx, `
SELECT metric_key, id FROM ts.series
WHERE tenant_id = $1 AND device_id = $2
`, tenantID, deviceID)
if err != nil {
return err
}
for srows.Next() {
var k string
var id int64
if err := srows.Scan(&k, &id); err != nil {
srows.Close()
return err
}
byKey[k] = append(byKey[k], id)
}
srows.Close()
if err := srows.Err(); err != nil {
return err
}
for _, t := range tpls {
graphs, err := templateGraphs(ctx, tx, t.id)
if err != nil {
return err
}
for _, g := range graphs {
var ids []int64
for _, k := range g.MetricKeys {
ids = append(ids, byKey[k]...)
}
if len(ids) == 0 {
continue
}
out = append(out, DeviceGraph{
TemplateGraph: g, TemplateName: t.name, SeriesIDs: ids,
})
}
}
return nil
})
return out, err
}
// CloneTemplate робить власну копію шаблону.
//
// Ключ отримує суфікс: копія з тим самим ключем ховала б оригінал, а
// клонують саме тоді, коли потрібні обидва — «як було» і «як хочу».
func (s *Store) CloneTemplate(ctx context.Context, tenantID, id string) (string, error) {
var newID string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var t Template
if err := tx.QueryRow(ctx, `
SELECT key, name, COALESCE(description,''), COALESCE(vendor,'')
FROM tpl.templates WHERE id = $1
`, id).Scan(&t.Key, &t.Name, &t.Description, &t.Vendor); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
// Вільний ключ шукаємо перебором, а не випадковим суфіксом:
// «snmp-mikrotik-2» читається, «snmp-mikrotik-a7f3» — ні.
base := t.Key
key := base + "-copy"
for i := 2; ; i++ {
var taken bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM tpl.templates
WHERE key = $1 AND tenant_id = $2)
`, key, tenantID).Scan(&taken); err != nil {
return err
}
if !taken {
break
}
key = fmt.Sprintf("%s-copy-%d", base, i)
}
if err := tx.QueryRow(ctx, `
INSERT INTO tpl.templates (tenant_id, key, name, description, vendor, is_builtin)
VALUES ($1, $2, $3, NULLIF($4,''), NULLIF($5,''), false)
RETURNING id::text
`, tenantID, key, t.Name+" (копія)", t.Description, t.Vendor).Scan(&newID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO tpl.items
(template_id, key, name, check_type, oid, metric_key, unit,
scale, interval_sec, enabled, params)
SELECT $1, key, name, check_type, oid, metric_key, unit,
scale, interval_sec, enabled, params
FROM tpl.items WHERE template_id = $2
`, newID, id); err != nil {
return err
}
_, err := tx.Exec(ctx, `
INSERT INTO tpl.graphs (template_id, key, name, kind, metric_keys, options, position)
SELECT $1, key, name, kind, metric_keys, options, position
FROM tpl.graphs WHERE template_id = $2
`, newID, id)
return err
})
return newID, err
}