Netpulse_SasS/server/internal/store/alerts_query.go
byrsapty 2fe562936d
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Тригери описуються в шаблоні; форма шаблону — на вкладках
Правило «процесор вище 85% — це проблема» описує клас пристроїв, а не
окремий хост. Тепер воно живе поруч із перевірками, які дають йому
дані, а не окремою сторінкою, де його доводилось повторювати руками
для кожного комутатора.

Тригер розгортається в ОДНЕ правило alr.rules із селектором за
шаблоном, а не в правило на кожен хост: призначили шаблон новому
пристрою — він одразу під правилом, без перегенерації.

Форма шаблону розкладена на вкладки Загальне/Перевірки/Графіки/Тригери
з лічильниками; смуга вкладок винесена у спільний компонент. Умова
тригера редагується полями, JSON лишився запасним виходом.

Правила з шаблону в списку правил помічені й не редагуються там.
Клон копіює тригери вимкненими, щоб копія не подвоїла сповіщення.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:45:49 +03:00

400 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
})
}