Netpulse_SasS/server/internal/httpapi/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

169 lines
5.5 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 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
}