«Правила» пішли з бічної панелі у вкладку поруч з «Алертами»: тригери переїхали в шаблони, а окремий пункт меню й далі пропонував заводити правила там, звідки їх прибрали. Кнопки перестали бути виділюваним текстом — у них типово user-select: auto, і в підпис можна було поставити блимаючий курсор. Бічна панель згортається у значки, стан у localStorage. Дорогою закрито дірку: вбудований шаблон нікого не сповіщав. Тригери стають правилами лише при збереженні шаблону, а вбудований нікому не належить і не зберігається — хост із icmp-basic збирав пінг і мовчав про недоступність. Плюс індекс правил з 0024 не враховував тенанта, через що другий кабінет із тим самим вбудованим шаблоном отримав би помилку унікальності. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
956 lines
32 KiB
Go
956 lines
32 KiB
Go
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"`
|
||
Triggers []TemplateTrigger `json:"triggers,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
|
||
triggers, err := templateTriggers(ctx, tx, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
t.Triggers = triggers
|
||
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,
|
||
¶ms); 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
|
||
}
|
||
if err := s.ReconcileDeviceTemplates(ctx, tenantID, deviceID); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Тригери призначених шаблонів теж мають перетворитись на правила.
|
||
//
|
||
// Саме тут, а не лише при збереженні шаблону: вбудовані шаблони
|
||
// нікому не належать і не зберігаються, тож без цього кроку хост із
|
||
// «icmp-basic» збирав би пінг і мовчав про недоступність — тобто
|
||
// коробка виглядала б робочою й не робила головного.
|
||
for _, id := range templateIDs {
|
||
if err := s.ReconcileTemplateRules(ctx, tenantID, id); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Реконсиляція: шаблони → 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, ¶ms); 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
|
||
}
|
||
|
||
if _, 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); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Тригери копіюються вимкненими.
|
||
//
|
||
// Клонують, щоб щось змінити, і копія з живими тригерами почала
|
||
// б слати ті самі сповіщення вдвічі — рівно з моменту, коли її
|
||
// призначать першому хосту, і не пояснивши, звідки дубль.
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO tpl.triggers
|
||
(template_id, key, name, description, source, severity,
|
||
condition, recovery, for_seconds, depends_on_topology,
|
||
enabled, position)
|
||
SELECT $1, key, name, description, source, severity,
|
||
condition, recovery, for_seconds, depends_on_topology,
|
||
false, position
|
||
FROM tpl.triggers WHERE template_id = $2
|
||
`, newID, id)
|
||
return err
|
||
})
|
||
return newID, err
|
||
}
|