Дашборди: схема core.dashboards лежала з Етапу 1 без жодного рядка коду. Сітка на 12 колонок, шість видів плиток (графік, число, шкала, список алертів, сітка хостів, текст), автооновлення з інтервалом дашборда, режим редагування. Права окремі від maps:* — дашборд збирає дані з усього тенанта. Esc і клік повз панель закривають будь-яке вікно. Слухач на document, бо фокус може стояти де завгодно; закриття за mousedown, а не click, щоб виділення тексту, доведене за межі вікна, не втрачало набране. Канали сповіщень редагуються (раніше лише створювались і видалялись). Протокол доступу змінюється — з вимогою ввести пароль заново, бо секрет зашифрований під видом старого протоколу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
169 lines
5.5 KiB
Go
169 lines
5.5 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
// Види плиток, які фронтенд уміє намалювати.
|
||
//
|
||
// Дублює ENUM у БД навмисно: помилка в назві має давати 400 зі
|
||
// зрозумілим текстом, а не 500 із глибини запиту. Список коротший за
|
||
// ENUM — те, чого ще немає в UI, тут не приймається, бо збережена
|
||
// плитка, яку ніхто не малює, виглядає як зламаний дашборд.
|
||
var validWidgetKinds = map[string]bool{
|
||
"timeseries": true, "stat": true, "gauge": true,
|
||
"alert_list": true, "device_grid": true, "map": true, "text": true,
|
||
}
|
||
|
||
var slugRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]{0,62}[a-z0-9])?$`)
|
||
|
||
func (s *Server) handleListDashboards(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "dashboards:read") {
|
||
return
|
||
}
|
||
list, err := s.store.ListDashboards(r.Context(), p.TenantID)
|
||
if err != nil {
|
||
s.writeStoreError(w, "дашборди", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"dashboards": list})
|
||
}
|
||
|
||
func (s *Server) handleGetDashboard(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "dashboards:read") {
|
||
return
|
||
}
|
||
d, err := s.store.GetDashboard(r.Context(), p.TenantID, r.PathValue("id"))
|
||
if err != nil {
|
||
s.writeStoreError(w, "дашборд", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, d)
|
||
}
|
||
|
||
// handleSaveDashboard створює (POST) або замінює (PUT) дашборд.
|
||
func (s *Server) handleSaveDashboard(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "dashboards:write") {
|
||
return
|
||
}
|
||
|
||
var in struct {
|
||
Name string `json:"name"`
|
||
Slug string `json:"slug"`
|
||
RefreshSec int `json:"refresh_sec"`
|
||
Widgets []struct {
|
||
Kind string `json:"kind"`
|
||
Title string `json:"title"`
|
||
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"`
|
||
Queries json.RawMessage `json:"queries"`
|
||
Options json.RawMessage `json:"options"`
|
||
} `json:"widgets"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||
return
|
||
}
|
||
|
||
in.Name = strings.TrimSpace(in.Name)
|
||
in.Slug = strings.TrimSpace(in.Slug)
|
||
if in.Name == "" {
|
||
writeError(w, http.StatusBadRequest, "bad_request", "потрібна назва дашборда")
|
||
return
|
||
}
|
||
if in.Slug == "" {
|
||
in.Slug = slugify(in.Name)
|
||
}
|
||
if !slugRe.MatchString(in.Slug) {
|
||
writeError(w, http.StatusBadRequest, "bad_slug",
|
||
"ідентифікатор: латиниця, цифри, дефіс і підкреслення")
|
||
return
|
||
}
|
||
|
||
d := store.Dashboard{
|
||
ID: r.PathValue("id"),
|
||
Name: in.Name,
|
||
Slug: in.Slug,
|
||
RefreshSec: in.RefreshSec,
|
||
}
|
||
for _, wg := range in.Widgets {
|
||
if !validWidgetKinds[wg.Kind] {
|
||
writeError(w, http.StatusBadRequest, "bad_widget",
|
||
"невідомий вид плитки: "+wg.Kind)
|
||
return
|
||
}
|
||
d.Widgets = append(d.Widgets, store.Widget{
|
||
Kind: wg.Kind, Title: strings.TrimSpace(wg.Title),
|
||
GridX: wg.GridX, GridY: wg.GridY, GridW: wg.GridW, GridH: wg.GridH,
|
||
MapID: wg.MapID, Queries: wg.Queries, Options: wg.Options,
|
||
})
|
||
}
|
||
|
||
id, err := s.store.SaveDashboard(r.Context(), p.TenantID, d)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrNotFound) {
|
||
writeError(w, http.StatusNotFound, "not_found", "дашборд не знайдено")
|
||
return
|
||
}
|
||
if isUniqueViolation(err) {
|
||
writeError(w, http.StatusConflict, "duplicate", "дашборд із таким ідентифікатором уже є")
|
||
return
|
||
}
|
||
s.writeStoreError(w, "збереження дашборда", err)
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]any{"id": id})
|
||
}
|
||
|
||
func (s *Server) handleDeleteDashboard(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "dashboards:write") {
|
||
return
|
||
}
|
||
if err := s.store.DeleteDashboard(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
|
||
s.writeStoreError(w, "видалення дашборда", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
func (s *Server) handleSetDefaultDashboard(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||
if !requirePerm(w, p, "dashboards:write") {
|
||
return
|
||
}
|
||
if err := s.store.SetDefaultDashboard(r.Context(), p.TenantID, r.PathValue("id")); err != nil {
|
||
s.writeStoreError(w, "типовий дашборд", err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// slugify робить ідентифікатор із назви.
|
||
//
|
||
// Кирилиця в slug не лишається: він потрапляє в URL, а «%D0%9A%D0%B8»
|
||
// в адресному рядку — це не ідентифікатор, а шум. Якщо після чистки не
|
||
// лишилось нічого, людина введе slug сама.
|
||
func slugify(name string) string {
|
||
var b strings.Builder
|
||
for _, r := range strings.ToLower(name) {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||
b.WriteRune(r)
|
||
case r == ' ' || r == '-' || r == '_':
|
||
b.WriteRune('-')
|
||
}
|
||
}
|
||
out := strings.Trim(b.String(), "-")
|
||
for strings.Contains(out, "--") {
|
||
out = strings.ReplaceAll(out, "--", "-")
|
||
}
|
||
return out
|
||
}
|