From 387980d655268c9c16bb0a0ef355a519cf92f47a Mon Sep 17 00:00:00 2001 From: byrsapty Date: Mon, 24 Aug 2026 19:11:24 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=B0=D1=80=D1=82=D0=BA=D0=B0=20=D1=85?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=20=D0=BD=D0=B0=20=D0=B2=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=D1=85,=20=D0=B3=D1=80=D0=B0=D1=84?= =?UTF-8?q?=D1=96=D0=BA=D0=B8=20=D0=B2=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=D1=85,=20=D0=BF=D0=BE=D1=88=D1=83=D0=BA=D0=BE?= =?UTF-8?q?=D0=B2=D1=96=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Форма хоста стала вкладками: Хост, Шаблони, Доступи, Збір конфігів, Ручні перевірки. Інтервалів опитування тут більше немає — вони живуть у шаблонах, і два джерела правди про одне число розійшлися б саме тоді, коли треба швидко зрозуміти, чому хост опитується не так, як написано. Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек. Групи, шаблони й доступи обираються пошуком із чипами: список галочок працює на пʼяти позиціях і перестає працювати на пʼятдесяти. Графіки описуються в шаблоні (tpl.graphs): шість видів, жорсткі межі осей, перелік ключів метрик замість посилань на елементи — графік має право показувати й те, що прийшло з іншого шаблону. Графік без жодного знайденого ряду не показується: порожня рамка з підписом — це обіцянка даних, яких немає. Знайдено живим прогоном і виправлено: обробник виділення на полотні створював новий масив щорендеру й зациклював оновлення. Цикл зʼїдав головний потік, і мертвими ставали всі сторінки, не лише мапа. Co-Authored-By: Claude Opus 5 --- HISTORY.md | 77 +++++ ROADMAP.md | 3 + db/migrations/0022_template_graphs.sql | 79 +++++ server/internal/httpapi/checks.go | 18 ++ server/internal/httpapi/server.go | 3 + server/internal/httpapi/templates.go | 74 +++++ server/internal/store/checks.go | 30 ++ server/internal/store/templates.go | 180 ++++++++++- web/src/api/client.ts | 20 ++ web/src/components/BackupPolicyForm.tsx | 29 +- web/src/components/DeviceForm.tsx | 386 ++++++++++++++++++++++++ web/src/components/MapCanvas.tsx | 25 +- web/src/components/MetricChart.tsx | 112 +++++-- web/src/components/Picker.tsx | 127 ++++++++ web/src/pages/DevicesPage.tsx | 347 +-------------------- web/src/pages/MetricsPage.tsx | 142 ++++++++- web/src/pages/TemplatesPage.tsx | 196 +++++++++++- web/src/types.ts | 21 ++ 18 files changed, 1488 insertions(+), 381 deletions(-) create mode 100644 db/migrations/0022_template_graphs.sql create mode 100644 web/src/components/DeviceForm.tsx create mode 100644 web/src/components/Picker.tsx diff --git a/HISTORY.md b/HISTORY.md index f7e9a60..6b30987 100644 --- a/HISTORY.md +++ b/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` на кожному рендері, а мій обробник щоразу +створював новий масив: колбек міняв стан сторінки, сторінка +перемальовувала полотно, полотно кликало колбек. Цикл зʼїдав головний +потік, і мертвими ставали **всі** сторінки, не лише мапа. + +Тепер набір віддається назовні лише коли справді змінився — порівняння +за склеєним рядком ідентифікаторів. + diff --git a/ROADMAP.md b/ROADMAP.md index bcefb91..b3df72a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -105,6 +105,9 @@ POST /api/v1/roles кастомна роль із набором > **Додано 2026-08-24:** шаблон описує перевірки будь-якого типу (не лише > OID), імпорт/експорт глобальний і поштучний, ручні інтервали. > +> **Додано 2026-08-24:** графіки описуються в шаблоні (шість видів, +> жорсткі межі осей), картка хоста на вкладках без інтервалів. +> > **Лишилось:** автопризначення за `sysObjectID`, прототипи шаблонів, > `snmp.walk` як тип елемента (таблиці з динамічним індексом), тригери > всередині шаблону. diff --git a/db/migrations/0022_template_graphs.sql b/db/migrations/0022_template_graphs.sql new file mode 100644 index 0000000..007c959 --- /dev/null +++ b/db/migrations/0022_template_graphs.sql @@ -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; diff --git a/server/internal/httpapi/checks.go b/server/internal/httpapi/checks.go index 6d88fda..d20fb61 100644 --- a/server/internal/httpapi/checks.go +++ b/server/internal/httpapi/checks.go @@ -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}) +} + diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index a48be9b..1fa4ddd 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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)) diff --git a/server/internal/httpapi/templates.go b/server/internal/httpapi/templates.go index 56f95a3..f52e6ca 100644 --- a/server/internal/httpapi/templates.go +++ b/server/internal/httpapi/templates.go @@ -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}) +} + diff --git a/server/internal/store/checks.go b/server/internal/store/checks.go index 85f706a..171070d 100644 --- a/server/internal/store/checks.go +++ b/server/internal/store/checks.go @@ -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 +} diff --git a/server/internal/store/templates.go b/server/internal/store/templates.go index 2a66f33..84e892f 100644 --- a/server/internal/store/templates.go +++ b/server/internal/store/templates.go @@ -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 +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 620ae90..812060c 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`/api/v1/icons/${id}`, { method: 'DELETE' }), + + // --- графіки шаблонів --- + + saveTemplateGraphs: (templateID: string, graphs: TemplateGraph[]) => + request(`/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 ?? [], + ), } diff --git a/web/src/components/BackupPolicyForm.tsx b/web/src/components/BackupPolicyForm.tsx index da60305..63f8753 100644 --- a/web/src/components/BackupPolicyForm.tsx +++ b/web/src/components/BackupPolicyForm.tsx @@ -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([]) const [policy, setPolicy] = useState(null) @@ -60,11 +63,10 @@ export function BackupPolicyForm({ const cliCreds = credentials.filter((c) => c.proto === 'ssh' || c.proto === 'telnet') - return ( - - {loading ? ( -

Завантаження…

- ) : ( + const body = ( + loading ? ( +

Завантаження…

+ ) : (
- )} + ) + ) + + // Всередині картки хоста форма живе окремою вкладкою, а не власним + // вікном: вікно у вікні читається як помилка інтерфейсу. + if (inline) return body + + return ( + {})}> + {body} ) } diff --git a/web/src/components/DeviceForm.tsx b/web/src/components/DeviceForm.tsx new file mode 100644 index 0000000..750d8f8 --- /dev/null +++ b/web/src/components/DeviceForm.tsx @@ -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 = { + 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 + onDelete?: () => Promise +}) { + const [tab, setTab] = useState('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(device?.group_ids ?? []) + const [credIDs, setCredIDs] = useState([]) + const [credsLoaded, setCredsLoaded] = useState(!device) + + const [templates, setTemplates] = useState([]) + const [templateIDs, setTemplateIDs] = useState([]) + const [templatesLoaded, setTemplatesLoaded] = useState(false) + + const [checks, setChecks] = useState([]) + const [checksLoaded, setChecksLoaded] = useState(!device) + + const [confirm, setConfirm] = useState(null) + const [busy, setBusy] = useState(false) + const [err, setErr] = useState(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 ( + +
+
+ {tabs.map((t) => ( + + ))} +
+ + {tab === 'main' && ( + <> + + setName(e.target.value)} + /> + + +
+ + setAddress(e.target.value)} + /> + + + + +
+ +
+ + setVendor(e.target.value)} + /> + + + + +
+ + + ({ id: g.id, label: g.name }))} + value={groupIDs} + onChange={setGroupIDs} + empty="Груп ще немає — заведіть їх на сторінці «Групи»" + /> + + + + + )} + + {tab === 'templates' && ( + <> +

+ Шаблон приносить набір перевірок разом з інтервалами. Саме там задається, як + опитувати хост — окремо міняти інтервали тут не треба. +

+ + ({ + id: t.id, + label: t.name, + hint: plural(t.item_count, 'метрика', 'метрики', 'метрик'), + }))} + value={templateIDs} + onChange={setTemplateIDs} + empty="Шаблонів ще немає" + /> + + {templateIDs.length === 0 && checks.length === 0 && ( +

+ Без жодного шаблону хост лежатиме в списку й не опитуватиметься. Найпростіший + варіант — «Доступність (ICMP)». +

+ )} + + )} + + {tab === 'access' && ( + <> +

+ SNMP-community й паролі SSH заводяться на сторінці «Доступи». Тут лише вибір, які з + них застосовувати до цього хоста. +

+ + ({ id: c.id, label: c.name, hint: c.proto }))} + value={credIDs} + onChange={setCredIDs} + empty="Доступів ще немає" + /> + + + )} + + {tab === 'ncm' && + (device ? ( + + ) : ( +

+ Розклад збору конфігів налаштовується після створення хоста: політиці потрібен сам + хост, до якого вона кріпиться. +

+ ))} + + {tab === 'checks' && ( + <> +

+ Одиничні перевірки, яких немає в жодному шаблоні. Якщо те саме потрібно кільком + хостам — краще шаблон: виправлений там OID доїжджає до всіх сам. +

+ {checksLoaded ? ( + + ) : ( +

Завантаження перевірок…

+ )} + + )} + + {err} + setConfirm(null)} /> + +
+ {onDelete && ( + + )} + + +
+
+
+ ) +} diff --git a/web/src/components/MapCanvas.tsx b/web/src/components/MapCanvas.tsx index e280858..25bc4a6 100644 --- a/web/src/components/MapCanvas.tsx +++ b/web/src/components/MapCanvas.tsx @@ -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[]) => { 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} diff --git a/web/src/components/MetricChart.tsx b/web/src/components/MetricChart.tsx index ed52f33..c4cb3c0 100644 --- a/web/src/components/MetricChart.tsx +++ b/web/src/components/MetricChart.tsx @@ -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[] }) { ))} - {lines.map((ln, i) => ( - - 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) => ( + + )), + ) + : lines.map((ln, i) => ( + + {/* Заливка під лінією. Для «stacked» значення вже + складені в build(), тож малюється те саме — різниця + лише в тому, що саме порахували. */} + {(kind === 'area' || kind === 'stacked') && + ln.segments.map((seg, si) => ( + + `${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} + /> + ))} + + 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" + /> + + ))} ) } @@ -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() + 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) } } diff --git a/web/src/components/Picker.tsx b/web/src/components/Picker.tsx new file mode 100644 index 0000000..4fb0aa2 --- /dev/null +++ b/web/src/components/Picker.tsx @@ -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(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 ( +
+ {value.length > 0 && ( +
+ {value.map((id) => ( + + {byID.get(id)?.label ?? id} + {!disabled && ( + + )} + + ))} +
+ )} + + 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 && ( +
    + {matches.map((i) => ( +
  • + +
  • + ))} + {matches.length === 0 && ( +
  • {empty}
  • + )} +
+ )} +
+ ) +} diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index 0fd31c8..45405e8 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -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 = { @@ -40,22 +35,6 @@ const statusLabel: Record = { } // Порядок такий самий, як у ENUM inv.device_kind на сервері. -const kindLabel: Record = { - 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 - onDelete?: () => Promise -}) { - 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(device?.group_ids ?? []) - const [credIDs, setCredIDs] = useState([]) - const [templates, setTemplates] = useState([]) - const [templateIDs, setTemplateIDs] = useState([]) - const [templatesLoaded, setTemplatesLoaded] = useState(false) - const [confirm, setConfirm] = useState(null) - const [busy, setBusy] = useState(false) - const [err, setErr] = useState(null) - - // Новий хост починає з пінга: це єдина перевірка, яка працює будь-де - // без налаштування, а хост без жодної перевірки не опитується взагалі. - const [checks, setChecks] = useState( - 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 ( - -
- - setName(e.target.value)} - /> - - -
- - setAddress(e.target.value)} - /> - - - - -
- -
- - setVendor(e.target.value)} - /> - - - - -
- -
- Групи - {groups.length === 0 ? ( -

- Груп ще немає — створіть їх у розділі «Групи» -

- ) : ( -
    - {groups.map((g) => ( -
  • - -
  • - ))} -
- )} -
- - {credentials.length > 0 && ( -
- - Доступи - - SNMP-community, SSH — потрібні для всього, крім пінга - - -
    - {credentials.map((c) => ( -
  • - -
  • - ))} -
-
- )} - - {templates.length > 0 && ( -
-
Шаблони опитування
-

- Метрики шаблону додаються поверх перевірок нижче й оновлюються разом із ним -

-
    - {templates.map((t) => ( -
  • - -
  • - ))} -
-
- )} - - {checksLoaded ? ( - - ) : ( -

Завантаження перевірок…

- )} - - - - {err} - - setConfirm(null)} /> - -
- {onDelete && ( - - )} - - -
-
-
- ) -} - function DeviceDetail({ device, groups, diff --git a/web/src/pages/MetricsPage.tsx b/web/src/pages/MetricsPage.tsx index 23982b6..749296a 100644 --- a/web/src/pages/MetricsPage.tsx +++ b/web/src/pages/MetricsPage.tsx @@ -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([]) const [range, setRange] = useState('6h') const [data, setData] = useState(null) + const [graphs, setGraphs] = useState([]) + // Готові графіки з шаблонів чи власний добір метрик. + // + // Шаблонні першими: у дев'яти випадках із десяти людина приходить + // подивитись «як там пам'ять», а не збирати графік з нуля. + const [mode, setMode] = useState<'templates' | 'custom'>('templates') const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) const [err, setErr] = useState(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() { ))} + {graphs.length > 0 && ( +
+ + +
+ )}
{RANGES.map((r) => ( + )} +
+

+ «Завантаження каналу — це вхід і вихід на одній осі» описує клас пристроїв, а не + окремий хост. Графік, заданий тут, зʼявиться на кожному, кому чіпляють шаблон. +

+ + {graphs.map((g, i) => ( +
+
+ + + setGraphs(graphs.map((x, n) => (n === i ? { ...x, name: e.target.value } : x))) + } + /> + + + + +
+ + + + setGraphs( + graphs.map((x, n) => + n === i + ? { + ...x, + metric_keys: e.target.value + .split(',') + .map((k) => k.trim()) + .filter(Boolean), + } + : x, + ), + ) + } + /> + + +
+ + + setGraphs( + graphs.map((x, n) => + n === i + ? { + ...x, + options: { + ...x.options, + min: e.target.value === '' ? undefined : Number(e.target.value), + }, + } + : x, + ), + ) + } + /> + + + + setGraphs( + graphs.map((x, n) => + n === i + ? { + ...x, + options: { + ...x.options, + max: e.target.value === '' ? undefined : Number(e.target.value), + }, + } + : x, + ), + ) + } + /> + + + + setGraphs( + graphs.map((x, n) => + n === i ? { ...x, options: { ...x.options, unit: e.target.value } } : x, + ), + ) + } + /> + +
+ + {!readOnly && ( +
+ +
+ )} +
+ ))} + + {err}
@@ -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({
) } + +/** + * Ключ графіка з назви. + * + * Потрібен базі, але не людині: у формі його немає, бо жодного рішення + * він не несе. Кирилиця відпадає — ключ має бути придатним для URL. + */ +function slugifyKey(name: string, i: number): string { + const s = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + return s || `graph-${i + 1}` +} diff --git a/web/src/types.ts b/web/src/types.ts index a51094e..0466d30 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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[] +} +