Netpulse_SasS/server/internal/store/checks.go
byrsapty 7fa8adc506 Дашборди, Esc/клік повз вікно, редагування каналів і протоколів
Дашборди: схема core.dashboards лежала з Етапу 1 без жодного рядка коду.
Сітка на 12 колонок, шість видів плиток (графік, число, шкала, список
алертів, сітка хостів, текст), автооновлення з інтервалом дашборда,
режим редагування. Права окремі від maps:* — дашборд збирає дані з
усього тенанта.

Esc і клік повз панель закривають будь-яке вікно. Слухач на document, бо
фокус може стояти де завгодно; закриття за mousedown, а не click, щоб
виділення тексту, доведене за межі вікна, не втрачало набране.

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

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

529 lines
18 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"
"github.com/jackc/pgx/v5"
"github.com/netpulse/netpulse/server/internal/crypto"
)
var ErrCheckNotFound = errors.New("перевірку не знайдено")
// CheckType — що взагалі вміє система опитувати.
//
// Перелік читається з БД, а не зашитий у фронтенд: плагіни додають свої
// типи міграцією, і UI має підхоплювати їх без перезбирання.
type CheckType struct {
Key string `json:"key"`
PluginKey string `json:"plugin_key"`
Name string `json:"name"`
ParamsSchema json.RawMessage `json:"params_schema"`
Metrics json.RawMessage `json:"metrics"`
/** Чи ввімкнений плагін у цього тенанта. */
Available bool `json:"available"`
}
// Check — одна перевірка на хості.
type Check struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
CheckType string `json:"check_type"`
TypeName string `json:"type_name,omitempty"`
Params json.RawMessage `json:"params"`
IntervalSec int `json:"interval_sec"`
TimeoutMs int `json:"timeout_ms"`
Retries int `json:"retries"`
Enabled bool `json:"enabled"`
LastRunAt *string `json:"last_run_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// ListCheckTypes віддає доступні типи перевірок.
func (s *Store) ListCheckTypes(ctx context.Context, tenantID string) ([]CheckType, error) {
var out []CheckType
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT ct.key, ct.plugin_key::text, ct.name,
ct.params_schema::text, ct.metrics::text,
-- Плагін доступний, якщо його явно ввімкнули цьому
-- тенанту або він базовий: вимагати «встановлення»
-- для пінга означало б зустрічати кожного клієнта
-- порожнім списком перевірок.
(p.is_core OR COALESCE(pi.enabled, false))
FROM core.check_types ct
JOIN core.plugins p ON p.key = ct.plugin_key
LEFT JOIN core.plugin_installs pi
ON pi.plugin_key = ct.plugin_key AND pi.tenant_id = $1
ORDER BY ct.plugin_key, ct.key
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c CheckType
var schema, metrics string
if err := rows.Scan(&c.Key, &c.PluginKey, &c.Name, &schema, &metrics,
&c.Available); err != nil {
return err
}
c.ParamsSchema = json.RawMessage(schema)
c.Metrics = json.RawMessage(metrics)
out = append(out, c)
}
return rows.Err()
})
return out, err
}
// ListDeviceChecks — перевірки одного хоста.
func (s *Store) ListDeviceChecks(ctx context.Context, tenantID, deviceID string) ([]Check, error) {
var out []Check
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT c.id::text, c.device_id::text, c.check_type, COALESCE(ct.name,''),
c.params::text, c.interval_sec, c.timeout_ms, c.retries, c.enabled,
to_char(c.last_run_at, 'YYYY-MM-DD"T"HH24:MI:SSOF'),
COALESCE(c.last_error,'')
FROM core.checks c
LEFT JOIN core.check_types ct ON ct.key = c.check_type
WHERE c.tenant_id = $1 AND c.device_id = $2
AND c.template_id IS NULL
ORDER BY c.check_type
`, tenantID, deviceID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c Check
var params string
if err := rows.Scan(&c.ID, &c.DeviceID, &c.CheckType, &c.TypeName, &params,
&c.IntervalSec, &c.TimeoutMs, &c.Retries, &c.Enabled,
&c.LastRunAt, &c.LastError); err != nil {
return err
}
c.Params = json.RawMessage(params)
out = append(out, c)
}
return rows.Err()
})
return out, err
}
// CheckInput — те, що приходить із форми. Порожній ID означає нову
// перевірку.
type CheckInput struct {
ID string
CheckType string
Params string
IntervalSec int
TimeoutMs int
Retries int
Enabled bool
}
// SetDeviceChecks замінює набір перевірок хоста цілком.
//
// Заміна, а не додавання: форма показує повний список, і прибраний
// рядок треба вміти прибрати.
//
// Наявні перевірки оновлюються за своїм id, а не перестворюються.
// Причина не в економії: унікальний індекс включає md5(params), тому
// «видалити й вставити» на зміні інтервалу спрацювало б, а на зміні
// параметрів створило б ДРУГУ перевірку того самого типу. Плюс
// перестворення скидає next_run_at і збиває рівномірність опитування
// по всьому парку.
func (s *Store) SetDeviceChecks(ctx context.Context, tenantID, deviceID string, in []CheckInput) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var owned bool
if err := tx.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM inv.devices
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL)
`, deviceID, tenantID).Scan(&owned); err != nil {
return err
}
if !owned {
return ErrNotFound
}
keep := make([]string, 0, len(in))
for _, c := range in {
if c.IntervalSec < 5 {
c.IntervalSec = 60
}
if c.IntervalSec > 86400 {
c.IntervalSec = 86400
}
if c.TimeoutMs <= 0 {
c.TimeoutMs = 3000
}
if c.Retries < 0 {
c.Retries = 0
}
params := c.Params
if params == "" {
params = "{}"
}
var id string
if c.ID != "" {
err := tx.QueryRow(ctx, `
UPDATE core.checks
SET params = $4::jsonb, interval_sec = $5, timeout_ms = $6,
retries = $7, enabled = $8, updated_at = now()
WHERE tenant_id = $1 AND device_id = $2 AND id = $3
RETURNING id::text
`, tenantID, deviceID, c.ID, params, c.IntervalSec,
c.TimeoutMs, c.Retries, c.Enabled).Scan(&id)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("перевірка %s: %w", c.CheckType, err)
}
}
// Рядка з таким id немає (або його не передали) — створюємо.
if id == "" {
if err := tx.QueryRow(ctx, `
INSERT INTO core.checks
(tenant_id, device_id, check_type, params, interval_sec,
timeout_ms, retries, enabled)
VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8)
RETURNING id::text
`, tenantID, deviceID, c.CheckType, params, c.IntervalSec,
c.TimeoutMs, c.Retries, c.Enabled).Scan(&id); err != nil {
return fmt.Errorf("перевірка %s: %w", c.CheckType, err)
}
}
keep = append(keep, id)
}
// Прибираємо зняте у формі. Перевірки на інтерфейсах не чіпаємо:
// їх заводить автовиявлення, і форма хоста про них не знає.
// Породжені шаблоном — так само: ними володіє реконсиляція, і
// видалення тут означало б, що вони зникають на кожне
// збереження форми, щоб за секунду з'явитися знову.
_, err := tx.Exec(ctx, `
DELETE FROM core.checks
WHERE tenant_id = $1 AND device_id = $2
AND interface_id IS NULL
AND template_id IS NULL
AND NOT (id = ANY($3::uuid[]))
`, tenantID, deviceID, nonNilIDs(keep))
return err
})
}
// ---------------------------------------------------------------------
// Креденшели хоста
// ---------------------------------------------------------------------
type Credential struct {
ID string `json:"id"`
Name string `json:"name"`
Proto string `json:"proto"`
Username string `json:"username,omitempty"`
Port int `json:"port,omitempty"`
IsDefault bool `json:"is_default"`
}
func (s *Store) ListCredentials(ctx context.Context, tenantID string) ([]Credential, error) {
var out []Credential
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT id::text, name, proto::text, COALESCE(username,''),
COALESCE(port,0), is_default
FROM inv.credentials
WHERE tenant_id = $1
ORDER BY proto, name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var c Credential
if err := rows.Scan(&c.ID, &c.Name, &c.Proto, &c.Username,
&c.Port, &c.IsDefault); err != nil {
return err
}
out = append(out, c)
}
return rows.Err()
})
return out, err
}
// CreateCredential зберігає доступ із зашифрованим секретом.
func (s *Store) CreateCredential(ctx context.Context, tenantID, name, proto, username string,
port int, secret string, options string, ring *crypto.Keyring) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var secretID any
if secret != "" {
if ring == nil {
return errors.New("сервер запущено без ключа шифрування")
}
aad := tenantID + "|inv.credential"
sec, err := ring.Encrypt([]byte(secret), aad)
if err != nil {
return err
}
kind := "snmp_v3"
switch proto {
case "snmp_v2c":
// Community — це не пароль SNMPv3, але шифрується так само:
// у дампі БД вона дає рівно той самий доступ до обладнання.
kind = "api_token"
case "ssh":
kind = "ssh_password"
case "telnet":
kind = "telnet"
}
var sid string
if err := tx.QueryRow(ctx, `
INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad)
VALUES ($1, $2::core.secret_kind, $3, $4, $5, $6, $7)
RETURNING id::text
`, tenantID, kind, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad).
Scan(&sid); err != nil {
return err
}
secretID = sid
}
opts := options
if opts == "" {
opts = "{}"
}
return tx.QueryRow(ctx, `
INSERT INTO inv.credentials
(tenant_id, name, proto, username, port, secret_id, options)
VALUES ($1, $2, $3::inv.credential_proto, NULLIF($4,''), NULLIF($5,0), $6, $7::jsonb)
RETURNING id::text
`, tenantID, name, proto, username, port, secretID, opts).Scan(&id)
})
return id, err
}
// SetDeviceCredentials замінює прив'язку доступів до хоста.
func (s *Store) SetDeviceCredentials(ctx context.Context, tenantID, deviceID string, credIDs []string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `
DELETE FROM inv.device_credentials dc
USING inv.credentials c
WHERE dc.credential_id = c.id AND dc.device_id = $1 AND c.tenant_id = $2
`, deviceID, tenantID); err != nil {
return err
}
if len(credIDs) == 0 {
return nil
}
_, err := tx.Exec(ctx, `
INSERT INTO inv.device_credentials (device_id, credential_id, priority)
SELECT $1, c.id, 100
FROM inv.credentials c
WHERE c.tenant_id = $2 AND c.id = ANY($3::uuid[])
ON CONFLICT DO NOTHING
`, deviceID, tenantID, credIDs)
return err
})
}
// DeviceCredentialsOf — які доступи прив'язані до кожного хоста.
func (s *Store) DeviceCredentialsOf(ctx context.Context, tenantID string) (map[string][]string, error) {
out := map[string][]string{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT dc.device_id::text, dc.credential_id::text
FROM inv.device_credentials dc
JOIN inv.credentials c ON c.id = dc.credential_id
WHERE c.tenant_id = $1
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var d, c string
if err := rows.Scan(&d, &c); err != nil {
return err
}
out[d] = append(out[d], c)
}
return rows.Err()
})
return out, err
}
// UpdateCredential змінює доступ.
//
// Порожній secret означає «пароль лишити як є»: форма ніколи не показує
// збережений секрет (розшифрувати його для показу — це віддати його
// туди, звідки він уже не повернеться), тож надіслати незмінений вона не
// може навіть теоретично.
func (s *Store) UpdateCredential(ctx context.Context, tenantID, id, name, proto, username string,
port int, secret string, options string, ring *crypto.Keyring) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var curProto string
var oldSecret *string
if err := tx.QueryRow(ctx, `
SELECT proto::text, secret_id::text FROM inv.credentials
WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(&curProto, &oldSecret); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
// Порожній proto означає «не чіпати». Зміна протоколу дозволена:
// community SNMP і пароль SSH зберігаються однаково, тож
// переналаштувати запис дешевше, ніж заводити новий і
// перевʼязувати всі хости.
if proto == "" {
proto = curProto
}
secretID := any(nil)
if oldSecret != nil {
secretID = *oldSecret
}
// Секрет зашифрований під видом старого протоколу. Якщо
// протокол змінили, а новий пароль не дали, збережений стає
// сміттям із чужим kind — тому вимагаємо ввести його заново.
if proto != curProto && secret == "" && oldSecret != nil {
return fmt.Errorf("%w: при зміні протоколу треба ввести пароль заново", ErrInvalid)
}
if secret != "" {
if ring == nil {
return errors.New("сервер запущено без ключа шифрування")
}
aad := tenantID + "|inv.credential"
sec, err := ring.Encrypt([]byte(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, $2::core.secret_kind, $3, $4, $5, $6, $7)
RETURNING id::text
`, tenantID, secretKind(proto), sec.KeyID, sec.Nonce, sec.Ciphertext,
sec.AuthTag, aad).Scan(&sid); err != nil {
return err
}
// Старий секрет прибираємо лише після того, як новий ліг:
// зворотний порядок на помилці шифрування лишив би доступ
// без пароля взагалі.
if oldSecret != nil {
if _, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *oldSecret); err != nil {
return err
}
}
secretID = sid
}
opts := options
if opts == "" {
opts = "{}"
}
ct, err := tx.Exec(ctx, `
UPDATE inv.credentials
SET name = $3, proto = $8::inv.credential_proto,
username = NULLIF($4,''), port = NULLIF($5,0),
secret_id = $6, options = $7::jsonb
WHERE id = $1 AND tenant_id = $2
`, id, tenantID, name, username, port, secretID, opts, proto)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// DeleteCredential прибирає доступ разом із секретом.
//
// Прив'язки до хостів зникають каскадом. Це саме те, що треба: доступ,
// якого немає, не має лишатись у списку хоста й створювати враження, що
// пристрій опитується.
func (s *Store) DeleteCredential(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
var secretID *string
if err := tx.QueryRow(ctx, `
SELECT secret_id::text FROM inv.credentials WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(&secretID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return err
}
if _, err := tx.Exec(ctx, `
DELETE FROM inv.credentials WHERE id = $1 AND tenant_id = $2
`, id, tenantID); err != nil {
return err
}
if secretID != nil {
_, err := tx.Exec(ctx, `DELETE FROM core.secrets WHERE id = $1`, *secretID)
return err
}
return nil
})
}
// CredentialUsage — скільки хостів користується доступом.
//
// Потрібне попередженню перед видаленням: «доступ використовують 34
// хости» — це інформація, а «Ви впевнені?» — ні.
func (s *Store) CredentialUsage(ctx context.Context, tenantID string) (map[string]int, error) {
out := map[string]int{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT c.id::text, count(dc.device_id)
FROM inv.credentials c
LEFT JOIN inv.device_credentials dc ON dc.credential_id = c.id
WHERE c.tenant_id = $1
GROUP BY c.id
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
var n int
if err := rows.Scan(&id, &n); err != nil {
return err
}
out[id] = n
}
return rows.Err()
})
return out, err
}
// secretKind — під яким видом секрет лягає в core.secrets.
//
// Community SNMPv2c — це не пароль, але шифрується так само: у дампі БД
// вона дає рівно той самий доступ до обладнання.
func secretKind(proto string) string {
switch proto {
case "snmp_v2c":
return "api_token"
case "ssh":
return "ssh_password"
case "telnet":
return "telnet"
default:
return "snmp_v3"
}
}