Правило «процесор вище 85% — це проблема» описує клас пристроїв, а не окремий хост. Тепер воно живе поруч із перевірками, які дають йому дані, а не окремою сторінкою, де його доводилось повторювати руками для кожного комутатора. Тригер розгортається в ОДНЕ правило alr.rules із селектором за шаблоном, а не в правило на кожен хост: призначили шаблон новому пристрою — він одразу під правилом, без перегенерації. Форма шаблону розкладена на вкладки Загальне/Перевірки/Графіки/Тригери з лічильниками; смуга вкладок винесена у спільний компонент. Умова тригера редагується полями, JSON лишився запасним виходом. Правила з шаблону в списку правил помічені й не редагуються там. Клон копіює тригери вимкненими, щоб копія не подвоїла сповіщення. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
400 lines
15 KiB
Go
400 lines
15 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
)
|
||
|
||
// ErrNotFound — об'єкта немає або він належить іншому тенанту.
|
||
// Обидва випадки навмисно нерозрізненні: відповідь «не ваш» сама по
|
||
// собі підтверджує, що такий id існує.
|
||
var ErrAlertNotFound = errors.New("алерт не знайдено")
|
||
|
||
// AlertFilter — параметри вибірки для списку.
|
||
type AlertFilter struct {
|
||
States []string
|
||
MinSeverity string
|
||
DeviceID string
|
||
Limit int
|
||
// Scope звужує вибірку до пристроїв, доступних цій людині. Алерт
|
||
// без пристрою (наприклад, про сам зонд) видно всім: сховати його
|
||
// від обмеженого користувача означало б приховати аварію, до якої
|
||
// групи не мають стосунку.
|
||
Scope Scope
|
||
}
|
||
|
||
// severityRank повторює порядок ENUM alr.severity. Тримати його в Go
|
||
// потрібно, бо порівняння «не нижче за warning» у SQL інакше вимагало б
|
||
// приведення до тексту й втрати індексу.
|
||
var severityRank = map[string]int{
|
||
"info": 0, "warning": 1, "average": 2, "high": 3, "disaster": 4,
|
||
}
|
||
|
||
// ListAlerts повертає активні алерти тенанта.
|
||
func (s *Store) ListAlerts(ctx context.Context, tenantID string, f AlertFilter) ([]Alert, error) {
|
||
if f.Limit <= 0 || f.Limit > 500 {
|
||
f.Limit = 200
|
||
}
|
||
states := f.States
|
||
if len(states) == 0 {
|
||
states = []string{"firing", "acknowledged", "suppressed"}
|
||
}
|
||
|
||
var out []Alert
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT a.id::text, COALESCE(a.rule_id::text,''), COALESCE(r.name,''),
|
||
COALESCE(a.device_id::text,''), COALESCE(d.name,''),
|
||
COALESCE(a.interface_id::text,''),
|
||
a.severity::text, a.state::text, a.title, COALESCE(a.message,''),
|
||
a.dedup_key, a.value, a.threshold, a.context::text,
|
||
COALESCE(a.root_alert_id::text,''), COALESCE(a.suppressed_by,''),
|
||
a.started_at, a.acked_at, COALESCE(u.email::text,''),
|
||
COALESCE(a.ack_comment,''), a.resolved_at, a.last_seen_at, a.notify_count
|
||
FROM alr.alerts a
|
||
LEFT JOIN alr.rules r ON r.id = a.rule_id
|
||
LEFT JOIN inv.devices d ON d.id = a.device_id
|
||
LEFT JOIN core.users u ON u.id = a.acked_by
|
||
WHERE a.tenant_id = $1
|
||
AND a.state::text = ANY($2::text[])
|
||
AND ($3::uuid IS NULL OR a.device_id = $3::uuid)
|
||
AND ($5::boolean OR a.device_id IS NULL OR a.device_id = ANY($6::uuid[]))
|
||
ORDER BY
|
||
CASE a.severity
|
||
WHEN 'disaster' THEN 0 WHEN 'high' THEN 1 WHEN 'average' THEN 2
|
||
WHEN 'warning' THEN 3 ELSE 4 END,
|
||
a.started_at DESC
|
||
LIMIT $4
|
||
`, tenantID, states, nullUUID(f.DeviceID), f.Limit,
|
||
f.Scope.Unrestricted, nonNilIDs(f.Scope.Readable))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
minRank := -1
|
||
if f.MinSeverity != "" {
|
||
if r, ok := severityRank[f.MinSeverity]; ok {
|
||
minRank = r
|
||
}
|
||
}
|
||
|
||
for rows.Next() {
|
||
var a Alert
|
||
var ctxJSON string
|
||
if err := rows.Scan(&a.ID, &a.RuleID, &a.RuleName, &a.DeviceID, &a.DeviceName,
|
||
&a.InterfaceID, &a.Severity, &a.State, &a.Title, &a.Message,
|
||
&a.DedupKey, &a.Value, &a.Threshold, &ctxJSON,
|
||
&a.RootAlertID, &a.SuppressedBy, &a.StartedAt, &a.AckedAt,
|
||
&a.AckedByEmail, &a.AckComment, &a.ResolvedAt, &a.LastSeenAt,
|
||
&a.NotifyCount); err != nil {
|
||
return err
|
||
}
|
||
if minRank >= 0 && severityRank[a.Severity] < minRank {
|
||
continue
|
||
}
|
||
a.Context = []byte(ctxJSON)
|
||
out = append(out, a)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// AlertCounts — зведення для шапки UI.
|
||
type AlertCounts struct {
|
||
Firing int `json:"firing"`
|
||
Acknowledged int `json:"acknowledged"`
|
||
Suppressed int `json:"suppressed"`
|
||
Disaster int `json:"disaster"`
|
||
High int `json:"high"`
|
||
}
|
||
|
||
func (s *Store) CountAlerts(ctx context.Context, tenantID string, sc Scope) (AlertCounts, error) {
|
||
var c AlertCounts
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
SELECT
|
||
count(*) FILTER (WHERE state = 'firing'),
|
||
count(*) FILTER (WHERE state = 'acknowledged'),
|
||
count(*) FILTER (WHERE state = 'suppressed'),
|
||
count(*) FILTER (WHERE severity = 'disaster' AND state = 'firing'),
|
||
count(*) FILTER (WHERE severity = 'high' AND state = 'firing')
|
||
FROM alr.alerts
|
||
WHERE tenant_id = $1 AND state IN ('firing','acknowledged','suppressed')
|
||
AND ($2::boolean OR device_id IS NULL OR device_id = ANY($3::uuid[]))
|
||
`, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable)).
|
||
Scan(&c.Firing, &c.Acknowledged, &c.Suppressed, &c.Disaster, &c.High)
|
||
})
|
||
return c, err
|
||
}
|
||
|
||
// AckAlert підтверджує алерт.
|
||
//
|
||
// Підтвердження не гасить проблему — воно зупиняє ескалацію й показує
|
||
// решті команди, що хтось уже взявся. Тому стан лишається активним, а
|
||
// не переходить у resolved.
|
||
func (s *Store) AckAlert(ctx context.Context, tenantID, alertID, userID, comment string) (Alert, error) {
|
||
var a Alert
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
err := tx.QueryRow(ctx, `
|
||
UPDATE alr.alerts
|
||
SET state = 'acknowledged', acked_at = now(), acked_by = $3, ack_comment = $4
|
||
WHERE tenant_id = $1 AND id = $2 AND state IN ('firing','suppressed')
|
||
RETURNING id::text, title, severity::text, state::text,
|
||
COALESCE(device_id::text,''), started_at, acked_at, last_seen_at
|
||
`, tenantID, alertID, nullUUID(userID), nullString(comment)).
|
||
Scan(&a.ID, &a.Title, &a.Severity, &a.State, &a.DeviceID,
|
||
&a.StartedAt, &a.AckedAt, &a.LastSeenAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return ErrAlertNotFound
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
_, err = tx.Exec(ctx, `
|
||
INSERT INTO core.event_outbox (tenant_id, topic, payload)
|
||
VALUES ($1, 'alert.acked', jsonb_build_object('alert_id', $2::text))
|
||
`, tenantID, a.ID)
|
||
return err
|
||
})
|
||
return a, err
|
||
}
|
||
|
||
// CloseAlert закриває алерт руками.
|
||
//
|
||
// Потрібне для правил, чия умова більше ніколи не стане хибною сама
|
||
// (разова подія на кшталт «конфіг змінено»): без ручного закриття такий
|
||
// алерт висів би на екрані вічно.
|
||
func (s *Store) CloseAlert(ctx context.Context, tenantID, alertID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE alr.alerts
|
||
SET state = 'resolved', resolved_at = now()
|
||
WHERE tenant_id = $1 AND id = $2
|
||
AND state IN ('firing','acknowledged','suppressed')
|
||
`, tenantID, alertID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrAlertNotFound
|
||
}
|
||
_, err = tx.Exec(ctx, `
|
||
INSERT INTO core.event_outbox (tenant_id, topic, payload)
|
||
VALUES ($1, 'alert.resolved', jsonb_build_object('alert_id', $2::text))
|
||
`, tenantID, alertID)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// MuteDevice глушить пристрій на визначений час.
|
||
func (s *Store) MuteDevice(ctx context.Context, tenantID, deviceID, userID, reason string, until time.Time) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
_, err := tx.Exec(ctx, `
|
||
INSERT INTO alr.mutes (tenant_id, device_id, until, reason, created_by)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
`, tenantID, deviceID, until, nullString(reason), nullUUID(userID))
|
||
return err
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Правила: CRUD для UI
|
||
// ---------------------------------------------------------------------
|
||
|
||
type RuleInput struct {
|
||
Name string
|
||
Description string
|
||
Source string
|
||
Severity string
|
||
Selector string
|
||
Condition string
|
||
ForSeconds int
|
||
DependsOnTopology bool
|
||
Enabled bool
|
||
// Куди слати. Порожньо — за загальними маршрутами тенанта.
|
||
ChannelIDs []string
|
||
NotifySchedule string
|
||
NotifyOnResolve bool
|
||
}
|
||
|
||
func (s *Store) ListRules(ctx context.Context, tenantID string) ([]map[string]any, error) {
|
||
var out []map[string]any
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT r.id::text, r.name, COALESCE(r.description,''), r.source::text,
|
||
r.severity::text, r.selector::text, r.condition::text,
|
||
r.for_seconds, r.depends_on_topology, r.enabled,
|
||
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')),
|
||
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)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
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, &tplID, &tplName); err != nil {
|
||
return err
|
||
}
|
||
if channelIDs == nil {
|
||
channelIDs = []string{}
|
||
}
|
||
out = append(out, map[string]any{
|
||
"id": id, "name": name, "description": desc, "source": source,
|
||
"severity": sev, "selector": rawJSON(sel), "condition": rawJSON(cond),
|
||
"for_seconds": forSec, "depends_on_topology": topo,
|
||
"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()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
func (s *Store) CreateRule(ctx context.Context, tenantID, userID string, in RuleInput) (string, error) {
|
||
var id string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
return tx.QueryRow(ctx, `
|
||
INSERT INTO alr.rules
|
||
(tenant_id, name, description, source, severity, selector, condition,
|
||
for_seconds, depends_on_topology, enabled, created_by,
|
||
channel_ids, notify_schedule, notify_on_resolve)
|
||
VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,$7::jsonb,
|
||
$8,$9,$10,$11,$12::uuid[],NULLIF($13,'')::jsonb,$14)
|
||
RETURNING id::text
|
||
`, tenantID, in.Name, nullString(in.Description), in.Source, in.Severity,
|
||
in.Selector, in.Condition, in.ForSeconds, in.DependsOnTopology,
|
||
in.Enabled, nullUUID(userID), in.ChannelIDs, in.NotifySchedule,
|
||
in.NotifyOnResolve).Scan(&id)
|
||
})
|
||
return id, err
|
||
}
|
||
|
||
func (s *Store) DeleteRule(ctx context.Context, tenantID, ruleID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
// Спершу закриваємо алерти, і лише потім саме правило.
|
||
//
|
||
// Порядок принциповий: alr.alerts.rule_id має ON DELETE SET NULL,
|
||
// тож після видалення правила його алерти лишаються без жодного
|
||
// зв'язку — движок їх більше не бачить і закрити не може, а на
|
||
// екрані вони висять вічно.
|
||
if err := resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило видалено"); err != nil {
|
||
return err
|
||
}
|
||
tag, err := tx.Exec(ctx,
|
||
`DELETE FROM alr.rules WHERE tenant_id = $1 AND id = $2`, tenantID, ruleID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrAlertNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
func (s *Store) SetRuleEnabled(ctx context.Context, tenantID, ruleID string, enabled bool) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
tag, err := tx.Exec(ctx, `
|
||
UPDATE alr.rules SET enabled = $3, updated_at = now()
|
||
WHERE tenant_id = $1 AND id = $2
|
||
`, tenantID, ruleID, enabled)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() == 0 {
|
||
return ErrAlertNotFound
|
||
}
|
||
// Вимкнене правило випадає з вибірки движка, тому закрити свої
|
||
// алерти вже не зможе — це треба зробити тут і зараз.
|
||
if !enabled {
|
||
return resolveRuleAlerts(ctx, tx, tenantID, ruleID, "правило вимкнено")
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// resolveRuleAlerts закриває всі активні алерти правила й повідомляє UI.
|
||
func resolveRuleAlerts(ctx context.Context, tx pgx.Tx, tenantID, ruleID, reason string) error {
|
||
_, err := tx.Exec(ctx, `
|
||
WITH closed AS (
|
||
UPDATE alr.alerts
|
||
SET state = 'resolved', resolved_at = now()
|
||
WHERE tenant_id = $1 AND rule_id = $2
|
||
AND state IN ('firing','acknowledged','suppressed')
|
||
RETURNING id
|
||
)
|
||
INSERT INTO core.event_outbox (tenant_id, topic, payload)
|
||
SELECT $1, 'alert.resolved',
|
||
jsonb_build_object('alert_id', id::text, 'reason', $3::text)
|
||
FROM closed
|
||
`, tenantID, ruleID, reason)
|
||
return err
|
||
}
|
||
|
||
func rawJSON(s string) any {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
return jsonRaw(s)
|
||
}
|
||
|
||
type jsonRaw string
|
||
|
||
func (j jsonRaw) MarshalJSON() ([]byte, error) { return []byte(j), nil }
|
||
|
||
// UpdateRule замінює правило цілком.
|
||
//
|
||
// Цілком, а не полями: форма показує повний стан правила, і часткові
|
||
// оновлення дали б спосіб отримати комбінацію, якої людина не бачила.
|
||
func (s *Store) UpdateRule(ctx context.Context, tenantID, ruleID string, in RuleInput) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
ct, err := tx.Exec(ctx, `
|
||
UPDATE alr.rules SET
|
||
name = $3, description = $4, source = $5::alr.rule_source,
|
||
severity = $6::alr.severity, selector = $7::jsonb,
|
||
condition = $8::jsonb, for_seconds = $9,
|
||
depends_on_topology = $10, enabled = $11,
|
||
channel_ids = $12::uuid[],
|
||
notify_schedule = NULLIF($13,'')::jsonb,
|
||
notify_on_resolve = $14,
|
||
updated_at = now()
|
||
WHERE id = $1 AND tenant_id = $2
|
||
`, ruleID, tenantID, in.Name, nullString(in.Description), in.Source,
|
||
in.Severity, in.Selector, in.Condition, in.ForSeconds,
|
||
in.DependsOnTopology, in.Enabled, in.ChannelIDs, in.NotifySchedule,
|
||
in.NotifyOnResolve)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if ct.RowsAffected() == 0 {
|
||
return ErrNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|