Дашборди, Esc/клік повз вікно, редагування каналів і протоколів
Дашборди: схема core.dashboards лежала з Етапу 1 без жодного рядка коду. Сітка на 12 колонок, шість видів плиток (графік, число, шкала, список алертів, сітка хостів, текст), автооновлення з інтервалом дашборда, режим редагування. Права окремі від maps:* — дашборд збирає дані з усього тенанта. Esc і клік повз панель закривають будь-яке вікно. Слухач на document, бо фокус може стояти де завгодно; закриття за mousedown, а не click, щоб виділення тексту, доведене за межі вікна, не втрачало набране. Канали сповіщень редагуються (раніше лише створювались і видалялись). Протокол доступу змінюється — з вимогою ввести пароль заново, бо секрет зашифрований під видом старого протоколу. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a4a44b1d76
commit
7fa8adc506
18 changed files with 1689 additions and 29 deletions
60
HISTORY.md
60
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 із конфігом
|
||||
вебхука — це інший обʼєкт, і чесніше завести новий.
|
||||
|
||||
**Протокол доступу** тепер змінюється. З одним застереженням: секрет
|
||||
зашифрований під видом старого протоколу, тож при зміні пароль треба
|
||||
ввести заново. Сервер відмовляє з поясненням, кнопка в формі теж не
|
||||
дає зберегти — краще сказати це до спроби, ніж після.
|
||||
|
||||
|
|
|
|||
30
db/migrations/0020_dashboard_perms.sql
Normal file
30
db/migrations/0020_dashboard_perms.sql
Normal file
|
|
@ -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;
|
||||
|
||||
-- Роль із «*» уже покриває все — її не чіпаємо.
|
||||
|
|
@ -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", "доступ не знайдено")
|
||||
|
|
|
|||
169
server/internal/httpapi/dashboards.go
Normal file
169
server/internal/httpapi/dashboards.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
228
server/internal/store/dashboards.go
Normal file
228
server/internal/store/dashboards.go
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="/dashboard" element={<Guard perm="dashboards:read" element={<DashboardPage />} />} />
|
||||
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
|
||||
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
|
||||
<Route path="/groups" element={<Guard perm="devices:read" element={<GroupsPage />} />} />
|
||||
|
|
|
|||
|
|
@ -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<void>(`/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<string, unknown>
|
||||
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<Dashboard>(`/api/v1/dashboards/${id}`),
|
||||
|
||||
saveDashboard: (id: string | null, d: Partial<Dashboard>) =>
|
||||
request<{ id: string }>(id ? `/api/v1/dashboards/${id}` : '/api/v1/dashboards', {
|
||||
method: id ? 'PUT' : 'POST',
|
||||
body: JSON.stringify(d),
|
||||
}),
|
||||
|
||||
deleteDashboard: (id: string) =>
|
||||
request<void>(`/api/v1/dashboards/${id}`, { method: 'DELETE' }),
|
||||
|
||||
setDefaultDashboard: (id: string) =>
|
||||
request<void>(`/api/v1/dashboards/${id}/default`, { method: 'POST' }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/70 md:items-center md:p-4">
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/70 md:items-center md:p-4"
|
||||
// Клік повз панель закриває. Перевіряємо, що натискання ПОЧАЛОСЬ
|
||||
// поза панеллю: інакше виділення тексту, доведене мишею за межі
|
||||
// вікна, закривало б форму разом із набраним.
|
||||
onMouseDown={(e) => {
|
||||
if (panel.current && !panel.current.contains(e.target as Node)) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={`max-h-[90dvh] w-full overflow-y-auto rounded-t-xl border border-slate-800
|
||||
bg-slate-900 md:rounded-xl ${wide ? 'md:max-w-3xl' : 'md:max-w-lg'}`}
|
||||
>
|
||||
<div className="flex items-center border-b border-slate-800 px-4 py-3">
|
||||
<div className="sticky top-0 z-10 flex items-center border-b border-slate-800 bg-slate-900 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-slate-100">{title}</h2>
|
||||
<button
|
||||
className="ml-auto px-2 text-slate-500 hover:text-slate-300"
|
||||
onClick={onClose}
|
||||
aria-label="Закрити"
|
||||
title="Закрити (Esc)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
|
|
|||
340
web/src/components/widgets/WidgetForm.tsx
Normal file
340
web/src/components/widgets/WidgetForm.tsx
Normal file
|
|
@ -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<void>
|
||||
}) {
|
||||
const cfg = (Array.isArray(widget?.queries) ? widget?.queries[0] : widget?.queries) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
const [kind, setKind] = useState<WidgetKind>(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<number[]>((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<DeviceSummary[]>([])
|
||||
const [series, setSeries] = useState<SeriesInfo[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(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 (
|
||||
<Modal title={widget ? 'Плитка' : 'Нова плитка'} onClose={onClose} wide>
|
||||
<div className="space-y-3">
|
||||
<Field label="Вид" hint={KINDS.find((k) => k.key === kind)?.hint}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as WidgetKind)}
|
||||
>
|
||||
{KINDS.map((k) => (
|
||||
<option key={k.key} value={k.key}>
|
||||
{k.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Заголовок" hint="Порожньо — підставиться вид плитки">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Ширина" hint="Із дванадцяти колонок">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={gridW}
|
||||
onChange={(e) => setGridW(Number(e.target.value))}
|
||||
>
|
||||
<option value={3}>чверть</option>
|
||||
<option value={4}>третина</option>
|
||||
<option value={6}>половина</option>
|
||||
<option value={8}>дві третини</option>
|
||||
<option value={12}>уся ширина</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{needsMetric(kind) && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Хост">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={deviceID}
|
||||
onChange={(e) => {
|
||||
setDeviceID(e.target.value)
|
||||
setSeriesIDs([])
|
||||
}}
|
||||
>
|
||||
<option value="">— оберіть —</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Період">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={range}
|
||||
onChange={(e) => setRange(e.target.value)}
|
||||
>
|
||||
{RANGES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{deviceID && (
|
||||
<Field
|
||||
label={single ? 'Метрика' : 'Метрики'}
|
||||
hint={single ? 'Одна: два значення в одне число не вміщуються' : undefined}
|
||||
>
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto rounded border border-slate-800 p-2">
|
||||
{series.length === 0 && (
|
||||
<li className="text-[11px] text-slate-600">
|
||||
Для цього хоста ще нічого не зібрано
|
||||
</li>
|
||||
)}
|
||||
{series.map((s) => (
|
||||
<li key={s.id}>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type={single ? 'radio' : 'checkbox'}
|
||||
checked={seriesIDs.includes(s.id)}
|
||||
onChange={() => toggleSeries(s.id)}
|
||||
/>
|
||||
<span className="truncate text-slate-200">{seriesLabel(s)}</span>
|
||||
{s.unit && <span className="text-xs text-slate-600">{s.unit}</span>}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{single && (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field label="Значення">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={agg}
|
||||
onChange={(e) => setAgg(e.target.value)}
|
||||
>
|
||||
{AGGS.map((a) => (
|
||||
<option key={a.key} value={a.key}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{kind === 'gauge' && (
|
||||
<>
|
||||
<Field label="Мінімум">
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
value={min}
|
||||
onChange={(e) => setMin(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Максимум">
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
value={max}
|
||||
onChange={(e) => setMax(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === 'alert_list' && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Рівень" hint="Порожньо — усі">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={severity}
|
||||
onChange={(e) => setSeverity(e.target.value)}
|
||||
>
|
||||
<option value="">усі</option>
|
||||
<option value="warning">попередження</option>
|
||||
<option value="average">середній</option>
|
||||
<option value="high">високий</option>
|
||||
<option value="disaster">аварія</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Скільки показувати">
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind === 'device_grid' && (
|
||||
<Field label="Скільки хостів" hint="Понад сотню квадратиків уже не читається">
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min="1"
|
||||
max="300"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{kind === 'text' && (
|
||||
<Field label="Текст">
|
||||
<textarea
|
||||
className={`${inputClass} h-24`}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || (needsMetric(kind) && seriesIDs.length === 0)}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const queries: Record<string, unknown> = {}
|
||||
if (needsMetric(kind)) {
|
||||
queries.device_id = deviceID
|
||||
queries.series_ids = seriesIDs
|
||||
queries.range = range
|
||||
if (single) queries.agg = agg
|
||||
if (kind === 'gauge') {
|
||||
queries.min = Number(min) || 0
|
||||
queries.max = Number(max) || 100
|
||||
}
|
||||
}
|
||||
if (kind === 'alert_list') {
|
||||
if (severity) queries.severity = severity
|
||||
queries.limit = Number(limit) || 8
|
||||
}
|
||||
if (kind === 'device_grid') queries.limit = Number(limit) || 60
|
||||
if (kind === 'text') queries.text = text
|
||||
|
||||
await onSave({
|
||||
kind,
|
||||
title: title.trim(),
|
||||
grid_x: widget?.grid_x ?? 0,
|
||||
grid_y: widget?.grid_y ?? 0,
|
||||
grid_w: gridW,
|
||||
grid_h: widget?.grid_h ?? 4,
|
||||
queries: [queries],
|
||||
})
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Зберегти'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
244
web/src/components/widgets/WidgetView.tsx
Normal file
244
web/src/components/widgets/WidgetView.tsx
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { api } from '../../api/client'
|
||||
import { MetricChart, fmtValue } from '../MetricChart'
|
||||
import { StatusBadge, ago } from '../ui'
|
||||
import type { LiveAlerts } from '../../hooks/useAlerts'
|
||||
import type { DeviceSummary, MetricResult, Widget } from '../../types'
|
||||
|
||||
type Query = {
|
||||
device_id?: string
|
||||
series_ids?: number[]
|
||||
range?: string
|
||||
/** Для stat/gauge: що робити з рядом — останнє, середнє, максимум. */
|
||||
agg?: 'last' | 'avg' | 'max' | 'min'
|
||||
/** Для gauge: межі шкали. */
|
||||
min?: number
|
||||
max?: number
|
||||
/** Для alert_list / device_grid. */
|
||||
severity?: string
|
||||
limit?: number
|
||||
text?: string
|
||||
}
|
||||
|
||||
const q = (w: Widget): Query => {
|
||||
const raw = w.queries
|
||||
if (Array.isArray(raw)) return (raw[0] as Query) ?? {}
|
||||
return (raw as Query) ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна плитка дашборда.
|
||||
*
|
||||
* Кожен вид сам вирішує, що йому потрібно, і сам це вантажить. Спільний
|
||||
* завантажувач на дашборд виглядав би охайніше, але змусив би плитку
|
||||
* «текст» чекати на запит метрик сусідки.
|
||||
*/
|
||||
export function WidgetView({ widget, alerts }: { widget: Widget; alerts: LiveAlerts }) {
|
||||
switch (widget.kind) {
|
||||
case 'timeseries':
|
||||
return <TimeseriesWidget widget={widget} />
|
||||
case 'stat':
|
||||
return <StatWidget widget={widget} />
|
||||
case 'gauge':
|
||||
return <GaugeWidget widget={widget} />
|
||||
case 'alert_list':
|
||||
return <AlertListWidget widget={widget} alerts={alerts} />
|
||||
case 'device_grid':
|
||||
return <DeviceGridWidget widget={widget} />
|
||||
case 'text':
|
||||
return (
|
||||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-slate-400">
|
||||
{q(widget).text || '—'}
|
||||
</p>
|
||||
)
|
||||
default:
|
||||
return <Empty>Цей вид плитки ще не малюється</Empty>
|
||||
}
|
||||
}
|
||||
|
||||
function Empty({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[6rem] items-center justify-center text-center text-[11px] text-slate-600">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useMetrics(widget: Widget) {
|
||||
const [data, setData] = useState<MetricResult | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const cfg = q(widget)
|
||||
const key = `${cfg.device_id}|${(cfg.series_ids ?? []).join(',')}|${cfg.range}`
|
||||
|
||||
useEffect(() => {
|
||||
if (!cfg.device_id || !cfg.series_ids?.length) return
|
||||
let alive = true
|
||||
api
|
||||
.queryMetrics(cfg.device_id, cfg.series_ids, cfg.range ?? '6h', 200)
|
||||
.then((r) => alive && setData(r))
|
||||
.catch((e) => alive && setErr(e instanceof Error ? e.message : String(e)))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
// key стискає всі параметри запиту в один рядок: інакше масив
|
||||
// series_ids перестворювався б щорендеру й крутив запити по колу.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key])
|
||||
|
||||
return { data, err, cfg }
|
||||
}
|
||||
|
||||
function TimeseriesWidget({ widget }: { widget: Widget }) {
|
||||
const { data, err, cfg } = useMetrics(widget)
|
||||
if (err) return <Empty>{err}</Empty>
|
||||
if (!cfg.device_id || !cfg.series_ids?.length) return <Empty>Метрику не обрано</Empty>
|
||||
if (!data) return <Empty>Завантаження…</Empty>
|
||||
return <MetricChart series={data.series} />
|
||||
}
|
||||
|
||||
/** Останнє (або агреговане) значення одного ряду великим шрифтом. */
|
||||
function StatWidget({ widget }: { widget: Widget }) {
|
||||
const { data, err, cfg } = useMetrics(widget)
|
||||
if (err) return <Empty>{err}</Empty>
|
||||
if (!data?.series.length) return <Empty>Метрику не обрано</Empty>
|
||||
|
||||
const s = data.series[0]
|
||||
const v = aggregate(
|
||||
s.points.map((p) => p.v),
|
||||
cfg.agg ?? 'last',
|
||||
)
|
||||
if (v == null) return <Empty>Даних немає</Empty>
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<span className="text-3xl font-semibold tabular-nums text-slate-100">{fmtValue(v)}</span>
|
||||
<span className="mt-1 text-[11px] text-slate-500">
|
||||
{s.metric_key}
|
||||
{s.unit ? `, ${s.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Шкала: те саме число, але з межами й кольором за порогом. */
|
||||
function GaugeWidget({ widget }: { widget: Widget }) {
|
||||
const { data, err, cfg } = useMetrics(widget)
|
||||
if (err) return <Empty>{err}</Empty>
|
||||
if (!data?.series.length) return <Empty>Метрику не обрано</Empty>
|
||||
|
||||
const s = data.series[0]
|
||||
const v = aggregate(
|
||||
s.points.map((p) => p.v),
|
||||
cfg.agg ?? 'last',
|
||||
)
|
||||
if (v == null) return <Empty>Даних немає</Empty>
|
||||
|
||||
const min = cfg.min ?? 0
|
||||
const max = cfg.max ?? 100
|
||||
const pct = Math.max(0, Math.min(1, (v - min) / Math.max(1e-9, max - min)))
|
||||
// Пороги ті самі, що звично читаються на будь-якій шкалі завантаження:
|
||||
// до 70 % спокійно, до 90 % варто глянути, далі погано.
|
||||
const color = pct >= 0.9 ? '#ef4444' : pct >= 0.7 ? '#f59e0b' : '#34d399'
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-center gap-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-2xl font-semibold tabular-nums text-slate-100">{fmtValue(v)}</span>
|
||||
<span className="text-[11px] text-slate-500">{s.unit}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-800">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{ width: `${pct * 100}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] tabular-nums text-slate-600">
|
||||
<span>{fmtValue(min)}</span>
|
||||
<span>{fmtValue(max)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertListWidget({ widget, alerts }: { widget: Widget; alerts: LiveAlerts }) {
|
||||
const cfg = q(widget)
|
||||
const limit = cfg.limit ?? 8
|
||||
const list = alerts.alerts
|
||||
.filter((a) => !cfg.severity || a.severity === cfg.severity)
|
||||
.slice(0, limit)
|
||||
|
||||
if (list.length === 0) return <Empty>Активних алертів немає</Empty>
|
||||
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{list.map((a) => (
|
||||
<li key={a.id} className="flex items-center gap-2 text-xs">
|
||||
<StatusBadge status={a.severity} />
|
||||
<span className="truncate text-slate-300">{a.device_name || a.title}</span>
|
||||
<span className="ml-auto shrink-0 text-[11px] text-slate-600">{ago(a.started_at)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceGridWidget({ widget }: { widget: Widget }) {
|
||||
const [devices, setDevices] = useState<DeviceSummary[]>([])
|
||||
const cfg = q(widget)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
api
|
||||
.listDevices()
|
||||
.then((d) => alive && setDevices(d))
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const list = devices.slice(0, cfg.limit ?? 60)
|
||||
if (list.length === 0) return <Empty>Хостів немає</Empty>
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{list.map((d) => (
|
||||
<span
|
||||
key={d.id}
|
||||
title={`${d.name} — ${d.status}`}
|
||||
className={`h-3.5 w-3.5 rounded-sm ${
|
||||
d.status === 'up'
|
||||
? 'bg-emerald-500'
|
||||
: d.status === 'down'
|
||||
? 'bg-red-500'
|
||||
: d.status === 'warning'
|
||||
? 'bg-amber-500'
|
||||
: 'bg-slate-600'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Згортання ряду в одне число.
|
||||
*
|
||||
* Пропуски викидаються, а не вважаються нулями: «середнє 0» на хості,
|
||||
* який просто мовчав, — це вигадка.
|
||||
*/
|
||||
function aggregate(values: (number | null)[], how: string): number | null {
|
||||
const v = values.filter((x): x is number => x != null)
|
||||
if (v.length === 0) return null
|
||||
switch (how) {
|
||||
case 'avg':
|
||||
return v.reduce((a, b) => a + b, 0) / v.length
|
||||
case 'max':
|
||||
return Math.max(...v)
|
||||
case 'min':
|
||||
return Math.min(...v)
|
||||
default:
|
||||
return v[v.length - 1]
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ export function ChannelsPage() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
const [editing, setEditing] = useState<Channel | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [testing, setTesting] = useState<string | null>(null)
|
||||
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; error?: string } | null>(
|
||||
|
|
@ -159,6 +160,7 @@ export function ChannelsPage() {
|
|||
>
|
||||
{testing === c.id ? '…' : 'Перевірити'}
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(c)}>Змінити</Button>
|
||||
<Button
|
||||
kind="danger"
|
||||
title="Видалити канал разом із його секретом"
|
||||
|
|
@ -189,11 +191,16 @@ export function ChannelsPage() {
|
|||
)}
|
||||
</PageBody>
|
||||
|
||||
{creating && (
|
||||
{(creating || editing) && (
|
||||
<ChannelForm
|
||||
onClose={() => setCreating(false)}
|
||||
channel={editing}
|
||||
onClose={() => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
}}
|
||||
onSaved={async () => {
|
||||
setCreating(false)
|
||||
setEditing(null)
|
||||
await reload()
|
||||
}}
|
||||
/>
|
||||
|
|
@ -203,24 +210,37 @@ export function ChannelsPage() {
|
|||
)
|
||||
}
|
||||
|
||||
function ChannelForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => Promise<void> }) {
|
||||
const [kind, setKind] = useState('telegram')
|
||||
const [name, setName] = useState('')
|
||||
const [minSeverity, setMinSeverity] = useState<Severity>('warning')
|
||||
function ChannelForm({
|
||||
channel,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
channel: Channel | null
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}) {
|
||||
const cfg = (channel?.config ?? {}) as Record<string, unknown>
|
||||
|
||||
const [kind, setKind] = useState(channel?.kind ?? 'telegram')
|
||||
const [name, setName] = useState(channel?.name ?? '')
|
||||
const [minSeverity, setMinSeverity] = useState<Severity>(
|
||||
(channel?.min_severity as Severity) ?? 'warning',
|
||||
)
|
||||
const [secret, setSecret] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
// Telegram
|
||||
const [chatID, setChatID] = useState('')
|
||||
const [chatID, setChatID] = useState(String(cfg.chat_id ?? ''))
|
||||
// Webhook і похідні
|
||||
const [url, setUrl] = useState('')
|
||||
const [url, setUrl] = useState(String(cfg.url ?? ''))
|
||||
// Email
|
||||
const [host, setHost] = useState('')
|
||||
const [port, setPort] = useState('587')
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const [user, setUser] = useState('')
|
||||
const [host, setHost] = useState(String(cfg.host ?? ''))
|
||||
const [port, setPort] = useState(String(cfg.port ?? 587))
|
||||
const [from, setFrom] = useState(String(cfg.from ?? ''))
|
||||
const [to, setTo] = useState(Array.isArray(cfg.to) ? (cfg.to as string[]).join(', ') : '')
|
||||
const [user, setUser] = useState(String(cfg.user ?? ''))
|
||||
|
||||
|
||||
const isWebhookish = ['webhook', 'slack', 'discord', 'mattermost'].includes(kind)
|
||||
|
||||
|
|
@ -243,12 +263,13 @@ function ChannelForm({ onClose, onSaved }: { onClose: () => void; onSaved: () =>
|
|||
user,
|
||||
}
|
||||
|
||||
await api.createChannel({
|
||||
await api.saveChannel(channel?.id ?? null, {
|
||||
kind,
|
||||
name: name.trim(),
|
||||
config,
|
||||
min_severity: minSeverity,
|
||||
secret,
|
||||
enabled: channel?.enabled ?? true,
|
||||
})
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
|
|
@ -259,11 +280,19 @@ function ChannelForm({ onClose, onSaved }: { onClose: () => void; onSaved: () =>
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal title="Новий канал" onClose={onClose}>
|
||||
<Modal title={channel ? `Канал: ${channel.name}` : 'Новий канал'} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Тип">
|
||||
<select className={inputClass} value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={kind}
|
||||
// Вид каналу не змінюється: telegram із конфігом вебхука —
|
||||
// це інший обʼєкт, і чесніше завести новий, ніж мовчки
|
||||
// лишити несумісні поля.
|
||||
disabled={channel !== null}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
{Object.keys(kindLabel).map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{kindLabel[k]}
|
||||
|
|
|
|||
|
|
@ -198,12 +198,15 @@ function CredentialForm({
|
|||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field
|
||||
label="Протокол"
|
||||
hint={editingExisting ? 'Змінити не можна — заведіть новий' : undefined}
|
||||
hint={
|
||||
editingExisting && proto !== credential?.proto
|
||||
? 'Пароль доведеться ввести заново'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={proto}
|
||||
disabled={editingExisting}
|
||||
onChange={(e) => {
|
||||
setProto(e.target.value)
|
||||
const p = PROTOS.find((x) => x.key === e.target.value)
|
||||
|
|
@ -263,7 +266,15 @@ function CredentialForm({
|
|||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !name.trim() || (!editingExisting && !secret)}
|
||||
disabled={
|
||||
busy ||
|
||||
!name.trim() ||
|
||||
(!editingExisting && !secret) ||
|
||||
// Секрет зашифрований під видом старого протоколу: після
|
||||
// зміни збережений стає непридатним, і сервер його не
|
||||
// прийме. Краще сказати це кнопкою, ніж помилкою.
|
||||
(editingExisting && proto !== credential?.proto && !secret)
|
||||
}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
|
|
@ -271,6 +282,7 @@ function CredentialForm({
|
|||
if (credential) {
|
||||
await api.updateCredential(credential.id, {
|
||||
name: name.trim(),
|
||||
proto,
|
||||
username,
|
||||
port,
|
||||
secret,
|
||||
|
|
|
|||
340
web/src/pages/DashboardPage.tsx
Normal file
340
web/src/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import { useAlerts } from '../hooks/useAlerts'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Confirm,
|
||||
ErrorNote,
|
||||
Field,
|
||||
Modal,
|
||||
PageBody,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
inputClass,
|
||||
plural,
|
||||
} from '../components/ui'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import { WidgetView } from '../components/widgets/WidgetView'
|
||||
import { WidgetForm } from '../components/widgets/WidgetForm'
|
||||
import type { Dashboard, Widget } from '../types'
|
||||
|
||||
/**
|
||||
* Дашборд: сторінка з плитками.
|
||||
*
|
||||
* Сітка — 12 колонок, як у більшості подібних систем: 12 ділиться на 2,
|
||||
* 3, 4 і 6, тож половина, третина й чверть ширини задаються цілими
|
||||
* числами. Розкладка зберігається в grid_x/grid_y/grid_w/grid_h.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const canWrite = session.can('dashboards:write')
|
||||
const alerts = useAlerts()
|
||||
|
||||
const [list, setList] = useState<Dashboard[]>([])
|
||||
const [current, setCurrent] = useState<Dashboard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [widgetAt, setWidgetAt] = useState<number | null>(null)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
|
||||
const open = useCallback(async (id: string) => {
|
||||
try {
|
||||
setCurrent(await api.getDashboard(id))
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const reload = useCallback(
|
||||
async (selectID?: string) => {
|
||||
try {
|
||||
const d = await api.listDashboards()
|
||||
setList(d)
|
||||
const pick = selectID ?? current?.id ?? d.find((x) => x.is_default)?.id ?? d[0]?.id
|
||||
if (pick) await open(pick)
|
||||
else setCurrent(null)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[current?.id, open],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
// Один раз на монтування: reload тримає id поточного дашборда, і
|
||||
// залежність від нього крутила б запити по колу.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Автооновлення. Інтервал задається на дашборді: NOC-екран на стіні
|
||||
// і сторінка, яку відкрили на хвилину, потребують різного.
|
||||
useEffect(() => {
|
||||
if (!current || editing) return
|
||||
const ms = Math.max(10, current.refresh_sec) * 1000
|
||||
const t = setInterval(() => void open(current.id), ms)
|
||||
return () => clearInterval(t)
|
||||
}, [current, editing, open])
|
||||
|
||||
async function saveWidgets(widgets: Widget[]) {
|
||||
if (!current) return
|
||||
await api.saveDashboard(current.id, {
|
||||
name: current.name,
|
||||
slug: current.slug,
|
||||
refresh_sec: current.refresh_sec,
|
||||
widgets,
|
||||
})
|
||||
await open(current.id)
|
||||
}
|
||||
|
||||
if (loading) return <Spinner />
|
||||
|
||||
const widgets = current?.widgets ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={current?.name ?? 'Дашборди'}
|
||||
subtitle={
|
||||
current
|
||||
? `${plural(widgets.length, 'плитка', 'плитки', 'плиток')} · оновлення кожні ${current.refresh_sec} с`
|
||||
: 'Зведення по всьому, що моніториться'
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{list.length > 0 && (
|
||||
<select
|
||||
className={`${inputClass} w-44 md:w-56`}
|
||||
value={current?.id ?? ''}
|
||||
onChange={(e) => void open(e.target.value)}
|
||||
>
|
||||
{list.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
{d.is_default ? ' ★' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{canWrite && current && (
|
||||
<Button
|
||||
kind={editing ? 'primary' : 'default'}
|
||||
onClick={() => setEditing((v) => !v)}
|
||||
title="Додавати, змінювати й прибирати плитки"
|
||||
>
|
||||
{editing ? 'Готово' : 'Редагувати'}
|
||||
</Button>
|
||||
)}
|
||||
{canWrite && <Button onClick={() => setCreating(true)}>+ Дашборд</Button>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageBody className="space-y-4">
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
{!current ? (
|
||||
<p className="py-12 text-center text-xs leading-relaxed text-slate-600">
|
||||
Дашбордів ще немає. Створіть перший — він стане головним.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{editing && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded border border-sky-900/60 bg-sky-950/20 p-2.5 text-xs text-sky-200">
|
||||
<span>Режим редагування: плитки можна додавати, змінювати й прибирати.</span>
|
||||
<Button kind="primary" onClick={() => setWidgetAt(widgets.length)}>
|
||||
+ Плитка
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити дашборд',
|
||||
message: (
|
||||
<>
|
||||
Дашборд <b>{current.name}</b> буде видалено разом з усіма плитками.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
'Самі дані не зникнуть — зникне тільки ця сторінка й розкладка на ній.',
|
||||
onConfirm: async () => {
|
||||
await api.deleteDashboard(current.id)
|
||||
setCurrent(null)
|
||||
await reload()
|
||||
},
|
||||
})
|
||||
}
|
||||
kind="danger"
|
||||
>
|
||||
Видалити дашборд
|
||||
</Button>
|
||||
{!current.is_default && (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await api.setDefaultDashboard(current.id)
|
||||
await reload(current.id)
|
||||
}}
|
||||
>
|
||||
Зробити головним
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widgets.length === 0 ? (
|
||||
<p className="py-12 text-center text-xs leading-relaxed text-slate-600">
|
||||
Плиток ще немає.
|
||||
{canWrite && ' Натисніть «Редагувати», щоб додати першу.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-12">
|
||||
{widgets.map((w, i) => (
|
||||
<div
|
||||
key={w.id ?? i}
|
||||
// На вузькому екрані сітка не має сенсу: плитка на
|
||||
// третину ширини телефона нечитабельна, тому колонки
|
||||
// з'являються лише від xl.
|
||||
className={`xl:col-span-${w.grid_w} min-w-0`}
|
||||
style={{ gridColumn: `span ${Math.min(12, Math.max(2, w.grid_w))}` }}
|
||||
>
|
||||
<Card className="flex h-full flex-col p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="truncate text-xs font-medium text-slate-300">
|
||||
{w.title || defaultTitle(w)}
|
||||
</span>
|
||||
{editing && (
|
||||
<span className="ml-auto flex gap-1">
|
||||
<Button onClick={() => setWidgetAt(i)}>Змінити</Button>
|
||||
<Button
|
||||
kind="danger"
|
||||
onClick={() =>
|
||||
void saveWidgets(widgets.filter((_, n) => n !== i))
|
||||
}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<WidgetView widget={w} alerts={alerts} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PageBody>
|
||||
|
||||
{widgetAt !== null && current && (
|
||||
<WidgetForm
|
||||
widget={widgets[widgetAt] ?? null}
|
||||
onClose={() => setWidgetAt(null)}
|
||||
onSave={async (w) => {
|
||||
const next = [...widgets]
|
||||
if (widgetAt >= next.length) next.push(w)
|
||||
else next[widgetAt] = { ...next[widgetAt], ...w }
|
||||
await saveWidgets(next)
|
||||
setWidgetAt(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<DashboardForm
|
||||
onClose={() => setCreating(false)}
|
||||
onSaved={async (id) => {
|
||||
setCreating(false)
|
||||
await reload(id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const KIND_TITLE: Record<string, string> = {
|
||||
timeseries: 'Графік',
|
||||
stat: 'Число',
|
||||
gauge: 'Шкала',
|
||||
alert_list: 'Алерти',
|
||||
device_grid: 'Хости',
|
||||
map: 'Мапа',
|
||||
text: 'Текст',
|
||||
}
|
||||
|
||||
function defaultTitle(w: Widget): string {
|
||||
return KIND_TITLE[w.kind] ?? w.kind
|
||||
}
|
||||
|
||||
function DashboardForm({
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSaved: (id: string) => void | Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [refresh, setRefresh] = useState('30')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Modal title="Новий дашборд" onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<Field label="Назва">
|
||||
<input
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Оновлювати кожні, с" hint="NOC-екран на стіні і вкладка на хвилину — різне">
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min="10"
|
||||
value={refresh}
|
||||
onChange={(e) => setRefresh(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !name.trim()}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const r = await api.saveDashboard(null, {
|
||||
name: name.trim(),
|
||||
refresh_sec: Number(refresh) || 30,
|
||||
widgets: [],
|
||||
})
|
||||
await onSaved(r.id)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Створення…' : 'Створити'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ export type Permission =
|
|||
| 'users:read' | 'users:write'
|
||||
| 'billing:read' | 'billing:manage'
|
||||
| 'audit:read' | 'settings:write'
|
||||
| 'dashboards:read' | 'dashboards:write'
|
||||
|
||||
export interface TenantMembership {
|
||||
tenant_id: string
|
||||
|
|
@ -539,3 +540,39 @@ export type MetricResult = {
|
|||
series: MetricSeries[]
|
||||
}
|
||||
|
||||
// --- дашборди ---
|
||||
|
||||
export type WidgetKind =
|
||||
| 'timeseries'
|
||||
| 'stat'
|
||||
| 'gauge'
|
||||
| 'alert_list'
|
||||
| 'device_grid'
|
||||
| 'map'
|
||||
| 'text'
|
||||
|
||||
export type Widget = {
|
||||
id?: string
|
||||
kind: WidgetKind
|
||||
title?: string
|
||||
grid_x: number
|
||||
grid_y: number
|
||||
grid_w: number
|
||||
grid_h: number
|
||||
map_id?: string
|
||||
/** Що показувати. Форма залежить від виду плитки. */
|
||||
queries?: unknown
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type Dashboard = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
kind: string
|
||||
refresh_sec: number
|
||||
is_default: boolean
|
||||
widgets?: Widget[]
|
||||
widget_count: number
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue