Netpulse_SasS/server/internal/store/dashboards.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

228 lines
7.2 KiB
Go
Raw Permalink 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"
"github.com/jackc/pgx/v5"
)
// Widget — плитка дашборда.
//
// Queries описує, що показувати; для кожного виду віджета своя форма:
// timeseries бере `series_ids` і `device_id`, alert_list — фільтр за
// серйозністю, device_grid — групи. Схему кожного виду тримає фронтенд:
// сервер тут — сховище, а не інтерпретатор.
type Widget struct {
ID string `json:"id,omitempty"`
Kind string `json:"kind"`
Title string `json:"title,omitempty"`
GridX int `json:"grid_x"`
GridY int `json:"grid_y"`
GridW int `json:"grid_w"`
GridH int `json:"grid_h"`
MapID string `json:"map_id,omitempty"`
Queries json.RawMessage `json:"queries,omitempty"`
Options json.RawMessage `json:"options,omitempty"`
}
// Dashboard — сторінка з віджетами.
type Dashboard struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Kind string `json:"kind"`
RefreshSec int `json:"refresh_sec"`
IsDefault bool `json:"is_default"`
Widgets []Widget `json:"widgets,omitempty"`
// Скільки плиток на дашборді — для списку, де самі плитки не потрібні.
WidgetCount int `json:"widget_count"`
}
// ListDashboards — перелік без плиток.
func (s *Store) ListDashboards(ctx context.Context, tenantID string) ([]Dashboard, error) {
out := []Dashboard{}
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
rows, err := tx.Query(ctx, `
SELECT d.id::text, d.name, d.slug, d.kind::text, d.refresh_sec, d.is_default,
(SELECT count(*) FROM core.dashboard_widgets w WHERE w.dashboard_id = d.id)
FROM core.dashboards d
WHERE d.tenant_id = $1
ORDER BY d.is_default DESC, d.name
`, tenantID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var d Dashboard
if err := rows.Scan(&d.ID, &d.Name, &d.Slug, &d.Kind, &d.RefreshSec,
&d.IsDefault, &d.WidgetCount); err != nil {
return err
}
out = append(out, d)
}
return rows.Err()
})
return out, err
}
// GetDashboard читає дашборд разом із плитками.
func (s *Store) GetDashboard(ctx context.Context, tenantID, id string) (*Dashboard, error) {
var d Dashboard
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `
SELECT id::text, name, slug, kind::text, refresh_sec, is_default
FROM core.dashboards WHERE id = $1 AND tenant_id = $2
`, id, tenantID).Scan(&d.ID, &d.Name, &d.Slug, &d.Kind,
&d.RefreshSec, &d.IsDefault); err != nil {
return err
}
rows, err := tx.Query(ctx, `
SELECT id::text, kind::text, COALESCE(title,''),
grid_x, grid_y, grid_w, grid_h,
COALESCE(map_id::text,''), queries::text, options::text
FROM core.dashboard_widgets
WHERE dashboard_id = $1
ORDER BY grid_y, grid_x
`, id)
if err != nil {
return err
}
defer rows.Close()
d.Widgets = []Widget{}
for rows.Next() {
var w Widget
var queries, options string
if err := rows.Scan(&w.ID, &w.Kind, &w.Title, &w.GridX, &w.GridY,
&w.GridW, &w.GridH, &w.MapID, &queries, &options); err != nil {
return err
}
w.Queries = json.RawMessage(queries)
w.Options = json.RawMessage(options)
d.Widgets = append(d.Widgets, w)
}
d.WidgetCount = len(d.Widgets)
return rows.Err()
})
if err != nil {
if isNoRows(err) {
return nil, ErrNotFound
}
return nil, err
}
return &d, nil
}
// SaveDashboard створює або замінює дашборд разом із плитками.
//
// Плитки замінюються цілком: дашборд редагується як єдине полотно, і
// часткові оновлення дали б спосіб отримати розкладку, якої людина не
// бачила.
func (s *Store) SaveDashboard(ctx context.Context, tenantID string, d Dashboard) (string, error) {
var id string
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if d.RefreshSec <= 0 {
d.RefreshSec = 30
}
if d.Kind == "" {
d.Kind = "standard"
}
if d.ID != "" {
if err := tx.QueryRow(ctx, `
UPDATE core.dashboards
SET name = $3, slug = $4, refresh_sec = $5, updated_at = now()
WHERE id = $1 AND tenant_id = $2
RETURNING id::text
`, d.ID, tenantID, d.Name, d.Slug, d.RefreshSec).Scan(&id); err != nil {
if isNoRows(err) {
return ErrNotFound
}
return err
}
if _, err := tx.Exec(ctx,
`DELETE FROM core.dashboard_widgets WHERE dashboard_id = $1`, id); err != nil {
return err
}
} else {
// Перший дашборд стає типовим сам: інакше людина створює
// його й не розуміє, чому головна сторінка досі порожня.
var have int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM core.dashboards WHERE tenant_id = $1`, tenantID).
Scan(&have); err != nil {
return err
}
if err := tx.QueryRow(ctx, `
INSERT INTO core.dashboards (tenant_id, name, slug, kind, refresh_sec, is_default)
VALUES ($1, $2, $3, $4::core.dashboard_kind, $5, $6)
RETURNING id::text
`, tenantID, d.Name, d.Slug, d.Kind, d.RefreshSec, have == 0).Scan(&id); err != nil {
return err
}
}
for _, w := range d.Widgets {
if len(w.Queries) == 0 {
w.Queries = json.RawMessage("[]")
}
if len(w.Options) == 0 {
w.Options = json.RawMessage("{}")
}
if w.GridW <= 0 {
w.GridW = 6
}
if w.GridH <= 0 {
w.GridH = 4
}
if _, err := tx.Exec(ctx, `
INSERT INTO core.dashboard_widgets
(tenant_id, dashboard_id, kind, title, grid_x, grid_y, grid_w, grid_h,
map_id, queries, options)
VALUES ($1,$2,$3::core.widget_kind,NULLIF($4,''),$5,$6,$7,$8,$9,$10::jsonb,$11::jsonb)
`, tenantID, id, w.Kind, w.Title, w.GridX, w.GridY, w.GridW, w.GridH,
nullUUID(w.MapID), string(w.Queries), string(w.Options)); err != nil {
return err
}
}
return nil
})
return id, err
}
// DeleteDashboard прибирає дашборд разом із плитками (каскадом).
func (s *Store) DeleteDashboard(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`DELETE FROM core.dashboards WHERE id = $1 AND tenant_id = $2`, id, tenantID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}
// SetDefaultDashboard робить дашборд головним.
func (s *Store) SetDefaultDashboard(ctx context.Context, tenantID, id string) error {
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
if _, err := tx.Exec(ctx,
`UPDATE core.dashboards SET is_default = false WHERE tenant_id = $1`, tenantID); err != nil {
return err
}
ct, err := tx.Exec(ctx,
`UPDATE core.dashboards SET is_default = true WHERE id = $1 AND tenant_id = $2`,
id, tenantID)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrNotFound
}
return nil
})
}