Дашборди, Esc/клік повз вікно, редагування каналів і протоколів

Дашборди: схема core.dashboards лежала з Етапу 1 без жодного рядка коду.
Сітка на 12 колонок, шість видів плиток (графік, число, шкала, список
алертів, сітка хостів, текст), автооновлення з інтервалом дашборда,
режим редагування. Права окремі від maps:* — дашборд збирає дані з
усього тенанта.

Esc і клік повз панель закривають будь-яке вікно. Слухач на document, бо
фокус може стояти де завгодно; закриття за mousedown, а не click, щоб
виділення тексту, доведене за межі вікна, не втрачало набране.

Канали сповіщень редагуються (раніше лише створювались і видалялись).
Протокол доступу змінюється — з вимогою ввести пароль заново, бо секрет
зашифрований під видом старого протоколу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 18:30:28 +03:00
parent a4a44b1d76
commit 7fa8adc506
18 changed files with 1689 additions and 29 deletions

View file

@ -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 із конфігом
вебхука — це інший обʼєкт, і чесніше завести новий.
**Протокол доступу** тепер змінюється. З одним застереженням: секрет
зашифрований під видом старого протоколу, тож при зміні пароль треба
ввести заново. Сервер відмовляє з поясненням, кнопка в формі теж не
дає зберегти — краще сказати це до спроби, ніж після.

View 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;
-- Роль із «*» уже покриває все — її не чіпаємо.

View file

@ -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", "доступ не знайдено")

View 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
}

View file

@ -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))

View file

@ -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
})
}

View file

@ -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
}

View 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
})
}

View file

@ -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 />} />} />

View file

@ -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' }),
}

View file

@ -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' },

View file

@ -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>

View 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>
)
}

View 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]
}
}

View file

@ -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]}

View file

@ -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,

View 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>
)
}

View file

@ -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
}