Тригери описуються в шаблоні; форма шаблону — на вкладках
Правило «процесор вище 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 чисто на обох гілках
|
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 — зелені
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
})
|
})
|
||||||
|
|
|
||||||
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,
|
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 ?? [],
|
||||||
|
|
|
||||||
|
|
@ -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' && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
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 }) {
|
export function ErrorNote({ children }: { children?: ReactNode }) {
|
||||||
if (!children) return null
|
if (!children) return null
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -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="Видалити правило разом з його алертами"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue