Картка хоста на вкладках, графіки в шаблонах, пошукові списки

Форма хоста стала вкладками: Хост, Шаблони, Доступи, Збір конфігів,
Ручні перевірки. Інтервалів опитування тут більше немає — вони живуть
у шаблонах, і два джерела правди про одне число розійшлися б саме тоді,
коли треба швидко зрозуміти, чому хост опитується не так, як написано.
Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек.

Групи, шаблони й доступи обираються пошуком із чипами: список галочок
працює на пʼяти позиціях і перестає працювати на пʼятдесяти.

Графіки описуються в шаблоні (tpl.graphs): шість видів, жорсткі межі
осей, перелік ключів метрик замість посилань на елементи — графік має
право показувати й те, що прийшло з іншого шаблону. Графік без жодного
знайденого ряду не показується: порожня рамка з підписом — це обіцянка
даних, яких немає.

Знайдено живим прогоном і виправлено: обробник виділення на полотні
створював новий масив щорендеру й зациклював оновлення. Цикл зʼїдав
головний потік, і мертвими ставали всі сторінки, не лише мапа.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 19:11:24 +03:00
parent 5aba75ccc3
commit 387980d655
18 changed files with 1488 additions and 381 deletions

View file

@ -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) → вісь жорстко 0100 з шаблону
gateway «Час відгуку» (line) → дві лінії, вісь 02.6 з даних
```
---
## Знайдено живим прогоном: полотно вбивало інтерфейс
Після додавання групового виділення сторінка перестала реагувати —
`Maximum update depth exceeded` у `SelectionListener`. React Flow
викликає `onSelectionChange` на кожному рендері, а мій обробник щоразу
створював новий масив: колбек міняв стан сторінки, сторінка
перемальовувала полотно, полотно кликало колбек. Цикл зʼїдав головний
потік, і мертвими ставали **всі** сторінки, не лише мапа.
Тепер набір віддається назовні лише коли справді змінився — порівняння
за склеєним рядком ідентифікаторів.

View file

@ -105,6 +105,9 @@ POST /api/v1/roles кастомна роль із набором
> **Додано 2026-08-24:** шаблон описує перевірки будь-якого типу (не лише
> OID), імпорт/експорт глобальний і поштучний, ручні інтервали.
>
> **Додано 2026-08-24:** графіки описуються в шаблоні (шість видів,
> жорсткі межі осей), картка хоста на вкладках без інтервалів.
>
> **Лишилось:** автопризначення за `sysObjectID`, прототипи шаблонів,
> `snmp.walk` як тип елемента (таблиці з динамічним індексом), тригери
> всередині шаблону.

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 ?? [],
),
}

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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