diff --git a/HISTORY.md b/HISTORY.md index 18e0578..9100fb3 100644 --- a/HISTORY.md +++ b/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":[""]}, важливість і витримка збережені +читання GET /templates/{id} повертає тригери назад +вимкнення enabled=false → правило прибрано +збіг імен два шаблони «Однакова назва» → «Однакова назва [qa-b]: …» +клон тригери скопійовані, усі вимкнені, правил не породили +видалення шаблон видалено → правила зникли каскадом +валідація невідоме джерело → 400 з поясненням +тести server (з базою) і web build — зелені +``` diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 0af2414..aa6b854 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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)) diff --git a/server/internal/httpapi/templates.go b/server/internal/httpapi/templates.go index 75f3a87..40e269f 100644 --- a/server/internal/httpapi/templates.go +++ b/server/internal/httpapi/templates.go @@ -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") { diff --git a/server/internal/store/alerts.go b/server/internal/store/alerts.go index 2092c3b..adaf84c 100644 --- a/server/internal/store/alerts.go +++ b/server/internal/store/alerts.go @@ -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 diff --git a/server/internal/store/alerts_query.go b/server/internal/store/alerts_query.go index fdc01f0..7699370 100644 --- a/server/internal/store/alerts_query.go +++ b/server/internal/store/alerts_query.go @@ -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() diff --git a/server/internal/store/template_triggers.go b/server/internal/store/template_triggers.go new file mode 100644 index 0000000..e227da2 --- /dev/null +++ b/server/internal/store/template_triggers.go @@ -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 + }) +} diff --git a/server/internal/store/templates.go b/server/internal/store/templates.go index 7820dd4..d422445 100644 --- a/server/internal/store/templates.go +++ b/server/internal/store/templates.go @@ -25,8 +25,9 @@ type Template struct { Description string `json:"description,omitempty"` Vendor string `json:"vendor,omitempty"` IsBuiltin bool `json:"is_builtin"` - Items []TemplateItem `json:"items,omitempty"` - Graphs []TemplateGraph `json:"graphs,omitempty"` + 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 }) diff --git a/server/migrations/0024_template_triggers.sql b/server/migrations/0024_template_triggers.sql new file mode 100644 index 0000000..f334702 --- /dev/null +++ b/server/migrations/0024_template_triggers.sql @@ -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; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index da38499..e61b5f4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`/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 ?? [], diff --git a/web/src/components/DeviceForm.tsx b/web/src/components/DeviceForm.tsx index a619f35..0ebc611 100644 --- a/web/src/components/DeviceForm.tsx +++ b/web/src/components/DeviceForm.tsx @@ -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 (
-
- {tabs.map((t) => ( - - ))} -
+ {tab === 'main' && ( <> diff --git a/web/src/components/TriggerEditor.tsx b/web/src/components/TriggerEditor.tsx new file mode 100644 index 0000000..a7efa95 --- /dev/null +++ b/web/src/components/TriggerEditor.tsx @@ -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 + +export function TriggerEditor({ + triggers, + readOnly, + metricKeys, + onChange, +}: { + triggers: TemplateTrigger[] + readOnly: boolean + /** Ключі метрик цього ж шаблону — щоб не згадувати їх напамʼять. */ + metricKeys: string[] + onChange: (next: TemplateTrigger[]) => void +}) { + function patch(i: number, next: Partial) { + 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 ( +
+
+ Тригери + {!readOnly && ( + + )} +
+ +

+ «Процесор вище 85% пʼять хвилин — це проблема» описує клас пристроїв, а не окремий хост. + Тригер, заданий тут, стане правилом для кожного, кому чіпляють шаблон, — і зникне разом + із відвʼязаним шаблоном. Разові правила на конкретний хост лишаються на сторінці + «Правила». +

+ + {triggers.length === 0 && ( +

+ Шаблон без тригерів збирає дані мовчки — про проблему не скаже ніхто. +

+ )} + + {triggers.map((t, i) => { + const cond = (t.condition ?? {}) as Cond + return ( +
+
+ + patch(i, { name: e.target.value })} + /> + +
+ + + + + + +
+
+ + {t.source === 'metric' && ( +
+ + patchCond(i, { metric_key: e.target.value })} + /> + + {metricKeys.map((k) => ( + + + + + + + patchCond(i, { window: e.target.value })} + /> + +
+ )} + + {t.source === 'icmp' && ( + +
+ + + patchCond(i, { value: Number(e.target.value) })} + /> + + {ICMP_FIELDS.find((f) => f.key === icmpField(cond))?.unit} + +
+
+ )} + + {t.source === 'syslog' && ( + + patchCond(i, { regex: e.target.value })} + /> + + )} + + {(t.source === 'metric' || t.source === 'interface') && ( +
+ + + + + patchCond(i, { value: Number(e.target.value) })} + /> + +
+ )} + + {(t.source === 'ncm' || t.source === 'agent') && ( + + { + try { + patch(i, { condition: JSON.parse(e.target.value) as Cond }) + } catch { + // Недописаний JSON — нормальний проміжний стан при + // наборі. Помилку показує збереження, а не кожне + // натискання клавіші. + } + }} + /> + + )} + +
+ + patch(i, { for_seconds: Number(e.target.value) || 0 })} + /> + + + patch(i, { description: e.target.value })} + /> + +
+ +
+ + + {!readOnly && ( + + )} +
+
+ ) + })} +
+ ) +} + +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] || '>' +} diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx index da4259e..702dee5 100644 --- a/web/src/components/ui.tsx +++ b/web/src/components/ui.tsx @@ -329,6 +329,46 @@ export function Modal({ ) } +/** + * Смуга вкладок форми. + * + * Лічильник у підписі — не прикраса: він єдиний спосіб побачити, що на + * сусідній вкладці щось є, не клацнувши по ній. Без нього форма з + * трьома вкладками виглядає порожньою рівно доти, доки не обійдеш усі. + */ +export function Tabs({ + tabs, + active, + onChange, +}: { + tabs: { key: T; label: string; badge?: number }[] + active: T + onChange: (key: T) => void +}) { + return ( +
+ {tabs.map((t) => ( + + ))} +
+ ) +} + export function ErrorNote({ children }: { children?: ReactNode }) { if (!children) return null return ( diff --git a/web/src/pages/RulesPage.tsx b/web/src/pages/RulesPage.tsx index cc16634..0a9cc05 100644 --- a/web/src/pages/RulesPage.tsx +++ b/web/src/pages/RulesPage.tsx @@ -122,7 +122,16 @@ export function RulesPage() { { key: 'name', header: 'Назва', - cell: (r) => {r.name}, + cell: (r) => ( +
+ {r.name} + {r.template_id && ( +
+ із шаблону «{r.template_name}» +
+ )} +
+ ), }, { key: 'source', @@ -167,8 +176,21 @@ export function RulesPage() { await reload() }} /> - {canWrite && } - {canWrite && ( + {canWrite && + (r.template_id ? ( + // Правило шаблону редагується в шаблоні. Дати + // правити копію тут означало б показати зміну, + // яку наступна звірка мовчки відкотить. + + у шаблоні + + ) : ( + + ))} + {canWrite && !r.template_id && (