From 7fa8adc506d95d00f82bf8cced1ed924f012f999 Mon Sep 17 00:00:00 2001 From: byrsapty Date: Mon, 24 Aug 2026 18:30:28 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4?= =?UTF-8?q?=D0=B8,=20Esc/=D0=BA=D0=BB=D1=96=D0=BA=20=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D0=B7=20=D0=B2=D1=96=D0=BA=D0=BD=D0=BE,=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=B3=D1=83=D0=B2=D0=B0=D0=BD=D0=BD=D1=8F=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=D0=BB=D1=96=D0=B2=20=D1=96=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=D0=BE=D0=BB=D1=96=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дашборди: схема core.dashboards лежала з Етапу 1 без жодного рядка коду. Сітка на 12 колонок, шість видів плиток (графік, число, шкала, список алертів, сітка хостів, текст), автооновлення з інтервалом дашборда, режим редагування. Права окремі від maps:* — дашборд збирає дані з усього тенанта. Esc і клік повз панель закривають будь-яке вікно. Слухач на document, бо фокус може стояти де завгодно; закриття за mousedown, а не click, щоб виділення тексту, доведене за межі вікна, не втрачало набране. Канали сповіщень редагуються (раніше лише створювались і видалялись). Протокол доступу змінюється — з вимогою ввести пароль заново, бо секрет зашифрований під видом старого протоколу. Co-Authored-By: Claude Opus 5 --- HISTORY.md | 60 ++++ db/migrations/0020_dashboard_perms.sql | 30 ++ server/internal/httpapi/checks.go | 8 +- server/internal/httpapi/dashboards.go | 169 +++++++++++ server/internal/httpapi/server.go | 8 + server/internal/store/alerts_channels.go | 69 +++++ server/internal/store/checks.go | 26 +- server/internal/store/dashboards.go | 228 +++++++++++++++ web/src/App.tsx | 2 + web/src/api/client.ts | 39 ++- web/src/components/AppShell.tsx | 1 + web/src/components/ui.tsx | 38 ++- web/src/components/widgets/WidgetForm.tsx | 340 ++++++++++++++++++++++ web/src/components/widgets/WidgetView.tsx | 244 ++++++++++++++++ web/src/pages/ChannelsPage.tsx | 61 +++- web/src/pages/CredentialsPage.tsx | 18 +- web/src/pages/DashboardPage.tsx | 340 ++++++++++++++++++++++ web/src/types.ts | 37 +++ 18 files changed, 1689 insertions(+), 29 deletions(-) create mode 100644 db/migrations/0020_dashboard_perms.sql create mode 100644 server/internal/httpapi/dashboards.go create mode 100644 server/internal/store/dashboards.go create mode 100644 web/src/components/widgets/WidgetForm.tsx create mode 100644 web/src/components/widgets/WidgetView.tsx create mode 100644 web/src/pages/DashboardPage.tsx diff --git a/HISTORY.md b/HISTORY.md index ad777b8..f7e9a60 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1694,3 +1694,63 @@ snmp-host if.in_bps · eth0 = 18.5k bps, sys.processes = 41, тільки за обривом — якщо ним підсвічувати ще й «невідомо», оператор перестане на нього реагувати. +--- + +## Дашборди + +Схема `core.dashboards` і `core.dashboard_widgets` лежала з Етапу 1 і не +мала жодного рядка коду. Тепер має. + +**Сітка — дванадцять колонок.** 12 ділиться на 2, 3, 4 і 6, тож +половина, третина й чверть ширини задаються цілими числами, а не +«41.6 %». + +**Плитки замінюються цілком.** Дашборд редагується як єдине полотно, і +часткові оновлення дали б спосіб отримати розкладку, якої людина не +бачила. + +**Перший дашборд стає головним сам.** Інакше людина створює його й не +розуміє, чому головна сторінка досі порожня. + +**Кожна плитка вантажить своє.** Спільний завантажувач на дашборд +виглядав би охайніше, але змусив би плитку «текст» чекати на запит +метрик сусідки. + +Види: графік, число, шкала, список алертів, сітка хостів, текст. +Перевірений список видів дублюється на сервері навмисно — збережена +плитка, яку ніхто не малює, виглядає як зламаний дашборд. + +**Права окремі від мап.** Дашборд збирає дані з усього тенанта, і «може +дивитись мапу» не означає «може дивитись зведення по всіх майданчиках». +Видані тим ролям, які вже мають відповідний рівень доступу до мап. + +--- + +## Esc і клік повз вікно + +Жодна модалка не закривалася нічим, крім хрестика. + +Слухач Esc — на `document`, а не на панелі: фокус може стояти в полі +вводу, у випадному списку або взагалі ніде, і вимагати спершу «влучити» +кудись — це рівно те, чого від Esc не чекають. + +Клік повз панель закриває **за `mousedown`, а не за `click`**: інакше +виділення тексту, доведене мишею за межі вікна, закривало б форму разом +із набраним. + +Заголовок вікна став липким: у довгих формах (шаблон із десятком +метрик) хрестик інакше їхав за межі екрана. + +--- + +## Редагування там, де його не було + +**Канали сповіщень** взагалі не редагувалися — лише створювались і +видалялись. Вид каналу лишається незмінним: telegram із конфігом +вебхука — це інший обʼєкт, і чесніше завести новий. + +**Протокол доступу** тепер змінюється. З одним застереженням: секрет +зашифрований під видом старого протоколу, тож при зміні пароль треба +ввести заново. Сервер відмовляє з поясненням, кнопка в формі теж не +дає зберегти — краще сказати це до спроби, ніж після. + diff --git a/db/migrations/0020_dashboard_perms.sql b/db/migrations/0020_dashboard_perms.sql new file mode 100644 index 0000000..d1d18ba --- /dev/null +++ b/db/migrations/0020_dashboard_perms.sql @@ -0,0 +1,30 @@ +-- ===================================================================== +-- NetPulse :: 0020_dashboard_perms.sql +-- Права на дашборди. +-- +-- Окремі від maps:* навмисно: дашборд збирає дані з усього тенанта, і +-- «може дивитись мапу» не означає «може дивитись зведення по грошах, +-- SLA й усіх майданчиках». +-- ===================================================================== + +INSERT INTO core.permissions (key, description) VALUES + ('dashboards:read', 'Перегляд дашбордів'), + ('dashboards:write', 'Створення та редагування дашбордів') +ON CONFLICT (key) DO NOTHING; + +-- Видають тим, хто вже має відповідний рівень доступу до мап: набір +-- ролей у продукті один, і розводити їх тут означало б завести роль, +-- яка бачить мапу й не бачить дашборда з тією ж мапою всередині. +INSERT INTO core.role_permissions (role_id, permission_key) +SELECT rp.role_id, 'dashboards:read' +FROM core.role_permissions rp +WHERE rp.permission_key = 'maps:read' +ON CONFLICT DO NOTHING; + +INSERT INTO core.role_permissions (role_id, permission_key) +SELECT rp.role_id, 'dashboards:write' +FROM core.role_permissions rp +WHERE rp.permission_key = 'maps:write' +ON CONFLICT DO NOTHING; + +-- Роль із «*» уже покриває все — її не чіпаємо. diff --git a/server/internal/httpapi/checks.go b/server/internal/httpapi/checks.go index d8bd419..6d88fda 100644 --- a/server/internal/httpapi/checks.go +++ b/server/internal/httpapi/checks.go @@ -508,6 +508,7 @@ func (s *Server) handleUpdateCredential(w http.ResponseWriter, r *http.Request, } var in struct { Name string `json:"name"` + Proto string `json:"proto"` Username string `json:"username"` Port int `json:"port"` Secret string `json:"secret"` @@ -526,8 +527,13 @@ func (s *Server) handleUpdateCredential(w http.ResponseWriter, r *http.Request, return } + if in.Proto != "" && !validCredProtos[in.Proto] { + writeError(w, http.StatusBadRequest, "bad_proto", "невідомий протокол доступу") + return + } + err := s.store.UpdateCredential(r.Context(), p.TenantID, r.PathValue("id"), - strings.TrimSpace(in.Name), in.Username, in.Port, in.Secret, + strings.TrimSpace(in.Name), in.Proto, in.Username, in.Port, in.Secret, jsonOr(in.Options, "{}"), s.keyring) if errors.Is(err, store.ErrNotFound) { writeError(w, http.StatusNotFound, "not_found", "доступ не знайдено") diff --git a/server/internal/httpapi/dashboards.go b/server/internal/httpapi/dashboards.go new file mode 100644 index 0000000..d58d0f4 --- /dev/null +++ b/server/internal/httpapi/dashboards.go @@ -0,0 +1,169 @@ +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 +} diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 2b11083..4280f9d 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -79,6 +79,13 @@ func (s *Server) Handler() http.Handler { mux.Handle("DELETE /api/v1/team/{id}", s.authenticated(s.handleRemoveMember)) mux.Handle("GET /api/v1/roles", s.authenticated(s.handleListRoles)) + mux.Handle("GET /api/v1/dashboards", s.authenticated(s.handleListDashboards)) + mux.Handle("POST /api/v1/dashboards", s.authenticated(s.handleSaveDashboard)) + mux.Handle("GET /api/v1/dashboards/{id}", s.authenticated(s.handleGetDashboard)) + mux.Handle("PUT /api/v1/dashboards/{id}", s.authenticated(s.handleSaveDashboard)) + mux.Handle("DELETE /api/v1/dashboards/{id}", s.authenticated(s.handleDeleteDashboard)) + mux.Handle("POST /api/v1/dashboards/{id}/default", s.authenticated(s.handleSetDefaultDashboard)) + mux.Handle("GET /api/v1/maps", s.authenticated(s.handleListMaps)) mux.Handle("POST /api/v1/maps", s.authenticated(s.handleCreateMap)) mux.Handle("GET /api/v1/maps/{id}", s.authenticated(s.handleGetMap)) @@ -146,6 +153,7 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/v1/channels", s.authenticated(s.handleListChannels)) mux.Handle("POST /api/v1/channels", s.authenticated(s.handleCreateChannel)) + mux.Handle("PUT /api/v1/channels/{id}", s.authenticated(s.handleCreateChannel)) mux.Handle("DELETE /api/v1/channels/{id}", s.authenticated(s.handleDeleteChannel)) mux.Handle("POST /api/v1/channels/{id}/test", s.authenticated(s.handleTestChannel)) diff --git a/server/internal/store/alerts_channels.go b/server/internal/store/alerts_channels.go index 9c456ba..3e99ebb 100644 --- a/server/internal/store/alerts_channels.go +++ b/server/internal/store/alerts_channels.go @@ -398,3 +398,72 @@ func (s *Store) LoadRuleActions(ctx context.Context, tenantID string) (map[strin }) return out, err } + +// UpdateChannel змінює канал. +// +// Порожній Secret означає «лишити токен як є»: розшифрувати збережений +// заради показу означає віддати його туди, звідки він уже не +// повернеться, тож форма надіслати незмінений не може навіть теоретично. +// +// Вид каналу не змінюється: telegram із конфігом вебхука — це інший +// об'єкт, і чесніше завести новий, ніж мовчки лишити несумісні поля. +func (s *Store) UpdateChannel(ctx context.Context, tenantID, channelID string, in ChannelInput, ring *crypto.Keyring) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var oldSecret *string + if err := tx.QueryRow(ctx, ` + SELECT secret_id::text FROM alr.channels WHERE id = $1 AND tenant_id = $2 + `, channelID, tenantID).Scan(&oldSecret); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + return err + } + + secretID := any(nil) + if oldSecret != nil { + secretID = *oldSecret + } + + if in.Secret != "" { + if ring == nil { + return errors.New("сервер запущено без ключа шифрування — зберегти секрет каналу ніяк") + } + aad := tenantID + "|alr.channel" + sec, err := ring.Encrypt([]byte(in.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, 'api_token', $2, $3, $4, $5, $6) + RETURNING id::text + `, tenantID, 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 + } + + ct, err := tx.Exec(ctx, ` + UPDATE alr.channels SET + name = $3, config = $4::jsonb, secret_id = $5, + template = $6, min_severity = $7::alr.severity, enabled = $8 + WHERE id = $1 AND tenant_id = $2 + `, channelID, tenantID, in.Name, in.Config, secretID, + nullString(in.Template), in.MinSeverity, in.Enabled) + if err != nil { + return err + } + if ct.RowsAffected() == 0 { + return ErrNotFound + } + return nil + }) +} diff --git a/server/internal/store/checks.go b/server/internal/store/checks.go index 3d4b68f..85f706a 100644 --- a/server/internal/store/checks.go +++ b/server/internal/store/checks.go @@ -365,27 +365,42 @@ func (s *Store) DeviceCredentialsOf(ctx context.Context, tenantID string) (map[s // збережений секрет (розшифрувати його для показу — це віддати його // туди, звідки він уже не повернеться), тож надіслати незмінений вона не // може навіть теоретично. -func (s *Store) UpdateCredential(ctx context.Context, tenantID, id, name, username string, +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 proto string + 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(&proto, &oldSecret); err != nil { + `, 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("сервер запущено без ключа шифрування") @@ -421,10 +436,11 @@ func (s *Store) UpdateCredential(ctx context.Context, tenantID, id, name, userna } ct, err := tx.Exec(ctx, ` UPDATE inv.credentials - SET name = $3, username = NULLIF($4,''), port = NULLIF($5,0), + 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) + `, id, tenantID, name, username, port, secretID, opts, proto) if err != nil { return err } diff --git a/server/internal/store/dashboards.go b/server/internal/store/dashboards.go new file mode 100644 index 0000000..602f3d7 --- /dev/null +++ b/server/internal/store/dashboards.go @@ -0,0 +1,228 @@ +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 + }) +} diff --git a/web/src/App.tsx b/web/src/App.tsx index b098200..204cf21 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,7 @@ import { GroupsPage } from './pages/GroupsPage' import { TemplatesPage } from './pages/TemplatesPage' import { CredentialsPage } from './pages/CredentialsPage' import { MetricsPage } from './pages/MetricsPage' +import { DashboardPage } from './pages/DashboardPage' import { ConfigsPage } from './pages/ConfigsPage' import { AlertsPage } from './pages/AlertsPage' import { RulesPage } from './pages/RulesPage' @@ -52,6 +53,7 @@ export default function App() { }> } /> + } />} /> } />} /> } />} /> } />} /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2c0381b..df798f1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -27,6 +27,7 @@ import type { UserGroup, UndoResult, BackupDefaults, + Dashboard, MetricResult, SeriesInfo, Template, @@ -348,7 +349,7 @@ export const api = { updateCredential: ( id: string, - c: { name: string; username?: string; port?: number; secret?: string }, + c: { name: string; proto?: string; username?: string; port?: number; secret?: string }, ) => request(`/api/v1/credentials/${id}`, { method: 'PATCH', body: JSON.stringify(c) }), deleteCredential: (id: string) => @@ -471,6 +472,23 @@ export const api = { listChannels: () => request<{ channels: Channel[] }>('/api/v1/channels').then((r) => r.channels ?? []), + saveChannel: ( + id: string | null, + ch: { + kind: string + name: string + config: Record + min_severity: string + secret?: string + template?: string + enabled?: boolean + }, + ) => + request<{ id: string }>(id ? `/api/v1/channels/${id}` : '/api/v1/channels', { + method: id ? 'PUT' : 'POST', + body: JSON.stringify(ch), + }), + createChannel: (ch: { kind: string name: string @@ -572,4 +590,23 @@ export const api = { `/api/v1/devices/${deviceID}/metrics?series=${seriesIDs.join(',')}` + `&range=${range}&points=${points}`, ), + + // --- дашборди --- + + listDashboards: () => + request<{ dashboards: Dashboard[] }>('/api/v1/dashboards').then((r) => r.dashboards ?? []), + + getDashboard: (id: string) => request(`/api/v1/dashboards/${id}`), + + saveDashboard: (id: string | null, d: Partial) => + request<{ id: string }>(id ? `/api/v1/dashboards/${id}` : '/api/v1/dashboards', { + method: id ? 'PUT' : 'POST', + body: JSON.stringify(d), + }), + + deleteDashboard: (id: string) => + request(`/api/v1/dashboards/${id}`, { method: 'DELETE' }), + + setDefaultDashboard: (id: string) => + request(`/api/v1/dashboards/${id}/default`, { method: 'POST' }), } diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 3d90db6..bae73bb 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -16,6 +16,7 @@ interface NavItem { } const navItems: NavItem[] = [ + { to: '/dashboard', label: 'Дашборд', icon: '▦', perm: 'dashboards:read' }, { to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' }, { to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' }, { to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' }, diff --git a/web/src/components/ui.tsx b/web/src/components/ui.tsx index 943c481..da4259e 100644 --- a/web/src/components/ui.tsx +++ b/web/src/components/ui.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' /** @@ -275,18 +275,50 @@ export function Modal({ /** Форми з двома колонками не читаються у вузькому вікні. */ wide?: boolean }) { + const panel = useRef(null) + + // Esc закриває будь-яке вікно. + // + // Слухач на document, а не на самій панелі: фокус може стояти в + // полі вводу, у випадному списку або взагалі ніде, і вимагати від + // людини спершу «влучити» кудись — це рівно те, чого від Esc не + // чекають. keydown, а не keyup: спрацювати має натискання. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() + onClose() + } + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [onClose]) + return ( -
+
{ + if (panel.current && !panel.current.contains(e.target as Node)) onClose() + }} + >
-
+

{title}

diff --git a/web/src/components/widgets/WidgetForm.tsx b/web/src/components/widgets/WidgetForm.tsx new file mode 100644 index 0000000..86bb683 --- /dev/null +++ b/web/src/components/widgets/WidgetForm.tsx @@ -0,0 +1,340 @@ +import { useEffect, useState } from 'react' +import { api } from '../../api/client' +import { Button, ErrorNote, Field, Modal, inputClass } from '../ui' +import { seriesLabel } from '../MetricChart' +import type { DeviceSummary, SeriesInfo, Widget, WidgetKind } from '../../types' + +// Види плиток, які фронтенд уміє намалювати. Список навмисно збігається +// з тим, що приймає сервер: плитка, яку ніхто не малює, виглядає як +// зламаний дашборд. +const KINDS: { key: WidgetKind; label: string; hint: string }[] = [ + { key: 'timeseries', label: 'Графік', hint: 'Одна або кілька ліній за період' }, + { key: 'stat', label: 'Число', hint: 'Одне значення великим шрифтом' }, + { key: 'gauge', label: 'Шкала', hint: 'Значення з межами й кольором за порогом' }, + { key: 'alert_list', label: 'Список алертів', hint: 'Активні, з фільтром за рівнем' }, + { key: 'device_grid', label: 'Сітка хостів', hint: 'Квадратик на хост, колір за станом' }, + { key: 'text', label: 'Текст', hint: 'Підпис, посилання, нагадування черговому' }, +] + +const RANGES = ['30m', '6h', '24h', '7d', '30d'] +const AGGS: { key: string; label: string }[] = [ + { key: 'last', label: 'останнє' }, + { key: 'avg', label: 'середнє' }, + { key: 'max', label: 'максимум' }, + { key: 'min', label: 'мінімум' }, +] + +const needsMetric = (k: string) => k === 'timeseries' || k === 'stat' || k === 'gauge' + +/** + * Налаштування плитки. + * + * Ширина задається в колонках дванадцятикутної сітки: 12 ділиться на 2, + * 3, 4 і 6, тож половина, третина й чверть — цілі числа, а не «41.6 %». + */ +export function WidgetForm({ + widget, + onClose, + onSave, +}: { + widget: Widget | null + onClose: () => void + onSave: (w: Widget) => Promise +}) { + const cfg = (Array.isArray(widget?.queries) ? widget?.queries[0] : widget?.queries) as + | Record + | undefined + + const [kind, setKind] = useState(widget?.kind ?? 'timeseries') + const [title, setTitle] = useState(widget?.title ?? '') + const [gridW, setGridW] = useState(widget?.grid_w ?? 6) + const [deviceID, setDeviceID] = useState(String(cfg?.device_id ?? '')) + const [seriesIDs, setSeriesIDs] = useState((cfg?.series_ids as number[]) ?? []) + const [range, setRange] = useState(String(cfg?.range ?? '6h')) + const [agg, setAgg] = useState(String(cfg?.agg ?? 'last')) + const [min, setMin] = useState(String(cfg?.min ?? 0)) + const [max, setMax] = useState(String(cfg?.max ?? 100)) + const [severity, setSeverity] = useState(String(cfg?.severity ?? '')) + const [limit, setLimit] = useState(String(cfg?.limit ?? 8)) + const [text, setText] = useState(String(cfg?.text ?? '')) + + const [devices, setDevices] = useState([]) + const [series, setSeries] = useState([]) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(null) + + useEffect(() => { + api.listDevices().then(setDevices).catch(() => {}) + }, []) + + useEffect(() => { + if (!deviceID) { + setSeries([]) + return + } + let alive = true + api + .listSeries(deviceID) + .then((s) => alive && setSeries(s)) + .catch(() => {}) + return () => { + alive = false + } + }, [deviceID]) + + // Один ряд для числа й шкали: два значення в одному числі не + // показати, а мовчазно взяти перший — це збрехати про друге. + const single = kind === 'stat' || kind === 'gauge' + + function toggleSeries(id: number) { + setSeriesIDs((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : single ? [id] : [...prev, id], + ) + } + + return ( + +
+ k.key === kind)?.hint}> + + + +
+ + setTitle(e.target.value)} + /> + + + + +
+ + {needsMetric(kind) && ( + <> +
+ + + + + + +
+ + {deviceID && ( + +
    + {series.length === 0 && ( +
  • + Для цього хоста ще нічого не зібрано +
  • + )} + {series.map((s) => ( +
  • + +
  • + ))} +
+
+ )} + + {single && ( +
+ + + + {kind === 'gauge' && ( + <> + + setMin(e.target.value)} + /> + + + setMax(e.target.value)} + /> + + + )} +
+ )} + + )} + + {kind === 'alert_list' && ( +
+ + + + + setLimit(e.target.value)} + /> + +
+ )} + + {kind === 'device_grid' && ( + + setLimit(e.target.value)} + /> + + )} + + {kind === 'text' && ( + +