Netpulse_SasS/server/internal/store/template_triggers.go
byrsapty d1c9d450f2
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Інтерфейс: правила у вкладці, кнопки не виділяються, панель згортається
«Правила» пішли з бічної панелі у вкладку поруч з «Алертами»: тригери
переїхали в шаблони, а окремий пункт меню й далі пропонував заводити
правила там, звідки їх прибрали.

Кнопки перестали бути виділюваним текстом — у них типово
user-select: auto, і в підпис можна було поставити блимаючий курсор.

Бічна панель згортається у значки, стан у localStorage.

Дорогою закрито дірку: вбудований шаблон нікого не сповіщав. Тригери
стають правилами лише при збереженні шаблону, а вбудований нікому не
належить і не зберігається — хост із icmp-basic збирав пінг і мовчав
про недоступність. Плюс індекс правил з 0024 не враховував тенанта,
через що другий кабінет із тим самим вбудованим шаблоном отримав би
помилку унікальності.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:52:09 +03:00

239 lines
8.9 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"
"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
})
}