Логін: - 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>
347 lines
12 KiB
Go
347 lines
12 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
|
||
}
|
||
|
||
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 }
|