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

Тригер розгортається в ОДНЕ правило alr.rules із селектором за
шаблоном, а не в правило на кожен хост: призначили шаблон новому
пристрою — він одразу під правилом, без перегенерації.

Форма шаблону розкладена на вкладки Загальне/Перевірки/Графіки/Тригери
з лічильниками; смуга вкладок винесена у спільний компонент. Умова
тригера редагується полями, JSON лишився запасним виходом.

Правила з шаблону в списку правил помічені й не редагуються там.
Клон копіює тригери вимкненими, щоб копія не подвоїла сповіщення.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:45:49 +03:00

234 lines
8.6 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_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 (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
})
}