Опитування хоста з форми й повне редагування користувачів
Форма створення хоста збирала назву, адресу, тип і групи — і не
збирала головного. Хост, доданий через 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:
parent
96692ec9df
commit
48848bd3a5
13 changed files with 1586 additions and 57 deletions
85
HISTORY.md
85
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). Історії й графіків за зібраними
|
||||
метриками.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
## Групи й доступ до хостів
|
||||
|
||||
Два незалежні виміри, які не можна змішувати в одному списку прав:
|
||||
|
|
|
|||
180
server/internal/httpapi/checks.go
Normal file
180
server/internal/httpapi/checks.go
Normal 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})
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
355
server/internal/store/checks.go
Normal file
355
server/internal/store/checks.go
Normal 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, ¶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
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
251
web/src/components/ChecksEditor.tsx
Normal file
251
web/src/components/ChecksEditor.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue