Netpulse_SasS/server/internal/store/alerts_query.go
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:00:04 +03:00

347 lines
12 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
}
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,
(SELECT count(*) FROM alr.alerts a
WHERE a.rule_id = r.id AND a.state IN ('firing','acknowledged'))
FROM alr.rules r
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 string
var forSec, active int
var topo, enabled bool
if err := rows.Scan(&id, &name, &desc, &source, &sev, &sel, &cond,
&forSec, &topo, &enabled, &active); err != nil {
return err
}
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,
})
}
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)
VALUES ($1,$2,$3,$4::alr.rule_source,$5::alr.severity,$6::jsonb,$7::jsonb,
$8,$9,$10,$11)
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)).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 }