Netpulse_SasS/server/internal/store/alerts_channels.go
byrsapty 36f29e23ec Повноцінний веб: навігація і вісім сторінок
До цього «веб» був одним екраном: логін, мапа й панель алертів. Усе,
що вміло API, доводилось викликати curl-ом.

Каркас:
- бічна навігація з мобільною шухлядою, індикатор зв'язку в шапці,
  живий лічильник алертів
- пункт, на який немає права, не показується взагалі; пряме посилання
  лишається робочим і дає пояснення з назвою потрібного права
- стартова сторінка залежить від ролі: глядача без прав на мапи вітати
  відмовою — поганий перший екран

Сторінки: мапа, пристрої з пошуком і картками алертів, алерти з
фільтрами, правила зі створенням через форму, канали з кнопкою
перевірки, зонди, команда з керуванням ролями, профіль зі зміною
пароля.

DataTable на телефоні перестає бути таблицею: горизонтальний скрол на
375 px робить дані формально присутніми й фактично нечитабельними.

Знайдено роботою з живим UI:
- WebSocket жив усередині мапи, тому на решті сторінок живих оновлень
  не було взагалі — лічильник алертів замерзав, щойно людина йшла з
  мапи. З'єднання винесено в модуль-одинак, яким володіє оболонка
- сторінка пристроїв перечитувала все на кожну подію алерту; зі
  злиттям сплеску й спільними алертами той самий сплеск коштує
  2 запити замість дванадцяти
- канал із секретом показувався як «секрету немає»: прапорець ставився
  лише в гілці розшифровки, а перелік для UI викликається без ключа

Перевірено в браузері проти повного стека: усі вісім сторінок із
живими даними, створення правила й користувача через форми, перевірка
каналу, обмеження глядача, мобільний вигляд на 375 px.

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

353 lines
11 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"
"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:0008: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
})
}