Тригери описуються в шаблоні; форма шаблону — на вкладках
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 чисто на обох гілках git fsck --strict чисто на обох гілках
тести server ×2 і agent — зелені тести 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("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate))
mux.Handle("POST /api/v1/templates/{id}/clone", s.authenticated(s.handleCloneTemplate)) 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}/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}/graphs", s.authenticated(s.handleDeviceGraphs))
mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates)) mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates))
mux.Handle("PUT /api/v1/devices/{id}/templates", s.authenticated(s.handleSetDeviceTemplates)) 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 return
} }
// Зайнятий ключ — помилка людини, а не сервера. «Внутрішня
// помилка» у відповідь на цілком зрозумілу ситуацію лишає
// людину гадати, що зламалось, замість виправити одне поле.
if isUniqueViolation(err) {
writeError(w, http.StatusConflict, "duplicate_key",
"шаблон із ключем «"+t.Key+"» уже є — виберіть інший ключ")
return
}
s.writeStoreError(w, "збереження шаблону", err) s.writeStoreError(w, "збереження шаблону", err)
return return
} }
@ -349,6 +357,76 @@ func (s *Server) handleSaveTemplateGraphs(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent) 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 — графіки, які дають шаблони цього хоста. // handleDeviceGraphs — графіки, які дають шаблони цього хоста.
func (s *Server) handleDeviceGraphs(w http.ResponseWriter, r *http.Request, p *Principal) { func (s *Server) handleDeviceGraphs(w http.ResponseWriter, r *http.Request, p *Principal) {
if !requirePerm(w, p, "devices:read") { if !requirePerm(w, p, "devices:read") {

View file

@ -33,6 +33,10 @@ type Selector struct {
GroupIDs []string `json:"group_ids"` GroupIDs []string `json:"group_ids"`
SiteIDs []string `json:"site_ids"` SiteIDs []string `json:"site_ids"`
Kinds []string `json:"kinds"` Kinds []string `json:"kinds"`
// Хости, яким призначено ці шаблони. Так тригер шаблону стає одним
// правилом замість правила на кожен хост: призначили шаблон новому
// комутатору — він одразу під правилом, без перегенерації.
TemplateIDs []string `json:"template_ids"`
Vendors []string `json:"vendors"` Vendors []string `json:"vendors"`
Tags map[string]string `json:"tags"` 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 { if len(sel.Vendors) > 0 {
parts = append(parts, fmt.Sprintf("d.vendor = ANY(%s::text[])", a.add(sel.Vendors))) 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 { if len(sel.GroupIDs) > 0 {
parts = append(parts, fmt.Sprintf(`EXISTS ( parts = append(parts, fmt.Sprintf(`EXISTS (
SELECT 1 FROM inv.device_group_members m 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.channel_ids::text[], COALESCE(r.notify_schedule::text,''),
r.notify_on_resolve, r.notify_on_resolve,
(SELECT count(*) FROM alr.alerts a (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 FROM alr.rules r
LEFT JOIN tpl.templates t ON t.id = r.template_id
WHERE r.tenant_id = $1 WHERE r.tenant_id = $1
ORDER BY r.name ORDER BY r.name
`, tenantID) `, tenantID)
@ -244,12 +246,13 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
var id, name, desc, source, sev, sel, cond, sched string var id, name, desc, source, sev, sel, cond, sched string
var tplID, tplName string
var forSec, active int var forSec, active int
var topo, enabled, onResolve bool var topo, enabled, onResolve bool
var channelIDs []string var channelIDs []string
if err := rows.Scan(&id, &name, &desc, &source, &sev, &sel, &cond, if err := rows.Scan(&id, &name, &desc, &source, &sev, &sel, &cond,
&forSec, &topo, &enabled, &channelIDs, &sched, &onResolve, &forSec, &topo, &enabled, &channelIDs, &sched, &onResolve,
&active); err != nil { &active, &tplID, &tplName); err != nil {
return err return err
} }
if channelIDs == nil { if channelIDs == nil {
@ -262,6 +265,10 @@ func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]an
"enabled": enabled, "active_alerts": active, "enabled": enabled, "active_alerts": active,
"channel_ids": channelIDs, "notify_on_resolve": onResolve, "channel_ids": channelIDs, "notify_on_resolve": onResolve,
"notify_schedule": rawJSON(sched), "notify_schedule": rawJSON(sched),
// Непорожній шаблон означає, що правило породжене його
// тригером: у списку його видно, але правлять його там,
// де воно описане один раз на клас пристроїв.
"template_id": tplID, "template_name": tplName,
}) })
} }
return rows.Err() 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

@ -25,8 +25,9 @@ type Template struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Vendor string `json:"vendor,omitempty"` Vendor string `json:"vendor,omitempty"`
IsBuiltin bool `json:"is_builtin"` IsBuiltin bool `json:"is_builtin"`
Items []TemplateItem `json:"items,omitempty"` Items []TemplateItem `json:"items,omitempty"`
Graphs []TemplateGraph `json:"graphs,omitempty"` Graphs []TemplateGraph `json:"graphs,omitempty"`
Triggers []TemplateTrigger `json:"triggers,omitempty"`
ItemCount int `json:"item_count"` ItemCount int `json:"item_count"`
DeviceCount int `json:"device_count"` DeviceCount int `json:"device_count"`
} }
@ -125,6 +126,11 @@ func (s *Store) GetTemplate(ctx context.Context, tenantID, id string) (*Template
return err return err
} }
t.Graphs = graphs t.Graphs = graphs
triggers, err := templateTriggers(ctx, tx, id)
if err != nil {
return err
}
t.Triggers = triggers
return tx.QueryRow(ctx, ` return tx.QueryRow(ctx, `
SELECT count(*) FROM tpl.device_templates WHERE template_id = $1 SELECT count(*) FROM tpl.device_templates WHERE template_id = $1
`, id).Scan(&t.DeviceCount) `, id).Scan(&t.DeviceCount)
@ -906,10 +912,28 @@ func (s *Store) CloneTemplate(ctx context.Context, tenantID, id string) (string,
return err return err
} }
_, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO tpl.graphs (template_id, key, name, kind, metric_keys, options, position) INSERT INTO tpl.graphs (template_id, key, name, kind, metric_keys, options, position)
SELECT $1, key, name, kind, metric_keys, options, position SELECT $1, key, name, kind, metric_keys, options, position
FROM tpl.graphs WHERE template_id = $2 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) `, newID, id)
return err 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, SeriesInfo,
Template, Template,
TemplateGraph, TemplateGraph,
TemplateTrigger,
} from '../types' } from '../types'
/** Те, що приймає POST/PATCH /api/v1/devices. */ /** Те, що приймає POST/PATCH /api/v1/devices. */
@ -651,6 +652,12 @@ export const api = {
body: JSON.stringify({ graphs }), 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) => deviceGraphs: (deviceID: string) =>
request<{ graphs: DeviceGraph[] }>(`/api/v1/devices/${deviceID}/graphs`).then( request<{ graphs: DeviceGraph[] }>(`/api/v1/devices/${deviceID}/graphs`).then(
(r) => r.graphs ?? [], (r) => r.graphs ?? [],

View file

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { api } from '../api/client' 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 type { ConfirmRequest } from './ui'
import { Picker } from './Picker' import { Picker } from './Picker'
import { TemplatePicker } from './TemplatePicker' import { TemplatePicker } from './TemplatePicker'
@ -166,18 +166,7 @@ export function DeviceForm({
return ( return (
<Modal title={device ? `Хост: ${device.name}` : 'Новий хост'} onClose={onClose} wide> <Modal title={device ? `Хост: ${device.name}` : 'Новий хост'} onClose={onClose} wide>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap gap-1 border-b border-slate-800 pb-2"> <Tabs tabs={tabs} active={tab} onChange={setTab} />
{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>
{tab === 'main' && ( {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 }) { export function ErrorNote({ children }: { children?: ReactNode }) {
if (!children) return null if (!children) return null
return ( return (

View file

@ -122,7 +122,16 @@ export function RulesPage() {
{ {
key: 'name', key: 'name',
header: 'Назва', 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', key: 'source',
@ -167,8 +176,21 @@ export function RulesPage() {
await reload() 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 <Button
kind="danger" kind="danger"
title="Видалити правило разом з його алертами" title="Видалити правило разом з його алертами"

View file

@ -10,6 +10,7 @@ import {
PageBody, PageBody,
PageHeader, PageHeader,
Spinner, Spinner,
Tabs,
Confirm, Confirm,
IntervalInput, IntervalInput,
Toggle, Toggle,
@ -17,7 +18,14 @@ import {
plural, plural,
} from '../components/ui' } from '../components/ui'
import type { ConfirmRequest } 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) : '', paramsText: it.params ? JSON.stringify(it.params) : '',
}) })
type TplTab = 'main' | 'items' | 'graphs' | 'triggers'
function TemplateForm({ function TemplateForm({
template, template,
readOnly, readOnly,
@ -269,6 +279,8 @@ function TemplateForm({
template?.items?.map(toEdit) ?? [emptyItem()], template?.items?.map(toEdit) ?? [emptyItem()],
) )
const [graphs, setGraphs] = useState<TemplateGraph[]>(template?.graphs ?? []) 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 [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null) const [err, setErr] = useState<string | null>(null)
@ -302,6 +314,18 @@ function TemplateForm({
</p> </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"> <div className="grid gap-3 sm:grid-cols-2">
<Field label="Назва"> <Field label="Назва">
<input <input
@ -339,7 +363,9 @@ function TemplateForm({
/> />
</Field> </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"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs font-medium text-slate-400">Перевірки</span> <span className="text-xs font-medium text-slate-400">Перевірки</span>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -522,7 +548,7 @@ function TemplateForm({
))} ))}
</div> </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"> <div className="flex items-center justify-between">
<span className="text-xs font-medium text-slate-400">Графіки</span> <span className="text-xs font-medium text-slate-400">Графіки</span>
{!readOnly && ( {!readOnly && (
@ -677,6 +703,15 @@ function TemplateForm({
))} ))}
</div> </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> <ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1"> <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) .filter((g) => g.name.trim() && g.metric_keys.length > 0)
.map((g, i) => ({ ...g, key: g.key || slugifyKey(g.name, i), position: i })), .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() onSaved()
} catch (e) { } catch (e) {
setErr( setErr(

View file

@ -450,6 +450,13 @@ export interface AlertRule {
channel_ids: string[] channel_ids: string[]
notify_on_resolve: boolean notify_on_resolve: boolean
notify_schedule?: { tz?: string; quiet?: { days?: number[]; from: string; to: string }[] } | null 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 is_builtin: boolean
items?: TemplateItem[] items?: TemplateItem[]
graphs?: TemplateGraph[] graphs?: TemplateGraph[]
triggers?: TemplateTrigger[]
item_count: number item_count: number
device_count: number device_count: number
} }
@ -627,6 +635,26 @@ export type Icon = {
export type GraphKind = 'line' | 'area' | 'stacked' | 'bar' | 'gauge' | 'stat' 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 = { export type TemplateGraph = {
id?: string id?: string
key: string key: string