diff --git a/HISTORY.md b/HISTORY.md index fd16a71..1bc7cb6 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -999,3 +999,88 @@ eng редагує чужий хост 403 Динамічних груп (`inv.device_groups.kind='dynamic'` у схемі є, правило відбору не читається), успадкування прав між групами, редагування груп хостів після створення (лише створення й видалення), масових операцій над хостами. + +--- + +## 2026-08-15 — Опитування хоста з форми й редагування користувачів + +Зауваження було точним: «додаєш хост — чому не можна вказати, як його +опитувати». Форма створення хоста, яку я зробив раніше, збирала назву, +адресу, тип і групи — і не збирала головного. Хост, доданий через UI, +не опитувався **взагалі**: жодного рядка в `core.checks`. Наявні два +хости на стенді працювали лише тому, що їхні перевірки я засіяв через +SQL. + +### Створено + +- `server/internal/store/checks.go` — типи перевірок, перевірки хоста, + доступи до обладнання. +- `server/internal/httpapi/checks.go` — 5 ендпоїнтів. +- `web/src/components/ChecksEditor.tsx` — редактор опитування, який + будує поля параметрів із `params_schema`, що віддає сервер. +- `UpdateUserProfile` у store + розширений `PATCH /api/v1/team/{id}`. +- `EditUserForm` на сторінці «Команда». + +### Прийняті рішення + +**Перевірки — частина створення хоста, а не окремий крок.** Вимагати +другого запиту означало б зробити «хост, який не опитується» типовим +станом. Новий хост у формі одразу отримує `icmp.ping`: це єдина +перевірка, яка працює будь-де без налаштування. + +**Поля параметрів будуються з JSON Schema типу.** Захардкодити їх у +фронтенді означало б забувати оновити його щоразу, коли плагін додає +новий тип. Сервер уже віддає `params_schema` — форма її і читає. + +**Правка перевірки йде за `id`.** Унікальний індекс `checks_uniq` +включає `md5(params)`, тому «видалити й вставити» на зміні інтервалу +спрацювало б, а на зміні параметрів створило б ДРУГУ перевірку того +самого типу. + +**Профіль редагується лише в того, хто працює тільки в цій +організації.** `core.users` глобальна, і логін із паролем — власність +людини, а не тенанта. Адмін філії, який змінює пароль тому, хто тим +самим акаунтом заходить у сусідню організацію, ламає їй доступ там, і +вона про це не дізнається. Спроба дає `409 shared_user`; роль і групи +локальні й редагуються завжди. + +**Порожнє поле у формі означає «не чіпати».** Тому перевірки шлються +лише коли форма справді їх завантажила: збереження форми, відкритої до +завантаження, інакше стерло б усе опитування хоста. + +### Знайдено при написанні + +`core.plugins` не має колонки `enabled` — активація на тенанта живе в +`core.plugin_installs`. Перший варіант запиту падав із +`column p.enabled does not exist`. Заодно з'ясувалося, що +`plugin_installs` порожня, тому доступність рахується як «плагін +базовий АБО явно ввімкнений»: вимагати «встановлення» для пінга +означало б зустрічати кожного нового клієнта порожнім списком перевірок. + +### Перевірено наживо + +``` +типи перевірок 8 доступних із 9 (modbus не базовий і не встановлений) +доступ SNMP створено 201 +хост із трьома перевірками 201 +що записалось icmp.ping 30 с, snmp.get 60 с, snmp.if 300 с +правка інтервалу і зняття однієї 10 с, params {"count": 5}, лишилось 2 — без задвоєння +хост без перевірок створюється, перевірок 0 +профіль + пароль змінено 204 +вхід новим паролем 200 +вхід старим паролем 401 bad_credentials +перейменування логіна 204, вхід новим логіном 200 +спроба змінити роль собі 403 +``` + +У браузері: форма нового хоста показує `icmp.ping` із полями `count` і +`packet_size`, узятими зі схеми, і список із семи інших доступних типів; +доданий через UI хост записав `icmp.ping` з інтервалом 30 с і `snmp.get` +із двома OID. + +### Чого ще немає + +Форми створення доступу (SNMP-community) у вебі — доступи заводяться +через API, а у формі хоста лише прив'язуються. Перевірок на рівні групи +хостів (у Zabbix це шаблони — Етап 6). Історії й графіків за зібраними +метриками. diff --git a/server/API.md b/server/API.md index 2ee976c..632cb87 100644 --- a/server/API.md +++ b/server/API.md @@ -84,6 +84,11 @@ JWT — ні. | `POST` | `/api/v1/devices` | додати хост (`devices:write`) | | `PATCH` | `/api/v1/devices/{id}` | змінити хост і його групи | | `DELETE` | `/api/v1/devices/{id}` | мʼяко видалити хост | +| `GET` | `/api/v1/check-types` | що система вміє опитувати | +| `GET` | `/api/v1/devices/{id}/checks` | перевірки хоста | +| `PUT` | `/api/v1/devices/{id}/checks` | замінити набір перевірок | +| `GET` | `/api/v1/credentials` | доступи до обладнання (без секретів) | +| `POST` | `/api/v1/credentials` | створити доступ (`devices:write`) | | `GET` | `/api/v1/device-groups` | групи хостів із лічильниками | | `POST` | `/api/v1/device-groups` | створити групу хостів | | `DELETE` | `/api/v1/device-groups/{id}` | видалити групу | @@ -170,6 +175,26 @@ API — відмова БД виглядає як «violates check constraint», Керування учасниками; усе, крім читання, потребує `users:write`. +`PATCH` міняє роль, профіль і пароль одним запитом: + +```jsonc +{"role_id": "…", "username": "noc-a", "email": "noc@acme.io", + "full_name": "Черговий", "password": "…"} +``` + +Порожнє поле означає «не чіпати» — форма не стирає того, чого не +показувала. Порожній `email` прибирає адресу (сигнал `-`). Зміна пароля +відкликає всі сесії цієї людини: інакше той, хто знав старий, лишається +всередині до місяця, доки не протермінується refresh. + +**Профіль редагується лише в того, хто працює тільки в цій організації.** +`core.users` глобальна: одна людина може мати доступ до кількох тенантів +(типово для MSP). Логін, пошта й пароль — її власність, а не власність +організації, тому адмін філії не може змінити їх тому, хто заходить тим +самим акаунтом ще кудись — інакше той навіть не дізнався б. Спроба дає +`409 shared_user`. Роль і членство в групах локальні й редагуються +завжди. + Три обмеження вшиті навмисно й не обходяться параметрами: роль `owner` не видається через API (лише CLI), не можна змінити роль самому собі й не можна прибрати себе з організації. Кожне з них рятує від одного й того самого — @@ -424,6 +449,54 @@ Sec-WebSocket-Protocol: netpulse.token.<токен> знімків в історії: 2 ``` +## Опитування хоста + +Хост сам по собі нічого не робить. Опитує його `core.checks` — рядок +«пристрій X, тип перевірки Y, кожні N секунд, з такими параметрами». +Тому `POST /api/v1/devices` приймає перевірки одразу: + +```jsonc +{ + "name": "sw-core-1", + "address": "10.0.0.1", + "kind": "switch", + "credential_ids": ["…"], + "checks": [ + {"check_type": "icmp.ping", "params": {"count": 3}, "interval_sec": 30}, + {"check_type": "snmp.if", "params": {"use_hc_counters": true}, "interval_sec": 300} + ] +} +``` + +Створювати хост без перевірок можна, але це свідомий вибір: такий хост +лежить у списку й не опитується ніколи. Форма в UI попереджає про це +прямим текстом і починає новий хост із `icmp.ping` — єдиної перевірки, +яка працює будь-де без налаштування. + +`GET /api/v1/check-types` віддає перелік із `params_schema` (JSON Schema) +для кожного типу. Форма будує поля з неї, а не зі свого списку: інакше +кожен новий тип перевірки, доданий плагіном, вимагав би перезбирання +фронтенду. Поле `available` каже, чи плагін увімкнений цьому тенанту — +базові (`is_core`) доступні завжди, решта потребує запису в +`core.plugin_installs`. + +**Правка перевірок іде за `id`, а не перестворенням.** Унікальний індекс +`checks_uniq` включає `md5(params)`, тому «видалити й вставити» на зміні +параметрів створило б ДРУГУ перевірку того самого типу. Плюс +перестворення скидає `next_run_at` і збиває рівномірність опитування по +всьому парку. + +`PUT /api/v1/devices/{id}/checks` замінює набір цілком: форма показує +повний список, і зняту перевірку треба вміти зняти. Перевірки на +інтерфейсах (`interface_id IS NOT NULL`) не чіпаються — їх заводить +автовиявлення, і форма хоста про них не знає. + +### Доступи до обладнання + +SNMP-community, паролі SSH і Telnet живуть в `inv.credentials`, +зашифровані тим самим кільцем, що й секрети каналів. Прив'язка до хоста — +`credential_ids` у тілі хоста. Без доступу працює лише `icmp.ping`. + ## Групи й доступ до хостів Два незалежні виміри, які не можна змішувати в одному списку прав: diff --git a/server/internal/httpapi/checks.go b/server/internal/httpapi/checks.go new file mode 100644 index 0000000..a781c91 --- /dev/null +++ b/server/internal/httpapi/checks.go @@ -0,0 +1,180 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/netpulse/netpulse/server/internal/store" +) + +func (s *Server) handleListCheckTypes(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + types, err := s.store.ListCheckTypes(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "типи перевірок", err) + return + } + if types == nil { + types = []store.CheckType{} + } + writeJSON(w, http.StatusOK, map[string]any{"check_types": types}) +} + +func (s *Server) handleListDeviceChecks(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 + } + checks, err := s.store.ListDeviceChecks(r.Context(), p.TenantID, deviceID) + if err != nil { + s.writeStoreError(w, "перевірки хоста", err) + return + } + if checks == nil { + checks = []store.Check{} + } + writeJSON(w, http.StatusOK, map[string]any{"checks": checks}) +} + +// handleSetDeviceChecks приймає повний набір перевірок хоста. +func (s *Server) handleSetDeviceChecks(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:write") { + return + } + deviceID := r.PathValue("id") + if !p.Scope().CanWrite(deviceID) { + writeError(w, http.StatusForbidden, "forbidden", "немає доступу на запис до цього хоста") + return + } + + var in struct { + Checks []struct { + ID string `json:"id"` + CheckType string `json:"check_type"` + Params json.RawMessage `json:"params"` + IntervalSec int `json:"interval_sec"` + TimeoutMs int `json:"timeout_ms"` + Retries int `json:"retries"` + Enabled *bool `json:"enabled"` + } `json:"checks"` + } + if !decodeBody(w, r, &in) { + return + } + + // Типи звіряємо з довідником: невідомий ключ інакше дійшов би до + // зонда й помер там як задача неіснуючому модулю — за кілометри від + // того місця, де його ввели. + known, err := s.store.ListCheckTypes(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "типи перевірок", err) + return + } + ok := map[string]bool{} + for _, t := range known { + ok[t.Key] = true + } + + out := make([]store.CheckInput, 0, len(in.Checks)) + for _, c := range in.Checks { + if !ok[c.CheckType] { + writeError(w, http.StatusBadRequest, "bad_check_type", + "невідомий тип перевірки: "+c.CheckType) + return + } + out = append(out, store.CheckInput{ + ID: c.ID, + CheckType: c.CheckType, + Params: jsonOr(c.Params, "{}"), + IntervalSec: c.IntervalSec, + TimeoutMs: c.TimeoutMs, + Retries: c.Retries, + Enabled: c.Enabled == nil || *c.Enabled, + }) + } + + if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, deviceID, out); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "хост не знайдено") + return + } + s.writeStoreError(w, "збереження перевірок", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------- +// Доступи до обладнання +// --------------------------------------------------------------------- + +func (s *Server) handleListCredentials(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:read") { + return + } + creds, err := s.store.ListCredentials(r.Context(), p.TenantID) + if err != nil { + s.writeStoreError(w, "перелік доступів", err) + return + } + if creds == nil { + creds = []store.Credential{} + } + writeJSON(w, http.StatusOK, map[string]any{"credentials": creds}) +} + +var validCredProtos = map[string]bool{ + "ssh": true, "telnet": true, "snmp_v2c": true, "snmp_v3": true, + "http": true, "https": true, "api": true, "modbus": true, +} + +func (s *Server) handleCreateCredential(w http.ResponseWriter, r *http.Request, p *Principal) { + if !requirePerm(w, p, "devices:write") { + return + } + if s.keyring == nil { + writeError(w, http.StatusServiceUnavailable, "no_keyring", + "сервер запущено без ключа шифрування — зберегти доступ ніяк") + return + } + + var in struct { + Name string `json:"name"` + Proto string `json:"proto"` + Username string `json:"username"` + Port int `json:"port"` + Secret string `json:"secret"` + Options json.RawMessage `json:"options"` + } + if !decodeBody(w, r, &in) { + return + } + if strings.TrimSpace(in.Name) == "" { + writeError(w, http.StatusBadRequest, "invalid", "потрібна назва доступу") + return + } + if !validCredProtos[in.Proto] { + writeError(w, http.StatusBadRequest, "bad_proto", "невідомий протокол доступу") + return + } + + id, err := s.store.CreateCredential(r.Context(), p.TenantID, strings.TrimSpace(in.Name), + in.Proto, in.Username, in.Port, in.Secret, jsonOr(in.Options, "{}"), s.keyring) + if err != nil { + if isUniqueViolation(err) { + writeError(w, http.StatusConflict, "duplicate", "доступ із такою назвою вже є") + return + } + s.writeStoreError(w, "створення доступу", err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"id": id}) +} diff --git a/server/internal/httpapi/groups.go b/server/internal/httpapi/groups.go index 8f9b887..8244e1b 100644 --- a/server/internal/httpapi/groups.go +++ b/server/internal/httpapi/groups.go @@ -1,6 +1,7 @@ package httpapi import ( + "encoding/json" "errors" "net/http" "strings" @@ -84,7 +85,7 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request, p *P if !requirePerm(w, p, "devices:write") { return } - in, ok := decodeDevice(w, r) + in, body, ok := decodeDevice(w, r) if !ok { return } @@ -98,6 +99,21 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request, p *P s.writeStoreError(w, "створення хоста", err) return } + + // Перевірки й доступи — частина створення, а не окремий крок. + // Хост без жодної перевірки просто лежить у списку й ніколи не + // опитується; вимагати від людини другого запиту означало б робити + // такий стан типовим. + if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, id, body.checkInputs()); err != nil { + s.writeStoreError(w, "перевірки хоста", err) + return + } + if body.CredentialIDs != nil { + if err := s.store.SetDeviceCredentials(r.Context(), p.TenantID, id, body.CredentialIDs); err != nil { + s.writeStoreError(w, "доступи хоста", err) + return + } + } writeJSON(w, http.StatusCreated, map[string]any{"id": id}) } @@ -113,7 +129,7 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request, p *P return } - in, ok := decodeDevice(w, r) + in, body, ok := decodeDevice(w, r) if !ok { return } @@ -129,6 +145,20 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request, p *P s.writeStoreError(w, "групи хоста", err) return } + // nil означає «форма про це не говорила» — тоді не чіпаємо. Порожній + // масив означає «прибрати все», і це різні наміри. + if body.Checks != nil { + if err := s.store.SetDeviceChecks(r.Context(), p.TenantID, deviceID, body.checkInputs()); err != nil { + s.writeStoreError(w, "перевірки хоста", err) + return + } + } + if body.CredentialIDs != nil { + if err := s.store.SetDeviceCredentials(r.Context(), p.TenantID, deviceID, body.CredentialIDs); err != nil { + s.writeStoreError(w, "доступи хоста", err) + return + } + } w.WriteHeader(http.StatusNoContent) } @@ -152,30 +182,62 @@ func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, p *P w.WriteHeader(http.StatusNoContent) } -func decodeDevice(w http.ResponseWriter, r *http.Request) (store.DeviceInput, bool) { - var in struct { - Name string `json:"name"` - Address string `json:"address"` - Kind string `json:"kind"` - Vendor string `json:"vendor"` - AgentID string `json:"agent_id"` - Enabled *bool `json:"enabled"` - GroupIDs []string `json:"group_ids"` - Notes string `json:"notes"` +// deviceBody — усе, що приходить із форми хоста, разом із тим, ЯК його +// опитувати. Хост без перевірок не опитується взагалі й лише займає +// місце в списку, тому форма має задавати їх одразу. +type deviceBody struct { + Name string `json:"name"` + Address string `json:"address"` + Kind string `json:"kind"` + Vendor string `json:"vendor"` + AgentID string `json:"agent_id"` + Enabled *bool `json:"enabled"` + GroupIDs []string `json:"group_ids"` + Notes string `json:"notes"` + + Checks []struct { + ID string `json:"id"` + CheckType string `json:"check_type"` + Params json.RawMessage `json:"params"` + IntervalSec int `json:"interval_sec"` + TimeoutMs int `json:"timeout_ms"` + Retries int `json:"retries"` + Enabled *bool `json:"enabled"` + } `json:"checks"` + CredentialIDs []string `json:"credential_ids"` +} + +func (b deviceBody) checkInputs() []store.CheckInput { + out := make([]store.CheckInput, 0, len(b.Checks)) + for _, c := range b.Checks { + out = append(out, store.CheckInput{ + ID: c.ID, + CheckType: c.CheckType, + Params: jsonOr(c.Params, "{}"), + IntervalSec: c.IntervalSec, + TimeoutMs: c.TimeoutMs, + Retries: c.Retries, + Enabled: c.Enabled == nil || *c.Enabled, + }) } + return out +} + +func decodeDevice(w http.ResponseWriter, r *http.Request) (store.DeviceInput, deviceBody, bool) { + var in deviceBody if !decodeBody(w, r, &in) { - return store.DeviceInput{}, false + return store.DeviceInput{}, in, false } if strings.TrimSpace(in.Name) == "" { writeError(w, http.StatusBadRequest, "invalid", "потрібна назва хоста") - return store.DeviceInput{}, false + return store.DeviceInput{}, in, false } if in.Kind == "" { in.Kind = "other" } if !validDeviceKinds[in.Kind] { writeError(w, http.StatusBadRequest, "bad_kind", "невідомий тип пристрою") - return store.DeviceInput{}, false + return store.DeviceInput{}, in, false } return store.DeviceInput{ Name: strings.TrimSpace(in.Name), @@ -186,7 +248,7 @@ func decodeDevice(w http.ResponseWriter, r *http.Request) (store.DeviceInput, bo Enabled: in.Enabled == nil || *in.Enabled, GroupIDs: in.GroupIDs, Notes: in.Notes, - }, true + }, in, true } func isPlanLimit(err error) bool { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 7ed9bf9..44446df 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -91,6 +91,12 @@ func (s *Server) Handler() http.Handler { mux.Handle("PATCH /api/v1/devices/{id}", s.authenticated(s.handleUpdateDevice)) mux.Handle("DELETE /api/v1/devices/{id}", s.authenticated(s.handleDeleteDevice)) + mux.Handle("GET /api/v1/check-types", s.authenticated(s.handleListCheckTypes)) + mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks)) + mux.Handle("PUT /api/v1/devices/{id}/checks", s.authenticated(s.handleSetDeviceChecks)) + mux.Handle("GET /api/v1/credentials", s.authenticated(s.handleListCredentials)) + mux.Handle("POST /api/v1/credentials", s.authenticated(s.handleCreateCredential)) + mux.Handle("GET /api/v1/device-groups", s.authenticated(s.handleListDeviceGroups)) mux.Handle("POST /api/v1/device-groups", s.authenticated(s.handleCreateDeviceGroup)) mux.Handle("DELETE /api/v1/device-groups/{id}", s.authenticated(s.handleDeleteDeviceGroup)) diff --git a/server/internal/httpapi/users.go b/server/internal/httpapi/users.go index 6c9aac9..f962fc0 100644 --- a/server/internal/httpapi/users.go +++ b/server/internal/httpapi/users.go @@ -97,30 +97,87 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri writeJSON(w, http.StatusCreated, u) } +// handleSetRole міняє роль і профіль учасника. +// +// Один ендпоїнт, а не три: у формі це один екран і одна кнопка +// «Зберегти». Розбивати її на окремі запити означало б лишати +// користувача в напівзбереженому стані, якщо другий не пройде. func (s *Server) handleSetRole(w http.ResponseWriter, r *http.Request, p *Principal) { if !requirePerm(w, p, "users:write") { return } var in struct { - RoleID string `json:"role_id"` + RoleID string `json:"role_id"` + Username string `json:"username"` + Email string `json:"email"` + FullName string `json:"full_name"` + Password string `json:"password"` } if !decodeBody(w, r, &in) { return } userID := r.PathValue("id") - // Себе понизити не можна: інакше єдиний адміністратор тенанта - // одним кліком лишає організацію без керування. - if p.IsUser() && userID == p.UserID { - writeError(w, http.StatusForbidden, "self_demotion", "не можна змінити власну роль") + + // Роль собі не міняють: організація без жодного власника не + // відновлюється через інтерфейс. + if in.RoleID != "" && userID == p.UserID { + writeError(w, http.StatusForbidden, "forbidden", "не можна змінити роль самому собі") return } - if err := s.store.SetRole(r.Context(), p.TenantID, userID, in.RoleID); err != nil { - s.writeStoreError(w, "зміна ролі", err) + if in.Username != "" { + in.Username = strings.ToLower(strings.TrimSpace(in.Username)) + if !usernameRe.MatchString(in.Username) { + writeError(w, http.StatusBadRequest, "bad_username", + "логін: 3–64 символи, лише латиниця, цифри, крапка, дефіс і підкреслення") + return + } + } + if in.Password != "" && len(in.Password) < 8 { + writeError(w, http.StatusBadRequest, "weak_password", "пароль щонайменше 8 символів") return } + + if in.Username != "" || in.Email != "" || in.FullName != "" || in.Password != "" { + err := s.store.UpdateUserProfile(r.Context(), p.TenantID, userID, store.UserProfileInput{ + Username: in.Username, + Email: in.Email, + FullName: in.FullName, + Password: in.Password, + }) + if errors.Is(err, store.ErrSharedUser) { + writeError(w, http.StatusConflict, "shared_user", + "цей акаунт працює ще в іншій організації — профіль і пароль "+ + "може змінити лише його власник") + return + } + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "учасника не знайдено") + return + } + if err != nil { + if isUniqueViolation(err) { + writeError(w, http.StatusConflict, "duplicate", "такий логін або email уже зайнято") + return + } + s.writeStoreError(w, "оновлення профілю", err) + return + } + } + + if in.RoleID != "" { + if err := s.store.SetRole(r.Context(), p.TenantID, userID, in.RoleID); err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "учасника або роль не знайдено") + return + } + s.writeStoreError(w, "зміна ролі", err) + return + } + } + w.WriteHeader(http.StatusNoContent) } diff --git a/server/internal/store/checks.go b/server/internal/store/checks.go new file mode 100644 index 0000000..b4f0b7a --- /dev/null +++ b/server/internal/store/checks.go @@ -0,0 +1,355 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/netpulse/netpulse/server/internal/crypto" +) + +var ErrCheckNotFound = errors.New("перевірку не знайдено") + +// CheckType — що взагалі вміє система опитувати. +// +// Перелік читається з БД, а не зашитий у фронтенд: плагіни додають свої +// типи міграцією, і UI має підхоплювати їх без перезбирання. +type CheckType struct { + Key string `json:"key"` + PluginKey string `json:"plugin_key"` + Name string `json:"name"` + ParamsSchema json.RawMessage `json:"params_schema"` + Metrics json.RawMessage `json:"metrics"` + /** Чи ввімкнений плагін у цього тенанта. */ + Available bool `json:"available"` +} + +// Check — одна перевірка на хості. +type Check struct { + ID string `json:"id"` + DeviceID string `json:"device_id"` + CheckType string `json:"check_type"` + TypeName string `json:"type_name,omitempty"` + Params json.RawMessage `json:"params"` + IntervalSec int `json:"interval_sec"` + TimeoutMs int `json:"timeout_ms"` + Retries int `json:"retries"` + Enabled bool `json:"enabled"` + LastRunAt *string `json:"last_run_at,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +// ListCheckTypes віддає доступні типи перевірок. +func (s *Store) ListCheckTypes(ctx context.Context, tenantID string) ([]CheckType, error) { + var out []CheckType + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT ct.key, ct.plugin_key::text, ct.name, + ct.params_schema::text, ct.metrics::text, + -- Плагін доступний, якщо його явно ввімкнули цьому + -- тенанту або він базовий: вимагати «встановлення» + -- для пінга означало б зустрічати кожного клієнта + -- порожнім списком перевірок. + (p.is_core OR COALESCE(pi.enabled, false)) + FROM core.check_types ct + JOIN core.plugins p ON p.key = ct.plugin_key + LEFT JOIN core.plugin_installs pi + ON pi.plugin_key = ct.plugin_key AND pi.tenant_id = $1 + ORDER BY ct.plugin_key, ct.key + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var c CheckType + var schema, metrics string + if err := rows.Scan(&c.Key, &c.PluginKey, &c.Name, &schema, &metrics, + &c.Available); err != nil { + return err + } + c.ParamsSchema = json.RawMessage(schema) + c.Metrics = json.RawMessage(metrics) + out = append(out, c) + } + return rows.Err() + }) + return out, err +} + +// ListDeviceChecks — перевірки одного хоста. +func (s *Store) ListDeviceChecks(ctx context.Context, tenantID, deviceID string) ([]Check, error) { + var out []Check + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT c.id::text, c.device_id::text, c.check_type, COALESCE(ct.name,''), + c.params::text, c.interval_sec, c.timeout_ms, c.retries, c.enabled, + to_char(c.last_run_at, 'YYYY-MM-DD"T"HH24:MI:SSOF'), + COALESCE(c.last_error,'') + FROM core.checks c + LEFT JOIN core.check_types ct ON ct.key = c.check_type + WHERE c.tenant_id = $1 AND c.device_id = $2 + ORDER BY c.check_type + `, tenantID, deviceID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var c Check + var params string + if err := rows.Scan(&c.ID, &c.DeviceID, &c.CheckType, &c.TypeName, ¶ms, + &c.IntervalSec, &c.TimeoutMs, &c.Retries, &c.Enabled, + &c.LastRunAt, &c.LastError); err != nil { + return err + } + c.Params = json.RawMessage(params) + out = append(out, c) + } + return rows.Err() + }) + return out, err +} + +// CheckInput — те, що приходить із форми. Порожній ID означає нову +// перевірку. +type CheckInput struct { + ID string + CheckType string + Params string + IntervalSec int + TimeoutMs int + Retries int + Enabled bool +} + +// SetDeviceChecks замінює набір перевірок хоста цілком. +// +// Заміна, а не додавання: форма показує повний список, і прибраний +// рядок треба вміти прибрати. +// +// Наявні перевірки оновлюються за своїм id, а не перестворюються. +// Причина не в економії: унікальний індекс включає md5(params), тому +// «видалити й вставити» на зміні інтервалу спрацювало б, а на зміні +// параметрів створило б ДРУГУ перевірку того самого типу. Плюс +// перестворення скидає next_run_at і збиває рівномірність опитування +// по всьому парку. +func (s *Store) SetDeviceChecks(ctx context.Context, tenantID, deviceID string, in []CheckInput) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var owned bool + if err := tx.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM inv.devices + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL) + `, deviceID, tenantID).Scan(&owned); err != nil { + return err + } + if !owned { + return ErrNotFound + } + + keep := make([]string, 0, len(in)) + for _, c := range in { + if c.IntervalSec < 5 { + c.IntervalSec = 60 + } + if c.IntervalSec > 86400 { + c.IntervalSec = 86400 + } + if c.TimeoutMs <= 0 { + c.TimeoutMs = 3000 + } + if c.Retries < 0 { + c.Retries = 0 + } + params := c.Params + if params == "" { + params = "{}" + } + + var id string + if c.ID != "" { + err := tx.QueryRow(ctx, ` + UPDATE core.checks + SET params = $4::jsonb, interval_sec = $5, timeout_ms = $6, + retries = $7, enabled = $8, updated_at = now() + WHERE tenant_id = $1 AND device_id = $2 AND id = $3 + RETURNING id::text + `, tenantID, deviceID, c.ID, params, c.IntervalSec, + c.TimeoutMs, c.Retries, c.Enabled).Scan(&id) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("перевірка %s: %w", c.CheckType, err) + } + } + + // Рядка з таким id немає (або його не передали) — створюємо. + if id == "" { + if err := tx.QueryRow(ctx, ` + INSERT INTO core.checks + (tenant_id, device_id, check_type, params, interval_sec, + timeout_ms, retries, enabled) + VALUES ($1, $2, $3, $4::jsonb, $5, $6, $7, $8) + RETURNING id::text + `, tenantID, deviceID, c.CheckType, params, c.IntervalSec, + c.TimeoutMs, c.Retries, c.Enabled).Scan(&id); err != nil { + return fmt.Errorf("перевірка %s: %w", c.CheckType, err) + } + } + keep = append(keep, id) + } + + // Прибираємо зняте у формі. Перевірки на інтерфейсах не чіпаємо: + // їх заводить автовиявлення, і форма хоста про них не знає. + _, err := tx.Exec(ctx, ` + DELETE FROM core.checks + WHERE tenant_id = $1 AND device_id = $2 + AND interface_id IS NULL + AND NOT (id = ANY($3::uuid[])) + `, tenantID, deviceID, nonNilIDs(keep)) + return err + }) +} + +// --------------------------------------------------------------------- +// Креденшели хоста +// --------------------------------------------------------------------- + +type Credential struct { + ID string `json:"id"` + Name string `json:"name"` + Proto string `json:"proto"` + Username string `json:"username,omitempty"` + Port int `json:"port,omitempty"` + IsDefault bool `json:"is_default"` +} + +func (s *Store) ListCredentials(ctx context.Context, tenantID string) ([]Credential, error) { + var out []Credential + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT id::text, name, proto::text, COALESCE(username,''), + COALESCE(port,0), is_default + FROM inv.credentials + WHERE tenant_id = $1 + ORDER BY proto, name + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var c Credential + if err := rows.Scan(&c.ID, &c.Name, &c.Proto, &c.Username, + &c.Port, &c.IsDefault); err != nil { + return err + } + out = append(out, c) + } + return rows.Err() + }) + return out, err +} + +// CreateCredential зберігає доступ із зашифрованим секретом. +func (s *Store) CreateCredential(ctx context.Context, tenantID, name, proto, username string, + port int, secret string, options string, ring *crypto.Keyring) (string, error) { + + var id string + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var secretID any + if secret != "" { + if ring == nil { + return errors.New("сервер запущено без ключа шифрування") + } + aad := tenantID + "|inv.credential" + sec, err := ring.Encrypt([]byte(secret), aad) + if err != nil { + return err + } + kind := "snmp_v3" + switch proto { + case "snmp_v2c": + // Community — це не пароль SNMPv3, але шифрується так само: + // у дампі БД вона дає рівно той самий доступ до обладнання. + kind = "api_token" + case "ssh": + kind = "ssh_password" + case "telnet": + kind = "telnet" + } + var sid string + if err := tx.QueryRow(ctx, ` + INSERT INTO core.secrets (tenant_id, kind, key_id, nonce, ciphertext, auth_tag, aad) + VALUES ($1, $2::core.secret_kind, $3, $4, $5, $6, $7) + RETURNING id::text + `, tenantID, kind, sec.KeyID, sec.Nonce, sec.Ciphertext, sec.AuthTag, aad). + Scan(&sid); err != nil { + return err + } + secretID = sid + } + + opts := options + if opts == "" { + opts = "{}" + } + return tx.QueryRow(ctx, ` + INSERT INTO inv.credentials + (tenant_id, name, proto, username, port, secret_id, options) + VALUES ($1, $2, $3::inv.credential_proto, NULLIF($4,''), NULLIF($5,0), $6, $7::jsonb) + RETURNING id::text + `, tenantID, name, proto, username, port, secretID, opts).Scan(&id) + }) + return id, err +} + +// SetDeviceCredentials замінює прив'язку доступів до хоста. +func (s *Store) SetDeviceCredentials(ctx context.Context, tenantID, deviceID string, credIDs []string) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + if _, err := tx.Exec(ctx, ` + DELETE FROM inv.device_credentials dc + USING inv.credentials c + WHERE dc.credential_id = c.id AND dc.device_id = $1 AND c.tenant_id = $2 + `, deviceID, tenantID); err != nil { + return err + } + if len(credIDs) == 0 { + return nil + } + _, err := tx.Exec(ctx, ` + INSERT INTO inv.device_credentials (device_id, credential_id, priority) + SELECT $1, c.id, 100 + FROM inv.credentials c + WHERE c.tenant_id = $2 AND c.id = ANY($3::uuid[]) + ON CONFLICT DO NOTHING + `, deviceID, tenantID, credIDs) + return err + }) +} + +// DeviceCredentialsOf — які доступи прив'язані до кожного хоста. +func (s *Store) DeviceCredentialsOf(ctx context.Context, tenantID string) (map[string][]string, error) { + out := map[string][]string{} + err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + rows, err := tx.Query(ctx, ` + SELECT dc.device_id::text, dc.credential_id::text + FROM inv.device_credentials dc + JOIN inv.credentials c ON c.id = dc.credential_id + WHERE c.tenant_id = $1 + `, tenantID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var d, c string + if err := rows.Scan(&d, &c); err != nil { + return err + } + out[d] = append(out[d], c) + } + return rows.Err() + }) + return out, err +} diff --git a/server/internal/store/users.go b/server/internal/store/users.go index 663a852..d0dd4d0 100644 --- a/server/internal/store/users.go +++ b/server/internal/store/users.go @@ -556,3 +556,92 @@ func (s *Store) ChangePassword(ctx context.Context, userID, oldPassword, newPass // Якщо після неї чужа сесія лишається живою, дія безглузда. return s.RevokeAllSessions(ctx, userID) } + +// --------------------------------------------------------------------- +// Редагування користувача адміністратором +// --------------------------------------------------------------------- + +// ErrSharedUser — обліковий запис належить кільком організаціям. +// +// core.users глобальна: одна людина може працювати в кількох тенантах +// (типово для MSP). Профіль, логін і пароль — її власність, а не +// власність організації, тому адмін одного тенанта не може міняти їх +// у того, хто працює ще десь. Роль і членство в групах — навпаки, +// локальні, і редагуються завжди. +var ErrSharedUser = errors.New("користувач працює в кількох організаціях") + +// UserProfileInput — те, що адміністратор може змінити в профілі. +// Порожній рядок означає «не чіпати». +type UserProfileInput struct { + Username string + Email string + FullName string + Password string +} + +// UpdateUserProfile міняє профіль учасника. +// +// Перед будь-якою зміною перевіряється, що людина належить лише цьому +// тенанту. Інакше адмін філії міг би змінити пароль людині, яка тим +// самим акаунтом заходить у сусідню організацію, — і та про це навіть +// не дізналася б. +func (s *Store) UpdateUserProfile(ctx context.Context, tenantID, userID string, in UserProfileInput) error { + return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error { + var member bool + if err := tx.QueryRow(ctx, ` + SELECT EXISTS (SELECT 1 FROM core.memberships + WHERE tenant_id = $1 AND user_id = $2) + `, tenantID, userID).Scan(&member); err != nil { + return err + } + if !member { + return ErrNotFound + } + + var tenants int + if err := tx.QueryRow(ctx, + `SELECT count(*)::int FROM core.memberships WHERE user_id = $1`, userID). + Scan(&tenants); err != nil { + return err + } + if tenants > 1 { + return ErrSharedUser + } + + var hash *string + if in.Password != "" { + h, err := auth.HashPassword(in.Password) + if err != nil { + return err + } + hash = &h + } + + if _, err := tx.Exec(ctx, ` + UPDATE core.users + SET username = COALESCE(NULLIF($2,'')::citext, username), + email = CASE WHEN $3 = '' THEN email + WHEN $3 = '-' THEN NULL + ELSE $3::citext END, + full_name = COALESCE(NULLIF($4,''), full_name), + password_hash = COALESCE($5, password_hash), + updated_at = now() + WHERE id = $1 + `, userID, in.Username, in.Email, in.FullName, hash); err != nil { + return fmt.Errorf("оновлення профілю: %w", err) + } + + // Зміна пароля відкликає сесії — інакше той, хто знав старий, + // лишається всередині рівно доти, доки не протермінується його + // refresh, тобто до місяця. + if in.Password != "" { + if _, err := tx.Exec(ctx, ` + UPDATE core.sessions SET revoked_at = now() + WHERE user_id = $1 AND revoked_at IS NULL + `, userID); err != nil { + return err + } + } + return nil + }) +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a96f845..790987a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -7,6 +7,9 @@ import type { AlertRule, BuildResult, Channel, + Check, + CheckType, + Credential, DeviceGroup, DeviceSummary, LoginResponse, @@ -21,6 +24,16 @@ import type { } from '../types' /** Те, що приймає POST/PATCH /api/v1/devices. */ +export interface CheckInput { + id?: string + check_type: string + params?: Record + interval_sec: number + timeout_ms?: number + retries?: number + enabled?: boolean +} + export interface DeviceInput { name: string address?: string @@ -30,6 +43,9 @@ export interface DeviceInput { enabled?: boolean group_ids?: string[] notes?: string + /** Як опитувати. Без цього хост лежить у списку й не опитується. */ + checks?: CheckInput[] + credential_ids?: string[] } @@ -180,6 +196,22 @@ export const api = { body: JSON.stringify({ role_id: roleID }), }), + /** + * Редагування учасника: роль, профіль і пароль одним запитом. + * Порожнє поле означає «не чіпати», щоб форма не стирала те, чого + * не показувала. + */ + updateMember: ( + userID: string, + patch: { + role_id?: string + username?: string + email?: string + full_name?: string + password?: string + }, + ) => request(`/api/v1/team/${userID}`, { method: 'PATCH', body: JSON.stringify(patch) }), + removeMember: (userID: string) => request(`/api/v1/team/${userID}`, { method: 'DELETE' }), @@ -223,6 +255,32 @@ export const api = { deleteDevice: (id: string) => request(`/api/v1/devices/${id}`, { method: 'DELETE' }), + // --- опитування --- + + listCheckTypes: () => + request<{ check_types: CheckType[] }>('/api/v1/check-types').then((r) => r.check_types ?? []), + + listDeviceChecks: (deviceID: string) => + request<{ checks: Check[] }>(`/api/v1/devices/${deviceID}/checks`).then((r) => r.checks ?? []), + + setDeviceChecks: (deviceID: string, checks: CheckInput[]) => + request(`/api/v1/devices/${deviceID}/checks`, { + method: 'PUT', + body: JSON.stringify({ checks }), + }), + + listCredentials: () => + request<{ credentials: Credential[] }>('/api/v1/credentials').then((r) => r.credentials ?? []), + + createCredential: (c: { + name: string + proto: string + username?: string + port?: number + secret?: string + options?: Record + }) => request<{ id: string }>('/api/v1/credentials', { method: 'POST', body: JSON.stringify(c) }), + // --- групи пристроїв --- listDeviceGroups: () => diff --git a/web/src/components/ChecksEditor.tsx b/web/src/components/ChecksEditor.tsx new file mode 100644 index 0000000..b2aafcf --- /dev/null +++ b/web/src/components/ChecksEditor.tsx @@ -0,0 +1,251 @@ +import { useState } from 'react' +import { Toggle, inputClass } from './ui' +import type { CheckInput } from '../api/client' +import type { CheckType } from '../types' + +/** Готові інтервали. Довільне число теж можна ввести. */ +const INTERVALS = [5, 10, 30, 60, 120, 300, 600, 1800, 3600] + +function humanInterval(sec: number): string { + if (sec < 60) return `${sec} с` + if (sec < 3600) return `${Math.round(sec / 60)} хв` + return `${Math.round(sec / 3600)} год` +} + +// Розумні типові інтервали. Пінг дешевий і має ловити обриви швидко; +// прохід по таблиці інтерфейсів коштує десятки OID-ів, і робити його +// щоп'ять секунд означає навантажити обладнання заради даних, які +// однаково усереднюються. +const DEFAULT_INTERVAL: Record = { + 'icmp.ping': 30, + 'snmp.get': 60, + 'snmp.if': 300, + 'snmp.walk': 300, + 'topology.discover': 3600, + 'http.status': 60, + 'http.ssl_expiry': 86400, + 'ncm.backup': 86400, + 'modbus.read': 60, +} + +/** + * Редактор опитування хоста. + * + * Форма показує параметри саме тих типів, які додали, і бере їхні + * значення за замовчуванням із `params_schema`, яку віддає сервер. + * Захардкодити поля тут означало б забути оновити фронтенд щоразу, коли + * плагін додає новий тип перевірки. + */ +export function ChecksEditor({ + types, + checks, + onChange, +}: { + types: CheckType[] + checks: CheckInput[] + onChange: (next: CheckInput[]) => void +}) { + const [adding, setAdding] = useState('') + + const used = new Set(checks.map((c) => c.check_type)) + const addable = types.filter((t) => t.available && !used.has(t.key)) + + function add(key: string) { + const t = types.find((x) => x.key === key) + if (!t) return + onChange([ + ...checks, + { + check_type: key, + params: defaultsFromSchema(t), + interval_sec: DEFAULT_INTERVAL[key] ?? 60, + timeout_ms: 3000, + retries: 2, + enabled: true, + }, + ]) + setAdding('') + } + + function patch(i: number, next: Partial) { + onChange(checks.map((c, idx) => (idx === i ? { ...c, ...next } : c))) + } + + return ( +
+
+ Опитування + {checks.length === 0 && ( + + без жодної перевірки хост не опитуватиметься + + )} +
+ +
    + {checks.map((c, i) => { + const t = types.find((x) => x.key === c.check_type) + return ( +
  • +
    + + {t?.name ?? c.check_type} + {c.check_type} + + + + + patch(i, { enabled: v })} + /> + + +
    + + {t && patch(i, { params })} />} +
  • + ) + })} +
+ + {addable.length > 0 ? ( + + ) : ( + checks.length > 0 && ( +

Усі доступні типи вже додані

+ ) + )} +
+ ) +} + +/** + * Поля параметрів із JSON Schema типу перевірки. + * + * Підтримуються скаляри й масиви рядків — цього вистачає всім наявним + * типам. Складніші структури (перелік регістрів Modbus) поки редагуються + * як текст: показати неповну форму гірше, ніж показати чесний JSON. + */ +function ParamFields({ + type, + value, + onChange, +}: { + type: CheckType + value: Record + onChange: (v: Record) => void +}) { + const props = type.params_schema?.properties ?? {} + const keys = Object.keys(props) + if (keys.length === 0) return null + + const required = new Set(type.params_schema?.required ?? []) + + return ( +
+ {keys.map((k) => { + const spec = props[k] ?? {} + const cur = value[k] + + if (spec.type === 'boolean') { + return ( + + ) + } + + const isArray = spec.type === 'array' + return ( + + ) + })} +
+ ) +} + +function defaultsFromSchema(t: CheckType): Record { + const out: Record = {} + for (const [k, spec] of Object.entries(t.params_schema?.properties ?? {})) { + if (spec?.default !== undefined) out[k] = spec.default + } + return out +} diff --git a/web/src/pages/DevicesPage.tsx b/web/src/pages/DevicesPage.tsx index 7f691ee..6f53a47 100644 --- a/web/src/pages/DevicesPage.tsx +++ b/web/src/pages/DevicesPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useOutletContext } from 'react-router-dom' -import { api, type DeviceInput } from '../api/client' +import { api, type CheckInput, type DeviceInput } from '../api/client' import { session } from '../api/session' import { liveEvents } from '../api/ws' import type { LiveAlerts } from '../hooks/useAlerts' @@ -16,7 +16,15 @@ import { ago, inputClass, } from '../components/ui' -import type { AgentSummary, Alert, DeviceGroup, DeviceSummary } from '../types' +import { ChecksEditor } from '../components/ChecksEditor' +import type { + AgentSummary, + Alert, + CheckType, + Credential, + DeviceGroup, + DeviceSummary, +} from '../types' const statusLabel: Record = { up: 'на зв’язку', @@ -53,6 +61,8 @@ export function DevicesPage() { const [devices, setDevices] = useState([]) const [groups, setGroups] = useState([]) const [agents, setAgents] = useState([]) + const [checkTypes, setCheckTypes] = useState([]) + const [creds, setCreds] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [query, setQuery] = useState('') @@ -67,14 +77,18 @@ export function DevicesPage() { const reload = useCallback(async () => { try { - const [d, g, a] = await Promise.all([ + const [d, g, a, ct, cr] = await Promise.all([ api.listDevices(), api.listDeviceGroups().catch(() => [] as DeviceGroup[]), session.can('agents:read') ? api.listAgents() : Promise.resolve([] as AgentSummary[]), + api.listCheckTypes().catch(() => [] as CheckType[]), + api.listCredentials().catch(() => [] as Credential[]), ]) setDevices(d) setGroups(g) setAgents(a) + setCheckTypes(ct) + setCreds(cr) setError(null) } catch (e) { setError(e instanceof Error ? e.message : String(e)) @@ -278,6 +292,8 @@ export function DevicesPage() { setCreating(false)} onSave={async (in_) => { await api.createDevice(in_) @@ -292,6 +308,8 @@ export function DevicesPage() { device={detail} groups={groups} agents={agents} + checkTypes={checkTypes} + credentials={creds} alerts={alertsByDevice.get(detail.id) ?? []} canWrite={canWrite && detail.writable} canMute={canMute && detail.writable} @@ -319,6 +337,8 @@ function DeviceForm({ device, groups, agents, + checkTypes, + credentials, onClose, onSave, onDelete, @@ -326,6 +346,8 @@ function DeviceForm({ device?: DeviceSummary groups: DeviceGroup[] agents: AgentSummary[] + checkTypes: CheckType[] + credentials: Credential[] onClose: () => void onSave: (in_: DeviceInput) => Promise onDelete?: () => Promise @@ -337,9 +359,48 @@ function DeviceForm({ const [agentID, setAgentID] = useState('') const [enabled, setEnabled] = useState(device?.enabled ?? true) const [groupIDs, setGroupIDs] = useState(device?.group_ids ?? []) + const [credIDs, setCredIDs] = useState([]) 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]) + const toggleGroup = (id: string) => setGroupIDs((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])) @@ -423,6 +484,42 @@ function DeviceForm({ )} + {credentials.length > 0 && ( +
+ + Доступи + + SNMP-community, SSH — потрібні для всього, крім пінга + + +
    + {credentials.map((c) => ( +
  • + +
  • + ))} +
+
+ )} + + {checksLoaded ? ( + + ) : ( +

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

+ )} +