Опитування хоста з форми й повне редагування користувачів

Форма створення хоста збирала назву, адресу, тип і групи — і не
збирала головного. Хост, доданий через UI, не опитувався взагалі:
жодного рядка в core.checks. Наявні хости на стенді працювали лише
тому, що їхні перевірки засіяні через SQL.

Опитування:
- POST /api/v1/devices приймає перевірки одразу; новий хост у формі
  починає з icmp.ping — єдиної перевірки, яка працює будь-де без
  налаштування
- поля параметрів будуються з params_schema, яку віддає сервер, а не
  з захардкодженого списку: інакше кожен новий тип від плагіна вимагав
  би перезбирання фронтенду
- правка йде за id, а не перестворенням: унікальний індекс включає
  md5(params), тож зміна параметрів створила б другу перевірку того
  самого типу
- доступи (SNMP-community, SSH) шифруються тим самим кільцем, що й
  секрети каналів, і прив'язуються до хоста

Користувачі:
- PATCH /api/v1/team/{id} міняє роль, логін, пошту, імʼя й пароль одним
  запитом; зміна пароля відкликає всі сесії
- профіль редагується лише в того, хто працює тільки в цій організації:
  core.users глобальна, і адмін філії не має міняти пароль тому, хто
  тим самим акаунтом заходить у сусідню — той навіть не дізнався б.
  Спроба дає 409 shared_user

Знайдено при написанні: core.plugins не має колонки enabled — активація
на тенанта живе в core.plugin_installs, і вона порожня, тому базові
плагіни доступні без явного встановлення.

Живий прогін: 8 типів із 9, три перевірки записались, правка інтервалу
не задвоїла, зміна пароля пустила новим і відхилила старий, зміна ролі
собі — 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
byrsapty 2026-08-24 02:22:56 +03:00
parent 96692ec9df
commit 48848bd3a5
13 changed files with 1586 additions and 57 deletions

View file

@ -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). Історії й графіків за зібраними
метриками.

View file

@ -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`.
## Групи й доступ до хостів
Два незалежні виміри, які не можна змішувати в одному списку прав:

View file

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

View file

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

View file

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

View file

@ -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",
"логін: 364 символи, лише латиниця, цифри, крапка, дефіс і підкреслення")
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)
}

View file

@ -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, &params,
&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
}

View file

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

View file

@ -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<string, unknown>
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<void>(`/api/v1/team/${userID}`, { method: 'PATCH', body: JSON.stringify(patch) }),
removeMember: (userID: string) =>
request<void>(`/api/v1/team/${userID}`, { method: 'DELETE' }),
@ -223,6 +255,32 @@ export const api = {
deleteDevice: (id: string) => request<void>(`/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<void>(`/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<string, unknown>
}) => request<{ id: string }>('/api/v1/credentials', { method: 'POST', body: JSON.stringify(c) }),
// --- групи пристроїв ---
listDeviceGroups: () =>

View file

@ -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<string, number> = {
'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<CheckInput>) {
onChange(checks.map((c, idx) => (idx === i ? { ...c, ...next } : c)))
}
return (
<div>
<div className="mb-1 flex items-center gap-2">
<span className="text-xs font-medium text-slate-400">Опитування</span>
{checks.length === 0 && (
<span className="text-[11px] text-amber-400">
без жодної перевірки хост не опитуватиметься
</span>
)}
</div>
<ul className="space-y-1.5">
{checks.map((c, i) => {
const t = types.find((x) => x.key === c.check_type)
return (
<li key={c.id ?? c.check_type} className="rounded border border-slate-800 bg-slate-900 p-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm text-slate-100">
{t?.name ?? c.check_type}
<code className="ml-1.5 text-[11px] text-slate-600">{c.check_type}</code>
</span>
<label className="flex items-center gap-1.5 text-[11px] text-slate-500">
кожні
<select
className="rounded border border-slate-700 bg-slate-800 px-1.5 py-1 text-xs text-slate-200"
value={INTERVALS.includes(c.interval_sec) ? c.interval_sec : 0}
onChange={(e) => {
const v = Number(e.target.value)
if (v > 0) patch(i, { interval_sec: v })
}}
>
{INTERVALS.map((s) => (
<option key={s} value={s}>
{humanInterval(s)}
</option>
))}
{!INTERVALS.includes(c.interval_sec) && (
<option value={0}>{humanInterval(c.interval_sec)}</option>
)}
</select>
</label>
<Toggle
checked={c.enabled !== false}
onChange={(v) => patch(i, { enabled: v })}
/>
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
title="Прибрати перевірку"
onClick={() => onChange(checks.filter((_, idx) => idx !== i))}
>
</button>
</div>
{t && <ParamFields type={t} value={c.params ?? {}}
onChange={(params) => patch(i, { params })} />}
</li>
)
})}
</ul>
{addable.length > 0 ? (
<select
className={`${inputClass} mt-2 py-1.5`}
value={adding}
onChange={(e) => add(e.target.value)}
>
<option value="">+ додати перевірку</option>
{addable.map((t) => (
<option key={t.key} value={t.key}>
{t.name} ({t.key})
</option>
))}
</select>
) : (
checks.length > 0 && (
<p className="mt-2 text-[11px] text-slate-600">Усі доступні типи вже додані</p>
)
)}
</div>
)
}
/**
* Поля параметрів із JSON Schema типу перевірки.
*
* Підтримуються скаляри й масиви рядків цього вистачає всім наявним
* типам. Складніші структури (перелік регістрів Modbus) поки редагуються
* як текст: показати неповну форму гірше, ніж показати чесний JSON.
*/
function ParamFields({
type,
value,
onChange,
}: {
type: CheckType
value: Record<string, unknown>
onChange: (v: Record<string, unknown>) => 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 (
<div className="mt-2 grid grid-cols-2 gap-2 border-t border-slate-800 pt-2">
{keys.map((k) => {
const spec = props[k] ?? {}
const cur = value[k]
if (spec.type === 'boolean') {
return (
<label key={k} className="col-span-2 flex items-center gap-2 text-xs text-slate-400">
<input
type="checkbox"
checked={cur === undefined ? Boolean(spec.default) : Boolean(cur)}
onChange={(e) => onChange({ ...value, [k]: e.target.checked })}
/>
{k}
</label>
)
}
const isArray = spec.type === 'array'
return (
<label key={k} className={isArray ? 'col-span-2 block' : 'block'}>
<span className="mb-0.5 block text-[11px] text-slate-500">
{k}
{required.has(k) && <span className="ml-1 text-amber-500">*</span>}
</span>
<input
className={`${inputClass} py-1 text-xs`}
placeholder={
isArray
? 'через кому'
: spec.default !== undefined
? String(spec.default)
: ''
}
value={
Array.isArray(cur) ? cur.join(', ') : cur === undefined ? '' : String(cur)
}
onChange={(e) => {
const raw = e.target.value
let next: unknown = raw
if (isArray) {
next = raw
.split(',')
.map((x) => x.trim())
.filter(Boolean)
} else if (spec.type === 'integer' || spec.type === 'number') {
// Порожнє поле означає «за замовчуванням», а не нуль.
next = raw === '' ? undefined : Number(raw)
}
const v = { ...value }
if (next === undefined || (typeof next === 'string' && next === '')) delete v[k]
else v[k] = next
onChange(v)
}}
/>
</label>
)
})}
</div>
)
}
function defaultsFromSchema(t: CheckType): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [k, spec] of Object.entries(t.params_schema?.properties ?? {})) {
if (spec?.default !== undefined) out[k] = spec.default
}
return out
}

View file

@ -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<string, string> = {
up: 'на зв’язку',
@ -53,6 +61,8 @@ export function DevicesPage() {
const [devices, setDevices] = useState<DeviceSummary[]>([])
const [groups, setGroups] = useState<DeviceGroup[]>([])
const [agents, setAgents] = useState<AgentSummary[]>([])
const [checkTypes, setCheckTypes] = useState<CheckType[]>([])
const [creds, setCreds] = useState<Credential[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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() {
<DeviceForm
groups={groups}
agents={agents}
checkTypes={checkTypes}
credentials={creds}
onClose={() => 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<void>
onDelete?: () => Promise<void>
@ -337,9 +359,48 @@ function DeviceForm({
const [agentID, setAgentID] = useState('')
const [enabled, setEnabled] = useState(device?.enabled ?? true)
const [groupIDs, setGroupIDs] = useState<string[]>(device?.group_ids ?? [])
const [credIDs, setCredIDs] = useState<string[]>([])
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])
const toggleGroup = (id: string) =>
setGroupIDs((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
@ -423,6 +484,42 @@ function DeviceForm({
)}
</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>
)}
{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"
@ -472,6 +569,11 @@ function DeviceForm({
agent_id: agentID,
enabled,
group_ids: groupIDs,
// Перевірки шлемо лише коли справді їх бачили: інакше
// збереження форми, відкритої до завантаження, стерло б
// усе опитування хоста.
...(checksLoaded ? { checks } : {}),
...(credIDs.length > 0 ? { credential_ids: credIDs } : {}),
})
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
@ -492,6 +594,8 @@ function DeviceDetail({
device,
groups,
agents,
checkTypes,
credentials,
alerts,
canWrite,
canMute,
@ -503,6 +607,8 @@ function DeviceDetail({
device: DeviceSummary
groups: DeviceGroup[]
agents: AgentSummary[]
checkTypes: CheckType[]
credentials: Credential[]
alerts: Alert[]
canWrite: boolean
canMute: boolean
@ -519,6 +625,8 @@ function DeviceDetail({
device={device}
groups={groups}
agents={agents}
checkTypes={checkTypes}
credentials={credentials}
onClose={onClose}
onSave={onSave}
onDelete={onDelete}

View file

@ -20,6 +20,7 @@ export function TeamPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [adding, setAdding] = useState(false)
const [editing, setEditing] = useState<TeamMember | null>(null)
const canWrite = session.can('users:write')
const me = session.me()
@ -108,29 +109,7 @@ export function TeamPage() {
{
key: 'role',
header: 'Роль',
cell: (m) => {
// Роль власника не видається через API — вона й не має
// з’являтися у списку вибору. Так само не можна змінити
// роль собі: організація без власника не відновлюється.
const editable =
canWrite && m.user_id !== me?.userID && m.role_key !== 'owner'
if (!editable) return <span className="text-slate-400">{m.role_name}</span>
return (
<select
className="rounded border border-slate-700 bg-slate-800 px-2 py-1 text-xs text-slate-200"
value={m.role_id}
onChange={(e) => void act(() => api.setRole(m.user_id, e.target.value))}
>
{roles
.filter((r) => r.key !== 'owner')
.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
)
},
cell: (m) => <span className="text-slate-400">{m.role_name}</span>,
},
{
key: 'login',
@ -143,14 +122,21 @@ export function TeamPage() {
key: 'act',
header: '',
cell: (m) =>
canWrite && m.user_id !== me?.userID && m.role_key !== 'owner' ? (
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
title="Прибрати з організації — усі його сесії тут буде відкликано"
onClick={() => void act(() => api.removeMember(m.user_id))}
>
</button>
canWrite ? (
<div className="flex items-center gap-1.5">
<Button onClick={() => setEditing(m)}>Змінити</Button>
{/* Себе не прибирають, власника теж: організація
без власника не відновлюється через інтерфейс. */}
{m.user_id !== me?.userID && m.role_key !== 'owner' && (
<button
className="px-1 text-xs text-slate-600 hover:text-red-400"
title="Прибрати з організації — усі його сесії тут буде відкликано"
onClick={() => void act(() => api.removeMember(m.user_id))}
>
</button>
)}
</div>
) : null,
},
]}
@ -184,6 +170,19 @@ export function TeamPage() {
</div>
</div>
{editing && (
<EditUserForm
member={editing}
roles={roles}
isSelf={editing.user_id === me?.userID}
onClose={() => setEditing(null)}
onSaved={async () => {
setEditing(null)
await reload()
}}
/>
)}
{adding && (
<AddUserForm
roles={roles.filter((r) => r.key !== 'owner')}
@ -315,3 +314,173 @@ function AddUserForm({
</Modal>
)
}
/**
* Редагування учасника.
*
* Пароль задається лише коли його справді хочуть змінити: порожнє поле
* означає «не чіпати». Показувати наявний неможливо у БД лежить
* argon2id-хеш, і це правильно.
*/
function EditUserForm({
member,
roles,
isSelf,
onClose,
onSaved,
}: {
member: TeamMember
roles: Role[]
isSelf: boolean
onClose: () => void
onSaved: () => Promise<void>
}) {
const [username, setUsername] = useState(member.username)
const [email, setEmail] = useState(member.email ?? '')
const [fullName, setFullName] = useState(member.full_name ?? '')
const [roleID, setRoleID] = useState(member.role_id)
const [password, setPassword] = useState('')
const [repeat, setRepeat] = useState('')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const usernameOK = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(username.trim())
const passwordOK = password === '' || (password.length >= 8 && password === repeat)
const isOwner = member.role_key === 'owner'
async function submit() {
setBusy(true)
setErr(null)
try {
const patch: Record<string, string> = {}
if (username.trim() !== member.username) patch.username = username.trim()
// Прочерк — умовний сигнал «прибрати пошту»: порожнє поле вже
// зайняте значенням «не чіпати».
if (email.trim() !== (member.email ?? '')) patch.email = email.trim() || '-'
if (fullName.trim() !== (member.full_name ?? '')) patch.full_name = fullName.trim()
if (password) patch.password = password
if (roleID !== member.role_id && !isSelf && !isOwner) patch.role_id = roleID
if (Object.keys(patch).length === 0) {
onClose()
return
}
await api.updateMember(member.user_id, patch)
await onSaved()
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<Modal title={`Користувач: ${member.username}`} onClose={onClose}>
<div className="space-y-3">
<Field label="Логін">
<input
className={inputClass}
autoCapitalize="none"
spellCheck={false}
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase())}
/>
</Field>
<Field label="Імʼя">
<input
className={inputClass}
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
</Field>
<Field label="Email" hint="Порожнє поле прибере адресу">
<input
className={inputClass}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Field
label="Роль"
hint={
isSelf
? 'Свою роль змінити не можна'
: isOwner
? 'Роль власника не змінюється через інтерфейс'
: undefined
}
>
<select
className={inputClass}
value={roleID}
disabled={isSelf || isOwner}
onChange={(e) => setRoleID(e.target.value)}
>
{roles
.filter((r) => r.key !== 'owner' || isOwner)
.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</Field>
<div className="rounded border border-slate-800 p-2.5">
<span className="mb-2 block text-xs font-medium text-slate-400">
Новий пароль
<span className="ml-1.5 font-normal text-slate-600">
лишіть порожнім, щоб не міняти
</span>
</span>
<div className="grid grid-cols-2 gap-2">
<input
className={inputClass}
type="password"
autoComplete="new-password"
placeholder="новий"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
className={inputClass}
type="password"
autoComplete="new-password"
placeholder="ще раз"
value={repeat}
onChange={(e) => setRepeat(e.target.value)}
/>
</div>
{password !== '' && (
<p className="mt-1.5 text-[11px] text-slate-600">
{password.length < 8
? 'Щонайменше 8 символів'
: password !== repeat
? 'Паролі не збігаються'
: 'Усі сесії цієї людини буде відкликано'}
</p>
)}
</div>
{!usernameOK && <ErrorNote>Некоректний логін</ErrorNote>}
<ErrorNote>{err}</ErrorNote>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={onClose}>Скасувати</Button>
<Button
kind="primary"
disabled={busy || !usernameOK || !passwordOK}
onClick={() => void submit()}
>
{busy ? 'Збереження…' : 'Зберегти'}
</Button>
</div>
</div>
</Modal>
)
}

View file

@ -175,6 +175,42 @@ export interface DeviceSummary {
writable: boolean
}
export interface CheckType {
key: string
plugin_key: string
name: string
params_schema: {
properties?: Record<string, { type?: string; default?: unknown; items?: unknown }>
required?: string[]
}
metrics: unknown
/** Чи ввімкнений плагін у тенанта — вимкнені показуємо, але не даємо додати. */
available: boolean
}
export interface Check {
id: string
device_id: string
check_type: string
type_name?: string
params: Record<string, unknown>
interval_sec: number
timeout_ms: number
retries: number
enabled: boolean
last_run_at?: string
last_error?: string
}
export interface Credential {
id: string
name: string
proto: string
username?: string
port?: number
is_default: boolean
}
export interface AgentSummary {
id: string
name: string