«Правила» пішли з бічної панелі у вкладку поруч з «Алертами»: тригери переїхали в шаблони, а окремий пункт меню й далі пропонував заводити правила там, звідки їх прибрали. Кнопки перестали бути виділюваним текстом — у них типово user-select: auto, і в підпис можна було поставити блимаючий курсор. Бічна панель згортається у значки, стан у localStorage. Дорогою закрито дірку: вбудований шаблон нікого не сповіщав. Тригери стають правилами лише при збереженні шаблону, а вбудований нікому не належить і не зберігається — хост із icmp-basic збирав пінг і мовчав про недоступність. Плюс індекс правил з 0024 не враховував тенанта, через що другий кабінет із тим самим вбудованим шаблоном отримав би помилку унікальності. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
239 lines
8.9 KiB
Go
239 lines
8.9 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// TemplateTrigger — тригер, описаний у шаблоні.
|
||
//
|
||
// Той самий набір полів, що в alr.rules, мінус селектор: до чого
|
||
// застосовується тригер, вирішує не він сам, а те, кому призначено
|
||
// шаблон. У цьому вся суть — умова описується один раз на клас
|
||
// пристроїв.
|
||
type TemplateTrigger struct {
|
||
ID string `json:"id,omitempty"`
|
||
Key string `json:"key"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description,omitempty"`
|
||
Source string `json:"source"`
|
||
Severity string `json:"severity"`
|
||
Condition json.RawMessage `json:"condition"`
|
||
Recovery json.RawMessage `json:"recovery,omitempty"`
|
||
ForSeconds int `json:"for_seconds"`
|
||
DependsOnTopology bool `json:"depends_on_topology"`
|
||
Enabled bool `json:"enabled"`
|
||
Position int `json:"position"`
|
||
}
|
||
|
||
func templateTriggers(ctx context.Context, tx pgx.Tx, templateID string) ([]TemplateTrigger, error) {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, key, name, description, source::text, severity::text,
|
||
condition::text, COALESCE(recovery::text,''), for_seconds,
|
||
depends_on_topology, enabled, position
|
||
FROM tpl.triggers WHERE template_id = $1 ORDER BY position, name
|
||
`, templateID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []TemplateTrigger{}
|
||
for rows.Next() {
|
||
var (
|
||
t TemplateTrigger
|
||
condition, recov string
|
||
)
|
||
if err := rows.Scan(&t.ID, &t.Key, &t.Name, &t.Description, &t.Source,
|
||
&t.Severity, &condition, &recov, &t.ForSeconds,
|
||
&t.DependsOnTopology, &t.Enabled, &t.Position); err != nil {
|
||
return nil, err
|
||
}
|
||
t.Condition = json.RawMessage(condition)
|
||
if recov != "" && recov != "null" {
|
||
t.Recovery = json.RawMessage(recov)
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// SaveTemplateTriggers замінює набір тригерів шаблону цілком.
|
||
//
|
||
// Цілком, а не по одному: форма редагує шаблон як документ, і «прибрати
|
||
// тригер» має бути таким самим звичайним рухом, як «додати».
|
||
func (s *Store) SaveTemplateTriggers(ctx context.Context, tenantID, templateID string, triggers []TemplateTrigger) error {
|
||
err := 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
|
||
}
|
||
if owner == nil {
|
||
return ErrBuiltin
|
||
}
|
||
|
||
if _, err := tx.Exec(ctx, `DELETE FROM tpl.triggers WHERE template_id = $1`, templateID); err != nil {
|
||
return err
|
||
}
|
||
|
||
for i, t := range triggers {
|
||
if t.Key == "" || t.Name == "" {
|
||
return fmt.Errorf("%w: тригер без ключа або назви", ErrInvalid)
|
||
}
|
||
cond := "{}"
|
||
if len(t.Condition) > 0 {
|
||
cond = string(t.Condition)
|
||
}
|
||
var recovery any
|
||
if len(t.Recovery) > 0 && string(t.Recovery) != "null" {
|
||
recovery = string(t.Recovery)
|
||
}
|
||
if t.Source == "" {
|
||
t.Source = "metric"
|
||
}
|
||
if t.Severity == "" {
|
||
t.Severity = "warning"
|
||
}
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO tpl.triggers
|
||
(template_id, key, name, description, source, severity,
|
||
condition, recovery, for_seconds, depends_on_topology,
|
||
enabled, position)
|
||
VALUES ($1,$2,$3,$4,$5::alr.rule_source,$6::alr.severity,
|
||
$7::jsonb,$8::jsonb,$9,$10,$11,$12)
|
||
`, templateID, t.Key, t.Name, t.Description, t.Source, t.Severity,
|
||
cond, recovery, t.ForSeconds, t.DependsOnTopology,
|
||
t.Enabled, i); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.ReconcileTemplateRules(ctx, tenantID, templateID)
|
||
}
|
||
|
||
// ReconcileTemplateRules розгортає тригери шаблону в правила сповіщень
|
||
// для одного тенанта.
|
||
//
|
||
// Тенант у сигнатурі не зайвий навіть для власного шаблону: вбудовані
|
||
// шаблони спільні для всіх, і кожен кабінет отримує свій рядок правила
|
||
// з тим самим template_id.
|
||
//
|
||
// Одне правило на тригер, а не на пару «тригер + хост». Прив'язка йде
|
||
// через селектор `{"template_ids":[...]}`, тож хост, якому щойно
|
||
// призначили шаблон, одразу підпадає під його тригери — перегенеровувати
|
||
// нічого не треба.
|
||
//
|
||
// Правило на кожен хост дало б тисячі рядків, які довелося б тримати в
|
||
// синхроні з призначеннями шаблонів, а кожне розходження виглядало б як
|
||
// «алерт не спрацював» — найгірший спосіб дізнатись про помилку.
|
||
func (s *Store) ReconcileTemplateRules(ctx context.Context, tenantID, templateID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
triggers, err := templateTriggers(ctx, tx, templateID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var tplName, tplKey string
|
||
if err := tx.QueryRow(ctx,
|
||
`SELECT name, key FROM tpl.templates WHERE id = $1`, templateID).Scan(&tplName, &tplKey); err != nil {
|
||
if isNoRows(err) {
|
||
return ErrNotFound
|
||
}
|
||
return err
|
||
}
|
||
|
||
selector, err := json.Marshal(map[string]any{"template_ids": []string{templateID}})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
kept := make([]string, 0, len(triggers))
|
||
for _, t := range triggers {
|
||
if !t.Enabled {
|
||
continue
|
||
}
|
||
// Ім'я правила містить шаблон: у списку правил має бути
|
||
// видно, звідки воно взялося й де його правити.
|
||
name := tplName + ": " + t.Name
|
||
|
||
// Ім'я правила унікальне в межах тенанта, а імена шаблонів —
|
||
// ні. Два шаблони з однаковою назвою й однойменним тригером
|
||
// давали помилку унікальності, і збереження другого падало
|
||
// п'ятисоткою. Ключ шаблону унікальний завжди, тож ним і
|
||
// розрізняємо — але лише коли справді треба, інакше він
|
||
// заважав би читати список.
|
||
var clash bool
|
||
if err := tx.QueryRow(ctx, `
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM alr.rules
|
||
WHERE tenant_id = $1 AND name = $2
|
||
AND template_id IS DISTINCT FROM $3
|
||
)
|
||
`, tenantID, name, templateID).Scan(&clash); err != nil {
|
||
return err
|
||
}
|
||
if clash {
|
||
name = tplName + " [" + tplKey + "]: " + t.Name
|
||
}
|
||
|
||
var recovery any
|
||
if len(t.Recovery) > 0 && string(t.Recovery) != "null" {
|
||
recovery = string(t.Recovery)
|
||
}
|
||
cond := "{}"
|
||
if len(t.Condition) > 0 {
|
||
cond = string(t.Condition)
|
||
}
|
||
|
||
var id string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO alr.rules
|
||
(tenant_id, name, description, source, severity, selector,
|
||
condition, recovery, for_seconds, depends_on_topology,
|
||
enabled, template_id, template_trigger_key)
|
||
VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,
|
||
$7::jsonb,$8::jsonb,$9,$10,true,$11,$12)
|
||
ON CONFLICT (tenant_id, template_id, template_trigger_key)
|
||
WHERE template_id IS NOT NULL
|
||
DO UPDATE SET
|
||
name = EXCLUDED.name,
|
||
description = EXCLUDED.description,
|
||
source = EXCLUDED.source,
|
||
severity = EXCLUDED.severity,
|
||
selector = EXCLUDED.selector,
|
||
condition = EXCLUDED.condition,
|
||
recovery = EXCLUDED.recovery,
|
||
for_seconds = EXCLUDED.for_seconds,
|
||
depends_on_topology = EXCLUDED.depends_on_topology,
|
||
enabled = true,
|
||
updated_at = now()
|
||
RETURNING id::text
|
||
`, tenantID, name, t.Description, t.Source, t.Severity, string(selector),
|
||
cond, recovery, t.ForSeconds, t.DependsOnTopology,
|
||
templateID, t.Key).Scan(&id); err != nil {
|
||
return fmt.Errorf("тригер %s: %w", t.Key, err)
|
||
}
|
||
kept = append(kept, id)
|
||
}
|
||
|
||
// Прибираємо правила зниклих і вимкнених тригерів. Видалення, а
|
||
// не вимкнення: правило, породжене шаблоном, не має власного
|
||
// життя — джерело істини лишається одне.
|
||
_, err = tx.Exec(ctx, `
|
||
DELETE FROM alr.rules
|
||
WHERE template_id = $1 AND NOT (id = ANY($2::uuid[]))
|
||
`, templateID, kept)
|
||
return err
|
||
})
|
||
}
|