Дашборди: схема core.dashboards лежала з Етапу 1 без жодного рядка коду. Сітка на 12 колонок, шість видів плиток (графік, число, шкала, список алертів, сітка хостів, текст), автооновлення з інтервалом дашборда, режим редагування. Права окремі від maps:* — дашборд збирає дані з усього тенанта. Esc і клік повз панель закривають будь-яке вікно. Слухач на document, бо фокус може стояти де завгодно; закриття за mousedown, а не click, щоб виділення тексту, доведене за межі вікна, не втрачало набране. Канали сповіщень редагуються (раніше лише створювались і видалялись). Протокол доступу змінюється — з вимогою ввести пароль заново, бо секрет зашифрований під видом старого протоколу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
7.2 KiB
Go
228 lines
7.2 KiB
Go
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
|
||
})
|
||
}
|