Тригери описуються в шаблоні; форма шаблону — на вкладках
Правило «процесор вище 85% — це проблема» описує клас пристроїв, а не окремий хост. Тепер воно живе поруч із перевірками, які дають йому дані, а не окремою сторінкою, де його доводилось повторювати руками для кожного комутатора. Тригер розгортається в ОДНЕ правило alr.rules із селектором за шаблоном, а не в правило на кожен хост: призначили шаблон новому пристрою — він одразу під правилом, без перегенерації. Форма шаблону розкладена на вкладки Загальне/Перевірки/Графіки/Тригери з лічильниками; смуга вкладок винесена у спільний компонент. Умова тригера редагується полями, JSON лишився запасним виходом. Правила з шаблону в списку правил помічені й не редагуються там. Клон копіює тригери вимкненими, щоб копія не подвоїла сповіщення. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0bc2335661
commit
2fe562936d
15 changed files with 1085 additions and 24 deletions
83
HISTORY.md
83
HISTORY.md
|
|
@ -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 — зелені
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
234
server/internal/store/template_triggers.go
Normal file
234
server/internal/store/template_triggers.go
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
105
server/migrations/0024_template_triggers.sql
Normal file
105
server/migrations/0024_template_triggers.sql
Normal 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;
|
||||
|
|
@ -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 ?? [],
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
<>
|
||||
|
|
|
|||
385
web/src/components/TriggerEditor.tsx
Normal file
385
web/src/components/TriggerEditor.tsx
Normal 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] || '>'
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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="Видалити правило разом з його алертами"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue