Вхід за логіном, групи хостів і права доступу
Логін: - core.users.username замість пошти як ідентифікатор: у мережевій інсталяції половина акаунтів технічні (noc, monitoring, oncall) і скриньки не мають узагалі. Пошта лишилась необов'язковим полем - наявним користувачам логін виведено з пошти, збіги розведено суфіксом: мовчки злити admin@a.com і admin@b.com в один логін — це втрата акаунта, а не міграція - сервер шукає за логіном і за поштою, тому звичка вводити email нікого не відхиляє Групи (модель Zabbix): - групи хостів і групи доступу; права read/write/deny на групу хостів - роль каже, що вільно робити; група — над якими хостами. Інженер над філією та інженер над усією мережею мають однакову роль і різний доступ - хто не входить у жодну групу, групами не обмежений — свідомо не по-заббіксівськи: там кожна нова інсталяція починається з питання «чому порожньо» - заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу - фільтр накладається в самому запиті, а не після вибірки Хости: додавання, редагування, м'яке видалення, прив'язка до груп, фільтр за групою в таблиці. Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис, значок, розмір, ширина, колір рамки, закріплення. Виправлено за скаргами: - перемикач вилазив на 14px за трек: у ручки не заданий left, а статичну позицію зсуває типове text-align: center у <button> - сторінка правил не оновлювала «активних»: після повернення правила алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого перечитування — бракувало підписки на живі події - канал із секретом показувався як «секрету немає» Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3, обидва без запису, алертів 1 замість 3, редагування чужого хоста 403. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
36f29e23ec
commit
96692ec9df
30 changed files with 2792 additions and 217 deletions
102
HISTORY.md
102
HISTORY.md
|
|
@ -897,3 +897,105 @@ curl-ом. Цей етап закриває розрив між готовим
|
|||
немає), сторінки налаштувань зонда, редагування правила (лише
|
||||
створення й вимкнення), маршрутів сповіщень і вікон обслуговування
|
||||
(є в схемі й у движку, у UI — ні), NOC-режиму на телевізор.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-15 — Вхід за логіном, групи хостів і права доступу
|
||||
|
||||
Три зауваження за одну сесію: перемикачі виглядають зламано, входити треба
|
||||
логіном, а не поштою, і бракує заббіксівського — груп, прав на них, додавання
|
||||
хостів і редагування мапи. Плюс два власні спостереження користувача про
|
||||
сторінку правил і мапу.
|
||||
|
||||
### Створено
|
||||
|
||||
- `db/migrations/0013_groups_login.sql` — `core.users.username`,
|
||||
`core.user_groups`, `core.user_group_members`, `core.group_permissions`,
|
||||
функції `core.device_access_level` і `core.accessible_devices`, RLS на нові
|
||||
таблиці.
|
||||
- `server/internal/store/groups.go` — CRUD груп, хостів і обчислення `Scope`.
|
||||
- `server/internal/httpapi/groups.go` — 11 ендпоїнтів.
|
||||
- `web/src/pages/GroupsPage.tsx` — групи хостів і груп доступу на одному екрані.
|
||||
- `web/src/components/NodeInspector.tsx` — підпис, значок, розмір, ширина,
|
||||
колір, закріплення вузла.
|
||||
- Форма хоста (створення й редагування) і фільтр за групами на сторінці хостів.
|
||||
- Створення мапи з транслітерацією slug.
|
||||
|
||||
### Прийняті рішення
|
||||
|
||||
**Логін замість пошти.** У мережевій інсталяції половина облікових записів
|
||||
технічні — `noc`, `monitoring`, `oncall`, — і скриньки не мають узагалі. Пошта
|
||||
лишилась необов'язковим полем для сповіщень. Сервер шукає за обома, тому людину,
|
||||
яка за звичкою ввела email, ніхто не відхиляє.
|
||||
|
||||
**Наявним користувачам логін вивели з пошти**, а збіги розвели суфіксом за
|
||||
порядком створення. Мовчки злити `admin@a.com` і `admin@b.com` в один логін
|
||||
було б не міграцією, а втратою акаунта. Механізм одразу знадобився: на стенді
|
||||
жили залишки від тестових прогонів, і `admin` дістався саме їм.
|
||||
|
||||
**Ролі й групи — два незалежні виміри.** Роль каже, що людині вільно робити;
|
||||
група доступу — над якими хостами. Інженер над однією філією та інженер над
|
||||
усією мережею мають однакову роль і різний доступ, і змішати це в один список
|
||||
прав неможливо без втрати сенсу.
|
||||
|
||||
**Хто не входить у жодну групу — не обмежений групами.** Це свідомо не
|
||||
по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова
|
||||
інсталяція починається з питання «чому порожньо». Тут звуження вмикається тоді,
|
||||
коли його справді налаштували.
|
||||
|
||||
**Заборона перемагає дозвіл.** Інакше її можна обійти, додавши хост у будь-яку
|
||||
іншу групу.
|
||||
|
||||
**Фільтр видимості накладається в самому запиті**, а не після вибірки:
|
||||
відсіювати вже прочитане означало б тягнути з БД чужі рядки й покладатися на те,
|
||||
що жоден не проскочить у відповідь.
|
||||
|
||||
**Правки вузла застосовуються кнопкою, а не на кожну літеру.** Кожне збереження
|
||||
— це нова ревізія полотна й подія для всіх, хто дивиться на ту саму мапу.
|
||||
|
||||
### Знайдено й виправлено
|
||||
|
||||
**Перемикач вилазив за межі треку.** Скарга була «вигляд глюкнутий»; вимір
|
||||
показав причину: у ручки не заданий `left`, тож вона стає на статичну позицію,
|
||||
а та у `<button>` зсунута типовим `text-align: center`. Обчислений `left`
|
||||
виходив 18px замість 2px, ручка виступала на 14px і накривала сусідній хрестик.
|
||||
|
||||
**Сторінка правил не оновлювала лічильник «активних».** Друга скарга, і причина
|
||||
не та, що здається. Після вимкнення правила його алерти закриваються одразу, а
|
||||
після повернення піднімаються лише наступним тіком движка — тобто через секунди
|
||||
ПІСЛЯ нашого перечитування. Сторінка не була підписана на живі події, тому
|
||||
показувала нулі до перезавантаження. Той самий недогляд, що раніше знайшовся на
|
||||
сторінці хостів; тепер обидві користуються спільним хуком.
|
||||
|
||||
**Канал із секретом показувався як «секрету немає»** — прапорець ставився
|
||||
всередині гілки розшифровки, а перелік для UI викликається без ключа навмисно.
|
||||
|
||||
### Перевірено наживо
|
||||
|
||||
Прогін проти справжнього стенду:
|
||||
|
||||
```
|
||||
вхід за логіном 'admin' 200, роль owner
|
||||
вхід тим самим, але поштою 200 — сумісність збережена
|
||||
дві групи хостів 201/201
|
||||
хости розкладено gateway→Магістраль, snmp-host→Доступ
|
||||
новий хост через API 201
|
||||
група доступу для 'eng' лише «Доступ», рівень read
|
||||
що бачить eng snmp-host і test-host, обидва writable=false;
|
||||
gateway зник із вибірки
|
||||
алерти під eng 1 замість 3
|
||||
eng редагує чужий хост 403
|
||||
```
|
||||
|
||||
У браузері: вхід логіном (у шапці «admin», не пошта); перемикачі вміщаються в
|
||||
трек в обох станах із відступом 2px; правила оновлюються 2 → 0 → 2 без
|
||||
перезавантаження; сторінка груп показує обидві половини; інспектор вузла змінив
|
||||
підпис, значок і ширину до 220px із ревізією 13 → 14; створення мапи дало slug
|
||||
`kyiv-iadro` з «Київ ядро» і порожнє полотно.
|
||||
|
||||
### Чого ще немає
|
||||
|
||||
Динамічних груп (`inv.device_groups.kind='dynamic'` у схемі є, правило відбору
|
||||
не читається), успадкування прав між групами, редагування груп хостів після
|
||||
створення (лише створення й видалення), масових операцій над хостами.
|
||||
|
|
|
|||
219
db/migrations/0013_groups_login.sql
Normal file
219
db/migrations/0013_groups_login.sql
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0013_groups_login.sql
|
||||
-- Вхід за логіном; групи користувачів і права на групи пристроїв
|
||||
-- у моделі, знайомій за Zabbix.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Логін
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Email лишається — він потрібен для сповіщень і відновлення доступу, —
|
||||
-- але входом більше не є. Причина проста: у мережевій інсталяції
|
||||
-- половина облікових записів технічні (noc, monitoring, oncall) і
|
||||
-- поштової скриньки не має взагалі.
|
||||
ALTER TABLE core.users ADD COLUMN IF NOT EXISTS username citext;
|
||||
|
||||
-- Наявним користувачам логін виводимо з локальної частини пошти.
|
||||
-- Збіги неминучі (admin@a.com і admin@b.com), тому додаємо суфікс за
|
||||
-- порядком створення: мовчки злити два акаунти в один логін не можна.
|
||||
WITH numbered AS (
|
||||
SELECT id,
|
||||
split_part(email::text, '@', 1) AS base,
|
||||
row_number() OVER (
|
||||
PARTITION BY split_part(email::text, '@', 1)
|
||||
ORDER BY created_at, id
|
||||
) AS n
|
||||
FROM core.users
|
||||
WHERE username IS NULL
|
||||
)
|
||||
UPDATE core.users u
|
||||
SET username = CASE WHEN n = 1 THEN numbered.base
|
||||
ELSE numbered.base || n::text END
|
||||
FROM numbered
|
||||
WHERE u.id = numbered.id;
|
||||
|
||||
ALTER TABLE core.users ALTER COLUMN username SET NOT NULL;
|
||||
|
||||
-- Email більше не обов'язковий: технічний акаунт може жити без нього.
|
||||
ALTER TABLE core.users ALTER COLUMN email DROP NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'users_username_uniq'
|
||||
) THEN
|
||||
ALTER TABLE core.users ADD CONSTRAINT users_username_uniq UNIQUE (username);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Логін бере на себе роль ідентифікатора, тому й обмеження на нього
|
||||
-- жорсткіші за поштові: жодних пробілів, крапок на краях і регістрових
|
||||
-- сюрпризів (citext уже прибирає останнє).
|
||||
ALTER TABLE core.users
|
||||
ADD CONSTRAINT users_username_format
|
||||
CHECK (username ~ '^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Групи користувачів
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Ролі відповідають на питання «що людині вільно робити», групи — «над
|
||||
-- якими пристроями». Два незалежні виміри, і змішувати їх в одному
|
||||
-- списку прав не можна: інженер над однією філією та інженер над усією
|
||||
-- мережею мають однакову роль і різний доступ.
|
||||
CREATE TABLE IF NOT EXISTS core.user_groups (
|
||||
id uuid PRIMARY KEY DEFAULT core.new_id(),
|
||||
tenant_id uuid NOT NULL REFERENCES core.tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS core.user_group_members (
|
||||
group_id uuid NOT NULL REFERENCES core.user_groups(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES core.users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (group_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS user_group_members_user_idx
|
||||
ON core.user_group_members (user_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Права на групи пристроїв
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'access_level') THEN
|
||||
CREATE TYPE core.access_level AS ENUM ('deny','read','write');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS core.group_permissions (
|
||||
user_group_id uuid NOT NULL REFERENCES core.user_groups(id) ON DELETE CASCADE,
|
||||
device_group_id uuid NOT NULL REFERENCES inv.device_groups(id) ON DELETE CASCADE,
|
||||
level core.access_level NOT NULL DEFAULT 'read',
|
||||
PRIMARY KEY (user_group_id, device_group_id)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Обчислення доступу
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Рівень доступу людини до одного пристрою.
|
||||
--
|
||||
-- Три правила, і кожне має причину:
|
||||
--
|
||||
-- 1. Хто не входить у жодну групу — не обмежений групами взагалі.
|
||||
-- Це свідомо не по-заббіксівськи. Там користувач без груп не бачить
|
||||
-- нічого, і кожна нова інсталяція починається з питання «чому
|
||||
-- порожньо». Тут звуження вмикається тоді, коли його справді
|
||||
-- налаштували, а до того доступ визначає роль.
|
||||
--
|
||||
-- 2. Заборона перемагає дозвіл. Пристрій у двох групах, де одна дає
|
||||
-- читання, а друга забороняє, лишається невидимим: інакше заборону
|
||||
-- можна обійти, додавши об'єкт у будь-яку іншу групу.
|
||||
--
|
||||
-- 3. Серед дозволів виграє найширший. Читання в одній групі й запис у
|
||||
-- другій дають запис — людині надали обидва, і слабший не є
|
||||
-- обмеженням для сильнішого.
|
||||
CREATE OR REPLACE FUNCTION core.device_access_level(p_user uuid, p_device uuid)
|
||||
RETURNS core.access_level
|
||||
LANGUAGE sql STABLE AS $$
|
||||
WITH my_groups AS (
|
||||
SELECT group_id FROM core.user_group_members WHERE user_id = p_user
|
||||
),
|
||||
perms AS (
|
||||
SELECT gp.level
|
||||
FROM core.group_permissions gp
|
||||
JOIN my_groups g ON g.group_id = gp.user_group_id
|
||||
JOIN inv.device_group_members m ON m.group_id = gp.device_group_id
|
||||
WHERE m.device_id = p_device
|
||||
)
|
||||
SELECT CASE
|
||||
-- Без груп — без звуження.
|
||||
WHEN NOT EXISTS (SELECT 1 FROM my_groups) THEN 'write'::core.access_level
|
||||
WHEN EXISTS (SELECT 1 FROM perms WHERE level = 'deny') THEN 'deny'::core.access_level
|
||||
WHEN EXISTS (SELECT 1 FROM perms WHERE level = 'write') THEN 'write'::core.access_level
|
||||
WHEN EXISTS (SELECT 1 FROM perms WHERE level = 'read') THEN 'read'::core.access_level
|
||||
-- У групах є, але цього пристрою в них немає — значить, не його.
|
||||
ELSE 'deny'::core.access_level
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Перелік доступних пристроїв одним набором.
|
||||
--
|
||||
-- Окрема функція, а не виклик попередньої в циклі: список пристроїв
|
||||
-- читається на кожному екрані, і перевірка по одному перетворила б
|
||||
-- звичайну вибірку на N запитів.
|
||||
CREATE OR REPLACE FUNCTION core.accessible_devices(p_user uuid, p_tenant uuid, p_need core.access_level)
|
||||
RETURNS TABLE (device_id uuid)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
WITH my_groups AS (
|
||||
SELECT group_id FROM core.user_group_members WHERE user_id = p_user
|
||||
),
|
||||
unrestricted AS (
|
||||
SELECT NOT EXISTS (SELECT 1 FROM my_groups) AS yes
|
||||
),
|
||||
granted AS (
|
||||
SELECT m.device_id,
|
||||
bool_or(gp.level = 'deny') AS denied,
|
||||
bool_or(gp.level = 'write') AS writable
|
||||
FROM core.group_permissions gp
|
||||
JOIN my_groups g ON g.group_id = gp.user_group_id
|
||||
JOIN inv.device_group_members m ON m.group_id = gp.device_group_id
|
||||
GROUP BY m.device_id
|
||||
)
|
||||
SELECT d.id
|
||||
FROM inv.devices d
|
||||
WHERE d.tenant_id = p_tenant
|
||||
AND d.deleted_at IS NULL
|
||||
AND (
|
||||
(SELECT yes FROM unrestricted)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM granted
|
||||
WHERE granted.device_id = d.id
|
||||
AND NOT granted.denied
|
||||
AND (p_need <> 'write' OR granted.writable)
|
||||
)
|
||||
);
|
||||
$$;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- RLS для нових таблиць
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE core.user_groups ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE core.user_groups FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON core.user_groups;
|
||||
CREATE POLICY tenant_isolation ON core.user_groups
|
||||
USING (tenant_id = core.current_tenant())
|
||||
WITH CHECK (tenant_id = core.current_tenant());
|
||||
|
||||
-- Членство й права прив'язані до групи, тому їхня ізоляція випливає з
|
||||
-- ізоляції самої групи. Окремої колонки tenant_id тут немає навмисно:
|
||||
-- вона могла б розійтися з групою й тихо створити дірку.
|
||||
ALTER TABLE core.user_group_members ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE core.user_group_members FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON core.user_group_members;
|
||||
CREATE POLICY tenant_isolation ON core.user_group_members
|
||||
USING (EXISTS (SELECT 1 FROM core.user_groups g
|
||||
WHERE g.id = group_id AND g.tenant_id = core.current_tenant()))
|
||||
WITH CHECK (EXISTS (SELECT 1 FROM core.user_groups g
|
||||
WHERE g.id = group_id AND g.tenant_id = core.current_tenant()));
|
||||
|
||||
ALTER TABLE core.group_permissions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE core.group_permissions FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS tenant_isolation ON core.group_permissions;
|
||||
CREATE POLICY tenant_isolation ON core.group_permissions
|
||||
USING (EXISTS (SELECT 1 FROM core.user_groups g
|
||||
WHERE g.id = user_group_id AND g.tenant_id = core.current_tenant()))
|
||||
WITH CHECK (EXISTS (SELECT 1 FROM core.user_groups g
|
||||
WHERE g.id = user_group_id AND g.tenant_id = core.current_tenant()));
|
||||
|
||||
-- Шлях входу читає користувача до того, як тенант відомий, — так само,
|
||||
-- як у 0012 для пошуку за поштою.
|
||||
DROP POLICY IF EXISTS users_auth_lookup ON core.users;
|
||||
CREATE POLICY users_auth_lookup ON core.users
|
||||
FOR SELECT USING (core.current_tenant() IS NULL);
|
||||
|
|
@ -24,7 +24,12 @@ Authorization: Bearer <токен>
|
|||
токен. Це навмисно не залежить від префікса, бо префікс може змінитись, а формат
|
||||
JWT — ні.
|
||||
|
||||
**Люди** входять через `/api/v1/auth/login` і отримують короткий access-токен
|
||||
**Люди** входять **за логіном** (`core.users.username`), а не за поштою: у
|
||||
мережевій інсталяції половина облікових записів технічні (`noc`, `monitoring`,
|
||||
`oncall`) і скриньки не мають узагалі. Пошта лишається прийнятною — сервер шукає
|
||||
за обома полями, — але вона більше не обов'язкова.
|
||||
|
||||
Люди входять через `/api/v1/auth/login` і отримують короткий access-токен
|
||||
(HS256, 15 хвилин, у пам'яті вкладки) плюс refresh-сесію в `httpOnly`-кукі
|
||||
`np_refresh` (30 днів, `SameSite=Strict`, `Path=/api/v1/auth`). Access-токен
|
||||
свідомо не кладеться в `localStorage`: будь-який XSS звідти його забирає, а з
|
||||
|
|
@ -48,7 +53,7 @@ JWT — ні.
|
|||
через API:
|
||||
|
||||
```bash
|
||||
./netpulse-user -dsn "postgres://..." -tenant acme -email admin@acme.io -role owner
|
||||
./netpulse-user -dsn "postgres://..." -tenant acme -login admin -role owner
|
||||
```
|
||||
|
||||
`-list` показує учасників, `-reset-password` міняє пароль і відкликає всі сесії.
|
||||
|
|
@ -58,7 +63,7 @@ JWT — ні.
|
|||
| Метод | Шлях | Призначення |
|
||||
|-------|------|-------------|
|
||||
| `GET` | `/healthz` | стан процесу й кількість WebSocket-підписників |
|
||||
| `POST` | `/api/v1/auth/login` | вхід за email і паролем |
|
||||
| `POST` | `/api/v1/auth/login` | вхід за логіном і паролем |
|
||||
| `POST` | `/api/v1/auth/refresh` | обмін refresh-кукі на новий access-токен |
|
||||
| `POST` | `/api/v1/auth/logout` | відкликати поточну сесію |
|
||||
| `POST` | `/api/v1/auth/password` | змінити свій пароль |
|
||||
|
|
@ -75,7 +80,17 @@ JWT — ні.
|
|||
| `DELETE` | `/api/v1/maps/{id}` | м'яко видалити мапу (топологія лишається) |
|
||||
| `POST` | `/api/v1/maps/{id}/build` | добудувати мапу з виявленої топології |
|
||||
| `POST` | `/api/v1/maps/{id}/undo` | відкотити останню зміну полотна |
|
||||
| `GET` | `/api/v1/devices` | інвентар |
|
||||
| `GET` | `/api/v1/devices` | інвентар (звужений групами доступу) |
|
||||
| `POST` | `/api/v1/devices` | додати хост (`devices:write`) |
|
||||
| `PATCH` | `/api/v1/devices/{id}` | змінити хост і його групи |
|
||||
| `DELETE` | `/api/v1/devices/{id}` | мʼяко видалити хост |
|
||||
| `GET` | `/api/v1/device-groups` | групи хостів із лічильниками |
|
||||
| `POST` | `/api/v1/device-groups` | створити групу хостів |
|
||||
| `DELETE` | `/api/v1/device-groups/{id}` | видалити групу |
|
||||
| `GET` | `/api/v1/user-groups` | групи доступу з правами |
|
||||
| `POST` | `/api/v1/user-groups` | створити групу доступу |
|
||||
| `PATCH` | `/api/v1/user-groups/{id}` | склад і права одним запитом |
|
||||
| `DELETE` | `/api/v1/user-groups/{id}` | видалити групу доступу |
|
||||
| `GET` | `/api/v1/agents` | зонди, версії, самометрики |
|
||||
| `GET` | `/api/v1/alerts` | активні алерти + лічильники |
|
||||
| `POST` | `/api/v1/alerts/{id}/ack` | підтвердити (`alerts:ack`) |
|
||||
|
|
@ -94,16 +109,19 @@ JWT — ні.
|
|||
### `POST /api/v1/auth/login` — вхід
|
||||
|
||||
```jsonc
|
||||
{"email": "admin@acme.io", "password": "…", "tenant_id": "…"} // tenant_id — опційно
|
||||
{"login": "admin", "password": "…", "tenant_id": "…"} // tenant_id — опційно
|
||||
```
|
||||
|
||||
Поле `login` приймає і логін, і пошту. Старе `email` теж лишається робочим:
|
||||
інтеграції, написані до перейменування, не мають ламатися через назву ключа.
|
||||
|
||||
Успіх:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"access_token": "eyJ…",
|
||||
"expires_in": 900,
|
||||
"user": {"id": "…", "email": "admin@acme.io", "full_name": "Admin"},
|
||||
"user": {"id": "…", "username": "admin", "email": "admin@acme.io", "full_name": "Admin"},
|
||||
"tenant": {"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"},
|
||||
"permissions": ["maps:read", "maps:write", "users:write", …]
|
||||
}
|
||||
|
|
@ -116,12 +134,12 @@ JWT — ні.
|
|||
{"tenants": [{"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"}, …]}
|
||||
```
|
||||
|
||||
Помилки навмисно нерозрізненні: невідомий email і невірний пароль дають однаковий
|
||||
Помилки навмисно нерозрізненні: невідомий логін і невірний пароль дають однаковий
|
||||
`401 bad_credentials`, і на неіснуючому користувачі сервер спалює стільки ж часу на
|
||||
фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які адреси
|
||||
зареєстровані.
|
||||
фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які акаунти
|
||||
існують.
|
||||
|
||||
Після 10 невдач з одного email або IP за 15 хвилин — `429 too_many_attempts`.
|
||||
Після 10 невдач з одного логіна або IP за 15 хвилин — `429 too_many_attempts`.
|
||||
Лічильник живе в `core.login_attempts` (гіпертаблиця), тому переживає рестарт
|
||||
процесу й працює на кількох інстансах одразу.
|
||||
|
||||
|
|
@ -143,6 +161,11 @@ JWT — ні.
|
|||
|
||||
Для машинного токена замість `user_id`/`email` приходить `token_name`.
|
||||
|
||||
Формат логіна: 3–64 символи з латиниці, цифр, крапки, дефіса й підкреслення;
|
||||
перший і останній символ — літера або цифра. Перевіряється і в БД (CHECK), і в
|
||||
API — відмова БД виглядає як «violates check constraint», і людині з неї нічого
|
||||
не зрозуміло.
|
||||
|
||||
### `GET|POST|PATCH|DELETE /api/v1/team`
|
||||
|
||||
Керування учасниками; усе, крім читання, потребує `users:write`.
|
||||
|
|
@ -401,6 +424,56 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
|
|||
знімків в історії: 2
|
||||
```
|
||||
|
||||
## Групи й доступ до хостів
|
||||
|
||||
Два незалежні виміри, які не можна змішувати в одному списку прав:
|
||||
|
||||
- **Роль** відповідає на питання «що людині вільно робити» — `maps:write`,
|
||||
`alerts:ack`, `users:write`.
|
||||
- **Група доступу** відповідає на «над якими хостами». Інженер над однією
|
||||
філією та інженер над усією мережею мають однакову роль і різний доступ.
|
||||
|
||||
Група доступу (`core.user_groups`) містить людей і видає права на групи хостів
|
||||
(`inv.device_groups`) одним із трьох рівнів: `read`, `write`, `deny`.
|
||||
|
||||
### Три правила обчислення
|
||||
|
||||
1. **Хто не входить у жодну групу — не обмежений групами взагалі.** Це свідомо
|
||||
не по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова
|
||||
інсталяція починається з питання «чому порожньо». Тут звуження вмикається
|
||||
тоді, коли його справді налаштували, а до того доступ визначає роль.
|
||||
2. **Заборона перемагає дозвіл.** Хост у двох групах, де одна дає читання, а
|
||||
друга забороняє, лишається невидимим — інакше заборону можна обійти,
|
||||
додавши об'єкт у будь-яку іншу групу.
|
||||
3. **Серед дозволів виграє найширший.** Читання в одній групі й запис у другій
|
||||
дають запис.
|
||||
|
||||
Обчислення живе в БД (`core.accessible_devices`) і читається раз на запит:
|
||||
питати про кожен хост окремо означало б перетворити список на N запитів рівно
|
||||
тоді, коли хостів багато.
|
||||
|
||||
Звуження діє на `/api/v1/devices`, `/api/v1/alerts`, лічильники алертів і
|
||||
операції запису (редагування хоста, заглушення). Алерт **без** пристрою
|
||||
(наприклад, про сам зонд) видно всім: сховати його від обмеженого користувача
|
||||
означало б приховати аварію, до якої групи не мають стосунку.
|
||||
|
||||
### Живий прогін груп
|
||||
|
||||
```
|
||||
1) вхід за логіном 'admin' 200, роль owner
|
||||
2) вхід тим самим, але поштою 200 — сумісність збережена
|
||||
3) створено дві групи хостів 201/201
|
||||
4) хости розкладено gateway→Магістраль, snmp-host→Доступ
|
||||
5) новий хост через API 201
|
||||
6) лічильники груп Доступ=2, Магістраль=1
|
||||
7) група доступу для 'eng' лише «Доступ», рівень read
|
||||
8) що бачить eng snmp-host, test-host — обидва writable=false
|
||||
gateway зник із вибірки
|
||||
9) алерти під eng 1 замість 3
|
||||
10) eng редагує чужий хост 403
|
||||
11) видалення хоста власником 204
|
||||
```
|
||||
|
||||
## Алерти
|
||||
|
||||
Движок правил живе всередині `netpulse-api` і обчислюється раз на
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ func run() error {
|
|||
var (
|
||||
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
||||
tenant = flag.String("tenant", "", "slug або uuid тенанта")
|
||||
email = flag.String("email", "", "email користувача")
|
||||
login = flag.String("login", "", "логін користувача")
|
||||
email = flag.String("email", "", "email (необовʼязково — для сповіщень)")
|
||||
fullName = flag.String("name", "", "повне ім'я")
|
||||
role = flag.String("role", "owner", "owner|admin|engineer|operator|viewer")
|
||||
password = flag.String("password", "", "пароль; порожній — спитати інтерактивно")
|
||||
|
|
@ -65,8 +66,8 @@ func run() error {
|
|||
return listUsers(ctx, conn, tenantID, tenantName)
|
||||
}
|
||||
|
||||
if *email == "" {
|
||||
return errors.New("не вказано -email")
|
||||
if *login == "" {
|
||||
return errors.New("не вказано -login")
|
||||
}
|
||||
|
||||
pw := *password
|
||||
|
|
@ -88,13 +89,13 @@ func run() error {
|
|||
if *reset {
|
||||
tag, err := conn.Exec(ctx, `
|
||||
UPDATE core.users SET password_hash = $2, updated_at = now()
|
||||
WHERE email = $1 AND deleted_at IS NULL
|
||||
`, *email, hash)
|
||||
WHERE username = $1 AND deleted_at IS NULL
|
||||
`, *login, hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("користувача %s не знайдено", *email)
|
||||
return fmt.Errorf("користувача %s не знайдено", *login)
|
||||
}
|
||||
// Зміна пароля адміністратором — це реакція на втрату доступу
|
||||
// або на компрометацію. В обох випадках старі сесії мають померти.
|
||||
|
|
@ -117,14 +118,15 @@ func run() error {
|
|||
|
||||
var userID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO core.users (email, password_hash, full_name, email_verified_at)
|
||||
VALUES ($1, $2, NULLIF($3,''), now())
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
INSERT INTO core.users (username, email, password_hash, full_name, email_verified_at)
|
||||
VALUES ($1, NULLIF($2,'')::citext, $3, NULLIF($4,''),
|
||||
CASE WHEN NULLIF($2,'') IS NULL THEN NULL ELSE now() END)
|
||||
ON CONFLICT (username) DO UPDATE
|
||||
SET password_hash = EXCLUDED.password_hash,
|
||||
full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name),
|
||||
updated_at = now()
|
||||
RETURNING id::text
|
||||
`, *email, hash, *fullName).Scan(&userID); err != nil {
|
||||
`, *login, *email, hash, *fullName).Scan(&userID); err != nil {
|
||||
return fmt.Errorf("користувач: %w", err)
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +152,7 @@ func run() error {
|
|||
}
|
||||
|
||||
fmt.Printf("користувач %s (%s) доданий до «%s» з роллю %s\n",
|
||||
*email, userID, tenantName, *role)
|
||||
*login, userID, tenantName, *role)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -167,13 +169,13 @@ func resolveTenant(ctx context.Context, conn *pgx.Conn, ref string) (id, name st
|
|||
|
||||
func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string) error {
|
||||
rows, err := conn.Query(ctx, `
|
||||
SELECT u.email::text, COALESCE(u.full_name,''), r.key::text,
|
||||
SELECT u.username::text, COALESCE(u.full_name,''), r.key::text,
|
||||
COALESCE(u.last_login_at::text, '—')
|
||||
FROM core.memberships m
|
||||
JOIN core.users u ON u.id = m.user_id
|
||||
JOIN core.roles r ON r.id = m.role_id
|
||||
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
|
||||
ORDER BY u.email
|
||||
ORDER BY u.username
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -183,11 +185,11 @@ func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string)
|
|||
fmt.Printf("Користувачі «%s»:\n", tenantName)
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var email, name, role, last string
|
||||
if err := rows.Scan(&email, &name, &role, &last); err != nil {
|
||||
var login, name, role, last string
|
||||
if err := rows.Scan(&login, &name, &role, &last); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf(" %-30s %-12s %-20s вхід: %s\n", email, role, name, last)
|
||||
fmt.Printf(" %-30s %-12s %-20s вхід: %s\n", login, role, name, last)
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Pri
|
|||
f := store.AlertFilter{
|
||||
MinSeverity: r.URL.Query().Get("min_severity"),
|
||||
DeviceID: r.URL.Query().Get("device_id"),
|
||||
Scope: p.Scope(),
|
||||
}
|
||||
if v := r.URL.Query().Get("state"); v != "" {
|
||||
f.States = strings.Split(v, ",")
|
||||
|
|
@ -44,7 +45,7 @@ func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Pri
|
|||
alerts = []store.Alert{}
|
||||
}
|
||||
|
||||
counts, err := s.store.CountAlerts(r.Context(), p.TenantID)
|
||||
counts, err := s.store.CountAlerts(r.Context(), p.TenantID, p.Scope())
|
||||
if err != nil {
|
||||
s.log.Error("лічильники алертів", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||
|
|
@ -125,6 +126,11 @@ func (s *Server) handleMuteDevice(w http.ResponseWriter, r *http.Request, p *Pri
|
|||
req.Minutes = 60
|
||||
}
|
||||
|
||||
if !p.Scope().CanWrite(req.DeviceID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цього хоста")
|
||||
return
|
||||
}
|
||||
|
||||
until := time.Now().Add(time.Duration(req.Minutes) * time.Minute)
|
||||
if max := time.Now().Add(maxMute); until.After(max) {
|
||||
until = max
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
)
|
||||
|
||||
// Гальмування перебору: після 10 невдач за 15 хвилин вхід для цього
|
||||
// email тимчасово закритий. Рахуємо за email, а не за IP: атака з
|
||||
// логін тимчасово закритий. Рахуємо за логіном, а не за IP: атака з
|
||||
// ботнета міняє IP на кожен запит, а ціль лишається та сама.
|
||||
const (
|
||||
maxFailedLogins = 10
|
||||
|
|
@ -25,6 +25,9 @@ const (
|
|||
const refreshCookie = "np_refresh"
|
||||
|
||||
type loginRequest struct {
|
||||
// Login — те, що людина ввела в поле «Логін». Приймається і пошта:
|
||||
// відмовляти тому, хто за звичкою ввів email, було б причепливістю.
|
||||
Login string `json:"login"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
// Необовʼязковий: потрібен лише коли людина працює в кількох тенантах.
|
||||
|
|
@ -45,8 +48,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.Email == "" || in.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "потрібні email і пароль")
|
||||
// Старе поле лишається прийнятним: інтеграції, написані до
|
||||
// перейменування, не мають ламатися через назву ключа в JSON.
|
||||
if in.Login == "" {
|
||||
in.Login = in.Email
|
||||
}
|
||||
if in.Login == "" || in.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "потрібні логін і пароль")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -54,20 +62,20 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||
ip := clientIP(r)
|
||||
ua := r.UserAgent()
|
||||
|
||||
if n, err := s.store.FailedLoginsSince(ctx, in.Email, failWindow); err == nil && n >= maxFailedLogins {
|
||||
s.log.Warn("вхід заблоковано через перебір", "email", in.Email, "спроб", n)
|
||||
if n, err := s.store.FailedLoginsSince(ctx, in.Login, failWindow); err == nil && n >= maxFailedLogins {
|
||||
s.log.Warn("вхід заблоковано через перебір", "login", in.Login, "спроб", n)
|
||||
writeError(w, http.StatusTooManyRequests, "too_many_attempts",
|
||||
"забагато невдалих спроб, спробуйте за 15 хвилин")
|
||||
return
|
||||
}
|
||||
|
||||
user, memberships, err := s.store.AuthenticateUser(ctx, in.Email, in.Password, ip, ua)
|
||||
user, memberships, err := s.store.AuthenticateUser(ctx, in.Login, in.Password, ip, ua)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrBadCredentials):
|
||||
// Однакова відповідь для «немає такого email» і «невірний
|
||||
// Однакова відповідь для «немає такого логіна» і «невірний
|
||||
// пароль»: різні тексти перетворюють форму входу на довідник
|
||||
// зареєстрованих адрес.
|
||||
writeError(w, http.StatusUnauthorized, "bad_credentials", "невірний email або пароль")
|
||||
writeError(w, http.StatusUnauthorized, "bad_credentials", "невірний логін або пароль")
|
||||
return
|
||||
case errors.Is(err, store.ErrNoMembership):
|
||||
writeError(w, http.StatusForbidden, "no_membership", "користувача не додано до жодної організації")
|
||||
|
|
|
|||
|
|
@ -3,13 +3,18 @@ package httpapi_test
|
|||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedUser заводить людину з паролем і роллю в тенанті фікстури.
|
||||
//
|
||||
// Логін виводиться з пошти, щоб тести лишались читабельними: у них
|
||||
// важлива роль користувача, а не форма його ідентифікатора.
|
||||
func (f *fixture) seedUser(t *testing.T, email, password, roleKey string) string {
|
||||
t.Helper()
|
||||
id, err := f.store.CreateUser(f.ctx, f.tenantID, email, password, "Тест Тестович", roleKey)
|
||||
login, _, _ := strings.Cut(email, "@")
|
||||
id, err := f.store.CreateUser(f.ctx, f.tenantID, login, email, password, "Тест Тестович", roleKey)
|
||||
if err != nil {
|
||||
t.Fatalf("створення користувача: %v", err)
|
||||
}
|
||||
|
|
@ -21,8 +26,9 @@ type loginResp struct {
|
|||
ExpiresIn int `json:"expires_in"`
|
||||
Permissions []string `json:"permissions"`
|
||||
User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
} `json:"user"`
|
||||
Tenant *struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
|
|
@ -35,7 +41,8 @@ type loginResp struct {
|
|||
|
||||
func (f *fixture) login(t *testing.T, email, password string) (int, loginResp, *http.Cookie) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||
login, _, _ := strings.Cut(email, "@")
|
||||
body, _ := json.Marshal(map[string]string{"login": login, "password": password})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/login", bytesReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
|
|
|||
298
server/internal/httpapi/groups.go
Normal file
298
server/internal/httpapi/groups.go
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Групи пристроїв
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleListDeviceGroups(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:read") {
|
||||
return
|
||||
}
|
||||
groups, err := s.store.ListDeviceGroups(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "перелік груп", err)
|
||||
return
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []store.DeviceGroup{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateDeviceGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва групи")
|
||||
return
|
||||
}
|
||||
id, err := s.store.CreateDeviceGroup(r.Context(), p.TenantID, strings.TrimSpace(in.Name), in.Color)
|
||||
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})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteDeviceGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
err := s.store.DeleteDeviceGroup(r.Context(), p.TenantID, r.PathValue("id"))
|
||||
if errors.Is(err, store.ErrGroupNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "видалення групи", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Хости
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
var validDeviceKinds = map[string]bool{
|
||||
"router": true, "switch": true, "firewall": true, "server": true,
|
||||
"ap": true, "olt": true, "onu": true, "ups": true, "printer": true,
|
||||
"camera": true, "sensor": true, "vm": true, "container": true, "other": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "devices:write") {
|
||||
return
|
||||
}
|
||||
in, ok := decodeDevice(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := s.store.CreateDevice(r.Context(), p.TenantID, in)
|
||||
if err != nil {
|
||||
if isPlanLimit(err) {
|
||||
writeError(w, http.StatusPaymentRequired, "plan_limit",
|
||||
"вичерпано ліміт пристроїв тарифу")
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "створення хоста", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateDevice(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
|
||||
}
|
||||
|
||||
in, ok := decodeDevice(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := s.store.UpdateDevice(r.Context(), p.TenantID, deviceID, in); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "хост не знайдено")
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "оновлення хоста", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.SetDeviceGroups(r.Context(), p.TenantID, deviceID, in.GroupIDs); err != nil {
|
||||
s.writeStoreError(w, "групи хоста", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteDevice(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
|
||||
}
|
||||
if err := s.store.DeleteDevice(r.Context(), p.TenantID, deviceID); 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 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"`
|
||||
}
|
||||
if !decodeBody(w, r, &in) {
|
||||
return store.DeviceInput{}, false
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва хоста")
|
||||
return store.DeviceInput{}, false
|
||||
}
|
||||
if in.Kind == "" {
|
||||
in.Kind = "other"
|
||||
}
|
||||
if !validDeviceKinds[in.Kind] {
|
||||
writeError(w, http.StatusBadRequest, "bad_kind", "невідомий тип пристрою")
|
||||
return store.DeviceInput{}, false
|
||||
}
|
||||
return store.DeviceInput{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Address: strings.TrimSpace(in.Address),
|
||||
Kind: in.Kind,
|
||||
Vendor: in.Vendor,
|
||||
AgentID: in.AgentID,
|
||||
Enabled: in.Enabled == nil || *in.Enabled,
|
||||
GroupIDs: in.GroupIDs,
|
||||
Notes: in.Notes,
|
||||
}, true
|
||||
}
|
||||
|
||||
func isPlanLimit(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "ліміт")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Групи користувачів
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleListUserGroups(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "users:read") {
|
||||
return
|
||||
}
|
||||
groups, err := s.store.ListUserGroups(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "перелік груп користувачів", err)
|
||||
return
|
||||
}
|
||||
if groups == nil {
|
||||
groups = []store.UserGroup{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "users:write") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "потрібна назва групи")
|
||||
return
|
||||
}
|
||||
id, err := s.store.CreateUserGroup(r.Context(), p.TenantID, strings.TrimSpace(in.Name), in.Description)
|
||||
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})
|
||||
}
|
||||
|
||||
// handlePatchUserGroup міняє склад і права одним запитом.
|
||||
//
|
||||
// Разом, а не окремими ендпоїнтами: у формі це один екран і одна
|
||||
// кнопка «Зберегти», і розбивати її на два запити означало б лишати
|
||||
// групу в напівзбереженому стані, якщо другий не пройде.
|
||||
func (s *Server) handlePatchUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "users:write") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
MemberIDs *[]string `json:"member_ids"`
|
||||
Permissions *map[string]string `json:"permissions"`
|
||||
}
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
|
||||
groupID := r.PathValue("id")
|
||||
if in.MemberIDs != nil {
|
||||
if err := s.store.SetUserGroupMembers(r.Context(), p.TenantID, groupID, *in.MemberIDs); err != nil {
|
||||
if errors.Is(err, store.ErrGroupNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "склад групи", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Permissions != nil {
|
||||
if err := s.store.SetUserGroupPermissions(r.Context(), p.TenantID, groupID, *in.Permissions); err != nil {
|
||||
if errors.Is(err, store.ErrGroupNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "права групи", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUserGroup(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "users:write") {
|
||||
return
|
||||
}
|
||||
err := s.store.DeleteUserGroup(r.Context(), p.TenantID, r.PathValue("id"))
|
||||
if errors.Is(err, store.ErrGroupNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "групу не знайдено")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "видалення групи", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/auth"
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Principal — хто саме робить запит.
|
||||
|
|
@ -18,8 +19,9 @@ import (
|
|||
type Principal struct {
|
||||
TenantID string
|
||||
// Порожній для машинного токена.
|
||||
UserID string
|
||||
Email string
|
||||
UserID string
|
||||
Username string
|
||||
Email string
|
||||
// Порожній для людини.
|
||||
TokenID string
|
||||
TokenName string
|
||||
|
|
@ -28,6 +30,20 @@ type Principal struct {
|
|||
// Машинний токен без scopes успадковує повний доступ тенанта:
|
||||
// так поводяться ключі, які власник створює сам для себе.
|
||||
unrestricted bool
|
||||
|
||||
// scope — які саме пристрої видно. Права відповідають на питання
|
||||
// «що вільно робити», scope — «над чим». Два незалежні виміри:
|
||||
// інженер над однією філією та інженер над усією мережею мають
|
||||
// однакову роль і різний доступ.
|
||||
scope store.Scope
|
||||
}
|
||||
|
||||
// Scope — обмеження видимості для вибірок.
|
||||
func (p *Principal) Scope() store.Scope {
|
||||
if p == nil {
|
||||
return store.Scope{Unrestricted: true}
|
||||
}
|
||||
return p.scope
|
||||
}
|
||||
|
||||
func (p *Principal) Can(perm string) bool {
|
||||
|
|
@ -114,6 +130,15 @@ func (s *Server) resolvePrincipal(r *http.Request, raw string) (*Principal, erro
|
|||
for _, k := range perms {
|
||||
p.perms[k] = true
|
||||
}
|
||||
|
||||
// Обмеження за групами читається на кожному запиті разом із
|
||||
// правами — з тієї самої причини: зміна складу групи має діяти
|
||||
// одразу, а не після протермінування токена.
|
||||
sc, err := s.store.LoadScope(ctx, claims.TenantID, claims.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.scope = sc
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
@ -134,6 +159,9 @@ func (s *Server) resolvePrincipal(r *http.Request, raw string) (*Principal, erro
|
|||
for _, k := range tok.Scopes {
|
||||
p.perms[k] = true
|
||||
}
|
||||
// Машинний токен групами не обмежений: у нього немає членства, а
|
||||
// його межі задає власний scopes.
|
||||
p.scope = store.Scope{Unrestricted: true}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,18 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.Handle("POST /api/v1/maps/{id}/build", s.authenticated(s.handleBuildMap))
|
||||
mux.Handle("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap))
|
||||
mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices))
|
||||
mux.Handle("POST /api/v1/devices", s.authenticated(s.handleCreateDevice))
|
||||
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/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))
|
||||
|
||||
mux.Handle("GET /api/v1/user-groups", s.authenticated(s.handleListUserGroups))
|
||||
mux.Handle("POST /api/v1/user-groups", s.authenticated(s.handleCreateUserGroup))
|
||||
mux.Handle("PATCH /api/v1/user-groups/{id}", s.authenticated(s.handlePatchUserGroup))
|
||||
mux.Handle("DELETE /api/v1/user-groups/{id}", s.authenticated(s.handleDeleteUserGroup))
|
||||
mux.Handle("GET /api/v1/agents", s.authenticated(s.handleListAgents))
|
||||
|
||||
mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts))
|
||||
|
|
@ -256,7 +268,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, p *Pr
|
|||
return
|
||||
}
|
||||
|
||||
devices, err := s.store.ListDevices(r.Context(), p.TenantID)
|
||||
devices, err := s.store.ListDevices(r.Context(), p.TenantID, p.Scope())
|
||||
if err != nil {
|
||||
s.log.Error("перелік пристроїв", "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||
|
|
|
|||
|
|
@ -3,10 +3,17 @@ package httpapi
|
|||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// usernameRe повторює CHECK із міграції 0013. Дублювання свідоме:
|
||||
// відмова БД виглядає як «violates check constraint users_username_format»,
|
||||
// і людині з неї нічого не зрозуміло.
|
||||
var usernameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$`)
|
||||
|
||||
func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "users:read") {
|
||||
return
|
||||
|
|
@ -50,6 +57,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri
|
|||
}
|
||||
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
FullName string `json:"full_name"`
|
||||
|
|
@ -58,8 +66,16 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri
|
|||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.Email == "" || in.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "потрібні email і пароль")
|
||||
in.Username = strings.ToLower(strings.TrimSpace(in.Username))
|
||||
if in.Username == "" || in.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "потрібні логін і пароль")
|
||||
return
|
||||
}
|
||||
// Формат перевіряє й БД, але помилка звідти виглядає як
|
||||
// «violates check constraint» — людині з неї нічого не зрозуміло.
|
||||
if !usernameRe.MatchString(in.Username) {
|
||||
writeError(w, http.StatusBadRequest, "bad_username",
|
||||
"логін: 3–64 символи, лише латиниця, цифри, крапка, дефіс і підкреслення")
|
||||
return
|
||||
}
|
||||
if in.RoleKey == "" {
|
||||
|
|
@ -72,7 +88,8 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri
|
|||
return
|
||||
}
|
||||
|
||||
u, err := s.store.CreateUser(r.Context(), p.TenantID, in.Email, in.Password, in.FullName, in.RoleKey)
|
||||
u, err := s.store.CreateUser(r.Context(), p.TenantID, in.Username, in.Email,
|
||||
in.Password, in.FullName, in.RoleKey)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "створення користувача", err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ type AlertFilter struct {
|
|||
MinSeverity string
|
||||
DeviceID string
|
||||
Limit int
|
||||
// Scope звужує вибірку до пристроїв, доступних цій людині. Алерт
|
||||
// без пристрою (наприклад, про сам зонд) видно всім: сховати його
|
||||
// від обмеженого користувача означало б приховати аварію, до якої
|
||||
// групи не мають стосунку.
|
||||
Scope Scope
|
||||
}
|
||||
|
||||
// severityRank повторює порядок ENUM alr.severity. Тримати його в Go
|
||||
|
|
@ -56,13 +61,15 @@ func (s *Store) ListAlerts(ctx context.Context, tenantID string, f AlertFilter)
|
|||
WHERE a.tenant_id = $1
|
||||
AND a.state::text = ANY($2::text[])
|
||||
AND ($3::uuid IS NULL OR a.device_id = $3::uuid)
|
||||
AND ($5::boolean OR a.device_id IS NULL OR a.device_id = ANY($6::uuid[]))
|
||||
ORDER BY
|
||||
CASE a.severity
|
||||
WHEN 'disaster' THEN 0 WHEN 'high' THEN 1 WHEN 'average' THEN 2
|
||||
WHEN 'warning' THEN 3 ELSE 4 END,
|
||||
a.started_at DESC
|
||||
LIMIT $4
|
||||
`, tenantID, states, nullUUID(f.DeviceID), f.Limit)
|
||||
`, tenantID, states, nullUUID(f.DeviceID), f.Limit,
|
||||
f.Scope.Unrestricted, nonNilIDs(f.Scope.Readable))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -106,7 +113,7 @@ type AlertCounts struct {
|
|||
High int `json:"high"`
|
||||
}
|
||||
|
||||
func (s *Store) CountAlerts(ctx context.Context, tenantID string) (AlertCounts, error) {
|
||||
func (s *Store) CountAlerts(ctx context.Context, tenantID string, sc Scope) (AlertCounts, error) {
|
||||
var c AlertCounts
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
return tx.QueryRow(ctx, `
|
||||
|
|
@ -118,7 +125,9 @@ func (s *Store) CountAlerts(ctx context.Context, tenantID string) (AlertCounts,
|
|||
count(*) FILTER (WHERE severity = 'high' AND state = 'firing')
|
||||
FROM alr.alerts
|
||||
WHERE tenant_id = $1 AND state IN ('firing','acknowledged','suppressed')
|
||||
`, tenantID).Scan(&c.Firing, &c.Acknowledged, &c.Suppressed, &c.Disaster, &c.High)
|
||||
AND ($2::boolean OR device_id IS NULL OR device_id = ANY($3::uuid[]))
|
||||
`, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable)).
|
||||
Scan(&c.Firing, &c.Acknowledged, &c.Suppressed, &c.Disaster, &c.High)
|
||||
})
|
||||
return c, err
|
||||
}
|
||||
|
|
|
|||
501
server/internal/store/groups.go
Normal file
501
server/internal/store/groups.go
Normal file
|
|
@ -0,0 +1,501 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrGroupNotFound = errors.New("групу не знайдено")
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Групи пристроїв
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type DeviceGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Color string `json:"color,omitempty"`
|
||||
DeviceCount int `json:"device_count"`
|
||||
}
|
||||
|
||||
func (s *Store) ListDeviceGroups(ctx context.Context, tenantID string) ([]DeviceGroup, error) {
|
||||
var out []DeviceGroup
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT g.id::text, g.name, g.kind::text, COALESCE(g.color,''),
|
||||
(SELECT count(*) FROM inv.device_group_members m
|
||||
JOIN inv.devices d ON d.id = m.device_id AND d.deleted_at IS NULL
|
||||
WHERE m.group_id = g.id)::int
|
||||
FROM inv.device_groups g
|
||||
WHERE g.tenant_id = $1
|
||||
ORDER BY g.name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var g DeviceGroup
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Kind, &g.Color, &g.DeviceCount); err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateDeviceGroup(ctx context.Context, tenantID, name, color string) (string, error) {
|
||||
var id string
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
return tx.QueryRow(ctx, `
|
||||
INSERT INTO inv.device_groups (tenant_id, name, kind, color)
|
||||
VALUES ($1, $2, 'static', NULLIF($3,''))
|
||||
RETURNING id::text
|
||||
`, tenantID, name, color).Scan(&id)
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteDeviceGroup(ctx context.Context, tenantID, groupID string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx,
|
||||
`DELETE FROM inv.device_groups WHERE tenant_id = $1 AND id = $2`, tenantID, groupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrGroupNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetDeviceGroups замінює членство пристрою цілком.
|
||||
//
|
||||
// Саме заміна, а не додавання: інтерфейс показує повний набір галочок,
|
||||
// і зняту галочку треба вміти зняти. Часткові операції довелося б
|
||||
// узгоджувати з тим, що бачить людина, і будь-яке розходження
|
||||
// закінчувалося б непоміченим доступом.
|
||||
func (s *Store) SetDeviceGroups(ctx context.Context, tenantID, deviceID string, groupIDs []string) 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
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM inv.device_group_members m
|
||||
USING inv.device_groups g
|
||||
WHERE m.group_id = g.id AND m.device_id = $1 AND g.tenant_id = $2
|
||||
`, deviceID, tenantID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(groupIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Групи звіряються з тенантом усередині запиту: інакше чужий id
|
||||
// у списку тихо додав би пристрій у групу сусіда.
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO inv.device_group_members (group_id, device_id)
|
||||
SELECT g.id, $1 FROM inv.device_groups g
|
||||
WHERE g.tenant_id = $2 AND g.id = ANY($3::uuid[])
|
||||
ON CONFLICT DO NOTHING
|
||||
`, deviceID, tenantID, groupIDs)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Групи користувачів і права на групи пристроїв
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type UserGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
MemberIDs []string `json:"member_ids"`
|
||||
Permissions map[string]string `json:"permissions"` // device_group_id → deny|read|write
|
||||
}
|
||||
|
||||
func (s *Store) ListUserGroups(ctx context.Context, tenantID string) ([]UserGroup, error) {
|
||||
var out []UserGroup
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT g.id::text, g.name, COALESCE(g.description,''),
|
||||
COALESCE(array_agg(DISTINCT m.user_id::text)
|
||||
FILTER (WHERE m.user_id IS NOT NULL), '{}')
|
||||
FROM core.user_groups g
|
||||
LEFT JOIN core.user_group_members m ON m.group_id = g.id
|
||||
WHERE g.tenant_id = $1
|
||||
GROUP BY g.id, g.name, g.description
|
||||
ORDER BY g.name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byID := map[string]int{}
|
||||
for rows.Next() {
|
||||
var g UserGroup
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.MemberIDs); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
g.Permissions = map[string]string{}
|
||||
byID[g.ID] = len(out)
|
||||
out = append(out, g)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
perms, err := tx.Query(ctx, `
|
||||
SELECT p.user_group_id::text, p.device_group_id::text, p.level::text
|
||||
FROM core.group_permissions p
|
||||
JOIN core.user_groups g ON g.id = p.user_group_id
|
||||
WHERE g.tenant_id = $1
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer perms.Close()
|
||||
for perms.Next() {
|
||||
var ug, dg, lvl string
|
||||
if err := perms.Scan(&ug, &dg, &lvl); err != nil {
|
||||
return err
|
||||
}
|
||||
if i, ok := byID[ug]; ok {
|
||||
out[i].Permissions[dg] = lvl
|
||||
}
|
||||
}
|
||||
return perms.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateUserGroup(ctx context.Context, tenantID, name, description string) (string, error) {
|
||||
var id string
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
return tx.QueryRow(ctx, `
|
||||
INSERT INTO core.user_groups (tenant_id, name, description)
|
||||
VALUES ($1, $2, NULLIF($3,''))
|
||||
RETURNING id::text
|
||||
`, tenantID, name, description).Scan(&id)
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserGroup(ctx context.Context, tenantID, groupID string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx,
|
||||
`DELETE FROM core.user_groups WHERE tenant_id = $1 AND id = $2`, tenantID, groupID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrGroupNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetUserGroupMembers і SetUserGroupPermissions — теж повна заміна, з
|
||||
// тієї самої причини, що й у пристроїв.
|
||||
func (s *Store) SetUserGroupMembers(ctx context.Context, tenantID, groupID string, userIDs []string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
if err := assertUserGroup(ctx, tx, tenantID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM core.user_group_members WHERE group_id = $1`, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
// Лише ті, хто справді є в цьому тенанті: додати чужого
|
||||
// користувача в свою групу означало б видати йому доступ.
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO core.user_group_members (group_id, user_id)
|
||||
SELECT $1, m.user_id
|
||||
FROM core.memberships m
|
||||
WHERE m.tenant_id = $2 AND m.user_id = ANY($3::uuid[])
|
||||
ON CONFLICT DO NOTHING
|
||||
`, groupID, tenantID, userIDs)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) SetUserGroupPermissions(ctx context.Context, tenantID, groupID string, perms map[string]string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
if err := assertUserGroup(ctx, tx, tenantID, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`DELETE FROM core.group_permissions WHERE user_group_id = $1`, groupID); err != nil {
|
||||
return err
|
||||
}
|
||||
for dg, lvl := range perms {
|
||||
switch lvl {
|
||||
case "deny", "read", "write":
|
||||
default:
|
||||
return fmt.Errorf("невідомий рівень доступу %q", lvl)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO core.group_permissions (user_group_id, device_group_id, level)
|
||||
SELECT $1, g.id, $3::core.access_level
|
||||
FROM inv.device_groups g
|
||||
WHERE g.id = $2 AND g.tenant_id = $4
|
||||
`, groupID, dg, lvl, tenantID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func assertUserGroup(ctx context.Context, tx pgx.Tx, tenantID, groupID string) error {
|
||||
var ok bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (SELECT 1 FROM core.user_groups WHERE id = $1 AND tenant_id = $2)
|
||||
`, groupID, tenantID).Scan(&ok); err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrGroupNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Обмеження видимості
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Scope — що саме цій людині видно.
|
||||
//
|
||||
// Обчислюється раз на запит і далі йде параметром у вибірки. Питати
|
||||
// БД про кожен пристрій окремо означало б перетворити список на N
|
||||
// запитів рівно тоді, коли пристроїв багато.
|
||||
type Scope struct {
|
||||
// Unrestricted — людина в жодній групі користувачів, тож групами не
|
||||
// обмежена; доступ визначає роль. Див. коментар у міграції 0013.
|
||||
Unrestricted bool
|
||||
Readable []string
|
||||
Writable []string
|
||||
}
|
||||
|
||||
func (s Scope) CanWrite(deviceID string) bool {
|
||||
if s.Unrestricted {
|
||||
return true
|
||||
}
|
||||
for _, id := range s.Writable {
|
||||
if id == deviceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s Scope) CanRead(deviceID string) bool {
|
||||
if s.Unrestricted {
|
||||
return true
|
||||
}
|
||||
for _, id := range s.Readable {
|
||||
if id == deviceID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LoadScope рахує доступні пристрої одним запитом.
|
||||
func (s *Store) LoadScope(ctx context.Context, tenantID, userID string) (Scope, error) {
|
||||
sc := Scope{Unrestricted: true}
|
||||
if userID == "" {
|
||||
// Машинний токен групами не обмежений: у нього немає членства,
|
||||
// а його межі задає власний scopes.
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
var inGroups bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM core.user_group_members m
|
||||
JOIN core.user_groups g ON g.id = m.group_id
|
||||
WHERE m.user_id = $1 AND g.tenant_id = $2)
|
||||
`, userID, tenantID).Scan(&inGroups); err != nil {
|
||||
return err
|
||||
}
|
||||
if !inGroups {
|
||||
return nil
|
||||
}
|
||||
sc.Unrestricted = false
|
||||
|
||||
for _, lvl := range []string{"read", "write"} {
|
||||
rows, err := tx.Query(ctx,
|
||||
`SELECT device_id::text FROM core.accessible_devices($1, $2, $3::core.access_level)`,
|
||||
userID, tenantID, lvl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if lvl == "read" {
|
||||
sc.Readable = ids
|
||||
} else {
|
||||
sc.Writable = ids
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return sc, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Хости
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type DeviceInput struct {
|
||||
Name string
|
||||
Address string
|
||||
Kind string
|
||||
Vendor string
|
||||
AgentID string
|
||||
SiteID string
|
||||
Enabled bool
|
||||
GroupIDs []string
|
||||
Notes string
|
||||
}
|
||||
|
||||
func (s *Store) CreateDevice(ctx context.Context, tenantID string, in DeviceInput) (string, error) {
|
||||
var id string
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO inv.devices
|
||||
(tenant_id, name, address, kind, vendor, agent_id, site_id, enabled, notes, source)
|
||||
VALUES ($1, $2, NULLIF($3,'')::inet, $4::inv.device_kind, NULLIF($5,''),
|
||||
$6, $7, $8, NULLIF($9,''), 'manual')
|
||||
RETURNING id::text
|
||||
`, tenantID, in.Name, in.Address, in.Kind, in.Vendor,
|
||||
nullUUID(in.AgentID), nullUUID(in.SiteID), in.Enabled, in.Notes).Scan(&id); err != nil {
|
||||
return fmt.Errorf("створення хоста: %w", err)
|
||||
}
|
||||
if len(in.GroupIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO inv.device_group_members (group_id, device_id)
|
||||
SELECT g.id, $1 FROM inv.device_groups g
|
||||
WHERE g.tenant_id = $2 AND g.id = ANY($3::uuid[])
|
||||
ON CONFLICT DO NOTHING
|
||||
`, id, tenantID, in.GroupIDs)
|
||||
return err
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateDevice(ctx context.Context, tenantID, deviceID string, in DeviceInput) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE inv.devices
|
||||
SET name = $3, address = NULLIF($4,'')::inet, kind = $5::inv.device_kind,
|
||||
vendor = NULLIF($6,''), agent_id = $7, enabled = $8,
|
||||
notes = NULLIF($9,''), updated_at = now()
|
||||
WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL
|
||||
`, tenantID, deviceID, in.Name, in.Address, in.Kind, in.Vendor,
|
||||
nullUUID(in.AgentID), in.Enabled, in.Notes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteDevice — м'яке видалення.
|
||||
//
|
||||
// Жорстке зняло б за собою всю телеметрію й історію алертів. Пристрій
|
||||
// зникає з інтерфейсу, але його минуле лишається доступним для розбору.
|
||||
func (s *Store) DeleteDevice(ctx context.Context, tenantID, deviceID string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE inv.devices SET deleted_at = now(), enabled = false
|
||||
WHERE tenant_id = $1 AND id = $2 AND deleted_at IS NULL
|
||||
`, tenantID, deviceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeviceGroupsOf — у яких групах пристрій. Потрібне формі редагування.
|
||||
func (s *Store) DeviceGroupsOf(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 m.device_id::text, m.group_id::text
|
||||
FROM inv.device_group_members m
|
||||
JOIN inv.device_groups g ON g.id = m.group_id
|
||||
WHERE g.tenant_id = $1
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var d, g string
|
||||
if err := rows.Scan(&d, &g); err != nil {
|
||||
return err
|
||||
}
|
||||
out[d] = append(out[d], g)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
|
||||
// nonNilIDs віддає порожній масив замість nil.
|
||||
//
|
||||
// pgx перетворює nil-слайс на SQL NULL, а `id = ANY(NULL)` — це не
|
||||
// «жоден», а NULL, тобто рядок не проходить умову, але й помилки немає.
|
||||
// Такий фільтр мовчки ховав би всі пристрої.
|
||||
func nonNilIDs(ids []string) []string {
|
||||
if ids == nil {
|
||||
return []string{}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
|
@ -20,10 +20,19 @@ type DeviceSummary struct {
|
|||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
IfaceCount int `json:"interface_count"`
|
||||
GroupIDs []string `json:"group_ids"`
|
||||
// Writable каже інтерфейсу, чи показувати дії. Рахується з груп
|
||||
// користувача, а не з ролі: роль дозволяє редагувати пристрої взагалі,
|
||||
// групи — саме цей.
|
||||
Writable bool `json:"writable"`
|
||||
}
|
||||
|
||||
// ListDevices — інвентар для таблиці в UI.
|
||||
func (s *Store) ListDevices(ctx context.Context, tenantID string) ([]DeviceSummary, error) {
|
||||
//
|
||||
// Фільтр видимості накладається в самому запиті, а не після вибірки:
|
||||
// відсіювати вже прочитане означало б тягнути з БД чужі рядки й
|
||||
// покладатися на те, що жоден із них не проскочить у відповідь.
|
||||
func (s *Store) ListDevices(ctx context.Context, tenantID string, sc Scope) ([]DeviceSummary, error) {
|
||||
var out []DeviceSummary
|
||||
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
|
|
@ -35,8 +44,9 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string) ([]DeviceSumma
|
|||
FROM inv.devices d
|
||||
LEFT JOIN inv.sites st ON st.id = d.site_id
|
||||
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
|
||||
AND ($2::boolean OR d.id = ANY($3::uuid[]))
|
||||
ORDER BY d.name
|
||||
`, tenantID)
|
||||
`, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -49,12 +59,28 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string) ([]DeviceSumma
|
|||
&d.IfaceCount); err != nil {
|
||||
return err
|
||||
}
|
||||
d.Writable = sc.CanWrite(d.ID)
|
||||
d.GroupIDs = []string{}
|
||||
out = append(out, d)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return out, err
|
||||
// Групи добираємо одним запитом і розкладаємо в пам'яті: агрегат у
|
||||
// головному запиті змусив би його групуватись по всіх колонках.
|
||||
groups, err := s.DeviceGroupsOf(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range out {
|
||||
if g, ok := groups[out[i].ID]; ok {
|
||||
out[i].GroupIDs = g
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type AgentSummary struct {
|
||||
|
|
|
|||
|
|
@ -20,14 +20,15 @@ import (
|
|||
const RefreshTTL = 30 * 24 * time.Hour
|
||||
|
||||
var (
|
||||
ErrBadCredentials = errors.New("невірний email або пароль")
|
||||
ErrBadCredentials = errors.New("невірний логін або пароль")
|
||||
ErrNoMembership = errors.New("користувач не належить жодному тенанту")
|
||||
ErrSessionInvalid = errors.New("сесія недійсна")
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
Locale string `json:"locale"`
|
||||
Timezone string `json:"timezone"`
|
||||
|
|
@ -46,7 +47,8 @@ type TenantMembership struct {
|
|||
// TeamMember — рядок сторінки «Команда».
|
||||
type TeamMember struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email,omitempty"`
|
||||
FullName string `json:"full_name,omitempty"`
|
||||
RoleID string `json:"role_id"`
|
||||
RoleKey string `json:"role_key"`
|
||||
|
|
@ -69,45 +71,56 @@ type Role struct {
|
|||
// Вхід
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// AuthenticateUser перевіряє email і пароль.
|
||||
// AuthenticateUser перевіряє логін і пароль.
|
||||
//
|
||||
// Запити йдуть БЕЗ tenant-контексту: у момент входу тенант ще невідомий,
|
||||
// його називає саме членство. Політики з міграції 0012 дозволяють це
|
||||
// рівно тоді, коли app.tenant_id порожній.
|
||||
func (s *Store) AuthenticateUser(ctx context.Context, email, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) {
|
||||
// Ідентифікатором є username, а не пошта: у мережевій інсталяції
|
||||
// половина облікових записів технічні (noc, monitoring, oncall) і
|
||||
// скриньки не мають узагалі. Пошта лишається прийнятною — люди звикли
|
||||
// вводити її, і відмовляти їм у вході через це було б причепливістю, —
|
||||
// але вона більше не єдиний шлях і не обов'язкове поле.
|
||||
func (s *Store) AuthenticateUser(ctx context.Context, login, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) {
|
||||
var (
|
||||
u User
|
||||
hash *string
|
||||
u User
|
||||
hash *string
|
||||
email *string
|
||||
)
|
||||
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone,
|
||||
mfa_enabled, password_hash
|
||||
SELECT id::text, username::text, email::text, COALESCE(full_name,''),
|
||||
locale, timezone, mfa_enabled, password_hash
|
||||
FROM core.users
|
||||
WHERE email = $1 AND deleted_at IS NULL
|
||||
`, email).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA, &hash)
|
||||
WHERE (username = $1 OR email = $1) AND deleted_at IS NULL
|
||||
-- Збіг за логіном виграє: пошта одного може випадково збігтися
|
||||
-- з логіном іншого, і тоді пріоритет має мати той, кому цей
|
||||
-- рядок належить як ідентифікатор.
|
||||
ORDER BY (username = $1) DESC
|
||||
LIMIT 1
|
||||
`, login).Scan(&u.ID, &u.Username, &email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA, &hash)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Спалюємо стільки ж часу, скільки справжня перевірка: інакше
|
||||
// швидка відповідь видає, що такого email немає, і перебір
|
||||
// перетворюється на збір списку зареєстрованих адрес.
|
||||
// швидка відповідь видає, що такого логіна немає, і перебір
|
||||
// перетворюється на збір списку зареєстрованих акаунтів.
|
||||
auth.DummyVerify(password)
|
||||
s.recordLogin(ctx, email, ip, userAgent, false, "no_user", "")
|
||||
s.recordLogin(ctx, login, ip, userAgent, false, "no_user", "")
|
||||
return nil, nil, ErrBadCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if email != nil {
|
||||
u.Email = *email
|
||||
}
|
||||
|
||||
if hash == nil || *hash == "" {
|
||||
// Користувач лише через SSO — локального пароля немає.
|
||||
auth.DummyVerify(password)
|
||||
s.recordLogin(ctx, email, ip, userAgent, false, "no_password", u.ID)
|
||||
s.recordLogin(ctx, login, ip, userAgent, false, "no_password", u.ID)
|
||||
return nil, nil, ErrBadCredentials
|
||||
}
|
||||
|
||||
if err := auth.VerifyPassword(*hash, password); err != nil {
|
||||
s.recordLogin(ctx, email, ip, userAgent, false, "bad_password", u.ID)
|
||||
s.recordLogin(ctx, login, ip, userAgent, false, "bad_password", u.ID)
|
||||
return nil, nil, ErrBadCredentials
|
||||
}
|
||||
|
||||
|
|
@ -116,11 +129,11 @@ func (s *Store) AuthenticateUser(ctx context.Context, email, password string, ip
|
|||
return nil, nil, err
|
||||
}
|
||||
if len(memberships) == 0 {
|
||||
s.recordLogin(ctx, email, ip, userAgent, false, "no_membership", u.ID)
|
||||
s.recordLogin(ctx, login, ip, userAgent, false, "no_membership", u.ID)
|
||||
return nil, nil, ErrNoMembership
|
||||
}
|
||||
|
||||
s.recordLogin(ctx, email, ip, userAgent, true, "", u.ID)
|
||||
s.recordLogin(ctx, login, ip, userAgent, true, "", u.ID)
|
||||
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`UPDATE core.users SET last_login_at = now() WHERE id = $1`, u.ID); err != nil {
|
||||
|
|
@ -352,14 +365,15 @@ func (s *Store) ListTeam(ctx context.Context, tenantID string) ([]TeamMember, er
|
|||
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT u.id::text, u.email::text, COALESCE(u.full_name,''),
|
||||
SELECT u.id::text, u.username::text, COALESCE(u.email::text,''),
|
||||
COALESCE(u.full_name,''),
|
||||
r.id::text, r.key::text, r.name,
|
||||
u.last_login_at, m.accepted_at, m.scope_group_ids::text[]
|
||||
FROM core.memberships m
|
||||
JOIN core.users u ON u.id = m.user_id
|
||||
JOIN core.roles r ON r.id = m.role_id
|
||||
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
|
||||
ORDER BY u.email
|
||||
ORDER BY u.username
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -368,7 +382,7 @@ func (s *Store) ListTeam(ctx context.Context, tenantID string) ([]TeamMember, er
|
|||
|
||||
for rows.Next() {
|
||||
var t TeamMember
|
||||
if err := rows.Scan(&t.UserID, &t.Email, &t.FullName,
|
||||
if err := rows.Scan(&t.UserID, &t.Username, &t.Email, &t.FullName,
|
||||
&t.RoleID, &t.RoleKey, &t.RoleName,
|
||||
&t.LastLogin, &t.AcceptedAt, &t.ScopeGroups); err != nil {
|
||||
return err
|
||||
|
|
@ -417,7 +431,7 @@ func (s *Store) ListRoles(ctx context.Context, tenantID string) ([]Role, error)
|
|||
// CreateUser заводить користувача й одразу дає йому членство.
|
||||
//
|
||||
// Використовується і CLI-початківцем (перший власник), і запрошеннями.
|
||||
func (s *Store) CreateUser(ctx context.Context, tenantID, email, password, fullName, roleKey string) (*User, error) {
|
||||
func (s *Store) CreateUser(ctx context.Context, tenantID, username, email, password, fullName, roleKey string) (*User, error) {
|
||||
hash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -426,17 +440,27 @@ func (s *Store) CreateUser(ctx context.Context, tenantID, email, password, fullN
|
|||
var u User
|
||||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
// Користувач глобальний: одна людина може працювати в кількох
|
||||
// тенантах (типово для MSP-провайдера).
|
||||
// тенантах (типово для MSP-провайдера). Тому конфлікт за логіном
|
||||
// означає «той самий чоловік», а не помилку — але пароль при
|
||||
// цьому НЕ перезаписується: інакше адмін одного тенанта міняв би
|
||||
// пароль людини в чужому.
|
||||
var mail *string
|
||||
err := tx.QueryRow(ctx, `
|
||||
INSERT INTO core.users (email, password_hash, full_name, email_verified_at)
|
||||
VALUES ($1, $2, NULLIF($3,''), now())
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
INSERT INTO core.users (username, email, password_hash, full_name, email_verified_at)
|
||||
VALUES ($1, NULLIF($2,'')::citext, $3, NULLIF($4,''),
|
||||
CASE WHEN NULLIF($2,'') IS NULL THEN NULL ELSE now() END)
|
||||
ON CONFLICT (username) DO UPDATE
|
||||
SET full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name)
|
||||
RETURNING id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled
|
||||
`, email, hash, fullName).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA)
|
||||
RETURNING id::text, username::text, email::text,
|
||||
COALESCE(full_name,''), locale, timezone, mfa_enabled
|
||||
`, username, email, hash, fullName).
|
||||
Scan(&u.ID, &u.Username, &mail, &u.FullName, &u.Locale, &u.Timezone, &u.MFA)
|
||||
if err != nil {
|
||||
return fmt.Errorf("створення користувача: %w", err)
|
||||
}
|
||||
if mail != nil {
|
||||
u.Email = *mail
|
||||
}
|
||||
|
||||
var roleID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { AppShell } from './components/AppShell'
|
|||
import { LoginPage } from './components/LoginPage'
|
||||
import { MapPage } from './pages/MapPage'
|
||||
import { DevicesPage } from './pages/DevicesPage'
|
||||
import { GroupsPage } from './pages/GroupsPage'
|
||||
import { AlertsPage } from './pages/AlertsPage'
|
||||
import { RulesPage } from './pages/RulesPage'
|
||||
import { ChannelsPage } from './pages/ChannelsPage'
|
||||
|
|
@ -49,6 +50,7 @@ export default function App() {
|
|||
<Route index element={<Home />} />
|
||||
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
|
||||
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
|
||||
<Route path="/groups" element={<Guard perm="devices:read" element={<GroupsPage />} />} />
|
||||
<Route path="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
|
||||
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
|
||||
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { meFromLogin, session } from './session'
|
||||
import type {
|
||||
AccessLevel,
|
||||
AgentSummary,
|
||||
Alert,
|
||||
AlertCounts,
|
||||
AlertRule,
|
||||
BuildResult,
|
||||
Channel,
|
||||
DeviceGroup,
|
||||
DeviceSummary,
|
||||
LoginResponse,
|
||||
MapPatch,
|
||||
|
|
@ -14,9 +16,23 @@ import type {
|
|||
PatchResult,
|
||||
Role,
|
||||
TeamMember,
|
||||
UserGroup,
|
||||
UndoResult,
|
||||
} from '../types'
|
||||
|
||||
/** Те, що приймає POST/PATCH /api/v1/devices. */
|
||||
export interface DeviceInput {
|
||||
name: string
|
||||
address?: string
|
||||
kind: string
|
||||
vendor?: string
|
||||
agent_id?: string
|
||||
enabled?: boolean
|
||||
group_ids?: string[]
|
||||
notes?: string
|
||||
}
|
||||
|
||||
|
||||
// Машинний токен зі змінної збірки лишається запасним шляхом для кіосків
|
||||
// і NOC-екранів, де входити нікому. Жива сесія людини має пріоритет.
|
||||
const STATIC_TOKEN = import.meta.env.VITE_API_TOKEN ?? ''
|
||||
|
|
@ -118,10 +134,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||
export const api = {
|
||||
// --- вхід ---
|
||||
|
||||
login: (email: string, password: string, tenantID?: string) =>
|
||||
login: (login: string, password: string, tenantID?: string) =>
|
||||
raw<LoginResponse>('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, tenant_id: tenantID }),
|
||||
body: JSON.stringify({ login, password, tenant_id: tenantID }),
|
||||
}),
|
||||
|
||||
/** Тихе відновлення сесії після перезавантаження сторінки. */
|
||||
|
|
@ -150,11 +166,13 @@ export const api = {
|
|||
|
||||
roles: () => request<{ roles: Role[] }>('/api/v1/roles').then((r) => r.roles ?? []),
|
||||
|
||||
addMember: (email: string, password: string, roleKey: string, fullName = '') =>
|
||||
request<{ id: string }>('/api/v1/team', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, role_key: roleKey, full_name: fullName }),
|
||||
}),
|
||||
addMember: (u: {
|
||||
username: string
|
||||
password: string
|
||||
role_key: string
|
||||
email?: string
|
||||
full_name?: string
|
||||
}) => request<{ id: string }>('/api/v1/team', { method: 'POST', body: JSON.stringify(u) }),
|
||||
|
||||
setRole: (userID: string, roleID: string) =>
|
||||
request<void>(`/api/v1/team/${userID}`, {
|
||||
|
|
@ -195,6 +213,49 @@ export const api = {
|
|||
listAgents: () =>
|
||||
request<{ agents: AgentSummary[] }>('/api/v1/agents').then((r) => r.agents ?? []),
|
||||
|
||||
// --- хости ---
|
||||
|
||||
createDevice: (d: DeviceInput) =>
|
||||
request<{ id: string }>('/api/v1/devices', { method: 'POST', body: JSON.stringify(d) }),
|
||||
|
||||
updateDevice: (id: string, d: DeviceInput) =>
|
||||
request<void>(`/api/v1/devices/${id}`, { method: 'PATCH', body: JSON.stringify(d) }),
|
||||
|
||||
deleteDevice: (id: string) => request<void>(`/api/v1/devices/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// --- групи пристроїв ---
|
||||
|
||||
listDeviceGroups: () =>
|
||||
request<{ groups: DeviceGroup[] }>('/api/v1/device-groups').then((r) => r.groups ?? []),
|
||||
|
||||
createDeviceGroup: (name: string, color = '') =>
|
||||
request<{ id: string }>('/api/v1/device-groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, color }),
|
||||
}),
|
||||
|
||||
deleteDeviceGroup: (id: string) =>
|
||||
request<void>(`/api/v1/device-groups/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// --- групи користувачів ---
|
||||
|
||||
listUserGroups: () =>
|
||||
request<{ groups: UserGroup[] }>('/api/v1/user-groups').then((r) => r.groups ?? []),
|
||||
|
||||
createUserGroup: (name: string, description = '') =>
|
||||
request<{ id: string }>('/api/v1/user-groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
}),
|
||||
|
||||
updateUserGroup: (
|
||||
id: string,
|
||||
patch: { member_ids?: string[]; permissions?: Record<string, AccessLevel> },
|
||||
) => request<void>(`/api/v1/user-groups/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
|
||||
deleteUserGroup: (id: string) =>
|
||||
request<void>(`/api/v1/user-groups/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// --- алерти ---
|
||||
|
||||
listAlerts: () =>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type { Permission } from '../types'
|
|||
|
||||
export interface Me {
|
||||
userID?: string
|
||||
username?: string
|
||||
email?: string
|
||||
fullName?: string
|
||||
tokenName?: string
|
||||
|
|
@ -59,13 +60,14 @@ export const session = {
|
|||
|
||||
/** Розбір відповіді входу в єдину форму. */
|
||||
export function meFromLogin(body: {
|
||||
user?: { id: string; email: string; full_name?: string }
|
||||
user?: { id: string; username?: string; email?: string; full_name?: string }
|
||||
tenant?: { tenant_id: string; tenant_name?: string; role_name?: string } | null
|
||||
permissions?: string[]
|
||||
tenant_id?: string
|
||||
}): Me {
|
||||
return {
|
||||
userID: body.user?.id,
|
||||
username: body.user?.username,
|
||||
email: body.user?.email,
|
||||
fullName: body.user?.full_name,
|
||||
tenantID: body.tenant?.tenant_id ?? body.tenant_id ?? '',
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ interface NavItem {
|
|||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
|
||||
{ to: '/devices', label: 'Пристрої', icon: '🖧', perm: 'devices:read' },
|
||||
{ to: '/devices', label: 'Хости', icon: '🖧', perm: 'devices:read' },
|
||||
{ to: '/groups', label: 'Групи', icon: '🗂', perm: 'devices:read' },
|
||||
{ to: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
|
||||
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
|
||||
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
|
||||
|
|
@ -108,15 +109,15 @@ export function AppShell() {
|
|||
</NavLink>
|
||||
)}
|
||||
|
||||
{me?.email && (
|
||||
{(me?.username || me?.email) && (
|
||||
<div className="flex items-center gap-1">
|
||||
<NavLink
|
||||
to="/profile"
|
||||
className="max-w-[9rem] truncate rounded border border-slate-700 px-2 py-1
|
||||
text-slate-400 hover:bg-slate-800"
|
||||
title={me.email}
|
||||
title={me.email || me.username}
|
||||
>
|
||||
{me.email}
|
||||
{me.username || me.email}
|
||||
</NavLink>
|
||||
<button
|
||||
className="rounded border border-slate-700 px-2 py-1 text-slate-500 hover:bg-slate-800"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ export type DeviceNodeData = {
|
|||
rtt?: number
|
||||
loss?: number
|
||||
icon?: string
|
||||
/** Ширина з map_nodes.width; нижня межа лишається за замовчуванням. */
|
||||
width?: number
|
||||
/** Масштаб підпису: 'sm' | 'md' | 'lg'. */
|
||||
size?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type DeviceNodeType = Node<DeviceNodeData, 'device'>
|
||||
|
|
@ -34,14 +39,28 @@ const ICONS: Record<string, string> = {
|
|||
other: '□',
|
||||
}
|
||||
|
||||
const TEXT_SIZE: Record<string, string> = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
}
|
||||
|
||||
export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
||||
const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown
|
||||
const icon = ICONS[data.icon ?? data.kind] ?? ICONS.other
|
||||
const textSize = TEXT_SIZE[data.size ?? 'md'] ?? TEXT_SIZE.md
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[150px] rounded-lg border-2 bg-slate-900/95 px-3 py-2 shadow-lg backdrop-blur
|
||||
className={`rounded-lg border-2 bg-slate-900/95 px-3 py-2 shadow-lg backdrop-blur
|
||||
${s.ring} ${selected ? 'ring-2 ring-sky-400' : ''}`}
|
||||
// Ширину задає людина в інспекторі; колір рамки — теж, і тоді він
|
||||
// перекриває статусний. Це свідомо: на схемі, де кольором
|
||||
// позначені майданчики, статус читають за крапкою.
|
||||
style={{
|
||||
minWidth: data.width ?? 150,
|
||||
...(data.color ? { borderColor: data.color } : {}),
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!h-2 !w-2 !bg-slate-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-2 !w-2 !bg-slate-500" />
|
||||
|
|
@ -49,7 +68,7 @@ export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
|||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
|
||||
<span className="text-base leading-none text-slate-400">{icon}</span>
|
||||
<span className="truncate text-sm font-medium text-slate-100">{data.label}</span>
|
||||
<span className={`truncate font-medium text-slate-100 ${textSize}`}>{data.label}</span>
|
||||
</div>
|
||||
|
||||
{/* Цифри показуємо лише коли вони є: порожній рядок «— мс»
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@ import type { TenantMembership } from '../types'
|
|||
*
|
||||
* Свідомо мінімальна: одне поле помилки, жодних підказок на кшталт
|
||||
* «такого користувача немає». Сервер і так відповідає однаково на
|
||||
* невідомий email і невірний пароль — показувати різне на клієнті
|
||||
* невідомий логін і невірний пароль — показувати різне на клієнті
|
||||
* означало б звести нанівець цей захист.
|
||||
*
|
||||
* Поле приймає і пошту: сервер шукає за обома. Людину, яка за звичкою
|
||||
* ввела email, відхиляти було б причепливістю.
|
||||
*/
|
||||
export function LoginPage({ onDone }: { onDone: () => void }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [login, setLogin] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [tenants, setTenants] = useState<TenantMembership[] | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
|
@ -22,7 +25,7 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
|
|||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await api.login(email, password, tenantID)
|
||||
const r = await api.login(login.trim(), password, tenantID)
|
||||
|
||||
// Людина працює в кількох організаціях — треба вибрати.
|
||||
if (!r.access_token && r.tenants && r.tenants.length > 0) {
|
||||
|
|
@ -43,7 +46,7 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
|
|||
? 'Забагато невдалих спроб. Спробуйте за 15 хвилин.'
|
||||
: e.status === 403
|
||||
? 'Користувача не додано до жодної організації'
|
||||
: 'Невірний email або пароль',
|
||||
: 'Невірний логін або пароль',
|
||||
)
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
|
|
@ -89,16 +92,18 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
|
|||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-400">Email</span>
|
||||
<span className="mb-1 block text-xs text-slate-400">Логін</span>
|
||||
<input
|
||||
type="email"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
autoFocus
|
||||
className="w-full rounded border border-slate-700 bg-slate-800 px-3 py-2.5 text-sm
|
||||
text-slate-100 outline-none focus:border-sky-600"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ export function MapCanvas({ state, onPatch, onSelect, readOnly = false }: Props)
|
|||
rtt: n.rtt_ms,
|
||||
loss: n.loss_pct,
|
||||
icon: n.style?.icon as string | undefined,
|
||||
width: n.width,
|
||||
size: n.style?.size as string | undefined,
|
||||
color: n.style?.color as string | undefined,
|
||||
},
|
||||
})),
|
||||
[state.nodes, readOnly],
|
||||
|
|
|
|||
218
web/src/components/NodeInspector.tsx
Normal file
218
web/src/components/NodeInspector.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Button, Field, inputClass } from './ui'
|
||||
import type { MapNode, NodeInput } from '../types'
|
||||
|
||||
const ICON_CHOICES = [
|
||||
{ key: '', label: 'за типом' },
|
||||
{ key: 'switch', label: 'комутатор ⇄' },
|
||||
{ key: 'router', label: 'маршрутизатор ⇉' },
|
||||
{ key: 'server', label: 'сервер ▤' },
|
||||
{ key: 'firewall', label: 'фаєрвол 🛡' },
|
||||
{ key: 'ap', label: 'точка доступу' },
|
||||
{ key: 'ups', label: 'ДБЖ ⚡' },
|
||||
{ key: 'olt', label: 'OLT ⌸' },
|
||||
{ key: 'cloud', label: 'хмара ☁' },
|
||||
{ key: 'other', label: 'інше □' },
|
||||
]
|
||||
|
||||
const SIZE_CHOICES = [
|
||||
{ key: 'sm', label: 'дрібний' },
|
||||
{ key: 'md', label: 'звичайний' },
|
||||
{ key: 'lg', label: 'великий' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Інспектор вузла: підпис, вигляд, розмір, положення.
|
||||
*
|
||||
* Правки застосовуються кнопкою, а не на кожне натискання клавіші.
|
||||
* Кожне збереження — це нова ревізія полотна й подія для всіх, хто
|
||||
* дивиться на ту саму мапу; писати їх по літері означало б засмітити
|
||||
* історію й змусити сусідні екрани перечитувати мапу десятки разів
|
||||
* поки хтось перейменовує вузол.
|
||||
*/
|
||||
export function NodeInspector({
|
||||
node,
|
||||
readOnly,
|
||||
onPatch,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
node: MapNode
|
||||
readOnly: boolean
|
||||
onPatch: (patch: NodeInput, comment: string) => Promise<void> | void
|
||||
onDelete: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [label, setLabel] = useState(node.label ?? '')
|
||||
const [icon, setIcon] = useState((node.style?.icon as string) ?? '')
|
||||
const [size, setSize] = useState((node.style?.size as string) ?? 'md')
|
||||
const [color, setColor] = useState((node.style?.color as string) ?? '')
|
||||
const [width, setWidth] = useState(node.width ? String(Math.round(node.width)) : '')
|
||||
const [locked, setLocked] = useState(node.locked)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// Вибір іншого вузла має перезаряджати форму, інакше вона показувала б
|
||||
// поля попереднього.
|
||||
useEffect(() => {
|
||||
setLabel(node.label ?? '')
|
||||
setIcon((node.style?.icon as string) ?? '')
|
||||
setSize((node.style?.size as string) ?? 'md')
|
||||
setColor((node.style?.color as string) ?? '')
|
||||
setWidth(node.width ? String(Math.round(node.width)) : '')
|
||||
setLocked(node.locked)
|
||||
}, [node.id, node.label, node.style, node.width, node.locked])
|
||||
|
||||
const dirty =
|
||||
label !== (node.label ?? '') ||
|
||||
icon !== ((node.style?.icon as string) ?? '') ||
|
||||
size !== ((node.style?.size as string) ?? 'md') ||
|
||||
color !== ((node.style?.color as string) ?? '') ||
|
||||
width !== (node.width ? String(Math.round(node.width)) : '') ||
|
||||
locked !== node.locked
|
||||
|
||||
async function apply() {
|
||||
setBusy(true)
|
||||
try {
|
||||
// Стиль шлемо цілком: сервер зберігає його як jsonb і зливати
|
||||
// часткові зміни всередині поля не вміє.
|
||||
const style: Record<string, unknown> = { ...(node.style ?? {}) }
|
||||
if (icon) style.icon = icon
|
||||
else delete style.icon
|
||||
if (size && size !== 'md') style.size = size
|
||||
else delete style.size
|
||||
if (color) style.color = color
|
||||
else delete style.color
|
||||
|
||||
const patch: NodeInput = { id: node.id, label, style, locked }
|
||||
const w = Number(width)
|
||||
if (width.trim() !== '' && Number.isFinite(w) && w > 0) patch.width = w
|
||||
|
||||
await onPatch(patch, 'зміна вигляду вузла')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2.5 text-xs
|
||||
md:absolute md:bottom-3 md:right-3 md:z-10 md:w-72 md:rounded-lg md:border"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium text-slate-100">{node.label || 'Вузол'}</span>
|
||||
<button className="px-1 text-slate-500 hover:text-slate-300" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid grid-cols-2 gap-x-3 gap-y-0.5">
|
||||
<Row k="статус" v={node.status || 'unknown'} />
|
||||
<Row k="позиція" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
|
||||
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(2)} мс`} />}
|
||||
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
|
||||
</div>
|
||||
|
||||
{readOnly ? (
|
||||
<p className="text-[11px] text-slate-600">Перегляд — редагування потребує maps:write</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Field label="Підпис">
|
||||
<input
|
||||
className={`${inputClass} py-1.5`}
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Значок">
|
||||
<select
|
||||
className={`${inputClass} py-1.5`}
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
>
|
||||
{ICON_CHOICES.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Розмір підпису">
|
||||
<select
|
||||
className={`${inputClass} py-1.5`}
|
||||
value={size}
|
||||
onChange={(e) => setSize(e.target.value)}
|
||||
>
|
||||
{SIZE_CHOICES.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="Ширина, px">
|
||||
<input
|
||||
className={`${inputClass} py-1.5`}
|
||||
type="number"
|
||||
min="100"
|
||||
placeholder="150"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Колір рамки">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
className="h-8 w-10 rounded border border-slate-700 bg-slate-800"
|
||||
value={color || '#64748b'}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
/>
|
||||
{color && (
|
||||
<button
|
||||
className="text-[11px] text-slate-500 hover:text-slate-300"
|
||||
onClick={() => setColor('')}
|
||||
title="Повернути колір за статусом"
|
||||
>
|
||||
скинути
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={locked}
|
||||
onChange={(e) => setLocked(e.target.checked)}
|
||||
/>
|
||||
Закріпити (не перетягується)
|
||||
</label>
|
||||
|
||||
<div className="flex gap-1.5 pt-0.5">
|
||||
<Button kind="primary" disabled={!dirty || busy} onClick={() => void apply()}>
|
||||
{busy ? 'Збереження…' : 'Застосувати'}
|
||||
</Button>
|
||||
<Button kind="danger" onClick={onDelete} title="Прибрати вузол із полотна">
|
||||
Видалити
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-slate-500">{k}</span>
|
||||
<span className="tabular-nums text-slate-300">{v}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -230,9 +230,13 @@ export function Toggle({
|
|||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-40
|
||||
${checked ? 'bg-sky-600' : 'bg-slate-700'}`}
|
||||
>
|
||||
{/* left задано явно. Без нього ручка стає на статичну позицію, а
|
||||
та у <button> зсунута типовим text-align: center — знайдено
|
||||
живим виміром: left виходив 18px замість 2px, і ручка
|
||||
вилазила на 14px за межі треку, накриваючи сусідню кнопку. */}
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform
|
||||
${checked ? 'translate-x-4' : 'translate-x-0.5'}`}
|
||||
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform
|
||||
${checked ? 'translate-x-4' : 'translate-x-0'}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import { api, type DeviceInput } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import { liveEvents } from '../api/ws'
|
||||
import type { LiveAlerts } from '../hooks/useAlerts'
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Button,
|
||||
DataTable,
|
||||
ErrorNote,
|
||||
Field,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
|
|
@ -15,7 +16,7 @@ import {
|
|||
ago,
|
||||
inputClass,
|
||||
} from '../components/ui'
|
||||
import type { Alert, DeviceSummary } from '../types'
|
||||
import type { AgentSummary, Alert, DeviceGroup, DeviceSummary } from '../types'
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
up: 'на зв’язку',
|
||||
|
|
@ -25,6 +26,24 @@ const statusLabel: Record<string, string> = {
|
|||
unknown: 'невідомо',
|
||||
}
|
||||
|
||||
// Порядок такий самий, як у ENUM inv.device_kind на сервері.
|
||||
const kindLabel: Record<string, string> = {
|
||||
router: 'Маршрутизатор',
|
||||
switch: 'Комутатор',
|
||||
firewall: 'Міжмережевий екран',
|
||||
server: 'Сервер',
|
||||
ap: 'Точка доступу',
|
||||
olt: 'OLT',
|
||||
onu: 'ONU',
|
||||
ups: 'ДБЖ',
|
||||
printer: 'Принтер',
|
||||
camera: 'Камера',
|
||||
sensor: 'Датчик',
|
||||
vm: 'Віртуальна машина',
|
||||
container: 'Контейнер',
|
||||
other: 'Інше',
|
||||
}
|
||||
|
||||
export function DevicesPage() {
|
||||
// Алерти беремо з оболонки, а не тягнемо власним запитом: вона вже
|
||||
// тримає їх живими для лічильника в шапці, і другий такий самий
|
||||
|
|
@ -32,18 +51,30 @@ export function DevicesPage() {
|
|||
const live = useOutletContext<LiveAlerts>()
|
||||
|
||||
const [devices, setDevices] = useState<DeviceSummary[]>([])
|
||||
const [groups, setGroups] = useState<DeviceGroup[]>([])
|
||||
const [agents, setAgents] = useState<AgentSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [query, setQuery] = useState('')
|
||||
const [groupFilter, setGroupFilter] = useState('')
|
||||
const [onlyProblems, setOnlyProblems] = useState(false)
|
||||
const [detail, setDetail] = useState<DeviceSummary | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const canWrite = session.can('devices:write')
|
||||
const canMute = session.can('alerts:ack')
|
||||
const alerts = live.alerts
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setDevices(await api.listDevices())
|
||||
const [d, g, a] = await Promise.all([
|
||||
api.listDevices(),
|
||||
api.listDeviceGroups().catch(() => [] as DeviceGroup[]),
|
||||
session.can('agents:read') ? api.listAgents() : Promise.resolve([] as AgentSummary[]),
|
||||
])
|
||||
setDevices(d)
|
||||
setGroups(g)
|
||||
setAgents(a)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
|
|
@ -56,11 +87,9 @@ export function DevicesPage() {
|
|||
void reload()
|
||||
}, [reload])
|
||||
|
||||
// Статус пристрою міняється не тут, а на зонді. Без підписки список
|
||||
// показував би стан на момент відкриття сторінки й тихо старів.
|
||||
//
|
||||
// Зміна застосовується точково: це один рядок, і перечитувати заради
|
||||
// нього весь список немає сенсу.
|
||||
// Статус пристрою міняється не тут, а на зонді. Зміна застосовується
|
||||
// точково: це один рядок, і перечитувати заради нього весь список
|
||||
// немає сенсу.
|
||||
useEffect(
|
||||
() =>
|
||||
liveEvents.subscribe((msg) => {
|
||||
|
|
@ -85,10 +114,20 @@ export function DevicesPage() {
|
|||
return m
|
||||
}, [alerts])
|
||||
|
||||
const groupName = useMemo(() => {
|
||||
const m = new Map<string, string>()
|
||||
for (const g of groups) m.set(g.id, g.name)
|
||||
return m
|
||||
}, [groups])
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return devices.filter((d) => {
|
||||
if (onlyProblems && d.status !== 'down' && d.status !== 'warning') return false
|
||||
if (groupFilter === '__none' && (d.group_ids?.length ?? 0) > 0) return false
|
||||
if (groupFilter && groupFilter !== '__none' && !d.group_ids?.includes(groupFilter)) {
|
||||
return false
|
||||
}
|
||||
if (!q) return true
|
||||
return (
|
||||
d.name.toLowerCase().includes(q) ||
|
||||
|
|
@ -96,14 +135,14 @@ export function DevicesPage() {
|
|||
(d.vendor ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [devices, query, onlyProblems])
|
||||
}, [devices, query, onlyProblems, groupFilter])
|
||||
|
||||
const problems = devices.filter((d) => d.status === 'down' || d.status === 'warning').length
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Пристрої"
|
||||
title="Хости"
|
||||
subtitle={
|
||||
loading
|
||||
? undefined
|
||||
|
|
@ -114,18 +153,36 @@ export function DevicesPage() {
|
|||
actions={
|
||||
<>
|
||||
<input
|
||||
className={`${inputClass} w-40 md:w-56`}
|
||||
className={`${inputClass} w-36 md:w-52`}
|
||||
placeholder="пошук за іменем, IP…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className={`${inputClass} w-36 md:w-44`}
|
||||
value={groupFilter}
|
||||
onChange={(e) => setGroupFilter(e.target.value)}
|
||||
>
|
||||
<option value="">усі групи</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name} ({g.device_count})
|
||||
</option>
|
||||
))}
|
||||
<option value="__none">без групи</option>
|
||||
</select>
|
||||
<Button
|
||||
kind={onlyProblems ? 'primary' : 'default'}
|
||||
onClick={() => setOnlyProblems((v) => !v)}
|
||||
title="Показати лише недоступні й проблемні"
|
||||
>
|
||||
Тільки проблеми
|
||||
Проблеми
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button kind="primary" onClick={() => setCreating(true)}>
|
||||
+ Хост
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
@ -146,13 +203,13 @@ export function DevicesPage() {
|
|||
onRowClick={setDetail}
|
||||
empty={
|
||||
devices.length === 0
|
||||
? 'Пристроїв ще немає — їх заводить автовиявлення або імпорт'
|
||||
? 'Хостів ще немає — додайте вручну або дочекайтесь автовиявлення'
|
||||
: 'Нічого не знайдено за цим фільтром'
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Пристрій',
|
||||
header: 'Хост',
|
||||
cell: (d) => (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate font-medium text-slate-100">{d.name}</span>
|
||||
|
|
@ -161,6 +218,11 @@ export function DevicesPage() {
|
|||
{alertsByDevice.get(d.id)!.length}
|
||||
</span>
|
||||
)}
|
||||
{!d.enabled && (
|
||||
<span className="rounded bg-slate-800 px-1.5 text-[11px] text-slate-500">
|
||||
вимкнено
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
|
@ -168,44 +230,81 @@ export function DevicesPage() {
|
|||
key: 'status',
|
||||
header: 'Стан',
|
||||
cell: (d) => (
|
||||
<StatusBadge status={d.status || 'unknown'} label={statusLabel[d.status || 'unknown']} />
|
||||
<StatusBadge
|
||||
status={d.status || 'unknown'}
|
||||
label={statusLabel[d.status || 'unknown']}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: 'address', header: 'Адреса', cell: (d) => d.address ?? '—' },
|
||||
{
|
||||
key: 'groups',
|
||||
header: 'Групи',
|
||||
cell: (d) =>
|
||||
d.group_ids?.length ? (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{d.group_ids.map((g) => (
|
||||
<span
|
||||
key={g}
|
||||
className="rounded bg-slate-800 px-1.5 py-0.5 text-[11px] text-slate-400"
|
||||
>
|
||||
{groupName.get(g) ?? '—'}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-600">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kind',
|
||||
header: 'Тип',
|
||||
hideOnMobile: true,
|
||||
cell: (d) => (
|
||||
<span className="text-slate-400">
|
||||
{d.kind}
|
||||
{d.vendor ? ` · ${d.vendor}` : ''}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ifs',
|
||||
header: 'Інтерфейси',
|
||||
hideOnMobile: true,
|
||||
cell: (d) => <span className="tabular-nums">{d.interface_count}</span>,
|
||||
cell: (d) => <span className="text-slate-400">{kindLabel[d.kind] ?? d.kind}</span>,
|
||||
},
|
||||
{
|
||||
key: 'seen',
|
||||
header: 'Востаннє',
|
||||
cell: (d) => <span className="tabular-nums text-slate-400">{ago(d.last_seen_at)}</span>,
|
||||
cell: (d) => (
|
||||
<span className="tabular-nums text-slate-400">{ago(d.last_seen_at)}</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<DeviceForm
|
||||
groups={groups}
|
||||
agents={agents}
|
||||
onClose={() => setCreating(false)}
|
||||
onSave={async (in_) => {
|
||||
await api.createDevice(in_)
|
||||
setCreating(false)
|
||||
await reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<DeviceDetail
|
||||
device={detail}
|
||||
groups={groups}
|
||||
agents={agents}
|
||||
alerts={alertsByDevice.get(detail.id) ?? []}
|
||||
canMute={canMute}
|
||||
onMute={async (minutes) => {
|
||||
await live.mute(detail.id, minutes)
|
||||
canWrite={canWrite && detail.writable}
|
||||
canMute={canMute && detail.writable}
|
||||
onMute={(minutes) => live.mute(detail.id, minutes)}
|
||||
onSave={async (in_) => {
|
||||
await api.updateDevice(detail.id, in_)
|
||||
setDetail(null)
|
||||
await reload()
|
||||
}}
|
||||
onDelete={async () => {
|
||||
await api.deleteDevice(detail.id)
|
||||
setDetail(null)
|
||||
await reload()
|
||||
}}
|
||||
onClose={() => setDetail(null)}
|
||||
/>
|
||||
|
|
@ -214,32 +313,235 @@ export function DevicesPage() {
|
|||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function DeviceForm({
|
||||
device,
|
||||
groups,
|
||||
agents,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
device?: DeviceSummary
|
||||
groups: DeviceGroup[]
|
||||
agents: AgentSummary[]
|
||||
onClose: () => void
|
||||
onSave: (in_: DeviceInput) => Promise<void>
|
||||
onDelete?: () => Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState(device?.name ?? '')
|
||||
const [address, setAddress] = useState(device?.address ?? '')
|
||||
const [kind, setKind] = useState(device?.kind ?? 'other')
|
||||
const [vendor, setVendor] = useState(device?.vendor ?? '')
|
||||
const [agentID, setAgentID] = useState('')
|
||||
const [enabled, setEnabled] = useState(device?.enabled ?? true)
|
||||
const [groupIDs, setGroupIDs] = useState<string[]>(device?.group_ids ?? [])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const toggleGroup = (id: string) =>
|
||||
setGroupIDs((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
|
||||
return (
|
||||
<Modal title={device ? `Хост: ${device.name}` : 'Новий хост'} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<Field label="Назва">
|
||||
<input
|
||||
className={inputClass}
|
||||
autoFocus={!device}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="IP-адреса" hint="За нею опитує зонд">
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder="192.168.1.1"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Тип">
|
||||
<select className={inputClass} value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
{Object.entries(kindLabel).map(([k, label]) => (
|
||||
<option key={k} value={k}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Виробник">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={vendor}
|
||||
onChange={(e) => setVendor(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Зонд" hint="Хто опитує цей хост">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={agentID}
|
||||
onChange={(e) => setAgentID(e.target.value)}
|
||||
>
|
||||
<option value="">— не змінювати —</option>
|
||||
{agents.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1 block text-xs font-medium text-slate-400">Групи</span>
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-xs text-slate-600">
|
||||
Груп ще немає — створіть їх у розділі «Групи»
|
||||
</p>
|
||||
) : (
|
||||
<ul className="max-h-40 space-y-0.5 overflow-y-auto rounded border border-slate-800 p-1.5">
|
||||
{groups.map((g) => (
|
||||
<li key={g.id}>
|
||||
<label className="flex items-center gap-2 rounded px-1 py-1 text-sm hover:bg-slate-800/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupIDs.includes(g.id)}
|
||||
onChange={() => toggleGroup(g.id)}
|
||||
/>
|
||||
<span className="text-slate-200">{g.name}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => setEnabled(e.target.checked)}
|
||||
/>
|
||||
Опитувати цей хост
|
||||
</label>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
||||
{onDelete && (
|
||||
<Button
|
||||
kind="danger"
|
||||
disabled={busy}
|
||||
className="mr-auto"
|
||||
title="Хост зникне зі списків, але його історія збережеться"
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await onDelete()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Видалити
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !name.trim()}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await onSave({
|
||||
name: name.trim(),
|
||||
address: address.trim(),
|
||||
kind,
|
||||
vendor: vendor.trim(),
|
||||
agent_id: agentID,
|
||||
enabled,
|
||||
group_ids: groupIDs,
|
||||
})
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Зберегти'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceDetail({
|
||||
device,
|
||||
groups,
|
||||
agents,
|
||||
alerts,
|
||||
canWrite,
|
||||
canMute,
|
||||
onMute,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
}: {
|
||||
device: DeviceSummary
|
||||
groups: DeviceGroup[]
|
||||
agents: AgentSummary[]
|
||||
alerts: Alert[]
|
||||
canWrite: boolean
|
||||
canMute: boolean
|
||||
onMute: (minutes: number) => Promise<void>
|
||||
onSave: (in_: DeviceInput) => Promise<void>
|
||||
onDelete: () => Promise<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
if (canWrite) {
|
||||
// Для того, хто має право писати, форма і є картка: окремий режим
|
||||
// «перегляд» додав би зайвий клік до кожної правки.
|
||||
return (
|
||||
<DeviceForm
|
||||
device={device}
|
||||
groups={groups}
|
||||
agents={agents}
|
||||
onClose={onClose}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={device.name} onClose={onClose}>
|
||||
<dl className="space-y-1.5 text-sm">
|
||||
<Pair k="Стан" v={<StatusBadge status={device.status || 'unknown'} label={statusLabel[device.status || 'unknown']} />} />
|
||||
<Pair
|
||||
k="Стан"
|
||||
v={
|
||||
<StatusBadge
|
||||
status={device.status || 'unknown'}
|
||||
label={statusLabel[device.status || 'unknown']}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pair k="Адреса" v={device.address ?? '—'} />
|
||||
<Pair k="Тип" v={device.kind} />
|
||||
<Pair k="Виробник" v={device.vendor ?? '—'} />
|
||||
<Pair k="Тип" v={kindLabel[device.kind] ?? device.kind} />
|
||||
<Pair k="Інтерфейсів" v={String(device.interface_count)} />
|
||||
<Pair k="Востаннє на зв’язку" v={ago(device.last_seen_at)} />
|
||||
<Pair k="Опитується" v={device.enabled ? 'так' : 'ні'} />
|
||||
</dl>
|
||||
|
||||
<h3 className="mb-2 mt-5 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
|
|
@ -258,36 +560,21 @@ function DeviceDetail({
|
|||
</ul>
|
||||
)}
|
||||
|
||||
{err && (
|
||||
<div className="mt-3">
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canMute && (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{[60, 240, 1440].map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await onMute(m)
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button key={m} onClick={() => void onMute(m).then(onClose)}>
|
||||
Заглушити на {m >= 1440 ? '24 год' : m >= 240 ? '4 год' : '1 год'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canWrite && (
|
||||
<p className="mt-4 text-[11px] leading-snug text-slate-600">
|
||||
Доступ лише на читання — редагувати цей хост дозволяє група доступу.
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
479
web/src/pages/GroupsPage.tsx
Normal file
479
web/src/pages/GroupsPage.tsx
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
ErrorNote,
|
||||
Field,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
inputClass,
|
||||
} from '../components/ui'
|
||||
import type { AccessLevel, DeviceGroup, TeamMember, UserGroup } from '../types'
|
||||
|
||||
const levelLabel: Record<AccessLevel | 'none', string> = {
|
||||
none: 'немає',
|
||||
read: 'читання',
|
||||
write: 'запис',
|
||||
deny: 'заборона',
|
||||
}
|
||||
|
||||
const levelTone: Record<AccessLevel | 'none', string> = {
|
||||
none: 'border-slate-700 text-slate-500',
|
||||
read: 'border-sky-800 bg-sky-950/50 text-sky-300',
|
||||
write: 'border-emerald-800 bg-emerald-950/50 text-emerald-300',
|
||||
deny: 'border-red-900 bg-red-950/50 text-red-300',
|
||||
}
|
||||
|
||||
/**
|
||||
* Групи: хости зліва, доступ справа.
|
||||
*
|
||||
* Дві половини одного питання. Група пристроїв відповідає «що це за
|
||||
* набір заліза», група користувачів — «кому з ним працювати». Тримати
|
||||
* їх на різних сторінках означало б змушувати людину клацати туди-сюди
|
||||
* рівно тоді, коли вона налаштовує зв'язок між ними.
|
||||
*/
|
||||
export function GroupsPage() {
|
||||
const [deviceGroups, setDeviceGroups] = useState<DeviceGroup[]>([])
|
||||
const [userGroups, setUserGroups] = useState<UserGroup[]>([])
|
||||
const [team, setTeam] = useState<TeamMember[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [newDeviceGroup, setNewDeviceGroup] = useState(false)
|
||||
const [newUserGroup, setNewUserGroup] = useState(false)
|
||||
const [editing, setEditing] = useState<UserGroup | null>(null)
|
||||
|
||||
const canWriteDevices = session.can('devices:write')
|
||||
const canWriteUsers = session.can('users:write')
|
||||
const canSeeUsers = session.can('users:read')
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const [dg, ug, t] = await Promise.all([
|
||||
api.listDeviceGroups(),
|
||||
canSeeUsers ? api.listUserGroups() : Promise.resolve([] as UserGroup[]),
|
||||
canSeeUsers ? api.team() : Promise.resolve([] as TeamMember[]),
|
||||
])
|
||||
setDeviceGroups(dg)
|
||||
setUserGroups(ug)
|
||||
setTeam(t)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [canSeeUsers])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [reload])
|
||||
|
||||
async function act(fn: () => Promise<unknown>) {
|
||||
try {
|
||||
await fn()
|
||||
await reload()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Групи"
|
||||
subtitle={
|
||||
loading ? undefined : `${deviceGroups.length} груп хостів · ${userGroups.length} груп доступу`
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
{canWriteDevices && (
|
||||
<Button onClick={() => setNewDeviceGroup(true)}>+ Група хостів</Button>
|
||||
)}
|
||||
{canWriteUsers && (
|
||||
<Button kind="primary" onClick={() => setNewUserGroup(true)}>
|
||||
+ Група доступу
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 md:p-4">
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card>
|
||||
<h2 className="border-b border-slate-800 px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Групи хостів
|
||||
</h2>
|
||||
<DataTable
|
||||
rows={deviceGroups}
|
||||
rowKey={(g) => g.id}
|
||||
empty="Груп хостів ще немає"
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Назва',
|
||||
cell: (g) => (
|
||||
<span className="flex items-center gap-2">
|
||||
{g.color && (
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ background: g.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="font-medium text-slate-100">{g.name}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'count',
|
||||
header: 'Хостів',
|
||||
cell: (g) => <span className="tabular-nums">{g.device_count}</span>,
|
||||
},
|
||||
{
|
||||
key: 'act',
|
||||
header: '',
|
||||
cell: (g) =>
|
||||
canWriteDevices && (
|
||||
<button
|
||||
className="px-1 text-xs text-slate-600 hover:text-red-400"
|
||||
title="Видалити групу — хости лишаться, зникне лише набір"
|
||||
onClick={() => void act(() => api.deleteDeviceGroup(g.id))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{canSeeUsers && (
|
||||
<Card>
|
||||
<h2 className="border-b border-slate-800 px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Групи доступу
|
||||
</h2>
|
||||
|
||||
{userGroups.length === 0 ? (
|
||||
<div className="px-4 py-6 text-xs leading-relaxed text-slate-600">
|
||||
Груп доступу ще немає. Поки їх немає, кожен бачить усі хости — межі задає
|
||||
лише роль. Щойно людину додадуть хоч в одну групу, їй стане видно рівно те,
|
||||
що ця група дозволяє.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-800/70">
|
||||
{userGroups.map((g) => (
|
||||
<li key={g.id} className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-100">{g.name}</span>
|
||||
<span className="text-xs text-slate-600">
|
||||
{g.member_ids.length} учасників
|
||||
</span>
|
||||
{canWriteUsers && (
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
<Button onClick={() => setEditing(g)}>Налаштувати</Button>
|
||||
<button
|
||||
className="px-1 text-xs text-slate-600 hover:text-red-400"
|
||||
onClick={() => void act(() => api.deleteUserGroup(g.id))}
|
||||
title="Видалити групу доступу"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{g.description && (
|
||||
<p className="mt-0.5 text-xs text-slate-500">{g.description}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{deviceGroups.map((dg) => {
|
||||
const lvl = g.permissions[dg.id]
|
||||
if (!lvl) return null
|
||||
return (
|
||||
<span
|
||||
key={dg.id}
|
||||
className={`rounded border px-1.5 py-0.5 text-[11px] ${levelTone[lvl]}`}
|
||||
>
|
||||
{dg.name}: {levelLabel[lvl]}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{Object.keys(g.permissions).length === 0 && (
|
||||
<span className="text-[11px] text-slate-600">
|
||||
прав не задано — учасники не побачать жодного хоста
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{newDeviceGroup && (
|
||||
<NameForm
|
||||
title="Нова група хостів"
|
||||
label="Назва"
|
||||
withColor
|
||||
onClose={() => setNewDeviceGroup(false)}
|
||||
onSave={async (name, color) => {
|
||||
await api.createDeviceGroup(name, color)
|
||||
setNewDeviceGroup(false)
|
||||
await reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{newUserGroup && (
|
||||
<NameForm
|
||||
title="Нова група доступу"
|
||||
label="Назва"
|
||||
withDescription
|
||||
onClose={() => setNewUserGroup(false)}
|
||||
onSave={async (name, _c, description) => {
|
||||
await api.createUserGroup(name, description)
|
||||
setNewUserGroup(false)
|
||||
await reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<UserGroupForm
|
||||
group={editing}
|
||||
deviceGroups={deviceGroups}
|
||||
team={team}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={async () => {
|
||||
setEditing(null)
|
||||
await reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NameForm({
|
||||
title,
|
||||
label,
|
||||
withColor,
|
||||
withDescription,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
title: string
|
||||
label: string
|
||||
withColor?: boolean
|
||||
withDescription?: boolean
|
||||
onClose: () => void
|
||||
onSave: (name: string, color: string, description: string) => Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [color, setColor] = useState('#38bdf8')
|
||||
const [description, setDescription] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Modal title={title} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<Field label={label}>
|
||||
<input
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{withColor && (
|
||||
<Field label="Колір" hint="Ним група позначається у списках">
|
||||
<input
|
||||
type="color"
|
||||
className="h-9 w-16 rounded border border-slate-700 bg-slate-800"
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{withDescription && (
|
||||
<Field label="Опис">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !name.trim()}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await onSave(name.trim(), withColor ? color : '', description.trim())
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Створити'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function UserGroupForm({
|
||||
group,
|
||||
deviceGroups,
|
||||
team,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
group: UserGroup
|
||||
deviceGroups: DeviceGroup[]
|
||||
team: TeamMember[]
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}) {
|
||||
const [members, setMembers] = useState<string[]>(group.member_ids ?? [])
|
||||
const [perms, setPerms] = useState<Record<string, AccessLevel>>({ ...group.permissions })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const toggleMember = (id: string) =>
|
||||
setMembers((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
|
||||
|
||||
const setLevel = (dgID: string, lvl: AccessLevel | 'none') =>
|
||||
setPerms((prev) => {
|
||||
const next = { ...prev }
|
||||
if (lvl === 'none') delete next[dgID]
|
||||
else next[dgID] = lvl
|
||||
return next
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal title={`Група доступу: ${group.name}`} onClose={onClose}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Учасники
|
||||
</h3>
|
||||
{team.length === 0 ? (
|
||||
<p className="text-xs text-slate-600">Немає кого додати</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{team.map((m) => (
|
||||
<li key={m.user_id}>
|
||||
<label className="flex items-center gap-2 rounded px-1 py-1 text-sm hover:bg-slate-800/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={members.includes(m.user_id)}
|
||||
onChange={() => toggleMember(m.user_id)}
|
||||
/>
|
||||
<span className="text-slate-200">{m.username}</span>
|
||||
<span className="text-xs text-slate-600">{m.full_name || m.role_name}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
Доступ до груп хостів
|
||||
</h3>
|
||||
<p className="mb-2 text-[11px] leading-snug text-slate-600">
|
||||
Заборона перемагає дозвіл: хост, що потрапив і в дозволену, і в заборонену групу,
|
||||
лишається невидимим.
|
||||
</p>
|
||||
|
||||
{deviceGroups.length === 0 ? (
|
||||
<p className="text-xs text-slate-600">Спершу створіть групу хостів</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{deviceGroups.map((dg) => {
|
||||
const cur = perms[dg.id] ?? 'none'
|
||||
return (
|
||||
<li key={dg.id} className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-slate-200">
|
||||
{dg.name}
|
||||
<span className="ml-1.5 text-xs text-slate-600">{dg.device_count}</span>
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{(['none', 'read', 'write', 'deny'] as const).map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
onClick={() => setLevel(dg.id, lvl)}
|
||||
className={`rounded border px-2 py-1 text-[11px] ${
|
||||
cur === lvl ? levelTone[lvl] : 'border-slate-800 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{levelLabel[lvl]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await api.updateUserGroup(group.id, {
|
||||
member_ids: members,
|
||||
permissions: perms,
|
||||
})
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Збереження…' : 'Зберегти'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import { useLiveMap } from '../hooks/useLiveMap'
|
||||
import { MapCanvas } from '../components/MapCanvas'
|
||||
import { Button, ErrorNote } from '../components/ui'
|
||||
import { NodeInspector } from '../components/NodeInspector'
|
||||
import { Button, ErrorNote, Field, Modal, inputClass } from '../components/ui'
|
||||
import type { MapSummary } from '../types'
|
||||
|
||||
export function MapPage() {
|
||||
|
|
@ -11,20 +12,26 @@ export function MapPage() {
|
|||
const [mapID, setMapID] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [listError, setListError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const live = useLiveMap(mapID)
|
||||
const canWrite = session.can('maps:write')
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.listMaps()
|
||||
.then((m) => {
|
||||
setMaps(m)
|
||||
setMapID((cur) => cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
|
||||
})
|
||||
.catch((e) => setListError(e instanceof Error ? e.message : String(e)))
|
||||
const loadMaps = useCallback(async (selectID?: string) => {
|
||||
try {
|
||||
const m = await api.listMaps()
|
||||
setMaps(m)
|
||||
setMapID((cur) => selectID ?? cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
|
||||
setListError(null)
|
||||
} catch (e) {
|
||||
setListError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadMaps()
|
||||
}, [loadMaps])
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const nodes = live.state?.nodes ?? []
|
||||
return {
|
||||
|
|
@ -54,6 +61,9 @@ export function MapPage() {
|
|||
|
||||
{canWrite && (
|
||||
<>
|
||||
<Button onClick={() => setCreating(true)} title="Створити порожню мапу">
|
||||
+ Мапа
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!mapID}
|
||||
onClick={() => void live.build()}
|
||||
|
|
@ -106,36 +116,28 @@ export function MapPage() {
|
|||
</div>
|
||||
|
||||
{node && (
|
||||
<div
|
||||
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2 text-xs
|
||||
md:absolute md:bottom-3 md:right-3 md:w-64 md:rounded-lg md:border"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="font-medium text-slate-100">{node.label}</span>
|
||||
<button
|
||||
className="px-2 text-slate-500 hover:text-slate-300"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 md:block">
|
||||
<Row k="статус" v={node.status || 'unknown'} />
|
||||
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(3)} мс`} />}
|
||||
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
|
||||
<Row k="координати" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
|
||||
</div>
|
||||
</div>
|
||||
<NodeInspector
|
||||
node={node}
|
||||
readOnly={!canWrite}
|
||||
onClose={() => setSelected(null)}
|
||||
onPatch={(patch, comment) => live.patch({ nodes: { upsert: [patch] } }, comment)}
|
||||
onDelete={() => {
|
||||
void live.patch({ nodes: { remove: [node.id] } }, 'видалення вузла')
|
||||
setSelected(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{creating && (
|
||||
<NewMapForm
|
||||
onClose={() => setCreating(false)}
|
||||
onCreated={async (id) => {
|
||||
setCreating(false)
|
||||
await loadMaps(id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ k, v }: { k: string; v: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-2 md:w-full">
|
||||
<span className="text-slate-500">{k}</span>
|
||||
<span className="tabular-nums text-slate-300">{v}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -180,3 +182,96 @@ function ConnectionBadge({
|
|||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Створення мапи.
|
||||
*
|
||||
* Slug виводиться з назви автоматично: він потрібен для посилань, але
|
||||
* вигадувати його руками — робота, якої можна не робити. Поле лишається
|
||||
* видимим, бо мапу з назвою «Київ» інколи хочуть бачити як /kyiv-core.
|
||||
*/
|
||||
function NewMapForm({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated: (id: string) => Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const effectiveSlug = touched ? slug : slugify(name)
|
||||
|
||||
return (
|
||||
<Modal title="Нова мапа" onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<Field label="Назва">
|
||||
<input
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Ідентифікатор" hint="Латиниця, цифри й дефіси">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={effectiveSlug}
|
||||
onChange={(e) => {
|
||||
setTouched(true)
|
||||
setSlug(e.target.value)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !name.trim() || !effectiveSlug}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const m = await api.createMap(name.trim(), effectiveSlug)
|
||||
await onCreated(m.id)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? 'Створення…' : 'Створити'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// Кирилиця транслітерується: slug іде в URL, і %D0%9A%D0%B8%D1%97%D0%B2
|
||||
// там не допомагає нікому.
|
||||
const TRANSLIT: Record<string, string> = {
|
||||
а: 'a', б: 'b', в: 'v', г: 'h', ґ: 'g', д: 'd', е: 'e', є: 'ie', ж: 'zh',
|
||||
з: 'z', и: 'y', і: 'i', ї: 'i', й: 'i', к: 'k', л: 'l', м: 'm', н: 'n',
|
||||
о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'kh', ц: 'ts',
|
||||
ч: 'ch', ш: 'sh', щ: 'shch', ь: '', ю: 'iu', я: 'ia',
|
||||
}
|
||||
|
||||
function slugify(v: string): string {
|
||||
return v
|
||||
.toLowerCase()
|
||||
.split('')
|
||||
.map((ch) => TRANSLIT[ch] ?? ch)
|
||||
.join('')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import { isAlertEvent, useLiveRefresh } from '../hooks/useLiveRefresh'
|
||||
import {
|
||||
Button,
|
||||
DataTable,
|
||||
|
|
@ -62,7 +63,7 @@ export function RulesPage() {
|
|||
|
||||
const canWrite = session.can('alerts:write')
|
||||
|
||||
const reload = async () => {
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setRules(await api.listAlertRules())
|
||||
setError(null)
|
||||
|
|
@ -71,11 +72,18 @@ export function RulesPage() {
|
|||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void reload()
|
||||
}, [])
|
||||
}, [reload])
|
||||
|
||||
// Лічильник «активних» рахує сам сервер, і міняє його движок, а не ця
|
||||
// сторінка. Після вимкнення правила його алерти закриються одразу, а
|
||||
// от після повернення вони піднімуться лише наступним тіком — тобто
|
||||
// за секунди ПІСЛЯ нашого перечитування. Без підписки таблиця так і
|
||||
// лишалася б із нулями до перезавантаження сторінки.
|
||||
useLiveRefresh(isAlertEvent, reload)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -84,9 +84,7 @@ export function TeamPage() {
|
|||
header: 'Користувач',
|
||||
cell: (m) => (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium text-slate-100">
|
||||
{m.full_name || m.email}
|
||||
</span>
|
||||
<span className="truncate font-medium text-slate-100">{m.username}</span>
|
||||
{m.user_id === me?.userID && (
|
||||
<span className="rounded bg-slate-800 px-1.5 text-[11px] text-slate-400">
|
||||
це ви
|
||||
|
|
@ -95,11 +93,17 @@ export function TeamPage() {
|
|||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Імʼя',
|
||||
hideOnMobile: true,
|
||||
cell: (m) => <span className="text-slate-400">{m.full_name || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'mail',
|
||||
header: 'Email',
|
||||
hideOnMobile: true,
|
||||
cell: (m) => <span className="text-slate-400">{m.email}</span>,
|
||||
cell: (m) => <span className="text-slate-400">{m.email || '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
|
|
@ -203,6 +207,7 @@ function AddUserForm({
|
|||
onClose: () => void
|
||||
onSaved: () => Promise<void>
|
||||
}) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [fullName, setFullName] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
|
@ -210,14 +215,32 @@ function AddUserForm({
|
|||
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())
|
||||
|
||||
return (
|
||||
<Modal title="Новий користувач" onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<Field label="Email">
|
||||
<Field
|
||||
label="Логін"
|
||||
hint="Латиниця, цифри, крапка, дефіс і підкреслення — 3–64 символи"
|
||||
>
|
||||
<input
|
||||
className={inputClass}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.toLowerCase())}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Email" hint="Необовʼязково — потрібен лише для сповіщень">
|
||||
<input
|
||||
className={inputClass}
|
||||
type="email"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
|
|
@ -255,19 +278,28 @@ function AddUserForm({
|
|||
</select>
|
||||
</Field>
|
||||
|
||||
{username.trim() !== '' && !usernameOK && (
|
||||
<ErrorNote>Логін має бути 3–64 символи з латиниці, цифр, крапки, дефіса чи підкреслення</ErrorNote>
|
||||
)}
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
disabled={busy || !email.trim() || password.length < 8}
|
||||
disabled={busy || !usernameOK || password.length < 8}
|
||||
title={password.length < 8 ? 'Пароль щонайменше 8 символів' : undefined}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await api.addMember(email.trim(), password, roleKey, fullName.trim())
|
||||
await api.addMember({
|
||||
username: username.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
role_key: roleKey,
|
||||
full_name: fullName.trim(),
|
||||
})
|
||||
await onSaved()
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
|
|
|
|||
|
|
@ -26,15 +26,36 @@ export interface TenantMembership {
|
|||
export interface LoginResponse {
|
||||
access_token?: string
|
||||
expires_in?: number
|
||||
user?: { id: string; email: string; full_name?: string }
|
||||
user?: { id: string; username: string; email?: string; full_name?: string }
|
||||
tenant?: TenantMembership | null
|
||||
tenants?: TenantMembership[]
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
export interface DeviceGroup {
|
||||
id: string
|
||||
name: string
|
||||
kind: string
|
||||
color?: string
|
||||
device_count: number
|
||||
}
|
||||
|
||||
/** deny перемагає read і write — так само, як на сервері. */
|
||||
export type AccessLevel = 'deny' | 'read' | 'write'
|
||||
|
||||
export interface UserGroup {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
member_ids: string[]
|
||||
/** device_group_id → рівень */
|
||||
permissions: Record<string, AccessLevel>
|
||||
}
|
||||
|
||||
export interface TeamMember {
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
email?: string
|
||||
full_name?: string
|
||||
role_id: string
|
||||
role_key: string
|
||||
|
|
@ -149,6 +170,9 @@ export interface DeviceSummary {
|
|||
enabled: boolean
|
||||
last_seen_at?: string
|
||||
interface_count: number
|
||||
group_ids: string[]
|
||||
/** Чи можна редагувати саме цей хост — рахується з груп користувача. */
|
||||
writable: boolean
|
||||
}
|
||||
|
||||
export interface AgentSummary {
|
||||
|
|
@ -177,6 +201,9 @@ export interface NodeInput {
|
|||
label?: string
|
||||
x?: number
|
||||
y?: number
|
||||
width?: number
|
||||
height?: number
|
||||
locked?: boolean
|
||||
style?: Record<string, unknown>
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue