Тригери описуються в шаблоні; форма шаблону — на вкладках
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>
This commit is contained in:
byrsapty 2026-08-25 13:45:49 +03:00
parent 0bc2335661
commit 2fe562936d
15 changed files with 1085 additions and 24 deletions

View file

@ -2262,3 +2262,86 @@ netpulse-gitsync 2 наявні версії stand-host → 2 коміти, б
git fsck --strict чисто на обох гілках
тести server ×2 і agent — зелені
```
## Тригери в шаблонах
### Чому вони переїхали
Правила сповіщень жили окремою сторінкою, і це було неправильно. «Процесор
вище 85% пʼять хвилин — це проблема» описує **клас пристроїв**, а не
окремий хост. Заводити те саме правило руками на кожен комутатор означає
повторювати одне рішення стільки разів, скільки в мережі заліза, і забути
про половину при наступній зміні порога.
Тепер тригер описується там само, де перевірки, які дають йому дані.
Сторінка «Правила» лишається — але для того, для чого справді потрібна:
разових правил на конкретний хост і правил, що перетинають класи.
### Одне правило, а не правило на хост
Тригер шаблону розгортається в **одне** правило `alr.rules` із селектором
`{"template_ids":[...]}`. Хост, якому щойно призначили шаблон, одразу
підпадає під його тригери — перегенеровувати нічого не треба.
Правило на кожен хост дало б тисячі рядків, які довелося б тримати в
синхроні з призначеннями шаблонів, а кожне розходження виглядало б як
«алерт не спрацював» — найгірший спосіб дізнатись про помилку.
Селектор отримав нове поле `template_ids`; решта механізму сповіщень не
змінилась зовсім — тригер шаблону і є звичайне правило, просто описане
один раз.
### Форма на вкладках
`Загальне · Перевірки · Графіки · Тригери`, з лічильником у підписі
кожної. Лічильник не прикраса: він єдиний спосіб побачити, що на сусідній
вкладці щось є, не клацнувши по ній.
Смуга вкладок поїхала в спільний `Tabs` — той самий вигляд уже був
руками зроблений у картці хоста, і третя копія була б зайвою.
Умова тригера редагується полями, а не JSON-ом: «cpu.util_pct більше 85
протягом 5 хв» — те, що людина тримає в голові, і змушувати її
перекладати це у фігурні дужки означає перекладати на неї роботу форми.
JSON лишився запасним виходом для джерел, яким полів ще немає.
Умова пінгу зберігається одним рядком (`"loss_pct >"`) — так її розуміє
движок; форма розбирає його на поле й оператор і збирає назад.
### Правила з шаблону — тільки для читання
У списку правил вони підписані «із шаблону «…»», кнопок «Змінити» й
«Видалити» не мають. Дати правити їх там означало б показати зміну, яку
наступна звірка мовчки відкотить.
### Клон копіює тригери вимкненими
Клонують, щоб щось змінити. Копія з живими тригерами почала б слати ті
самі сповіщення вдвічі — рівно з моменту, коли її призначать першому
хосту, і не пояснивши, звідки дубль.
### Дві помилки, знайдені живим прогоном
**Два шаблони з однаковою назвою ламали збереження.** Імʼя правила
унікальне в межах тенанта, а імена шаблонів — ні. Два шаблони «Однакова
назва» з однойменним тригером давали помилку унікальності, і збереження
другого падало пʼятисоткою. Тепер при збігу до імені додається ключ
шаблону — але лише при збігу, інакше він заважав би читати список.
**Зайнятий ключ шаблону теж давав «внутрішню помилку».** Це помилка
людини, а не сервера: тепер 409 із текстом «шаблон із ключем «…» уже є».
### Перевірено наскрізно
```
міграція 0024 на живій БД, два вбудовані тригери до icmp-basic
тригер PUT /templates/{id}/triggers → правило зʼявилось
селектор {"template_ids":["<tpl>"]}, важливість і витримка збережені
читання GET /templates/{id} повертає тригери назад
вимкнення enabled=false → правило прибрано
збіг імен два шаблони «Однакова назва» → «Однакова назва [qa-b]: …»
клон тригери скопійовані, усі вимкнені, правил не породили
видалення шаблон видалено → правила зникли каскадом
валідація невідоме джерело → 400 з поясненням
тести server (з базою) і web build — зелені
```

View file

@ -130,6 +130,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate))
mux.Handle("POST /api/v1/templates/{id}/clone", s.authenticated(s.handleCloneTemplate))
mux.Handle("PUT /api/v1/templates/{id}/graphs", s.authenticated(s.handleSaveTemplateGraphs))
mux.Handle("PUT /api/v1/templates/{id}/triggers", s.authenticated(s.handleSaveTemplateTriggers))
mux.Handle("GET /api/v1/devices/{id}/graphs", s.authenticated(s.handleDeviceGraphs))
mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates))
mux.Handle("PUT /api/v1/devices/{id}/templates", s.authenticated(s.handleSetDeviceTemplates))

View file

@ -156,6 +156,14 @@ func (s *Server) handleSaveTemplate(w http.ResponseWriter, r *http.Request, p *P
"вбудований шаблон не редагується — зробіть копію")
return
}
// Зайнятий ключ — помилка людини, а не сервера. «Внутрішня
// помилка» у відповідь на цілком зрозумілу ситуацію лишає
// людину гадати, що зламалось, замість виправити одне поле.
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate_key",
"шаблон із ключем «"+t.Key+"» уже є — виберіть інший ключ")
return
}
s.writeStoreError(w, "збереження шаблону", err)
return
}
@ -349,6 +357,76 @@ func (s *Server) handleSaveTemplateGraphs(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent)
}
// handleSaveTemplateTriggers — тригери шаблону цілим набором.
//
// Окремим запитом, як і графіки: три вкладки форми зберігаються
// незалежно, і правка порога не має вимагати надіслати назад усі
// перевірки шаблону разом із нею.
func (s *Server) handleSaveTemplateTriggers(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:write") {
return
}
var in struct {
Triggers []store.TemplateTrigger `json:"triggers"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
return
}
for i := range in.Triggers {
t := &in.Triggers[i]
t.Name = strings.TrimSpace(t.Name)
t.Key = strings.TrimSpace(t.Key)
if t.Name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "тригер без назви")
return
}
if t.Key == "" {
t.Key = slugify(t.Name)
}
if t.Key == "" {
t.Key = fmt.Sprintf("trigger-%d", i+1)
}
if t.Source == "" {
t.Source = "metric"
}
if !validSources[t.Source] {
writeError(w, http.StatusBadRequest, "bad_source", "невідоме джерело тригера: "+t.Source)
return
}
if t.Severity == "" {
t.Severity = "warning"
}
if !validSeverities[t.Severity] {
writeError(w, http.StatusBadRequest, "bad_severity", "невідома важливість: "+t.Severity)
return
}
if len(t.Condition) == 0 || string(t.Condition) == "{}" {
writeError(w, http.StatusBadRequest, "bad_request",
"тригер «"+t.Name+"» без умови")
return
}
if t.ForSeconds < 0 || t.ForSeconds > 86400 {
writeError(w, http.StatusBadRequest, "bad_request",
"тригер «"+t.Name+"»: витримка поза межами 0…86400 с")
return
}
}
err := s.store.SaveTemplateTriggers(r.Context(), p.TenantID, r.PathValue("id"), in.Triggers)
if err != nil {
if errors.Is(err, store.ErrBuiltin) {
writeError(w, http.StatusForbidden, "builtin",
"тригери вбудованого шаблону не редагуються — зробіть копію")
return
}
s.writeStoreError(w, "тригери шаблону", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleDeviceGraphs — графіки, які дають шаблони цього хоста.
func (s *Server) handleDeviceGraphs(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") {

View file

@ -33,6 +33,10 @@ type Selector struct {
GroupIDs []string `json:"group_ids"`
SiteIDs []string `json:"site_ids"`
Kinds []string `json:"kinds"`
// Хости, яким призначено ці шаблони. Так тригер шаблону стає одним
// правилом замість правила на кожен хост: призначили шаблон новому
// комутатору — він одразу під правилом, без перегенерації.
TemplateIDs []string `json:"template_ids"`
Vendors []string `json:"vendors"`
Tags map[string]string `json:"tags"`
}
@ -453,6 +457,12 @@ func (s *Store) selectorSQL(sel Selector, a *args, deviceCol string) (string, er
if len(sel.Vendors) > 0 {
parts = append(parts, fmt.Sprintf("d.vendor = ANY(%s::text[])", a.add(sel.Vendors)))
}
if len(sel.TemplateIDs) > 0 {
parts = append(parts, fmt.Sprintf(`EXISTS (
SELECT 1 FROM tpl.device_templates dt
WHERE dt.device_id = d.id AND dt.template_id = ANY(%s::uuid[]))`,
a.add(sel.TemplateIDs)))
}
if len(sel.GroupIDs) > 0 {
parts = append(parts, fmt.Sprintf(`EXISTS (
SELECT 1 FROM inv.device_group_members m

View file

@ -233,8 +233,10 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an
r.channel_ids::text[], COALESCE(r.notify_schedule::text,''),
r.notify_on_resolve,
(SELECT count(*) FROM alr.alerts a
WHERE a.rule_id = r.id AND a.state IN ('firing','acknowledged'))
WHERE a.rule_id = r.id AND a.state IN ('firing','acknowledged')),
COALESCE(r.template_id::text,''), COALESCE(t.name,'')
FROM alr.rules r
LEFT JOIN tpl.templates t ON t.id = r.template_id
WHERE r.tenant_id = $1
ORDER BY r.name
`, tenantID)
@ -244,12 +246,13 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an
defer rows.Close()
for rows.Next() {
var id, name, desc, source, sev, sel, cond, sched string
var tplID, tplName string
var forSec, active int
var topo, enabled, onResolve bool
var channelIDs []string
if err := rows.Scan(&id, &name, &desc, &source, &sev, &sel, &cond,
&forSec, &topo, &enabled, &channelIDs, &sched, &onResolve,
&active); err != nil {
&active, &tplID, &tplName); err != nil {
return err
}
if channelIDs == nil {
@ -262,6 +265,10 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an
"enabled": enabled, "active_alerts": active,
"channel_ids": channelIDs, "notify_on_resolve": onResolve,
"notify_schedule": rawJSON(sched),
// Непорожній шаблон означає, що правило породжене його
// тригером: у списку його видно, але правлять його там,
// де воно описане один раз на клас пристроїв.
"template_id": tplID, "template_name": tplName,
})
}
return rows.Err()

View file

@ -0,0 +1,234 @@
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
})
}

View file

@ -27,6 +27,7 @@ type Template struct {
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"`
}
@ -125,6 +126,11 @@ func (s *Store) GetTemplate(ctx context.Context, tenantID, id string) (*Template
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)
@ -906,10 +912,28 @@ func (s *Store) CloneTemplate(ctx context.Context, tenantID, id string) (string,
return err
}
_, err := tx.Exec(ctx, `
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
})

View file

@ -0,0 +1,105 @@
-- =====================================================================
-- NetPulse :: 0024_template_triggers.sql
-- Тригери описуються в шаблоні разом із перевірками й графіками.
--
-- Причина та сама, що для метрик і графіків: «завантаження процесора
-- вище 85% п'ять хвилин — це проблема» — властивість класу пристроїв, а
-- не окремого хоста. Заводити те саме правило руками на кожен комутатор
-- означає повторювати одне рішення стільки разів, скільки в мережі
-- заліза, і забути про половину при наступній зміні порога.
--
-- Окрема сторінка «Правила» лишається — але для того, для чого вона
-- справді потрібна: разових правил на конкретний хост і правил, що
-- перетинають класи («будь-що в стійці 3»).
-- =====================================================================
CREATE TABLE tpl.triggers (
id uuid PRIMARY KEY DEFAULT core.new_id(),
template_id uuid NOT NULL REFERENCES tpl.templates(id) ON DELETE CASCADE,
key core.slug NOT NULL,
name text NOT NULL,
description text NOT NULL DEFAULT '',
source alr.rule_source NOT NULL DEFAULT 'metric',
severity alr.severity NOT NULL DEFAULT 'warning',
-- Той самий формат, що в alr.rules.condition — тригер шаблону і є
-- правилом, просто описаним один раз на клас пристроїв.
condition jsonb NOT NULL,
recovery jsonb,
for_seconds int NOT NULL DEFAULT 60 CHECK (for_seconds BETWEEN 0 AND 86400),
depends_on_topology boolean NOT NULL DEFAULT true,
enabled boolean NOT NULL DEFAULT true,
position int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (template_id, key)
);
CREATE INDEX tpl_triggers_template_idx ON tpl.triggers (template_id);
ALTER TABLE tpl.triggers ENABLE ROW LEVEL SECURITY;
ALTER TABLE tpl.triggers FORCE ROW LEVEL SECURITY;
-- Видимість успадковується від шаблону: власний tenant_id у тригера
-- завжди дорівнював би шаблоновому, а два джерела однієї істини рано чи
-- пізно розходяться.
CREATE POLICY triggers_follow_template ON tpl.triggers
USING (EXISTS (
SELECT 1 FROM tpl.templates t
WHERE t.id = tpl.triggers.template_id
AND (t.tenant_id IS NULL OR t.tenant_id = core.current_tenant())
))
WITH CHECK (EXISTS (
SELECT 1 FROM tpl.templates t
WHERE t.id = tpl.triggers.template_id AND t.tenant_id = core.current_tenant()
));
-- ---------------------------------------------------------------------
-- Звідки взялося правило
-- ---------------------------------------------------------------------
-- Тригер шаблону розгортається В ОДНЕ правило, а не в правило на кожен
-- хост. Прив'язка йде через селектор `{"template_ids":[...]}`: хост,
-- якому щойно призначили шаблон, одразу підпадає під його тригери, і
-- нічого перегенеровувати не треба.
--
-- Правило на кожен хост дало б тисячі рядків, які треба тримати в
-- синхроні з призначеннями шаблонів — і кожне розходження виглядало б
-- як «алерт не спрацював», найгірший спосіб дізнатись про баг.
ALTER TABLE alr.rules
ADD COLUMN template_id uuid REFERENCES tpl.templates(id) ON DELETE CASCADE,
ADD COLUMN template_trigger_key text;
-- Часткова унікальність: правила, заведені руками, обмежень не мають.
CREATE UNIQUE INDEX alr_rules_from_template_uniq
ON alr.rules (template_id, template_trigger_key)
WHERE template_id IS NOT NULL;
COMMENT ON COLUMN alr.rules.template_id IS
'Не NULL — правило породжене тригером шаблону; правити його треба в шаблоні';
-- ---------------------------------------------------------------------
-- Готові тригери до вбудованих шаблонів
-- ---------------------------------------------------------------------
-- Без них шаблон «icmp-basic» лишається німим: перевірка є, а сказати
-- про недоступність нікому. Саме цього й чекають від коробки.
INSERT INTO tpl.triggers (template_id, key, name, description, source, severity,
condition, for_seconds, position)
SELECT t.id, 'icmp-down', 'Хост недоступний',
'Пінг не проходить довше хвилини',
'icmp', 'high',
'{"op":"loss_pct >","value":99}'::jsonb, 60, 0
FROM tpl.templates t
WHERE t.key = 'icmp-basic' AND t.tenant_id IS NULL
ON CONFLICT DO NOTHING;
INSERT INTO tpl.triggers (template_id, key, name, description, source, severity,
condition, for_seconds, position)
SELECT t.id, 'icmp-loss', 'Втрати пакетів',
'Понад 20% втрат протягом трьох хвилин — канал є, але поганий',
'icmp', 'warning',
'{"op":"loss_pct >","value":20}'::jsonb, 180, 1
FROM tpl.templates t
WHERE t.key = 'icmp-basic' AND t.tenant_id IS NULL
ON CONFLICT DO NOTHING;

View file

@ -36,6 +36,7 @@ import type {
SeriesInfo,
Template,
TemplateGraph,
TemplateTrigger,
} from '../types'
/** Те, що приймає POST/PATCH /api/v1/devices. */
@ -651,6 +652,12 @@ export const api = {
body: JSON.stringify({ graphs }),
}),
saveTemplateTriggers: (templateID: string, triggers: TemplateTrigger[]) =>
request<void>(`/api/v1/templates/${templateID}/triggers`, {
method: 'PUT',
body: JSON.stringify({ triggers }),
}),
deviceGraphs: (deviceID: string) =>
request<{ graphs: DeviceGraph[] }>(`/api/v1/devices/${deviceID}/graphs`).then(
(r) => r.graphs ?? [],

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Button, Confirm, ErrorNote, Field, Modal, Toggle, inputClass } from './ui'
import { Button, Confirm, ErrorNote, Field, Modal, Tabs, Toggle, inputClass } from './ui'
import type { ConfirmRequest } from './ui'
import { Picker } from './Picker'
import { TemplatePicker } from './TemplatePicker'
@ -166,18 +166,7 @@ export function DeviceForm({
return (
<Modal title={device ? `Хост: ${device.name}` : 'Новий хост'} onClose={onClose} wide>
<div className="space-y-3">
<div className="flex flex-wrap gap-1 border-b border-slate-800 pb-2">
{tabs.map((t) => (
<Button
key={t.key}
kind={tab === t.key ? 'primary' : 'ghost'}
onClick={() => setTab(t.key)}
>
{t.label}
{t.badge ? ` ${t.badge}` : ''}
</Button>
))}
</div>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
{tab === 'main' && (
<>

View file

@ -0,0 +1,385 @@
import { Button, Field, Toggle, inputClass } from './ui'
import type { TemplateTrigger } from '../types'
/**
* Редактор тригерів шаблону.
*
* Тригер те саме правило сповіщення, тільки описане один раз на клас
* пристроїв замість повторення на кожному хості. Тому й живе він у
* шаблоні поруч із перевірками, які дають йому дані.
*
* Умова редагується полями, а не JSON-ом: «cpu.util_pct більше 85
* протягом 5 хв» те, що людина тримає в голові, і змушувати її
* перекладати це у фігурні дужки означає перекладати на неї роботу
* форми. JSON лишається запасним виходом для джерел, яким полів мало.
*/
const SOURCES: { key: string; label: string; hint: string }[] = [
{ key: 'metric', label: 'Метрика', hint: 'Значення, зібране перевіркою шаблону' },
{ key: 'icmp', label: 'Пінг', hint: 'Втрати або затримка' },
{ key: 'interface', label: 'Інтерфейс', hint: 'Завантаження, помилки, стан порту' },
{ key: 'syslog', label: 'Syslog', hint: 'Збіг у тексті повідомлення' },
{ key: 'ncm', label: 'Конфіг', hint: 'Зміна або невдалий збір' },
{ key: 'agent', label: 'Зонд', hint: 'Зонд не на звʼязку' },
]
const SEVERITIES: { key: string; label: string; tone: string }[] = [
{ key: 'info', label: 'Інформація', tone: 'text-sky-300' },
{ key: 'warning', label: 'Попередження', tone: 'text-amber-300' },
{ key: 'average', label: 'Середня', tone: 'text-orange-300' },
{ key: 'high', label: 'Висока', tone: 'text-red-300' },
{ key: 'disaster', label: 'Аварія', tone: 'text-red-400' },
]
const OPS = ['>', '>=', '<', '<=', '==', '!='] as const
// Параметри пінгу, які має сенс порівнювати з числом.
const ICMP_FIELDS: { key: string; label: string; unit: string }[] = [
{ key: 'loss_pct', label: 'втрати', unit: '%' },
{ key: 'rtt_ms', label: 'затримка', unit: 'мс' },
]
const AGGS: { key: string; label: string }[] = [
{ key: 'avg', label: 'середнє' },
{ key: 'max', label: 'максимум' },
{ key: 'min', label: 'мінімум' },
{ key: 'last', label: 'останнє' },
]
type Cond = Record<string, unknown>
export function TriggerEditor({
triggers,
readOnly,
metricKeys,
onChange,
}: {
triggers: TemplateTrigger[]
readOnly: boolean
/** Ключі метрик цього ж шаблону — щоб не згадувати їх напамʼять. */
metricKeys: string[]
onChange: (next: TemplateTrigger[]) => void
}) {
function patch(i: number, next: Partial<TemplateTrigger>) {
onChange(triggers.map((t, n) => (n === i ? { ...t, ...next } : t)))
}
function patchCond(i: number, next: Cond) {
const cur = (triggers[i].condition ?? {}) as Cond
patch(i, { condition: { ...cur, ...next } })
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-400">Тригери</span>
{!readOnly && (
<Button
onClick={() =>
onChange([
...triggers,
{
key: '',
name: '',
description: '',
source: 'metric',
severity: 'warning',
condition: { metric_key: metricKeys[0] ?? '', agg: 'avg', window: '5m', op: '>', value: 0 },
for_seconds: 60,
depends_on_topology: true,
enabled: true,
position: triggers.length,
},
])
}
>
Додати тригер
</Button>
)}
</div>
<p className="text-[11px] leading-relaxed text-slate-600">
«Процесор вище 85% пʼять хвилин це проблема» описує клас пристроїв, а не окремий хост.
Тригер, заданий тут, стане правилом для кожного, кому чіпляють шаблон, і зникне разом
із відвʼязаним шаблоном. Разові правила на конкретний хост лишаються на сторінці
«Правила».
</p>
{triggers.length === 0 && (
<p className="py-6 text-center text-xs text-slate-600">
Шаблон без тригерів збирає дані мовчки про проблему не скаже ніхто.
</p>
)}
{triggers.map((t, i) => {
const cond = (t.condition ?? {}) as Cond
return (
<div key={i} className="rounded border border-slate-800 bg-slate-900/40 p-3">
<div className="grid gap-2 sm:grid-cols-2">
<Field label="Назва">
<input
className={inputClass}
value={t.name}
disabled={readOnly}
placeholder="Високе навантаження процесора"
onChange={(e) => patch(i, { name: e.target.value })}
/>
</Field>
<div className="grid grid-cols-2 gap-2">
<Field label="Джерело">
<select
className={inputClass}
value={t.source}
disabled={readOnly}
onChange={(e) => patch(i, { source: e.target.value, condition: defaultCond(e.target.value, metricKeys) })}
>
{SOURCES.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</Field>
<Field label="Важливість">
<select
className={inputClass}
value={t.severity}
disabled={readOnly}
onChange={(e) => patch(i, { severity: e.target.value })}
>
{SEVERITIES.map((s) => (
<option key={s.key} value={s.key}>
{s.label}
</option>
))}
</select>
</Field>
</div>
</div>
{t.source === 'metric' && (
<div className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr]">
<Field label="Метрика">
<input
className={`${inputClass} font-mono`}
list={`metrics-${i}`}
value={String(cond.metric_key ?? '')}
disabled={readOnly}
placeholder="cpu.util_pct"
onChange={(e) => patchCond(i, { metric_key: e.target.value })}
/>
<datalist id={`metrics-${i}`}>
{metricKeys.map((k) => (
<option key={k} value={k} />
))}
</datalist>
</Field>
<Field label="Як зводити">
<select
className={inputClass}
value={String(cond.agg ?? 'avg')}
disabled={readOnly}
onChange={(e) => patchCond(i, { agg: e.target.value })}
>
{AGGS.map((a) => (
<option key={a.key} value={a.key}>
{a.label}
</option>
))}
</select>
</Field>
<Field label="Вікно" hint="5m, 1h">
<input
className={inputClass}
value={String(cond.window ?? '5m')}
disabled={readOnly}
onChange={(e) => patchCond(i, { window: e.target.value })}
/>
</Field>
</div>
)}
{t.source === 'icmp' && (
<Field label="Умова">
<div className="flex gap-2">
<select
className={inputClass}
value={icmpField(cond)}
disabled={readOnly}
onChange={(e) => patchCond(i, { op: `${e.target.value} ${icmpOp(cond)}` })}
>
{ICMP_FIELDS.map((f) => (
<option key={f.key} value={f.key}>
{f.label}
</option>
))}
</select>
<select
className={`${inputClass} w-20`}
value={icmpOp(cond)}
disabled={readOnly}
onChange={(e) => patchCond(i, { op: `${icmpField(cond)} ${e.target.value}` })}
>
{OPS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<input
className={`${inputClass} w-28`}
type="number"
value={Number(cond.value ?? 0)}
disabled={readOnly}
onChange={(e) => patchCond(i, { value: Number(e.target.value) })}
/>
<span className="self-center text-xs text-slate-500">
{ICMP_FIELDS.find((f) => f.key === icmpField(cond))?.unit}
</span>
</div>
</Field>
)}
{t.source === 'syslog' && (
<Field label="Збіг у тексті" hint="Регулярний вираз">
<input
className={`${inputClass} font-mono`}
value={String(cond.regex ?? '')}
disabled={readOnly}
placeholder="%LINK-3-UPDOWN.*down"
onChange={(e) => patchCond(i, { regex: e.target.value })}
/>
</Field>
)}
{(t.source === 'metric' || t.source === 'interface') && (
<div className="grid grid-cols-2 gap-2">
<Field label="Порівняння">
<select
className={inputClass}
value={String(cond.op ?? '>')}
disabled={readOnly}
onChange={(e) => patchCond(i, { op: e.target.value })}
>
{OPS.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</Field>
<Field label="Значення">
<input
className={inputClass}
type="number"
value={Number(cond.value ?? 0)}
disabled={readOnly}
onChange={(e) => patchCond(i, { value: Number(e.target.value) })}
/>
</Field>
</div>
)}
{(t.source === 'ncm' || t.source === 'agent') && (
<Field label="Умова" hint="JSON — для цього джерела полів ще немає">
<input
className={`${inputClass} font-mono text-[11px]`}
value={JSON.stringify(cond)}
disabled={readOnly}
onChange={(e) => {
try {
patch(i, { condition: JSON.parse(e.target.value) as Cond })
} catch {
// Недописаний JSON — нормальний проміжний стан при
// наборі. Помилку показує збереження, а не кожне
// натискання клавіші.
}
}}
/>
</Field>
)}
<div className="grid gap-2 sm:grid-cols-2">
<Field label="Витримка, с" hint="Скільки триматись умові до алерту">
<input
className={inputClass}
type="number"
min="0"
max="86400"
value={t.for_seconds}
disabled={readOnly}
onChange={(e) => patch(i, { for_seconds: Number(e.target.value) || 0 })}
/>
</Field>
<Field label="Опис" hint="Що робити черговому">
<input
className={inputClass}
value={t.description ?? ''}
disabled={readOnly}
onChange={(e) => patch(i, { description: e.target.value })}
/>
</Field>
</div>
<div className="mt-2 flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-xs text-slate-400">
<Toggle
checked={t.enabled}
disabled={readOnly}
onChange={(v) => patch(i, { enabled: v })}
/>
Увімкнено
</label>
<label className="flex items-center gap-2 text-xs text-slate-400">
<Toggle
checked={t.depends_on_topology}
disabled={readOnly}
onChange={(v) => patch(i, { depends_on_topology: v })}
/>
Мовчати, якщо лежить вузол вище
</label>
{!readOnly && (
<Button
kind="danger"
className="ml-auto"
onClick={() => onChange(triggers.filter((_, n) => n !== i))}
>
Прибрати
</Button>
)}
</div>
</div>
)
})}
</div>
)
}
function defaultCond(source: string, metricKeys: string[]): Cond {
switch (source) {
case 'icmp':
return { op: 'loss_pct >', value: 20 }
case 'syslog':
return { regex: '' }
case 'interface':
return { metric: 'util_out_pct', op: '>', value: 90 }
case 'ncm':
return { event: 'changed' }
case 'agent':
return { event: 'offline' }
default:
return { metric_key: metricKeys[0] ?? '', agg: 'avg', window: '5m', op: '>', value: 0 }
}
}
// Умова пінгу тримає поле й оператор одним рядком ("loss_pct >"), бо так
// її розуміє движок. Форма показує їх окремо — розбираємо на льоту.
function icmpField(cond: Cond): string {
const [field] = String(cond.op ?? 'loss_pct >').split(' ')
return field || 'loss_pct'
}
function icmpOp(cond: Cond): string {
const parts = String(cond.op ?? 'loss_pct >').split(' ')
return parts[1] || '>'
}

View file

@ -329,6 +329,46 @@ export function Modal({
)
}
/**
* Смуга вкладок форми.
*
* Лічильник у підписі не прикраса: він єдиний спосіб побачити, що на
* сусідній вкладці щось є, не клацнувши по ній. Без нього форма з
* трьома вкладками виглядає порожньою рівно доти, доки не обійдеш усі.
*/
export function Tabs<T extends string>({
tabs,
active,
onChange,
}: {
tabs: { key: T; label: string; badge?: number }[]
active: T
onChange: (key: T) => void
}) {
return (
<div className="flex flex-wrap gap-1 border-b border-slate-800 pb-2">
{tabs.map((t) => (
<Button
key={t.key}
kind={active === t.key ? 'primary' : 'ghost'}
onClick={() => onChange(t.key)}
>
{t.label}
{t.badge ? (
<span
className={`ml-1.5 rounded px-1 text-[10px] tabular-nums ${
active === t.key ? 'bg-slate-900/40' : 'bg-slate-800 text-slate-400'
}`}
>
{t.badge}
</span>
) : null}
</Button>
))}
</div>
)
}
export function ErrorNote({ children }: { children?: ReactNode }) {
if (!children) return null
return (

View file

@ -122,7 +122,16 @@ export function RulesPage() {
{
key: 'name',
header: 'Назва',
cell: (r) => <span className="font-medium text-slate-100">{r.name}</span>,
cell: (r) => (
<div className="min-w-0">
<span className="font-medium text-slate-100">{r.name}</span>
{r.template_id && (
<div className="mt-0.5 text-[11px] text-sky-400/80">
із шаблону «{r.template_name}»
</div>
)}
</div>
),
},
{
key: 'source',
@ -167,8 +176,21 @@ export function RulesPage() {
await reload()
}}
/>
{canWrite && <Button onClick={() => setEditing(r)}>Змінити</Button>}
{canWrite && (
{canWrite &&
(r.template_id ? (
// Правило шаблону редагується в шаблоні. Дати
// правити копію тут означало б показати зміну,
// яку наступна звірка мовчки відкотить.
<span
className="text-[11px] text-slate-500"
title="Це правило описане тригером шаблону — змінюйте його там"
>
у шаблоні
</span>
) : (
<Button onClick={() => setEditing(r)}>Змінити</Button>
))}
{canWrite && !r.template_id && (
<Button
kind="danger"
title="Видалити правило разом з його алертами"

View file

@ -10,6 +10,7 @@ import {
PageBody,
PageHeader,
Spinner,
Tabs,
Confirm,
IntervalInput,
Toggle,
@ -17,7 +18,14 @@ import {
plural,
} from '../components/ui'
import type { ConfirmRequest } from '../components/ui'
import type { GraphKind, Template, TemplateGraph, TemplateItem } from '../types'
import { TriggerEditor } from '../components/TriggerEditor'
import type {
GraphKind,
Template,
TemplateGraph,
TemplateItem,
TemplateTrigger,
} from '../types'
// Типи перевірок, які вміє описати шаблон.
//
@ -250,6 +258,8 @@ const toEdit = (it: TemplateItem): EditItem => ({
paramsText: it.params ? JSON.stringify(it.params) : '',
})
type TplTab = 'main' | 'items' | 'graphs' | 'triggers'
function TemplateForm({
template,
readOnly,
@ -269,6 +279,8 @@ function TemplateForm({
template?.items?.map(toEdit) ?? [emptyItem()],
)
const [graphs, setGraphs] = useState<TemplateGraph[]>(template?.graphs ?? [])
const [triggers, setTriggers] = useState<TemplateTrigger[]>(template?.triggers ?? [])
const [tab, setTab] = useState<TplTab>('main')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
@ -302,6 +314,18 @@ function TemplateForm({
</p>
)}
<Tabs
tabs={[
{ key: 'main', label: 'Загальне' },
{ key: 'items', label: 'Перевірки', badge: items.length },
{ key: 'graphs', label: 'Графіки', badge: graphs.length },
{ key: 'triggers', label: 'Тригери', badge: triggers.length },
]}
active={tab}
onChange={setTab}
/>
<div className={tab === 'main' ? 'space-y-3' : 'hidden'}>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Назва">
<input
@ -339,7 +363,9 @@ function TemplateForm({
/>
</Field>
<div className="space-y-2 border-t border-slate-800 pt-3">
</div>
<div className={tab === 'items' ? 'space-y-2' : 'hidden'}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium text-slate-400">Перевірки</span>
<div className="flex flex-wrap gap-1.5">
@ -522,7 +548,7 @@ function TemplateForm({
))}
</div>
<div className="space-y-2 border-t border-slate-800 pt-3">
<div className={tab === 'graphs' ? 'space-y-2' : 'hidden'}>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-400">Графіки</span>
{!readOnly && (
@ -677,6 +703,15 @@ function TemplateForm({
))}
</div>
<div className={tab === 'triggers' ? '' : 'hidden'}>
<TriggerEditor
triggers={triggers}
readOnly={readOnly}
metricKeys={items.map((it) => it.metric_key).filter(Boolean)}
onChange={setTriggers}
/>
</div>
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
@ -725,6 +760,19 @@ function TemplateForm({
.filter((g) => g.name.trim() && g.metric_keys.length > 0)
.map((g, i) => ({ ...g, key: g.key || slugifyKey(g.name, i), position: i })),
)
// Тригери теж окремим запитом — і з тієї ж причини:
// сервер розгортає їх у правила сповіщень, і збій там
// не має відкочувати збережені метрики.
await api.saveTemplateTriggers(
saved.id,
triggers
.filter((t) => t.name.trim())
.map((t, i) => ({
...t,
key: t.key || slugifyKey(t.name, i),
position: i,
})),
)
onSaved()
} catch (e) {
setErr(

View file

@ -450,6 +450,13 @@ export interface AlertRule {
channel_ids: string[]
notify_on_resolve: boolean
notify_schedule?: { tz?: string; quiet?: { days?: number[]; from: string; to: string }[] } | null
/**
* Непорожнє правило породжене тригером шаблону. Правити його треба
* там: збережене тут, воно повернеться до шаблонного вигляду при
* наступній звірці.
*/
template_id?: string
template_name?: string
}
// ---------------------------------------------------------------------
@ -522,6 +529,7 @@ export type Template = {
is_builtin: boolean
items?: TemplateItem[]
graphs?: TemplateGraph[]
triggers?: TemplateTrigger[]
item_count: number
device_count: number
}
@ -627,6 +635,26 @@ export type Icon = {
export type GraphKind = 'line' | 'area' | 'stacked' | 'bar' | 'gauge' | 'stat'
/**
* Тригер шаблону правило сповіщення, описане один раз на клас
* пристроїв. Розгортається в одне правило `alr.rules` із селектором за
* шаблоном, а не в правило на кожен хост.
*/
export type TemplateTrigger = {
id?: string
key: string
name: string
description?: string
source: string
severity: string
condition: Record<string, unknown>
recovery?: Record<string, unknown>
for_seconds: number
depends_on_topology: boolean
enabled: boolean
position: number
}
export type TemplateGraph = {
id?: string
key: string