Картка хоста на вкладках, графіки в шаблонах, пошукові списки
Форма хоста стала вкладками: Хост, Шаблони, Доступи, Збір конфігів, Ручні перевірки. Інтервалів опитування тут більше немає — вони живуть у шаблонах, і два джерела правди про одне число розійшлися б саме тоді, коли треба швидко зрозуміти, чому хост опитується не так, як написано. Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек. Групи, шаблони й доступи обираються пошуком із чипами: список галочок працює на пʼяти позиціях і перестає працювати на пʼятдесяти. Графіки описуються в шаблоні (tpl.graphs): шість видів, жорсткі межі осей, перелік ключів метрик замість посилань на елементи — графік має право показувати й те, що прийшло з іншого шаблону. Графік без жодного знайденого ряду не показується: порожня рамка з підписом — це обіцянка даних, яких немає. Знайдено живим прогоном і виправлено: обробник виділення на полотні створював новий масив щорендеру й зациклював оновлення. Цикл зʼїдав головний потік, і мертвими ставали всі сторінки, не лише мапа. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5aba75ccc3
commit
387980d655
18 changed files with 1488 additions and 381 deletions
77
HISTORY.md
77
HISTORY.md
|
|
@ -1754,3 +1754,80 @@ snmp-host if.in_bps · eth0 = 18.5k bps, sys.processes = 41,
|
|||
ввести заново. Сервер відмовляє з поясненням, кнопка в формі теж не
|
||||
дає зберегти — краще сказати це до спроби, ніж після.
|
||||
|
||||
---
|
||||
|
||||
## Картка хоста в стилі Zabbix
|
||||
|
||||
Форма була однією довгою простинею з інтервалами опитування посередині.
|
||||
Стала вкладками: Хост, Шаблони, Доступи, Збір конфігів, Ручні перевірки —
|
||||
у порядку частоти звернень, а не у порядку появи в коді.
|
||||
|
||||
**Інтервалів опитування тут більше немає.** Вони живуть у шаблонах. Два
|
||||
джерела правди про одне число рано чи пізно розійдуться, і розійдуться
|
||||
саме тоді, коли треба буде швидко зрозуміти, чому хост опитується не
|
||||
так, як написано.
|
||||
|
||||
**Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек.**
|
||||
Перевірено: створений через форму хост дістав `icmp.ping` з інтервалом
|
||||
30 с саме від шаблону, разом із групою, обраною в пікері.
|
||||
|
||||
**Групи, шаблони й доступи обираються пошуком.** Список галочок працює
|
||||
на пʼяти позиціях і перестає працювати на пʼятдесяти. Обране показане
|
||||
чипами зверху, тож вибір видно цілком, не гортаючи. Backspace у
|
||||
порожньому полі прибирає останній чип — так поводяться всі поля з
|
||||
тегами, і руки це вже знають.
|
||||
|
||||
**Ручні перевірки лишились окремою вкладкою.** Вони потрібні для
|
||||
одиничних випадків, але це вже не типовий шлях, і в першому екрані їм
|
||||
нема чого робити.
|
||||
|
||||
---
|
||||
|
||||
## Графіки описуються в шаблоні
|
||||
|
||||
«Завантаження каналу — це вхід і вихід на одній осі» — властивість класу
|
||||
пристроїв, а не окремого хоста. Малювати той самий графік руками на
|
||||
кожному означає повторювати одне рішення стільки разів, скільки в мережі
|
||||
заліза.
|
||||
|
||||
`tpl.graphs` тримає назву, вид і перелік **ключів метрик**, а не
|
||||
посилань на елементи шаблону: графік має право показувати й те, що
|
||||
прийшло з іншого шаблону — саме так виглядає «трафік поруч із помилками
|
||||
на тому ж порту».
|
||||
|
||||
Види: лінії, з заливкою, з накопиченням, стовпчики, шкала, число.
|
||||
|
||||
**Накопичення обирає людина, а не евристика.** Сума має сенс там, де
|
||||
вона сама щось означає — трафік по портах, місце на дисках. На
|
||||
відсотках завантаження це була б нісенітниця.
|
||||
|
||||
**Жорсткі межі осі з шаблону перекривають пораховані.** «Завантаження
|
||||
від 0 до 100» має виглядати однаково на всіх хостах, інакше графіки не
|
||||
порівняти очима.
|
||||
|
||||
**Графік без жодного знайденого ряду не показується.** Порожня рамка з
|
||||
підписом «Памʼять» на хості, де памʼять не збирається, — це обіцянка
|
||||
даних, яких немає.
|
||||
|
||||
### Перевірено наживо
|
||||
|
||||
```
|
||||
snmp-host «Час роботи» (stat) → 877.6k s
|
||||
gateway «Втрати пакетів» (area) → вісь жорстко 0–100 з шаблону
|
||||
gateway «Час відгуку» (line) → дві лінії, вісь 0–2.6 з даних
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Знайдено живим прогоном: полотно вбивало інтерфейс
|
||||
|
||||
Після додавання групового виділення сторінка перестала реагувати —
|
||||
`Maximum update depth exceeded` у `SelectionListener`. React Flow
|
||||
викликає `onSelectionChange` на кожному рендері, а мій обробник щоразу
|
||||
створював новий масив: колбек міняв стан сторінки, сторінка
|
||||
перемальовувала полотно, полотно кликало колбек. Цикл зʼїдав головний
|
||||
потік, і мертвими ставали **всі** сторінки, не лише мапа.
|
||||
|
||||
Тепер набір віддається назовні лише коли справді змінився — порівняння
|
||||
за склеєним рядком ідентифікаторів.
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ POST /api/v1/roles кастомна роль із набором
|
|||
> **Додано 2026-08-24:** шаблон описує перевірки будь-якого типу (не лише
|
||||
> OID), імпорт/експорт глобальний і поштучний, ручні інтервали.
|
||||
>
|
||||
> **Додано 2026-08-24:** графіки описуються в шаблоні (шість видів,
|
||||
> жорсткі межі осей), картка хоста на вкладках без інтервалів.
|
||||
>
|
||||
> **Лишилось:** автопризначення за `sysObjectID`, прототипи шаблонів,
|
||||
> `snmp.walk` як тип елемента (таблиці з динамічним індексом), тригери
|
||||
> всередині шаблону.
|
||||
|
|
|
|||
79
db/migrations/0022_template_graphs.sql
Normal file
79
db/migrations/0022_template_graphs.sql
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0022_template_graphs.sql
|
||||
-- Графіки описуються в шаблоні разом із метриками.
|
||||
--
|
||||
-- Причина та сама, що для самих метрик: «завантаження каналу — це
|
||||
-- if.in_bps і if.out_bps на одній осі» — це властивість класу
|
||||
-- пристроїв, а не окремого хоста. Малювати той самий графік руками на
|
||||
-- кожному хості означає повторювати одне рішення стільки разів,
|
||||
-- скільки в мережі заліза.
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TYPE tpl.graph_kind AS ENUM (
|
||||
'line', -- звичайні лінії
|
||||
'area', -- лінії із заливкою
|
||||
'stacked', -- заливка з накопиченням: сума має сенс (трафік по портах)
|
||||
'bar', -- стовпчики: рідкі виміри, де лінія бреше про проміжок
|
||||
'gauge', -- одне значення зі шкалою
|
||||
'stat' -- одне значення числом
|
||||
);
|
||||
|
||||
CREATE TABLE tpl.graphs (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
template_id uuid NOT NULL REFERENCES tpl.templates(id) ON DELETE CASCADE,
|
||||
key core.slug NOT NULL,
|
||||
name text NOT NULL,
|
||||
kind tpl.graph_kind NOT NULL DEFAULT 'line',
|
||||
-- Метрики на графіку: ["cpu.util_pct","mem.available_bytes"].
|
||||
--
|
||||
-- Ключі метрик, а не посилання на tpl.items: графік має право
|
||||
-- показувати й те, що прийшло з іншого шаблону, — саме так і
|
||||
-- виглядає «трафік поруч із помилками на тому ж порту».
|
||||
metric_keys jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
-- Осі, кольори, межі, пороги: {"min":0,"max":100,"unit":"%"}
|
||||
options jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
position int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (template_id, key)
|
||||
);
|
||||
CREATE INDEX tpl_graphs_template_idx ON tpl.graphs (template_id);
|
||||
|
||||
ALTER TABLE tpl.graphs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tpl.graphs FORCE ROW LEVEL SECURITY;
|
||||
|
||||
-- Видимість успадковується від шаблону — власного tenant_id у графіка
|
||||
-- немає з тієї ж причини, що і в елементів: він завжди дорівнював би
|
||||
-- шаблоновому, а дублювання ключа ізоляції розходиться.
|
||||
CREATE POLICY graphs_follow_template ON tpl.graphs
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM tpl.templates t
|
||||
WHERE t.id = tpl.graphs.template_id
|
||||
AND (t.tenant_id IS NULL OR t.tenant_id = core.current_tenant())
|
||||
))
|
||||
WITH CHECK (EXISTS (
|
||||
SELECT 1 FROM tpl.templates t
|
||||
WHERE t.id = tpl.graphs.template_id AND t.tenant_id = core.current_tenant()
|
||||
));
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON tpl.graphs TO netpulse_app, netpulse_worker;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Графіки вбудованих шаблонів
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
INSERT INTO tpl.graphs (template_id, key, name, kind, metric_keys, options) VALUES
|
||||
('00000000-0000-0000-0000-0000000000c3', 'memory', 'Пам''ять', 'area',
|
||||
'["mem.available_bytes","mem.total_bytes"]'::jsonb, '{"unit":"B"}'::jsonb),
|
||||
('00000000-0000-0000-0000-0000000000c3', 'load', 'Середнє навантаження', 'line',
|
||||
'["cpu.load1","cpu.load5"]'::jsonb, '{"min":0}'::jsonb),
|
||||
('00000000-0000-0000-0000-0000000000c4', 'cpu', 'Завантаження CPU', 'gauge',
|
||||
'["cpu.util_pct"]'::jsonb, '{"min":0,"max":100,"unit":"%"}'::jsonb),
|
||||
('00000000-0000-0000-0000-0000000000c4', 'temp', 'Температура плати', 'line',
|
||||
'["sensor.temp_c"]'::jsonb, '{"unit":"°C"}'::jsonb),
|
||||
('00000000-0000-0000-0000-0000000000c5', 'rtt', 'Час відгуку', 'line',
|
||||
'["icmp.rtt_avg","icmp.jitter"]'::jsonb, '{"min":0,"unit":"ms"}'::jsonb),
|
||||
('00000000-0000-0000-0000-0000000000c5', 'loss', 'Втрати пакетів', 'area',
|
||||
'["icmp.loss_pct"]'::jsonb, '{"min":0,"max":100,"unit":"%"}'::jsonb),
|
||||
('00000000-0000-0000-0000-0000000000c1', 'uptime', 'Час роботи', 'stat',
|
||||
'["sys.uptime_sec"]'::jsonb, '{"unit":"s"}'::jsonb)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
|
@ -565,3 +565,21 @@ func (s *Server) handleDeleteCredential(w http.ResponseWriter, r *http.Request,
|
|||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetDeviceCredentials(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:read") {
|
||||
return
|
||||
}
|
||||
deviceID := r.PathValue("id")
|
||||
if !p.Scope().CanRead(deviceID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||||
return
|
||||
}
|
||||
ids, err := s.store.DeviceCredentialIDs(r.Context(), p.TenantID, deviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "доступи хоста", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"credential_ids": ids})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -128,9 +128,12 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.Handle("GET /api/v1/templates/{id}", s.authenticated(s.handleGetTemplate))
|
||||
mux.Handle("PUT /api/v1/templates/{id}", s.authenticated(s.handleSaveTemplate))
|
||||
mux.Handle("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate))
|
||||
mux.Handle("PUT /api/v1/templates/{id}/graphs", s.authenticated(s.handleSaveTemplateGraphs))
|
||||
mux.Handle("GET /api/v1/devices/{id}/graphs", s.authenticated(s.handleDeviceGraphs))
|
||||
mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates))
|
||||
mux.Handle("PUT /api/v1/devices/{id}/templates", s.authenticated(s.handleSetDeviceTemplates))
|
||||
|
||||
mux.Handle("GET /api/v1/devices/{id}/credentials", s.authenticated(s.handleGetDeviceCredentials))
|
||||
mux.Handle("GET /api/v1/credentials", s.authenticated(s.handleListCredentials))
|
||||
mux.Handle("POST /api/v1/credentials", s.authenticated(s.handleCreateCredential))
|
||||
mux.Handle("PATCH /api/v1/credentials/{id}", s.authenticated(s.handleUpdateCredential))
|
||||
|
|
|
|||
|
|
@ -273,3 +273,77 @@ func (s *Server) handleImportTemplates(w http.ResponseWriter, r *http.Request, p
|
|||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Графіки
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var validGraphKinds = map[string]bool{
|
||||
"line": true, "area": true, "stacked": true,
|
||||
"bar": true, "gauge": true, "stat": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleSaveTemplateGraphs(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Graphs []store.TemplateGraph `json:"graphs"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_json", "не вдалося прочитати тіло запиту")
|
||||
return
|
||||
}
|
||||
for i := range in.Graphs {
|
||||
g := &in.Graphs[i]
|
||||
g.Name = strings.TrimSpace(g.Name)
|
||||
g.Key = strings.TrimSpace(g.Key)
|
||||
if g.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "графік без назви")
|
||||
return
|
||||
}
|
||||
if g.Kind == "" {
|
||||
g.Kind = "line"
|
||||
}
|
||||
if !validGraphKinds[g.Kind] {
|
||||
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий вид графіка: "+g.Kind)
|
||||
return
|
||||
}
|
||||
if len(g.MetricKeys) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"графік «"+g.Name+"» не містить жодної метрики")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err := s.store.SaveTemplateGraphs(r.Context(), p.TenantID, r.PathValue("id"), in.Graphs)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrBuiltin) {
|
||||
writeError(w, http.StatusForbidden, "builtin",
|
||||
"графіки вбудованого шаблону не редагуються — зробіть копію")
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "графіки шаблону", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleDeviceGraphs — графіки, які дають шаблони цього хоста.
|
||||
func (s *Server) handleDeviceGraphs(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:read") {
|
||||
return
|
||||
}
|
||||
deviceID := r.PathValue("id")
|
||||
if !p.Scope().CanRead(deviceID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||||
return
|
||||
}
|
||||
list, err := s.store.DeviceGraphs(r.Context(), p.TenantID, deviceID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "графіки хоста", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"graphs": list})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -527,3 +527,33 @@ func secretKind(proto string) string {
|
|||
return "snmp_v3"
|
||||
}
|
||||
}
|
||||
|
||||
// DeviceCredentialIDs — які доступи прив'язані до хоста.
|
||||
//
|
||||
// Потрібне формі: показати порожній список там, де доступи задані,
|
||||
// означало б стерти їх першим же збереженням.
|
||||
func (s *Store) DeviceCredentialIDs(ctx context.Context, tenantID, deviceID string) ([]string, error) {
|
||||
ids := []string{}
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT dc.credential_id::text
|
||||
FROM inv.device_credentials dc
|
||||
JOIN inv.credentials c ON c.id = dc.credential_id
|
||||
WHERE dc.device_id = $1 AND c.tenant_id = $2
|
||||
ORDER BY dc.priority
|
||||
`, deviceID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return ids, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ type Template struct {
|
|||
Description string `json:"description,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
IsBuiltin bool `json:"is_builtin"`
|
||||
Items []TemplateItem `json:"items,omitempty"`
|
||||
Items []TemplateItem `json:"items,omitempty"`
|
||||
Graphs []TemplateGraph `json:"graphs,omitempty"`
|
||||
ItemCount int `json:"item_count"`
|
||||
DeviceCount int `json:"device_count"`
|
||||
}
|
||||
|
|
@ -109,6 +110,11 @@ func (s *Store) GetTemplate(ctx context.Context, tenantID, id string) (*Template
|
|||
}
|
||||
t.Items = items
|
||||
t.ItemCount = len(items)
|
||||
graphs, err := templateGraphs(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Graphs = graphs
|
||||
return tx.QueryRow(ctx, `
|
||||
SELECT count(*) FROM tpl.device_templates WHERE template_id = $1
|
||||
`, id).Scan(&t.DeviceCount)
|
||||
|
|
@ -646,3 +652,175 @@ func (s *Store) ImportTemplates(ctx context.Context, tenantID string, doc Templa
|
|||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Графіки шаблону
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// TemplateGraph — опис графіка.
|
||||
type TemplateGraph struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
MetricKeys []string `json:"metric_keys"`
|
||||
Options json.RawMessage `json:"options,omitempty"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
// DeviceGraph — графік, розгорнутий під конкретний хост.
|
||||
//
|
||||
// Ключі метрик перетворені на справжні ряди: шаблон каже «покажи
|
||||
// cpu.util_pct», а на хості це може бути один ряд або жоден.
|
||||
type DeviceGraph struct {
|
||||
TemplateGraph
|
||||
TemplateName string `json:"template_name"`
|
||||
SeriesIDs []int64 `json:"series_ids"`
|
||||
}
|
||||
|
||||
func templateGraphs(ctx context.Context, tx pgx.Tx, templateID string) ([]TemplateGraph, error) {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id::text, key, name, kind::text, metric_keys::text, options::text, position
|
||||
FROM tpl.graphs WHERE template_id = $1 ORDER BY position, name
|
||||
`, templateID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []TemplateGraph{}
|
||||
for rows.Next() {
|
||||
var g TemplateGraph
|
||||
var keys, opts string
|
||||
if err := rows.Scan(&g.ID, &g.Key, &g.Name, &g.Kind, &keys, &opts, &g.Position); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(keys), &g.MetricKeys); err != nil {
|
||||
g.MetricKeys = []string{}
|
||||
}
|
||||
if opts != "" && opts != "{}" {
|
||||
g.Options = json.RawMessage(opts)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SaveTemplateGraphs замінює графіки шаблону цілком.
|
||||
func (s *Store) SaveTemplateGraphs(ctx context.Context, tenantID, templateID string, graphs []TemplateGraph) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
var builtin bool
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT is_builtin FROM tpl.templates WHERE id = $1`, templateID).Scan(&builtin); err != nil {
|
||||
if isNoRows(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if builtin {
|
||||
return ErrBuiltin
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM tpl.graphs WHERE template_id = $1`, templateID); err != nil {
|
||||
return err
|
||||
}
|
||||
for i, g := range graphs {
|
||||
keys, err := json.Marshal(g.MetricKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts := "{}"
|
||||
if len(g.Options) > 0 {
|
||||
opts = string(g.Options)
|
||||
}
|
||||
if g.Kind == "" {
|
||||
g.Kind = "line"
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO tpl.graphs (template_id, key, name, kind, metric_keys, options, position)
|
||||
VALUES ($1,$2,$3,$4::tpl.graph_kind,$5::jsonb,$6::jsonb,$7)
|
||||
`, templateID, g.Key, g.Name, g.Kind, string(keys), opts, i); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeviceGraphs збирає графіки з усіх шаблонів хоста й підставляє ряди.
|
||||
//
|
||||
// Графік без жодного знайденого ряду не повертається: порожня рамка з
|
||||
// підписом «Пам'ять» на хості, де пам'ять не збирається, — це обіцянка
|
||||
// даних, яких немає.
|
||||
func (s *Store) DeviceGraphs(ctx context.Context, tenantID, deviceID string) ([]DeviceGraph, error) {
|
||||
out := []DeviceGraph{}
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT t.id::text, t.name
|
||||
FROM tpl.device_templates dt
|
||||
JOIN tpl.templates t ON t.id = dt.template_id
|
||||
WHERE dt.device_id = $1
|
||||
ORDER BY t.name
|
||||
`, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type tpl struct{ id, name string }
|
||||
var tpls []tpl
|
||||
for rows.Next() {
|
||||
var t tpl
|
||||
if err := rows.Scan(&t.id, &t.name); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
tpls = append(tpls, t)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ряди хоста один раз: графіків може бути десяток, і питати
|
||||
// однакове по разу на кожен — це десяток однакових запитів.
|
||||
byKey := map[string][]int64{}
|
||||
srows, err := tx.Query(ctx, `
|
||||
SELECT metric_key, id FROM ts.series
|
||||
WHERE tenant_id = $1 AND device_id = $2
|
||||
`, tenantID, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for srows.Next() {
|
||||
var k string
|
||||
var id int64
|
||||
if err := srows.Scan(&k, &id); err != nil {
|
||||
srows.Close()
|
||||
return err
|
||||
}
|
||||
byKey[k] = append(byKey[k], id)
|
||||
}
|
||||
srows.Close()
|
||||
if err := srows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, t := range tpls {
|
||||
graphs, err := templateGraphs(ctx, tx, t.id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, g := range graphs {
|
||||
var ids []int64
|
||||
for _, k := range g.MetricKeys {
|
||||
ids = append(ids, byKey[k]...)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, DeviceGraph{
|
||||
TemplateGraph: g, TemplateName: t.name, SeriesIDs: ids,
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@ import type {
|
|||
UndoResult,
|
||||
BackupDefaults,
|
||||
Dashboard,
|
||||
DeviceGraph,
|
||||
Icon,
|
||||
MapPermission,
|
||||
MetricResult,
|
||||
SeriesInfo,
|
||||
Template,
|
||||
TemplateGraph,
|
||||
} from '../types'
|
||||
|
||||
/** Те, що приймає POST/PATCH /api/v1/devices. */
|
||||
|
|
@ -340,6 +342,11 @@ export const api = {
|
|||
listNcmProfiles: () =>
|
||||
request<{ profiles: NcmProfile[] }>('/api/v1/ncm-profiles').then((r) => r.profiles ?? []),
|
||||
|
||||
getDeviceCredentials: (deviceID: string) =>
|
||||
request<{ credential_ids: string[] }>(`/api/v1/devices/${deviceID}/credentials`).then(
|
||||
(r) => r.credential_ids ?? [],
|
||||
),
|
||||
|
||||
listCredentials: () =>
|
||||
request<{ credentials: Credential[] }>('/api/v1/credentials').then((r) => r.credentials ?? []),
|
||||
|
||||
|
|
@ -631,4 +638,17 @@ export const api = {
|
|||
request<{ id: string }>('/api/v1/icons', { method: 'POST', body: JSON.stringify(i) }),
|
||||
|
||||
deleteIcon: (id: string) => request<void>(`/api/v1/icons/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// --- графіки шаблонів ---
|
||||
|
||||
saveTemplateGraphs: (templateID: string, graphs: TemplateGraph[]) =>
|
||||
request<void>(`/api/v1/templates/${templateID}/graphs`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ graphs }),
|
||||
}),
|
||||
|
||||
deviceGraphs: (deviceID: string) =>
|
||||
request<{ graphs: DeviceGraph[] }>(`/api/v1/devices/${deviceID}/graphs`).then(
|
||||
(r) => r.graphs ?? [],
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,13 @@ export function BackupPolicyForm({
|
|||
device,
|
||||
credentials,
|
||||
onClose,
|
||||
inline = false,
|
||||
}: {
|
||||
device: DeviceSummary
|
||||
credentials: Credential[]
|
||||
onClose: () => void
|
||||
onClose?: () => void
|
||||
/** Всередині чужої форми: без власного вікна й без кнопки «Скасувати». */
|
||||
inline?: boolean
|
||||
}) {
|
||||
const [profiles, setProfiles] = useState<NcmProfile[]>([])
|
||||
const [policy, setPolicy] = useState<BackupPolicy | null>(null)
|
||||
|
|
@ -60,11 +63,10 @@ export function BackupPolicyForm({
|
|||
|
||||
const cliCreds = credentials.filter((c) => c.proto === 'ssh' || c.proto === 'telnet')
|
||||
|
||||
return (
|
||||
<Modal title={`Резервне копіювання: ${device.name}`} onClose={onClose}>
|
||||
{loading ? (
|
||||
<p className="py-6 text-center text-xs text-slate-600">Завантаження…</p>
|
||||
) : (
|
||||
const body = (
|
||||
loading ? (
|
||||
<p className="py-6 text-center text-xs text-slate-600">Завантаження…</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2.5 text-sm text-slate-300">
|
||||
<Toggle checked={enabled} onChange={setEnabled} />
|
||||
|
|
@ -129,7 +131,7 @@ export function BackupPolicyForm({
|
|||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
{!inline && <Button onClick={onClose}>Скасувати</Button>}
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || cron.trim() === ''}
|
||||
|
|
@ -145,7 +147,7 @@ export function BackupPolicyForm({
|
|||
// Збереження цієї форми означає «в мене свій розклад».
|
||||
follows_default: false,
|
||||
})
|
||||
onClose()
|
||||
onClose?.()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
|
|
@ -157,7 +159,16 @@ export function BackupPolicyForm({
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
)
|
||||
|
||||
// Всередині картки хоста форма живе окремою вкладкою, а не власним
|
||||
// вікном: вікно у вікні читається як помилка інтерфейсу.
|
||||
if (inline) return body
|
||||
|
||||
return (
|
||||
<Modal title={`Резервне копіювання: ${device.name}`} onClose={onClose ?? (() => {})}>
|
||||
{body}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
386
web/src/components/DeviceForm.tsx
Normal file
386
web/src/components/DeviceForm.tsx
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { Button, Confirm, ErrorNote, Field, Modal, Toggle, inputClass, plural } from './ui'
|
||||
import type { ConfirmRequest } from './ui'
|
||||
import { Picker } from './Picker'
|
||||
import { ChecksEditor } from './ChecksEditor'
|
||||
import { BackupPolicyForm } from './BackupPolicyForm'
|
||||
import type { CheckInput, DeviceInput } from '../api/client'
|
||||
import type {
|
||||
AgentSummary,
|
||||
CheckType,
|
||||
Credential,
|
||||
DeviceGroup,
|
||||
DeviceSummary,
|
||||
Template,
|
||||
} from '../types'
|
||||
|
||||
export const kindLabel: Record<string, string> = {
|
||||
router: 'маршрутизатор',
|
||||
switch: 'комутатор',
|
||||
firewall: 'фаєрвол',
|
||||
server: 'сервер',
|
||||
ap: 'точка доступу',
|
||||
olt: 'OLT',
|
||||
onu: 'ONU',
|
||||
ups: 'ДБЖ',
|
||||
printer: 'принтер',
|
||||
camera: 'камера',
|
||||
sensor: 'датчик',
|
||||
vm: 'віртуальна машина',
|
||||
container: 'контейнер',
|
||||
other: 'інше',
|
||||
}
|
||||
|
||||
type Tab = 'main' | 'templates' | 'access' | 'ncm' | 'checks'
|
||||
|
||||
/**
|
||||
* Картка хоста.
|
||||
*
|
||||
* Вкладками, а не однією довгою простинею: у формі шість різних тем, і
|
||||
* половину з них відкривають раз на життя хоста. Порядок такий самий,
|
||||
* як частота звернень — назва й групи щодня, ручні перевірки майже
|
||||
* ніколи.
|
||||
*
|
||||
* Інтервалів опитування тут навмисно немає: вони живуть у шаблонах.
|
||||
* Два джерела правди про одне число рано чи пізно розійдуться, і
|
||||
* розійдуться саме тоді, коли треба буде швидко зрозуміти, чому хост
|
||||
* опитується не так, як написано.
|
||||
*/
|
||||
export function DeviceForm({
|
||||
device,
|
||||
groups,
|
||||
agents,
|
||||
checkTypes,
|
||||
credentials,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
device?: DeviceSummary
|
||||
groups: DeviceGroup[]
|
||||
agents: AgentSummary[]
|
||||
checkTypes: CheckType[]
|
||||
credentials: Credential[]
|
||||
onClose: () => void
|
||||
onSave: (in_: DeviceInput) => Promise<void>
|
||||
onDelete?: () => Promise<void>
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>('main')
|
||||
|
||||
const [name, setName] = useState(device?.name ?? '')
|
||||
const [address, setAddress] = useState(device?.address ?? '')
|
||||
const [kind, setKind] = useState(device?.kind ?? 'other')
|
||||
const [vendor, setVendor] = useState(device?.vendor ?? '')
|
||||
// Поточний зонд, а не порожньо: сервер трактує порожнє значення
|
||||
// буквально, і форма з порожнім полем відвʼязувала хост від зонда
|
||||
// при кожному збереженні.
|
||||
const [agentID, setAgentID] = useState(device?.agent_id ?? '')
|
||||
const [enabled, setEnabled] = useState(device?.enabled ?? true)
|
||||
const [groupIDs, setGroupIDs] = useState<string[]>(device?.group_ids ?? [])
|
||||
const [credIDs, setCredIDs] = useState<string[]>([])
|
||||
const [credsLoaded, setCredsLoaded] = useState(!device)
|
||||
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [templateIDs, setTemplateIDs] = useState<string[]>([])
|
||||
const [templatesLoaded, setTemplatesLoaded] = useState(false)
|
||||
|
||||
const [checks, setChecks] = useState<CheckInput[]>([])
|
||||
const [checksLoaded, setChecksLoaded] = useState(!device)
|
||||
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!device) return
|
||||
let alive = true
|
||||
api
|
||||
.listDeviceChecks(device.id)
|
||||
.then((cs) => {
|
||||
if (!alive) return
|
||||
setChecks(
|
||||
cs.map((c) => ({
|
||||
id: c.id,
|
||||
check_type: c.check_type,
|
||||
params: c.params,
|
||||
interval_sec: c.interval_sec,
|
||||
timeout_ms: c.timeout_ms,
|
||||
retries: c.retries,
|
||||
enabled: c.enabled,
|
||||
})),
|
||||
)
|
||||
setChecksLoaded(true)
|
||||
})
|
||||
.catch(() => setChecksLoaded(true))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [device])
|
||||
|
||||
useEffect(() => {
|
||||
if (!device) return
|
||||
let alive = true
|
||||
api
|
||||
.getDeviceCredentials(device.id)
|
||||
.then((ids) => {
|
||||
if (!alive) return
|
||||
setCredIDs(ids)
|
||||
setCredsLoaded(true)
|
||||
})
|
||||
.catch(() => setCredsLoaded(true))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [device])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
Promise.all([
|
||||
api.listTemplates().catch(() => [] as Template[]),
|
||||
device ? api.getDeviceTemplates(device.id).catch(() => [] as string[]) : Promise.resolve([]),
|
||||
]).then(([list, ids]) => {
|
||||
if (!alive) return
|
||||
setTemplates(list)
|
||||
// Новий хост починає з доступності: пінг — єдина перевірка, яка
|
||||
// працює будь-де без налаштування, а хост без жодної не
|
||||
// опитується взагалі.
|
||||
const icmp = list.find((t) => t.key === 'icmp-basic')
|
||||
setTemplateIDs(device ? ids : icmp ? [icmp.id] : [])
|
||||
setTemplatesLoaded(true)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [device])
|
||||
|
||||
const tabs: { key: Tab; label: string; badge?: number }[] = [
|
||||
{ key: 'main', label: 'Хост' },
|
||||
{ key: 'templates', label: 'Шаблони', badge: templateIDs.length },
|
||||
{ key: 'access', label: 'Доступи', badge: credIDs.length },
|
||||
{ key: 'ncm', label: 'Збір конфігів' },
|
||||
{ key: 'checks', label: 'Ручні перевірки', badge: checks.length },
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal title={device ? `Хост: ${device.name}` : 'Новий хост'} onClose={onClose} wide>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-1 border-b border-slate-800 pb-2">
|
||||
{tabs.map((t) => (
|
||||
<Button
|
||||
key={t.key}
|
||||
kind={tab === t.key ? 'primary' : 'ghost'}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
{t.badge ? ` ${t.badge}` : ''}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'main' && (
|
||||
<>
|
||||
<Field label="Назва">
|
||||
<input
|
||||
className={inputClass}
|
||||
autoFocus={!device}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="IP-адреса" hint="За нею опитує зонд">
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder="10.0.0.1"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Тип">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value)}
|
||||
>
|
||||
{Object.entries(kindLabel).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Виробник" hint="За ним підбирається профіль збору конфігу">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={vendor}
|
||||
onChange={(e) => setVendor(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Зонд" hint="Хто опитує цей хост">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={agentID}
|
||||
onChange={(e) => setAgentID(e.target.value)}
|
||||
>
|
||||
<option value="">— без зонда —</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Групи хостів" hint="Задають межі доступу й фільтри">
|
||||
<Picker
|
||||
items={groups.map((g) => ({ id: g.id, label: g.name }))}
|
||||
value={groupIDs}
|
||||
onChange={setGroupIDs}
|
||||
empty="Груп ще немає — заведіть їх на сторінці «Групи»"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<Toggle checked={enabled} onChange={setEnabled} />
|
||||
Опитувати цей хост
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'templates' && (
|
||||
<>
|
||||
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||
Шаблон приносить набір перевірок разом з інтервалами. Саме там задається, як
|
||||
опитувати хост — окремо міняти інтервали тут не треба.
|
||||
</p>
|
||||
<Field label="Шаблони опитування">
|
||||
<Picker
|
||||
items={templates.map((t) => ({
|
||||
id: t.id,
|
||||
label: t.name,
|
||||
hint: plural(t.item_count, 'метрика', 'метрики', 'метрик'),
|
||||
}))}
|
||||
value={templateIDs}
|
||||
onChange={setTemplateIDs}
|
||||
empty="Шаблонів ще немає"
|
||||
/>
|
||||
</Field>
|
||||
{templateIDs.length === 0 && checks.length === 0 && (
|
||||
<p className="rounded border border-amber-900/60 bg-amber-950/20 p-2.5 text-[11px] leading-relaxed text-amber-200">
|
||||
Без жодного шаблону хост лежатиме в списку й не опитуватиметься. Найпростіший
|
||||
варіант — «Доступність (ICMP)».
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'access' && (
|
||||
<>
|
||||
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||
SNMP-community й паролі SSH заводяться на сторінці «Доступи». Тут лише вибір, які з
|
||||
них застосовувати до цього хоста.
|
||||
</p>
|
||||
<Field label="Доступи до обладнання">
|
||||
<Picker
|
||||
items={credentials.map((c) => ({ id: c.id, label: c.name, hint: c.proto }))}
|
||||
value={credIDs}
|
||||
onChange={setCredIDs}
|
||||
empty="Доступів ще немає"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'ncm' &&
|
||||
(device ? (
|
||||
<BackupPolicyForm device={device} credentials={credentials} inline />
|
||||
) : (
|
||||
<p className="py-6 text-center text-xs leading-relaxed text-slate-600">
|
||||
Розклад збору конфігів налаштовується після створення хоста: політиці потрібен сам
|
||||
хост, до якого вона кріпиться.
|
||||
</p>
|
||||
))}
|
||||
|
||||
{tab === 'checks' && (
|
||||
<>
|
||||
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||
Одиничні перевірки, яких немає в жодному шаблоні. Якщо те саме потрібно кільком
|
||||
хостам — краще шаблон: виправлений там OID доїжджає до всіх сам.
|
||||
</p>
|
||||
{checksLoaded ? (
|
||||
<ChecksEditor types={checkTypes} checks={checks} onChange={setChecks} />
|
||||
) : (
|
||||
<p className="text-xs text-slate-600">Завантаження перевірок…</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 border-t border-slate-800 pt-3">
|
||||
{onDelete && (
|
||||
<Button
|
||||
kind="danger"
|
||||
disabled={busy}
|
||||
className="mr-auto"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити хост',
|
||||
message: (
|
||||
<>
|
||||
Хост <b>{device?.name ?? name}</b> зникне зі списків і мап.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
'Опитування припиниться, активні алерти по ньому закриються. Зібрані метрики та конфіги збережуться — хост позначається видаленим, а не витирається.',
|
||||
onConfirm: onDelete,
|
||||
})
|
||||
}
|
||||
>
|
||||
Видалити
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !name.trim()}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
address: address.trim(),
|
||||
kind,
|
||||
vendor: vendor.trim(),
|
||||
agent_id: agentID,
|
||||
enabled,
|
||||
group_ids: groupIDs,
|
||||
// Шлемо лише те, що встигли побачити: інакше швидке
|
||||
// збереження стерло б налаштування, які форма ще не
|
||||
// завантажила.
|
||||
...(checksLoaded ? { checks } : {}),
|
||||
...(credsLoaded ? { credential_ids: credIDs } : {}),
|
||||
...(templatesLoaded ? { template_ids: templateIDs } : {}),
|
||||
})
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Зберегти'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
|
|
@ -110,6 +110,25 @@ export function MapCanvas({
|
|||
|
||||
useEffect(() => setEdges(rfEdges), [rfEdges, setEdges])
|
||||
|
||||
// Виділення віддаємо назовні лише коли набір справді змінився.
|
||||
//
|
||||
// React Flow викликає onSelectionChange на кожному рендері, а новий
|
||||
// масив щоразу — це нескінченний цикл оновлень: колбек міняє стан
|
||||
// сторінки, сторінка перемальовує полотно, полотно кличе колбек.
|
||||
// Знайдено живим прогоном: «Maximum update depth exceeded» і мертвий
|
||||
// інтерфейс на всіх сторінках, бо цикл з'їдав головний потік.
|
||||
const lastSelection = useRef('')
|
||||
const handleSelectionChange = useCallback(
|
||||
({ nodes: sn }: { nodes: Node[] }) => {
|
||||
const ids = sn.map((n) => n.id)
|
||||
const key = ids.join(',')
|
||||
if (key === lastSelection.current) return
|
||||
lastSelection.current = key
|
||||
onSelectionChange?.(ids)
|
||||
},
|
||||
[onSelectionChange],
|
||||
)
|
||||
|
||||
const handleNodesChange = useCallback(
|
||||
(changes: NodeChange<DeviceNodeType>[]) => {
|
||||
onNodesChange(changes)
|
||||
|
|
@ -208,9 +227,7 @@ export function MapCanvas({
|
|||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStop={handleDragStop}
|
||||
onSelectionDragStop={handleSelectionDragStop}
|
||||
onSelectionChange={
|
||||
onSelectionChange ? ({ nodes: sn }) => onSelectionChange(sn.map((n) => n.id)) : undefined
|
||||
}
|
||||
onSelectionChange={onSelectionChange ? handleSelectionChange : undefined}
|
||||
onConnect={readOnly ? undefined : handleConnect}
|
||||
onDelete={readOnly ? undefined : handleDelete}
|
||||
nodesConnectable={!readOnly}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,20 @@ const PAD = { top: 8, right: 8, bottom: 20, left: 48 }
|
|||
* через діру каже «все було добре» там, де насправді нічого не відомо, —
|
||||
* у моніторингу це найгірший різновид брехні.
|
||||
*/
|
||||
export function MetricChart({ series }: { series: MetricSeries[] }) {
|
||||
const model = useMemo(() => build(series), [series])
|
||||
export function MetricChart({
|
||||
series,
|
||||
kind = 'line',
|
||||
min,
|
||||
max,
|
||||
}: {
|
||||
series: MetricSeries[]
|
||||
/** line | area | stacked | bar */
|
||||
kind?: string
|
||||
/** Жорсткі межі осі. Порожньо — рахуються з даних. */
|
||||
min?: number
|
||||
max?: number
|
||||
}) {
|
||||
const model = useMemo(() => build(series, kind, min, max), [series, kind, min, max])
|
||||
|
||||
if (!model) {
|
||||
return (
|
||||
|
|
@ -62,22 +74,61 @@ export function MetricChart({ series }: { series: MetricSeries[] }) {
|
|||
</g>
|
||||
))}
|
||||
|
||||
{lines.map((ln, i) => (
|
||||
<path
|
||||
key={ln.id}
|
||||
d={ln.segments
|
||||
.map((seg) =>
|
||||
seg
|
||||
.map((p, n) => `${n === 0 ? 'M' : 'L'} ${x(p.t).toFixed(1)} ${y(p.v).toFixed(1)}`)
|
||||
.join(' '),
|
||||
)
|
||||
.join(' ')}
|
||||
fill="none"
|
||||
stroke={COLORS[i % COLORS.length]}
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
{kind === 'bar'
|
||||
? lines.map((ln, i) =>
|
||||
ln.segments.flat().map((p, n) => (
|
||||
<rect
|
||||
key={`${ln.id}-${n}`}
|
||||
x={x(p.t) - 1.5}
|
||||
y={y(p.v)}
|
||||
width={3}
|
||||
height={Math.max(0, y(minY) - y(p.v))}
|
||||
fill={COLORS[i % COLORS.length]}
|
||||
opacity={0.85}
|
||||
/>
|
||||
)),
|
||||
)
|
||||
: lines.map((ln, i) => (
|
||||
<g key={ln.id}>
|
||||
{/* Заливка під лінією. Для «stacked» значення вже
|
||||
складені в build(), тож малюється те саме — різниця
|
||||
лише в тому, що саме порахували. */}
|
||||
{(kind === 'area' || kind === 'stacked') &&
|
||||
ln.segments.map((seg, si) => (
|
||||
<path
|
||||
key={si}
|
||||
d={
|
||||
seg
|
||||
.map(
|
||||
(p, n) =>
|
||||
`${n === 0 ? 'M' : 'L'} ${x(p.t).toFixed(1)} ${y(p.v).toFixed(1)}`,
|
||||
)
|
||||
.join(' ') +
|
||||
` L ${x(seg[seg.length - 1].t).toFixed(1)} ${y(minY).toFixed(1)}` +
|
||||
` L ${x(seg[0].t).toFixed(1)} ${y(minY).toFixed(1)} Z`
|
||||
}
|
||||
fill={COLORS[i % COLORS.length]}
|
||||
opacity={0.18}
|
||||
/>
|
||||
))}
|
||||
<path
|
||||
d={ln.segments
|
||||
.map((seg) =>
|
||||
seg
|
||||
.map(
|
||||
(p, n) =>
|
||||
`${n === 0 ? 'M' : 'L'} ${x(p.t).toFixed(1)} ${y(p.v).toFixed(1)}`,
|
||||
)
|
||||
.join(' '),
|
||||
)
|
||||
.join(' ')}
|
||||
fill="none"
|
||||
stroke={COLORS[i % COLORS.length]}
|
||||
strokeWidth={1.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -106,7 +157,25 @@ export function seriesLabel(s: { metric_key: string; interface_name?: string }):
|
|||
|
||||
type Line = { id: number; segments: { t: number; v: number }[][] }
|
||||
|
||||
function build(series: MetricSeries[]) {
|
||||
function build(series: MetricSeries[], kind = 'line', forceMin?: number, forceMax?: number) {
|
||||
// Накопичення: кожен наступний ряд лягає поверх суми попередніх.
|
||||
// Має сенс там, де сума сама по собі щось означає — трафік по портах,
|
||||
// місце на дисках. На відсотках завантаження це була б нісенітниця,
|
||||
// тому вид обирає людина, а не евристика.
|
||||
if (kind === 'stacked' && series.length > 1) {
|
||||
const running = new Map<string, number>()
|
||||
series = series.map((s) => ({
|
||||
...s,
|
||||
points: s.points.map((p) => {
|
||||
if (p.v == null) return p
|
||||
const key = p.ts
|
||||
const base = running.get(key) ?? 0
|
||||
const v = base + p.v
|
||||
running.set(key, v)
|
||||
return { ...p, v }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
let minX = Infinity
|
||||
let maxX = -Infinity
|
||||
let minY = Infinity
|
||||
|
|
@ -152,6 +221,11 @@ function build(series: MetricSeries[]) {
|
|||
if (minY < 0 && series.every((s) => s.points.every((p) => p.v == null || p.v >= 0))) {
|
||||
minY = 0
|
||||
}
|
||||
// Жорсткі межі з шаблону перекривають пораховані: «завантаження від 0
|
||||
// до 100» має виглядати однаково на всіх хостах, інакше графіки не
|
||||
// порівняти очима.
|
||||
if (forceMin != null) minY = forceMin
|
||||
if (forceMax != null) maxY = forceMax
|
||||
|
||||
return { minX, maxX, minY, maxY, lines, ticks: makeTicks(minY, maxY) }
|
||||
}
|
||||
|
|
|
|||
127
web/src/components/Picker.tsx
Normal file
127
web/src/components/Picker.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { useMemo, useRef, useState } from 'react'
|
||||
import { inputClass } from './ui'
|
||||
|
||||
export type PickerItem = { id: string; label: string; hint?: string }
|
||||
|
||||
/**
|
||||
* Вибір зі списку з пошуком і чипами — як «почніть друкувати для
|
||||
* пошуку» в Zabbix.
|
||||
*
|
||||
* Список галочок працює на п'яти позиціях і перестає працювати на
|
||||
* п'ятдесяти: групи хостів і шаблони саме такі. Обране показане чипами
|
||||
* зверху, тож видно вибір цілком, не гортаючи.
|
||||
*/
|
||||
export function Picker({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'почніть друкувати для пошуку…',
|
||||
disabled,
|
||||
single = false,
|
||||
empty = 'Нічого не знайдено',
|
||||
}: {
|
||||
items: PickerItem[]
|
||||
value: string[]
|
||||
onChange: (next: string[]) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** Один елемент замість набору. */
|
||||
single?: boolean
|
||||
empty?: string
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
|
||||
const byID = useMemo(() => new Map(items.map((i) => [i.id, i])), [items])
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return items
|
||||
.filter((i) => !value.includes(i.id))
|
||||
.filter((i) => !q || i.label.toLowerCase().includes(q) || (i.hint ?? '').toLowerCase().includes(q))
|
||||
.slice(0, 50)
|
||||
}, [items, value, query])
|
||||
|
||||
function add(id: string) {
|
||||
onChange(single ? [id] : [...value, id])
|
||||
setQuery('')
|
||||
// Список лишається відкритим при множинному виборі: відмічають
|
||||
// зазвичай кілька поспіль, і закривати після кожного означає
|
||||
// змусити клацати заново.
|
||||
setOpen(!single)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={box}>
|
||||
{value.length > 0 && (
|
||||
<div className="mb-1.5 flex flex-wrap gap-1">
|
||||
{value.map((id) => (
|
||||
<span
|
||||
key={id}
|
||||
className="flex items-center gap-1 rounded border border-slate-700 bg-slate-800 px-1.5 py-0.5 text-xs text-slate-200"
|
||||
>
|
||||
{byID.get(id)?.label ?? id}
|
||||
{!disabled && (
|
||||
<button
|
||||
className="text-slate-500 hover:text-red-400"
|
||||
onClick={() => onChange(value.filter((x) => x !== id))}
|
||||
aria-label="Прибрати"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
onFocus={() => setOpen(true)}
|
||||
// Закриття із затримкою: клік по пункту списку інакше не встигає
|
||||
// спрацювати — blur знімає список раніше, ніж миша до нього дійде.
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && matches.length > 0) {
|
||||
e.preventDefault()
|
||||
add(matches[0].id)
|
||||
}
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
// Backspace у порожньому полі прибирає останній чип — так
|
||||
// поводяться всі поля з тегами, і руки це вже знають.
|
||||
if (e.key === 'Backspace' && query === '' && value.length > 0) {
|
||||
onChange(value.slice(0, -1))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{open && !disabled && (
|
||||
<ul className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded border border-slate-700 bg-slate-900 shadow-xl">
|
||||
{matches.map((i) => (
|
||||
<li key={i.id}>
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-slate-200 hover:bg-slate-800"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => add(i.id)}
|
||||
>
|
||||
<span className="truncate">{i.label}</span>
|
||||
{i.hint && <span className="ml-auto shrink-0 text-[11px] text-slate-600">{i.hint}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{matches.length === 0 && (
|
||||
<li className="px-3 py-2 text-xs text-slate-600">{empty}</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,13 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { api, type CheckInput, type DeviceInput } from '../api/client'
|
||||
import { api, type DeviceInput } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import { liveEvents } from '../api/ws'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import type { LiveAlerts } from '../hooks/useAlerts'
|
||||
import {
|
||||
Button,
|
||||
DataTable,
|
||||
ErrorNote,
|
||||
Field,
|
||||
Confirm,
|
||||
Modal,
|
||||
PageBody,
|
||||
PageHeader,
|
||||
|
|
@ -18,9 +15,8 @@ import {
|
|||
StatusBadge,
|
||||
ago,
|
||||
inputClass,
|
||||
plural,
|
||||
} from '../components/ui'
|
||||
import { ChecksEditor } from '../components/ChecksEditor'
|
||||
import { DeviceForm, kindLabel } from '../components/DeviceForm'
|
||||
import type {
|
||||
AgentSummary,
|
||||
Alert,
|
||||
|
|
@ -28,7 +24,6 @@ import type {
|
|||
Credential,
|
||||
DeviceGroup,
|
||||
DeviceSummary,
|
||||
Template,
|
||||
} from '../types'
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
|
|
@ -40,22 +35,6 @@ const statusLabel: Record<string, string> = {
|
|||
}
|
||||
|
||||
// Порядок такий самий, як у ENUM inv.device_kind на сервері.
|
||||
const kindLabel: Record<string, string> = {
|
||||
router: 'Маршрутизатор',
|
||||
switch: 'Комутатор',
|
||||
firewall: 'Міжмережевий екран',
|
||||
server: 'Сервер',
|
||||
ap: 'Точка доступу',
|
||||
olt: 'OLT',
|
||||
onu: 'ONU',
|
||||
ups: 'ДБЖ',
|
||||
printer: 'Принтер',
|
||||
camera: 'Камера',
|
||||
sensor: 'Датчик',
|
||||
vm: 'Віртуальна машина',
|
||||
container: 'Контейнер',
|
||||
other: 'Інше',
|
||||
}
|
||||
|
||||
export function DevicesPage() {
|
||||
// Алерти беремо з оболонки, а не тягнемо власним запитом: вона вже
|
||||
|
|
@ -334,328 +313,6 @@ export function DevicesPage() {
|
|||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function DeviceForm({
|
||||
device,
|
||||
groups,
|
||||
agents,
|
||||
checkTypes,
|
||||
credentials,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
device?: DeviceSummary
|
||||
groups: DeviceGroup[]
|
||||
agents: AgentSummary[]
|
||||
checkTypes: CheckType[]
|
||||
credentials: Credential[]
|
||||
onClose: () => void
|
||||
onSave: (in_: DeviceInput) => Promise<void>
|
||||
onDelete?: () => Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState(device?.name ?? '')
|
||||
const [address, setAddress] = useState(device?.address ?? '')
|
||||
const [kind, setKind] = useState(device?.kind ?? 'other')
|
||||
const [vendor, setVendor] = useState(device?.vendor ?? '')
|
||||
// Поточний зонд, а не порожньо.
|
||||
//
|
||||
// Знайдено живим прогоном: поле починалося порожнім, підпис обіцяв
|
||||
// «не змінювати», а сервер трактував порожнє значення буквально —
|
||||
// і будь-яке збереження форми відв'язувало хост від зонда. Хост
|
||||
// після цього лишався в списку й переставав опитуватись, мовчки.
|
||||
const [agentID, setAgentID] = useState(device?.agent_id ?? '')
|
||||
const [enabled, setEnabled] = useState(device?.enabled ?? true)
|
||||
const [groupIDs, setGroupIDs] = useState<string[]>(device?.group_ids ?? [])
|
||||
const [credIDs, setCredIDs] = useState<string[]>([])
|
||||
const [templates, setTemplates] = useState<Template[]>([])
|
||||
const [templateIDs, setTemplateIDs] = useState<string[]>([])
|
||||
const [templatesLoaded, setTemplatesLoaded] = useState(false)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
// Новий хост починає з пінга: це єдина перевірка, яка працює будь-де
|
||||
// без налаштування, а хост без жодної перевірки не опитується взагалі.
|
||||
const [checks, setChecks] = useState<CheckInput[]>(
|
||||
device
|
||||
? []
|
||||
: [{ check_type: 'icmp.ping', params: { count: 3 }, interval_sec: 30, enabled: true }],
|
||||
)
|
||||
const [checksLoaded, setChecksLoaded] = useState(!device)
|
||||
|
||||
// Для наявного хоста тягнемо його перевірки. Показати порожній список
|
||||
// там, де опитування налаштоване, означало б стерти його першим же
|
||||
// збереженням.
|
||||
useEffect(() => {
|
||||
if (!device) return
|
||||
let alive = true
|
||||
api
|
||||
.listDeviceChecks(device.id)
|
||||
.then((cs) => {
|
||||
if (!alive) return
|
||||
setChecks(
|
||||
cs.map((c) => ({
|
||||
id: c.id,
|
||||
check_type: c.check_type,
|
||||
params: c.params,
|
||||
interval_sec: c.interval_sec,
|
||||
timeout_ms: c.timeout_ms,
|
||||
retries: c.retries,
|
||||
enabled: c.enabled,
|
||||
})),
|
||||
)
|
||||
setChecksLoaded(true)
|
||||
})
|
||||
.catch(() => setChecksLoaded(true))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [device])
|
||||
|
||||
// Довідник шаблонів і прив'язки хоста. Новий хост шаблонів не має —
|
||||
// але список усе одно потрібен, бо саме на створенні їх і чіпляють.
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
Promise.all([
|
||||
api.listTemplates().catch(() => [] as Template[]),
|
||||
device ? api.getDeviceTemplates(device.id).catch(() => [] as string[]) : Promise.resolve([]),
|
||||
]).then(([list, ids]) => {
|
||||
if (!alive) return
|
||||
setTemplates(list)
|
||||
setTemplateIDs(ids)
|
||||
setTemplatesLoaded(true)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [device])
|
||||
|
||||
const toggleTemplate = (id: string) =>
|
||||
setTemplateIDs((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
|
||||
const toggleGroup = (id: string) =>
|
||||
setGroupIDs((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
|
||||
return (
|
||||
<Modal title={device ? `Хост: ${device.name}` : 'Новий хост'} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<Field label="Назва">
|
||||
<input
|
||||
className={inputClass}
|
||||
autoFocus={!device}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="IP-адреса" hint="За нею опитує зонд">
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder="192.168.1.1"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Тип">
|
||||
<select className={inputClass} value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
{Object.entries(kindLabel).map(([k, label]) => (
|
||||
<option key={k} value={k}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Виробник">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={vendor}
|
||||
onChange={(e) => setVendor(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Зонд" hint="Хто опитує цей хост">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={agentID}
|
||||
onChange={(e) => setAgentID(e.target.value)}
|
||||
>
|
||||
<option value="">— без зонда —</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-medium text-slate-400">Групи</span>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-xs text-slate-600">
|
||||
Груп ще немає — створіть їх у розділі «Групи»
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-40 space-y-0.5 overflow-y-auto rounded border border-slate-800 p-1.5">
|
||||
{groups.map((g) => (
|
||||
<li key={g.id}>
|
||||
<label className="flex items-center gap-2 rounded px-1 py-1 text-sm hover:bg-slate-800/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupIDs.includes(g.id)}
|
||||
onChange={() => toggleGroup(g.id)}
|
||||
/>
|
||||
<span className="text-slate-200">{g.name}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{credentials.length > 0 && (
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-medium text-slate-400">
|
||||
Доступи
|
||||
<span className="ml-1.5 font-normal text-slate-600">
|
||||
SNMP-community, SSH — потрібні для всього, крім пінга
|
||||
</span>
|
||||
</span>
|
||||
<ul className="max-h-28 space-y-0.5 overflow-y-auto rounded border border-slate-800 p-1.5">
|
||||
{credentials.map((c) => (
|
||||
<li key={c.id}>
|
||||
<label className="flex items-center gap-2 rounded px-1 py-1 text-sm hover:bg-slate-800/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={credIDs.includes(c.id)}
|
||||
onChange={() =>
|
||||
setCredIDs((prev) =>
|
||||
prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-slate-200">{c.name}</span>
|
||||
<span className="text-xs text-slate-600">{c.proto}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templates.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-slate-400">Шаблони опитування</div>
|
||||
<p className="text-[11px] text-slate-600">
|
||||
Метрики шаблону додаються поверх перевірок нижче й оновлюються разом із ним
|
||||
</p>
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto rounded border border-slate-800 p-2">
|
||||
{templates.map((t) => (
|
||||
<li key={t.id}>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={templateIDs.includes(t.id)}
|
||||
onChange={() => toggleTemplate(t.id)}
|
||||
/>
|
||||
<span className="text-slate-200">{t.name}</span>
|
||||
<span className="text-xs text-slate-600">
|
||||
{plural(t.item_count, 'метрика', 'метрики', 'метрик')}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{checksLoaded ? (
|
||||
<ChecksEditor types={checkTypes} checks={checks} onChange={setChecks} />
|
||||
) : (
|
||||
<p className="text-xs text-slate-600">Завантаження перевірок…</p>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
Опитувати цей хост
|
||||
</label>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
||||
{onDelete && (
|
||||
<Button
|
||||
kind="danger"
|
||||
disabled={busy}
|
||||
className="mr-auto"
|
||||
title="Хост зникне зі списків, але його історія збережеться"
|
||||
onClick={() =>
|
||||
setConfirm({
|
||||
title: 'Видалити хост',
|
||||
message: (
|
||||
<>
|
||||
Хост <b>{device?.name ?? name}</b> зникне зі списків і мап.
|
||||
</>
|
||||
),
|
||||
detail:
|
||||
'Опитування припиниться, активні алерти по ньому закриються. Зібрані метрики та конфіги збережуться — хост позначається видаленим, а не витирається.',
|
||||
onConfirm: onDelete,
|
||||
})
|
||||
}
|
||||
>
|
||||
Видалити
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !name.trim()}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
address: address.trim(),
|
||||
kind,
|
||||
vendor: vendor.trim(),
|
||||
agent_id: agentID,
|
||||
enabled,
|
||||
group_ids: groupIDs,
|
||||
// Перевірки шлемо лише коли справді їх бачили: інакше
|
||||
// збереження форми, відкритої до завантаження, стерло б
|
||||
// усе опитування хоста.
|
||||
...(checksLoaded ? { checks } : {}),
|
||||
...(credIDs.length > 0 ? { credential_ids: credIDs } : {}),
|
||||
// Так само, як з перевірками: шлемо лише те, що встигли
|
||||
// побачити, інакше швидке збереження відв'язало б усі
|
||||
// шаблони хоста.
|
||||
...(templatesLoaded ? { template_ids: templateIDs } : {}),
|
||||
})
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Зберегти'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceDetail({
|
||||
device,
|
||||
groups,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
inputClass,
|
||||
} from '../components/ui'
|
||||
import { ChartLegend, MetricChart, fmtValue, seriesLabel } from '../components/MetricChart'
|
||||
import type { DeviceSummary, MetricResult, SeriesInfo } from '../types'
|
||||
import type { DeviceGraph, DeviceSummary, MetricResult, SeriesInfo } from '../types'
|
||||
|
||||
// Вікна перегляду. Ширші за добу вже читаються з роллапів, тому
|
||||
// коштують стільки ж, скільки вузькі.
|
||||
|
|
@ -40,6 +40,12 @@ export function MetricsPage() {
|
|||
const [selected, setSelected] = useState<number[]>([])
|
||||
const [range, setRange] = useState('6h')
|
||||
const [data, setData] = useState<MetricResult | null>(null)
|
||||
const [graphs, setGraphs] = useState<DeviceGraph[]>([])
|
||||
// Готові графіки з шаблонів чи власний добір метрик.
|
||||
//
|
||||
// Шаблонні першими: у дев'яти випадках із десяти людина приходить
|
||||
// подивитись «як там пам'ять», а не збирати графік з нуля.
|
||||
const [mode, setMode] = useState<'templates' | 'custom'>('templates')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
|
@ -63,6 +69,15 @@ export function MetricsPage() {
|
|||
setSeries([])
|
||||
setSelected([])
|
||||
setData(null)
|
||||
api
|
||||
.deviceGraphs(deviceID)
|
||||
.then((g) => {
|
||||
if (!alive) return
|
||||
setGraphs(g)
|
||||
// Без шаблонних графіків показувати порожню вкладку немає сенсу.
|
||||
setMode(g.length > 0 ? 'templates' : 'custom')
|
||||
})
|
||||
.catch(() => alive && setGraphs([]))
|
||||
api
|
||||
.listSeries(deviceID)
|
||||
.then((s) => {
|
||||
|
|
@ -136,6 +151,22 @@ export function MetricsPage() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
{graphs.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
kind={mode === 'templates' ? 'primary' : 'default'}
|
||||
onClick={() => setMode('templates')}
|
||||
>
|
||||
З шаблонів
|
||||
</Button>
|
||||
<Button
|
||||
kind={mode === 'custom' ? 'primary' : 'default'}
|
||||
onClick={() => setMode('custom')}
|
||||
>
|
||||
Свій добір
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{RANGES.map((r) => (
|
||||
<Button
|
||||
|
|
@ -161,6 +192,12 @@ export function MetricsPage() {
|
|||
Для цього хоста ще нічого не зібрано. Метрики з’являться після першого успішного
|
||||
опитування — перевірте, що хост увімкнено й до нього прив’язано зонд.
|
||||
</p>
|
||||
) : mode === 'templates' ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{graphs.map((g) => (
|
||||
<TemplateGraphCard key={`${g.template_name}-${g.key}`} graph={g} deviceID={deviceID} range={range} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-[18rem_1fr]">
|
||||
<Card className="max-h-[32rem] overflow-y-auto">
|
||||
|
|
@ -236,3 +273,106 @@ export function MetricsPage() {
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Один графік, описаний у шаблоні.
|
||||
*
|
||||
* Вантажить себе сам: графіків на хості буває десяток, і вимикати
|
||||
* половину з них заради одного спільного запиту означало б чекати на
|
||||
* найповільніший.
|
||||
*/
|
||||
function TemplateGraphCard({
|
||||
graph,
|
||||
deviceID,
|
||||
range,
|
||||
}: {
|
||||
graph: DeviceGraph
|
||||
deviceID: string
|
||||
range: string
|
||||
}) {
|
||||
const [data, setData] = useState<MetricResult | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
api
|
||||
.queryMetrics(deviceID, graph.series_ids, range, 250)
|
||||
.then((r) => alive && setData(r))
|
||||
.catch((e) => alive && setErr(e instanceof Error ? e.message : String(e)))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [deviceID, range, graph.series_ids])
|
||||
|
||||
const single = graph.kind === 'stat' || graph.kind === 'gauge'
|
||||
const last = data?.series[0]?.points.filter((p) => p.v != null).slice(-1)[0]?.v ?? null
|
||||
|
||||
return (
|
||||
<Card className="p-3">
|
||||
<div className="mb-2 flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-xs font-medium text-slate-300">{graph.name}</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-600">{graph.template_name}</span>
|
||||
</div>
|
||||
|
||||
{err ? (
|
||||
<p className="py-6 text-center text-[11px] text-slate-600">{err}</p>
|
||||
) : !data ? (
|
||||
<p className="py-6 text-center text-[11px] text-slate-600">Завантаження…</p>
|
||||
) : single ? (
|
||||
<SingleValue graph={graph} value={last} />
|
||||
) : (
|
||||
<>
|
||||
<MetricChart
|
||||
series={data.series}
|
||||
kind={graph.kind}
|
||||
min={graph.options?.min}
|
||||
max={graph.options?.max}
|
||||
/>
|
||||
<div className="mt-1">
|
||||
<ChartLegend series={data.series} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SingleValue({ graph, value }: { graph: DeviceGraph; value: number | null }) {
|
||||
if (value == null) {
|
||||
return <p className="py-6 text-center text-[11px] text-slate-600">Даних немає</p>
|
||||
}
|
||||
if (graph.kind === 'stat') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<span className="text-3xl font-semibold tabular-nums text-slate-100">
|
||||
{fmtValue(value)}
|
||||
</span>
|
||||
<span className="mt-1 text-[11px] text-slate-500">{graph.options?.unit}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const min = graph.options?.min ?? 0
|
||||
const max = graph.options?.max ?? 100
|
||||
const pct = Math.max(0, Math.min(1, (value - min) / Math.max(1e-9, max - min)))
|
||||
// Ті самі пороги, що на шкалах дашборда: до 70 % спокійно, до 90 %
|
||||
// варто глянути, далі погано.
|
||||
const color = pct >= 0.9 ? '#ef4444' : pct >= 0.7 ? '#f59e0b' : '#34d399'
|
||||
|
||||
return (
|
||||
<div className="space-y-2 py-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-2xl font-semibold tabular-nums text-slate-100">
|
||||
{fmtValue(value)}
|
||||
</span>
|
||||
<span className="text-[11px] text-slate-500">{graph.options?.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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
plural,
|
||||
} from '../components/ui'
|
||||
import type { ConfirmRequest } from '../components/ui'
|
||||
import type { Template, TemplateItem } from '../types'
|
||||
import type { GraphKind, Template, TemplateGraph, TemplateItem } from '../types'
|
||||
|
||||
// Типи перевірок, які вміє описати шаблон.
|
||||
//
|
||||
|
|
@ -32,6 +32,18 @@ const ITEM_TYPES: { key: string; label: string; byOID: boolean }[] = [
|
|||
|
||||
const byOID = (t: string) => ITEM_TYPES.find((x) => x.key === t)?.byOID ?? true
|
||||
|
||||
// Види графіків. Накопичення має сенс лише там, де сума сама щось
|
||||
// означає — трафік по портах, місце на дисках; на відсотках це була б
|
||||
// нісенітниця, тому вид обирає людина, а не евристика.
|
||||
const GRAPH_KINDS: { key: GraphKind; label: string }[] = [
|
||||
{ key: 'line', label: 'лінії' },
|
||||
{ key: 'area', label: 'з заливкою' },
|
||||
{ key: 'stacked', label: 'з накопиченням' },
|
||||
{ key: 'bar', label: 'стовпчики' },
|
||||
{ key: 'gauge', label: 'шкала' },
|
||||
{ key: 'stat', label: 'число' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Шаблони опитування.
|
||||
*
|
||||
|
|
@ -240,6 +252,7 @@ function TemplateForm({
|
|||
const [items, setItems] = useState<EditItem[]>(
|
||||
template?.items?.map(toEdit) ?? [emptyItem()],
|
||||
)
|
||||
const [graphs, setGraphs] = useState<TemplateGraph[]>(template?.graphs ?? [])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -485,6 +498,161 @@ function TemplateForm({
|
|||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-400">Графіки</span>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
setGraphs([
|
||||
...graphs,
|
||||
{ key: '', name: '', kind: 'line', metric_keys: [], position: graphs.length },
|
||||
])
|
||||
}
|
||||
>
|
||||
Додати графік
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-slate-600">
|
||||
«Завантаження каналу — це вхід і вихід на одній осі» описує клас пристроїв, а не
|
||||
окремий хост. Графік, заданий тут, зʼявиться на кожному, кому чіпляють шаблон.
|
||||
</p>
|
||||
|
||||
{graphs.map((g, i) => (
|
||||
<div key={i} className="rounded border border-slate-800 bg-slate-900/40 p-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field label="Назва">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={g.name}
|
||||
disabled={readOnly}
|
||||
onChange={(e) =>
|
||||
setGraphs(graphs.map((x, n) => (n === i ? { ...x, name: e.target.value } : x)))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Вид">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={g.kind}
|
||||
disabled={readOnly}
|
||||
onChange={(e) =>
|
||||
setGraphs(
|
||||
graphs.map((x, n) =>
|
||||
n === i ? { ...x, kind: e.target.value as GraphKind } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{GRAPH_KINDS.map((k) => (
|
||||
<option key={k.key} value={k.key}>
|
||||
{k.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Метрики" hint="Ключі метрик через кому — свої або з інших шаблонів">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
value={g.metric_keys.join(', ')}
|
||||
disabled={readOnly}
|
||||
placeholder="cpu.util_pct, mem.available_bytes"
|
||||
onChange={(e) =>
|
||||
setGraphs(
|
||||
graphs.map((x, n) =>
|
||||
n === i
|
||||
? {
|
||||
...x,
|
||||
metric_keys: e.target.value
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
: x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Field label="Мінімум" hint="порожньо — з даних">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={g.options?.min ?? ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) =>
|
||||
setGraphs(
|
||||
graphs.map((x, n) =>
|
||||
n === i
|
||||
? {
|
||||
...x,
|
||||
options: {
|
||||
...x.options,
|
||||
min: e.target.value === '' ? undefined : Number(e.target.value),
|
||||
},
|
||||
}
|
||||
: x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Максимум">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={g.options?.max ?? ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) =>
|
||||
setGraphs(
|
||||
graphs.map((x, n) =>
|
||||
n === i
|
||||
? {
|
||||
...x,
|
||||
options: {
|
||||
...x.options,
|
||||
max: e.target.value === '' ? undefined : Number(e.target.value),
|
||||
},
|
||||
}
|
||||
: x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Одиниця">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={g.options?.unit ?? ''}
|
||||
disabled={readOnly}
|
||||
onChange={(e) =>
|
||||
setGraphs(
|
||||
graphs.map((x, n) =>
|
||||
n === i ? { ...x, options: { ...x.options, unit: e.target.value } } : x,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
kind="danger"
|
||||
onClick={() => setGraphs(graphs.filter((_, n) => n !== i))}
|
||||
>
|
||||
Прибрати графік
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
|
|
@ -497,7 +665,7 @@ function TemplateForm({
|
|||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await api.saveTemplate(template?.id ?? null, {
|
||||
const saved = await api.saveTemplate(template?.id ?? null, {
|
||||
key: key.trim(),
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
|
|
@ -523,6 +691,16 @@ function TemplateForm({
|
|||
}
|
||||
}),
|
||||
})
|
||||
// Графіки окремим запитом: вони живуть у власній
|
||||
// таблиці, і мішати їх у тіло шаблону означало б
|
||||
// зробити збереження метрик залежним від того, чи
|
||||
// валідний графік, якого людина зараз не чіпала.
|
||||
await api.saveTemplateGraphs(
|
||||
saved.id,
|
||||
graphs
|
||||
.filter((g) => g.name.trim() && g.metric_keys.length > 0)
|
||||
.map((g, i) => ({ ...g, key: g.key || slugifyKey(g.name, i), position: i })),
|
||||
)
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setErr(
|
||||
|
|
@ -680,3 +858,17 @@ function ImportLine({
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ключ графіка з назви.
|
||||
*
|
||||
* Потрібен базі, але не людині: у формі його немає, бо жодного рішення
|
||||
* він не несе. Кирилиця відпадає — ключ має бути придатним для URL.
|
||||
*/
|
||||
function slugifyKey(name: string, i: number): string {
|
||||
const s = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return s || `graph-${i + 1}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -493,6 +493,7 @@ export type Template = {
|
|||
vendor?: string
|
||||
is_builtin: boolean
|
||||
items?: TemplateItem[]
|
||||
graphs?: TemplateGraph[]
|
||||
item_count: number
|
||||
device_count: number
|
||||
}
|
||||
|
|
@ -594,3 +595,23 @@ export type Icon = {
|
|||
size_bytes: number
|
||||
}
|
||||
|
||||
// --- графіки шаблонів ---
|
||||
|
||||
export type GraphKind = 'line' | 'area' | 'stacked' | 'bar' | 'gauge' | 'stat'
|
||||
|
||||
export type TemplateGraph = {
|
||||
id?: string
|
||||
key: string
|
||||
name: string
|
||||
kind: GraphKind
|
||||
metric_keys: string[]
|
||||
options?: { min?: number; max?: number; unit?: string }
|
||||
position: number
|
||||
}
|
||||
|
||||
/** Графік шаблону, розгорнутий під конкретний хост. */
|
||||
export type DeviceGraph = TemplateGraph & {
|
||||
template_name: string
|
||||
series_ids: number[]
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue