До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе, що вміло API, доводилось викликати curl-ом. Каркас: - бічна навігація з мобільною шухлядою, індикатор зв'язку в шапці, живий лічильник алертів - пункт, на який немає права, не показується взагалі; пряме посилання лишається робочим і дає пояснення з назвою потрібного права - стартова сторінка залежить від ролі: глядача без прав на мапи вітати відмовою — поганий перший екран Сторінки: мапа, пристрої з пошуком і картками алертів, алерти з фільтрами, правила зі створенням через форму, канали з кнопкою перевірки, зонди, команда з керуванням ролями, профіль зі зміною пароля. DataTable на телефоні перестає бути таблицею: горизонтальний скрол на 375 px робить дані формально присутніми й фактично нечитабельними. Знайдено роботою з живим UI: - WebSocket жив усередині мапи, тому на решті сторінок живих оновлень не було взагалі — лічильник алертів замерзав, щойно людина йшла з мапи. З'єднання винесено в модуль-одинак, яким володіє оболонка - сторінка пристроїв перечитувала все на кожну подію алерту; зі злиттям сплеску й спільними алертами той самий сплеск коштує 2 запити замість дванадцяти - канал із секретом показувався як «секрету немає»: прапорець ставився лише в гілці розшифровки, а перелік для UI викликається без ключа Перевірено в браузері проти повного стека: усі вісім сторінок із живими даними, створення правила й користувача через форми, перевірка каналу, обмеження глядача, мобільний вигляд на 375 px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
353 lines
11 KiB
Go
353 lines
11 KiB
Go
package store
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||
)
|
||
|
||
// Channel — куди слати. Секрет уже розшифрований: движок доставки не
|
||
// має доступу до БД секретів і не має його потребувати.
|
||
type Channel struct {
|
||
ID string `json:"id"`
|
||
TenantID string `json:"-"`
|
||
Kind string `json:"kind"`
|
||
Name string `json:"name"`
|
||
Config json.RawMessage `json:"config"`
|
||
Template string `json:"template,omitempty"`
|
||
MinSeverity string `json:"min_severity"`
|
||
Enabled bool `json:"enabled"`
|
||
Secret string `json:"-"`
|
||
HasSecret bool `json:"has_secret"`
|
||
}
|
||
|
||
// Route — правило маршрутизації алерту в канали.
|
||
type Route struct {
|
||
ID string
|
||
Name string
|
||
Priority int
|
||
Matcher RouteMatcher
|
||
ChannelIDs []string
|
||
Schedule *RouteSchedule
|
||
}
|
||
|
||
type RouteMatcher struct {
|
||
SeverityGte string `json:"severity_gte"`
|
||
RuleIDs []string `json:"rule_ids"`
|
||
SiteIDs []string `json:"site_ids"`
|
||
DeviceIDs []string `json:"device_ids"`
|
||
}
|
||
|
||
// RouteSchedule — тихі години.
|
||
type RouteSchedule struct {
|
||
TZ string `json:"tz"`
|
||
Quiet []struct {
|
||
Days []int `json:"days"` // 0=неділя, як у time.Weekday
|
||
From string `json:"from"` // "22:00"
|
||
To string `json:"to"`
|
||
} `json:"quiet"`
|
||
}
|
||
|
||
// IsQuiet каже, чи момент t потрапляє в тиху годину.
|
||
//
|
||
// Інтервал через північ («22:00–08:00») — не окремий випадок, а норма
|
||
// для чергувань, тому обробляється явно: без цього нічні сповіщення
|
||
// тихими годинами не глушились би взагалі.
|
||
func (s *RouteSchedule) IsQuiet(t time.Time) bool {
|
||
if s == nil || len(s.Quiet) == 0 {
|
||
return false
|
||
}
|
||
loc := time.UTC
|
||
if s.TZ != "" {
|
||
if l, err := time.LoadLocation(s.TZ); err == nil {
|
||
loc = l
|
||
}
|
||
}
|
||
lt := t.In(loc)
|
||
mins := lt.Hour()*60 + lt.Minute()
|
||
day := int(lt.Weekday())
|
||
|
||
for _, q := range s.Quiet {
|
||
if len(q.Days) > 0 && !containsInt(q.Days, day) {
|
||
continue
|
||
}
|
||
from, ok1 := parseHM(q.From)
|
||
to, ok2 := parseHM(q.To)
|
||
if !ok1 || !ok2 {
|
||
continue
|
||
}
|
||
if from <= to {
|
||
if mins >= from && mins < to {
|
||
return true
|
||
}
|
||
} else if mins >= from || mins < to {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func parseHM(s string) (int, bool) {
|
||
var h, m int
|
||
if _, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil {
|
||
return 0, false
|
||
}
|
||
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||
return 0, false
|
||
}
|
||
return h*60 + m, true
|
||
}
|
||
|
||
func containsInt(xs []int, v int) bool {
|
||
for _, x := range xs {
|
||
if x == v {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// LoadChannels читає канали тенанта й розшифровує їхні секрети.
|
||
func (s *Store) LoadChannels(ctx context.Context, tenantID string, ring *crypto.Keyring) ([]Channel, error) {
|
||
var out []Channel
|
||
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT c.id::text, c.kind::text, c.name, c.config::text,
|
||
COALESCE(c.template,''), c.min_severity::text, c.enabled,
|
||
s.key_id, s.nonce, s.ciphertext, s.auth_tag, COALESCE(s.aad,'')
|
||
FROM alr.channels c
|
||
LEFT JOIN core.secrets s ON s.id = c.secret_id
|
||
WHERE c.tenant_id = $1
|
||
ORDER BY c.name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var c Channel
|
||
var cfg string
|
||
var keyID, aad *string
|
||
var nonce, ct, tag []byte
|
||
|
||
if err := rows.Scan(&c.ID, &c.Kind, &c.Name, &cfg, &c.Template,
|
||
&c.MinSeverity, &c.Enabled, &keyID, &nonce, &ct, &tag, &aad); err != nil {
|
||
return err
|
||
}
|
||
c.TenantID = tenantID
|
||
c.Config = json.RawMessage(cfg)
|
||
|
||
// Наявність секрету — факт про канал, а не наслідок того,
|
||
// чи його зараз розшифровують. Перелік для UI викликається
|
||
// без кільця навмисно, і без цього рядка канал із токеном
|
||
// показувався б у ньому як «секрету немає».
|
||
c.HasSecret = keyID != nil
|
||
|
||
if keyID != nil && ring != nil {
|
||
plain, err := ring.Decrypt(&crypto.Secret{
|
||
KeyID: *keyID, Nonce: nonce, Ciphertext: ct, AuthTag: tag,
|
||
}, derefStr(aad))
|
||
if err != nil {
|
||
// Канал із нечитабельним секретом не має валити
|
||
// доставку решти: одна зіпсована інтеграція гірша
|
||
// за мовчання лише для себе самої.
|
||
return fmt.Errorf("канал %s: розшифровка секрету: %w", c.Name, err)
|
||
}
|
||
c.Secret = string(plain)
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
func derefStr(p *string) string {
|
||
if p == nil {
|
||
return ""
|
||
}
|
||
return *p
|
||
}
|
||
|
||
// LoadRoutes читає маршрути тенанта в порядку пріоритету.
|
||
func (s *Store) LoadRoutes(ctx context.Context, tenantID string) ([]Route, error) {
|
||
var out []Route
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
rows, err := tx.Query(ctx, `
|
||
SELECT id::text, name, priority, matcher::text,
|
||
channel_ids::text[], COALESCE(schedule::text,'')
|
||
FROM alr.routes
|
||
WHERE tenant_id = $1 AND enabled
|
||
ORDER BY priority, name
|
||
`, tenantID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer rows.Close()
|
||
for rows.Next() {
|
||
var r Route
|
||
var matcher, sched string
|
||
if err := rows.Scan(&r.ID, &r.Name, &r.Priority, &matcher,
|
||
&r.ChannelIDs, &sched); err != nil {
|
||
return err
|
||
}
|
||
if err := json.Unmarshal([]byte(matcher), &r.Matcher); err != nil {
|
||
return fmt.Errorf("маршрут %s: matcher: %w", r.Name, err)
|
||
}
|
||
if sched != "" {
|
||
var sc RouteSchedule
|
||
if err := json.Unmarshal([]byte(sched), &sc); err == nil {
|
||
r.Schedule = &sc
|
||
}
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return rows.Err()
|
||
})
|
||
return out, err
|
||
}
|
||
|
||
// Matches — чи цей маршрут бере цей алерт.
|
||
func (r Route) Matches(a Alert) bool {
|
||
if r.Matcher.SeverityGte != "" {
|
||
if severityRank[a.Severity] < severityRank[r.Matcher.SeverityGte] {
|
||
return false
|
||
}
|
||
}
|
||
if len(r.Matcher.RuleIDs) > 0 && !containsStr(r.Matcher.RuleIDs, a.RuleID) {
|
||
return false
|
||
}
|
||
if len(r.Matcher.DeviceIDs) > 0 && !containsStr(r.Matcher.DeviceIDs, a.DeviceID) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
func containsStr(xs []string, v string) bool {
|
||
for _, x := range xs {
|
||
if x == v {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// RecordNotification пише спробу доставки в журнал і рахує сповіщення
|
||
// на алерті.
|
||
//
|
||
// Журнал ведеться до відправлення, а не після: інакше падіння процесу
|
||
// між HTTP-запитом і записом лишало б слід «не надсилали» на вже
|
||
// доставленому повідомленні, і ретрай слав би дубль.
|
||
func (s *Store) RecordNotification(ctx context.Context, tenantID, alertID, channelID,
|
||
status, errMsg, externalID string, payload any) error {
|
||
|
||
data, _ := json.Marshal(payload)
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
if _, err := tx.Exec(ctx, `
|
||
INSERT INTO alr.notifications
|
||
(tenant_id, alert_id, channel_id, status, error, external_id, payload)
|
||
VALUES ($1, $2, $3, $4::alr.delivery_status, $5, $6, $7::jsonb)
|
||
`, tenantID, nullUUID(alertID), nullUUID(channelID), status,
|
||
nullString(errMsg), nullString(externalID), string(data)); err != nil {
|
||
return err
|
||
}
|
||
if status != "sent" || alertID == "" {
|
||
return nil
|
||
}
|
||
_, err := tx.Exec(ctx, `
|
||
UPDATE alr.alerts SET notify_count = notify_count + 1
|
||
WHERE tenant_id = $1 AND id = $2
|
||
`, tenantID, alertID)
|
||
return err
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------
|
||
// CRUD каналів для UI
|
||
// ---------------------------------------------------------------------
|
||
|
||
type ChannelInput struct {
|
||
Kind string
|
||
Name string
|
||
Config string
|
||
Template string
|
||
MinSeverity string
|
||
Secret string
|
||
Enabled bool
|
||
}
|
||
|
||
// CreateChannel зберігає канал; секрет шифрується тим самим кільцем, що
|
||
// й паролі від обладнання — окремого сховища для нього немає навмисно.
|
||
func (s *Store) CreateChannel(ctx context.Context, tenantID string, in ChannelInput, ring *crypto.Keyring) (string, error) {
|
||
var id string
|
||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
var secretID any
|
||
if in.Secret != "" {
|
||
if ring == nil {
|
||
return errors.New("сервер запущено без ключа шифрування — зберегти секрет каналу ніяк")
|
||
}
|
||
aad := tenantID + "|alr.channel"
|
||
sec, err := ring.Encrypt([]byte(in.Secret), aad)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var sid string
|
||
if err := tx.QueryRow(ctx, `
|
||
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
|
||
VALUES ($1, 'api_token', $2, $3, $4, $5, $6)
|
||
RETURNING id::text
|
||
`, tenantID, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad).Scan(&sid); err != nil {
|
||
return err
|
||
}
|
||
secretID = sid
|
||
}
|
||
|
||
return tx.QueryRow(ctx, `
|
||
INSERT INTO alr.channels
|
||
(tenant_id, kind, name, config, secret_id, template, min_severity, enabled)
|
||
VALUES ($1, $2::alr.channel_kind, $3, $4::jsonb, $5, $6, $7::alr.severity, $8)
|
||
RETURNING id::text
|
||
`, tenantID, in.Kind, in.Name, in.Config, secretID,
|
||
nullString(in.Template), in.MinSeverity, in.Enabled).Scan(&id)
|
||
})
|
||
return id, err
|
||
}
|
||
|
||
func (s *Store) DeleteChannel(ctx context.Context, tenantID, channelID string) error {
|
||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||
// Секрет видаляється разом із каналом: залишений «на всякий
|
||
// випадок» токен у core.secrets — це чинний доступ, про який
|
||
// уже ніхто не пам'ятає.
|
||
//
|
||
// Лічильник беремо з видалення каналу, а не секрету: канал без
|
||
// секрету (webhook без підпису) дав би 0 рядків у другому
|
||
// операторі, і відповідь була б «немає такого» на успішне
|
||
// видалення.
|
||
var deleted int
|
||
err := tx.QueryRow(ctx, `
|
||
WITH gone AS (
|
||
DELETE FROM alr.channels
|
||
WHERE tenant_id = $1 AND id = $2
|
||
RETURNING secret_id
|
||
), dropped AS (
|
||
DELETE FROM core.secrets
|
||
WHERE id IN (SELECT secret_id FROM gone WHERE secret_id IS NOT NULL)
|
||
)
|
||
SELECT count(*)::int FROM gone
|
||
`, tenantID, channelID).Scan(&deleted)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if deleted == 0 {
|
||
return ErrAlertNotFound
|
||
}
|
||
return nil
|
||
})
|
||
}
|