Вхід за логіном, групи хостів і права доступу
Логін: - 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-режиму на телевізор.
|
(є в схемі й у движку, у 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 — ні.
|
JWT — ні.
|
||||||
|
|
||||||
**Люди** входять через `/api/v1/auth/login` і отримують короткий access-токен
|
**Люди** входять **за логіном** (`core.users.username`), а не за поштою: у
|
||||||
|
мережевій інсталяції половина облікових записів технічні (`noc`, `monitoring`,
|
||||||
|
`oncall`) і скриньки не мають узагалі. Пошта лишається прийнятною — сервер шукає
|
||||||
|
за обома полями, — але вона більше не обов'язкова.
|
||||||
|
|
||||||
|
Люди входять через `/api/v1/auth/login` і отримують короткий access-токен
|
||||||
(HS256, 15 хвилин, у пам'яті вкладки) плюс refresh-сесію в `httpOnly`-кукі
|
(HS256, 15 хвилин, у пам'яті вкладки) плюс refresh-сесію в `httpOnly`-кукі
|
||||||
`np_refresh` (30 днів, `SameSite=Strict`, `Path=/api/v1/auth`). Access-токен
|
`np_refresh` (30 днів, `SameSite=Strict`, `Path=/api/v1/auth`). Access-токен
|
||||||
свідомо не кладеться в `localStorage`: будь-який XSS звідти його забирає, а з
|
свідомо не кладеться в `localStorage`: будь-який XSS звідти його забирає, а з
|
||||||
|
|
@ -48,7 +53,7 @@ JWT — ні.
|
||||||
через API:
|
через API:
|
||||||
|
|
||||||
```bash
|
```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` міняє пароль і відкликає всі сесії.
|
`-list` показує учасників, `-reset-password` міняє пароль і відкликає всі сесії.
|
||||||
|
|
@ -58,7 +63,7 @@ JWT — ні.
|
||||||
| Метод | Шлях | Призначення |
|
| Метод | Шлях | Призначення |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `GET` | `/healthz` | стан процесу й кількість WebSocket-підписників |
|
| `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/refresh` | обмін refresh-кукі на новий access-токен |
|
||||||
| `POST` | `/api/v1/auth/logout` | відкликати поточну сесію |
|
| `POST` | `/api/v1/auth/logout` | відкликати поточну сесію |
|
||||||
| `POST` | `/api/v1/auth/password` | змінити свій пароль |
|
| `POST` | `/api/v1/auth/password` | змінити свій пароль |
|
||||||
|
|
@ -75,7 +80,17 @@ JWT — ні.
|
||||||
| `DELETE` | `/api/v1/maps/{id}` | м'яко видалити мапу (топологія лишається) |
|
| `DELETE` | `/api/v1/maps/{id}` | м'яко видалити мапу (топологія лишається) |
|
||||||
| `POST` | `/api/v1/maps/{id}/build` | добудувати мапу з виявленої топології |
|
| `POST` | `/api/v1/maps/{id}/build` | добудувати мапу з виявленої топології |
|
||||||
| `POST` | `/api/v1/maps/{id}/undo` | відкотити останню зміну полотна |
|
| `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/agents` | зонди, версії, самометрики |
|
||||||
| `GET` | `/api/v1/alerts` | активні алерти + лічильники |
|
| `GET` | `/api/v1/alerts` | активні алерти + лічильники |
|
||||||
| `POST` | `/api/v1/alerts/{id}/ack` | підтвердити (`alerts:ack`) |
|
| `POST` | `/api/v1/alerts/{id}/ack` | підтвердити (`alerts:ack`) |
|
||||||
|
|
@ -94,16 +109,19 @@ JWT — ні.
|
||||||
### `POST /api/v1/auth/login` — вхід
|
### `POST /api/v1/auth/login` — вхід
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{"email": "admin@acme.io", "password": "…", "tenant_id": "…"} // tenant_id — опційно
|
{"login": "admin", "password": "…", "tenant_id": "…"} // tenant_id — опційно
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Поле `login` приймає і логін, і пошту. Старе `email` теж лишається робочим:
|
||||||
|
інтеграції, написані до перейменування, не мають ламатися через назву ключа.
|
||||||
|
|
||||||
Успіх:
|
Успіх:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"access_token": "eyJ…",
|
"access_token": "eyJ…",
|
||||||
"expires_in": 900,
|
"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": "Власник"},
|
"tenant": {"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"},
|
||||||
"permissions": ["maps:read", "maps:write", "users:write", …]
|
"permissions": ["maps:read", "maps:write", "users:write", …]
|
||||||
}
|
}
|
||||||
|
|
@ -116,12 +134,12 @@ JWT — ні.
|
||||||
{"tenants": [{"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"}, …]}
|
{"tenants": [{"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"}, …]}
|
||||||
```
|
```
|
||||||
|
|
||||||
Помилки навмисно нерозрізненні: невідомий email і невірний пароль дають однаковий
|
Помилки навмисно нерозрізненні: невідомий логін і невірний пароль дають однаковий
|
||||||
`401 bad_credentials`, і на неіснуючому користувачі сервер спалює стільки ж часу на
|
`401 bad_credentials`, і на неіснуючому користувачі сервер спалює стільки ж часу на
|
||||||
фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які адреси
|
фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які акаунти
|
||||||
зареєстровані.
|
існують.
|
||||||
|
|
||||||
Після 10 невдач з одного email або IP за 15 хвилин — `429 too_many_attempts`.
|
Після 10 невдач з одного логіна або IP за 15 хвилин — `429 too_many_attempts`.
|
||||||
Лічильник живе в `core.login_attempts` (гіпертаблиця), тому переживає рестарт
|
Лічильник живе в `core.login_attempts` (гіпертаблиця), тому переживає рестарт
|
||||||
процесу й працює на кількох інстансах одразу.
|
процесу й працює на кількох інстансах одразу.
|
||||||
|
|
||||||
|
|
@ -143,6 +161,11 @@ JWT — ні.
|
||||||
|
|
||||||
Для машинного токена замість `user_id`/`email` приходить `token_name`.
|
Для машинного токена замість `user_id`/`email` приходить `token_name`.
|
||||||
|
|
||||||
|
Формат логіна: 3–64 символи з латиниці, цифр, крапки, дефіса й підкреслення;
|
||||||
|
перший і останній символ — літера або цифра. Перевіряється і в БД (CHECK), і в
|
||||||
|
API — відмова БД виглядає як «violates check constraint», і людині з неї нічого
|
||||||
|
не зрозуміло.
|
||||||
|
|
||||||
### `GET|POST|PATCH|DELETE /api/v1/team`
|
### `GET|POST|PATCH|DELETE /api/v1/team`
|
||||||
|
|
||||||
Керування учасниками; усе, крім читання, потребує `users:write`.
|
Керування учасниками; усе, крім читання, потребує `users:write`.
|
||||||
|
|
@ -401,6 +424,56 @@ Sec-WebSocket-Protocol: netpulse.token.<токен>
|
||||||
знімків в історії: 2
|
знімків в історії: 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` і обчислюється раз на
|
Движок правил живе всередині `netpulse-api` і обчислюється раз на
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ func run() error {
|
||||||
var (
|
var (
|
||||||
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
dsn = flag.String("dsn", os.Getenv("NETPULSE_DSN"), "DSN PostgreSQL")
|
||||||
tenant = flag.String("tenant", "", "slug або uuid тенанта")
|
tenant = flag.String("tenant", "", "slug або uuid тенанта")
|
||||||
email = flag.String("email", "", "email користувача")
|
login = flag.String("login", "", "логін користувача")
|
||||||
|
email = flag.String("email", "", "email (необовʼязково — для сповіщень)")
|
||||||
fullName = flag.String("name", "", "повне ім'я")
|
fullName = flag.String("name", "", "повне ім'я")
|
||||||
role = flag.String("role", "owner", "owner|admin|engineer|operator|viewer")
|
role = flag.String("role", "owner", "owner|admin|engineer|operator|viewer")
|
||||||
password = flag.String("password", "", "пароль; порожній — спитати інтерактивно")
|
password = flag.String("password", "", "пароль; порожній — спитати інтерактивно")
|
||||||
|
|
@ -65,8 +66,8 @@ func run() error {
|
||||||
return listUsers(ctx, conn, tenantID, tenantName)
|
return listUsers(ctx, conn, tenantID, tenantName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if *email == "" {
|
if *login == "" {
|
||||||
return errors.New("не вказано -email")
|
return errors.New("не вказано -login")
|
||||||
}
|
}
|
||||||
|
|
||||||
pw := *password
|
pw := *password
|
||||||
|
|
@ -88,13 +89,13 @@ func run() error {
|
||||||
if *reset {
|
if *reset {
|
||||||
tag, err := conn.Exec(ctx, `
|
tag, err := conn.Exec(ctx, `
|
||||||
UPDATE core.users SET password_hash = $2, updated_at = now()
|
UPDATE core.users SET password_hash = $2, updated_at = now()
|
||||||
WHERE email = $1 AND deleted_at IS NULL
|
WHERE username = $1 AND deleted_at IS NULL
|
||||||
`, *email, hash)
|
`, *login, hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if tag.RowsAffected() == 0 {
|
if tag.RowsAffected() == 0 {
|
||||||
return fmt.Errorf("користувача %s не знайдено", *email)
|
return fmt.Errorf("користувача %s не знайдено", *login)
|
||||||
}
|
}
|
||||||
// Зміна пароля адміністратором — це реакція на втрату доступу
|
// Зміна пароля адміністратором — це реакція на втрату доступу
|
||||||
// або на компрометацію. В обох випадках старі сесії мають померти.
|
// або на компрометацію. В обох випадках старі сесії мають померти.
|
||||||
|
|
@ -117,14 +118,15 @@ func run() error {
|
||||||
|
|
||||||
var userID string
|
var userID string
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
INSERT INTO core.users (email, password_hash, full_name, email_verified_at)
|
INSERT INTO core.users (username, email, password_hash, full_name, email_verified_at)
|
||||||
VALUES ($1, $2, NULLIF($3,''), now())
|
VALUES ($1, NULLIF($2,'')::citext, $3, NULLIF($4,''),
|
||||||
ON CONFLICT (email) DO UPDATE
|
CASE WHEN NULLIF($2,'') IS NULL THEN NULL ELSE now() END)
|
||||||
|
ON CONFLICT (username) DO UPDATE
|
||||||
SET password_hash = EXCLUDED.password_hash,
|
SET password_hash = EXCLUDED.password_hash,
|
||||||
full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name),
|
full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id::text
|
RETURNING id::text
|
||||||
`, *email, hash, *fullName).Scan(&userID); err != nil {
|
`, *login, *email, hash, *fullName).Scan(&userID); err != nil {
|
||||||
return fmt.Errorf("користувач: %w", err)
|
return fmt.Errorf("користувач: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,7 +152,7 @@ func run() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("користувач %s (%s) доданий до «%s» з роллю %s\n",
|
fmt.Printf("користувач %s (%s) доданий до «%s» з роллю %s\n",
|
||||||
*email, userID, tenantName, *role)
|
*login, userID, tenantName, *role)
|
||||||
return nil
|
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 {
|
func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string) error {
|
||||||
rows, err := conn.Query(ctx, `
|
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, '—')
|
COALESCE(u.last_login_at::text, '—')
|
||||||
FROM core.memberships m
|
FROM core.memberships m
|
||||||
JOIN core.users u ON u.id = m.user_id
|
JOIN core.users u ON u.id = m.user_id
|
||||||
JOIN core.roles r ON r.id = m.role_id
|
JOIN core.roles r ON r.id = m.role_id
|
||||||
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
|
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
|
||||||
ORDER BY u.email
|
ORDER BY u.username
|
||||||
`, tenantID)
|
`, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -183,11 +185,11 @@ func listUsers(ctx context.Context, conn *pgx.Conn, tenantID, tenantName string)
|
||||||
fmt.Printf("Користувачі «%s»:\n", tenantName)
|
fmt.Printf("Користувачі «%s»:\n", tenantName)
|
||||||
n := 0
|
n := 0
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var email, name, role, last string
|
var login, name, role, last string
|
||||||
if err := rows.Scan(&email, &name, &role, &last); err != nil {
|
if err := rows.Scan(&login, &name, &role, &last); err != nil {
|
||||||
return err
|
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++
|
n++
|
||||||
}
|
}
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ func (s *Server) handleListAlerts(w http.ResponseWriter, r *http.Request, p *Pri
|
||||||
f := store.AlertFilter{
|
f := store.AlertFilter{
|
||||||
MinSeverity: r.URL.Query().Get("min_severity"),
|
MinSeverity: r.URL.Query().Get("min_severity"),
|
||||||
DeviceID: r.URL.Query().Get("device_id"),
|
DeviceID: r.URL.Query().Get("device_id"),
|
||||||
|
Scope: p.Scope(),
|
||||||
}
|
}
|
||||||
if v := r.URL.Query().Get("state"); v != "" {
|
if v := r.URL.Query().Get("state"); v != "" {
|
||||||
f.States = strings.Split(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{}
|
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 {
|
if err != nil {
|
||||||
s.log.Error("лічильники алертів", "err", err)
|
s.log.Error("лічильники алертів", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
|
|
@ -125,6 +126,11 @@ func (s *Server) handleMuteDevice(w http.ResponseWriter, r *http.Request, p *Pri
|
||||||
req.Minutes = 60
|
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)
|
until := time.Now().Add(time.Duration(req.Minutes) * time.Minute)
|
||||||
if max := time.Now().Add(maxMute); until.After(max) {
|
if max := time.Now().Add(maxMute); until.After(max) {
|
||||||
until = max
|
until = max
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Гальмування перебору: після 10 невдач за 15 хвилин вхід для цього
|
// Гальмування перебору: після 10 невдач за 15 хвилин вхід для цього
|
||||||
// email тимчасово закритий. Рахуємо за email, а не за IP: атака з
|
// логін тимчасово закритий. Рахуємо за логіном, а не за IP: атака з
|
||||||
// ботнета міняє IP на кожен запит, а ціль лишається та сама.
|
// ботнета міняє IP на кожен запит, а ціль лишається та сама.
|
||||||
const (
|
const (
|
||||||
maxFailedLogins = 10
|
maxFailedLogins = 10
|
||||||
|
|
@ -25,6 +25,9 @@ const (
|
||||||
const refreshCookie = "np_refresh"
|
const refreshCookie = "np_refresh"
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
|
// Login — те, що людина ввела в поле «Логін». Приймається і пошта:
|
||||||
|
// відмовляти тому, хто за звичкою ввів email, було б причепливістю.
|
||||||
|
Login string `json:"login"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
// Необовʼязковий: потрібен лише коли людина працює в кількох тенантах.
|
// Необовʼязковий: потрібен лише коли людина працює в кількох тенантах.
|
||||||
|
|
@ -45,8 +48,13 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
if !decodeBody(w, r, &in) {
|
if !decodeBody(w, r, &in) {
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,20 +62,20 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
ip := clientIP(r)
|
ip := clientIP(r)
|
||||||
ua := r.UserAgent()
|
ua := r.UserAgent()
|
||||||
|
|
||||||
if n, err := s.store.FailedLoginsSince(ctx, in.Email, failWindow); err == nil && n >= maxFailedLogins {
|
if n, err := s.store.FailedLoginsSince(ctx, in.Login, failWindow); err == nil && n >= maxFailedLogins {
|
||||||
s.log.Warn("вхід заблоковано через перебір", "email", in.Email, "спроб", n)
|
s.log.Warn("вхід заблоковано через перебір", "login", in.Login, "спроб", n)
|
||||||
writeError(w, http.StatusTooManyRequests, "too_many_attempts",
|
writeError(w, http.StatusTooManyRequests, "too_many_attempts",
|
||||||
"забагато невдалих спроб, спробуйте за 15 хвилин")
|
"забагато невдалих спроб, спробуйте за 15 хвилин")
|
||||||
return
|
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 {
|
switch {
|
||||||
case errors.Is(err, store.ErrBadCredentials):
|
case errors.Is(err, store.ErrBadCredentials):
|
||||||
// Однакова відповідь для «немає такого email» і «невірний
|
// Однакова відповідь для «немає такого логіна» і «невірний
|
||||||
// пароль»: різні тексти перетворюють форму входу на довідник
|
// пароль»: різні тексти перетворюють форму входу на довідник
|
||||||
// зареєстрованих адрес.
|
// зареєстрованих адрес.
|
||||||
writeError(w, http.StatusUnauthorized, "bad_credentials", "невірний email або пароль")
|
writeError(w, http.StatusUnauthorized, "bad_credentials", "невірний логін або пароль")
|
||||||
return
|
return
|
||||||
case errors.Is(err, store.ErrNoMembership):
|
case errors.Is(err, store.ErrNoMembership):
|
||||||
writeError(w, http.StatusForbidden, "no_membership", "користувача не додано до жодної організації")
|
writeError(w, http.StatusForbidden, "no_membership", "користувача не додано до жодної організації")
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,18 @@ package httpapi_test
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedUser заводить людину з паролем і роллю в тенанті фікстури.
|
// seedUser заводить людину з паролем і роллю в тенанті фікстури.
|
||||||
|
//
|
||||||
|
// Логін виводиться з пошти, щоб тести лишались читабельними: у них
|
||||||
|
// важлива роль користувача, а не форма його ідентифікатора.
|
||||||
func (f *fixture) seedUser(t *testing.T, email, password, roleKey string) string {
|
func (f *fixture) seedUser(t *testing.T, email, password, roleKey string) string {
|
||||||
t.Helper()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("створення користувача: %v", err)
|
t.Fatalf("створення користувача: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -21,8 +26,9 @@ type loginResp struct {
|
||||||
ExpiresIn int `json:"expires_in"`
|
ExpiresIn int `json:"expires_in"`
|
||||||
Permissions []string `json:"permissions"`
|
Permissions []string `json:"permissions"`
|
||||||
User struct {
|
User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
} `json:"user"`
|
} `json:"user"`
|
||||||
Tenant *struct {
|
Tenant *struct {
|
||||||
TenantID string `json:"tenant_id"`
|
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) {
|
func (f *fixture) login(t *testing.T, email, password string) (int, loginResp, *http.Cookie) {
|
||||||
t.Helper()
|
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, _ := http.NewRequest(http.MethodPost, f.srv.URL+"/api/v1/auth/login", bytesReader(body))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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"
|
"strings"
|
||||||
|
|
||||||
"github.com/netpulse/netpulse/server/internal/auth"
|
"github.com/netpulse/netpulse/server/internal/auth"
|
||||||
|
"github.com/netpulse/netpulse/server/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Principal — хто саме робить запит.
|
// Principal — хто саме робить запит.
|
||||||
|
|
@ -18,8 +19,9 @@ import (
|
||||||
type Principal struct {
|
type Principal struct {
|
||||||
TenantID string
|
TenantID string
|
||||||
// Порожній для машинного токена.
|
// Порожній для машинного токена.
|
||||||
UserID string
|
UserID string
|
||||||
Email string
|
Username string
|
||||||
|
Email string
|
||||||
// Порожній для людини.
|
// Порожній для людини.
|
||||||
TokenID string
|
TokenID string
|
||||||
TokenName string
|
TokenName string
|
||||||
|
|
@ -28,6 +30,20 @@ type Principal struct {
|
||||||
// Машинний токен без scopes успадковує повний доступ тенанта:
|
// Машинний токен без scopes успадковує повний доступ тенанта:
|
||||||
// так поводяться ключі, які власник створює сам для себе.
|
// так поводяться ключі, які власник створює сам для себе.
|
||||||
unrestricted bool
|
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 {
|
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 {
|
for _, k := range perms {
|
||||||
p.perms[k] = true
|
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
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,6 +159,9 @@ func (s *Server) resolvePrincipal(r *http.Request, raw string) (*Principal, erro
|
||||||
for _, k := range tok.Scopes {
|
for _, k := range tok.Scopes {
|
||||||
p.perms[k] = true
|
p.perms[k] = true
|
||||||
}
|
}
|
||||||
|
// Машинний токен групами не обмежений: у нього немає членства, а
|
||||||
|
// його межі задає власний scopes.
|
||||||
|
p.scope = store.Scope{Unrestricted: true}
|
||||||
return p, nil
|
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}/build", s.authenticated(s.handleBuildMap))
|
||||||
mux.Handle("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap))
|
mux.Handle("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap))
|
||||||
mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices))
|
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/agents", s.authenticated(s.handleListAgents))
|
||||||
|
|
||||||
mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
devices, err := s.store.ListDevices(r.Context(), p.TenantID)
|
devices, err := s.store.ListDevices(r.Context(), p.TenantID, p.Scope())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("перелік пристроїв", "err", err)
|
s.log.Error("перелік пристроїв", "err", err)
|
||||||
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,17 @@ package httpapi
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/netpulse/netpulse/server/internal/store"
|
"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) {
|
func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||||
if !requirePerm(w, p, "users:read") {
|
if !requirePerm(w, p, "users:read") {
|
||||||
return
|
return
|
||||||
|
|
@ -50,6 +57,7 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri
|
||||||
}
|
}
|
||||||
|
|
||||||
var in struct {
|
var in struct {
|
||||||
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
FullName string `json:"full_name"`
|
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) {
|
if !decodeBody(w, r, &in) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if in.Email == "" || in.Password == "" {
|
in.Username = strings.ToLower(strings.TrimSpace(in.Username))
|
||||||
writeError(w, http.StatusBadRequest, "invalid", "потрібні email і пароль")
|
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
|
return
|
||||||
}
|
}
|
||||||
if in.RoleKey == "" {
|
if in.RoleKey == "" {
|
||||||
|
|
@ -72,7 +88,8 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request, p *Pri
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
s.writeStoreError(w, "створення користувача", err)
|
s.writeStoreError(w, "створення користувача", err)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ type AlertFilter struct {
|
||||||
MinSeverity string
|
MinSeverity string
|
||||||
DeviceID string
|
DeviceID string
|
||||||
Limit int
|
Limit int
|
||||||
|
// Scope звужує вибірку до пристроїв, доступних цій людині. Алерт
|
||||||
|
// без пристрою (наприклад, про сам зонд) видно всім: сховати його
|
||||||
|
// від обмеженого користувача означало б приховати аварію, до якої
|
||||||
|
// групи не мають стосунку.
|
||||||
|
Scope Scope
|
||||||
}
|
}
|
||||||
|
|
||||||
// severityRank повторює порядок ENUM alr.severity. Тримати його в Go
|
// 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
|
WHERE a.tenant_id = $1
|
||||||
AND a.state::text = ANY($2::text[])
|
AND a.state::text = ANY($2::text[])
|
||||||
AND ($3::uuid IS NULL OR a.device_id = $3::uuid)
|
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
|
ORDER BY
|
||||||
CASE a.severity
|
CASE a.severity
|
||||||
WHEN 'disaster' THEN 0 WHEN 'high' THEN 1 WHEN 'average' THEN 2
|
WHEN 'disaster' THEN 0 WHEN 'high' THEN 1 WHEN 'average' THEN 2
|
||||||
WHEN 'warning' THEN 3 ELSE 4 END,
|
WHEN 'warning' THEN 3 ELSE 4 END,
|
||||||
a.started_at DESC
|
a.started_at DESC
|
||||||
LIMIT $4
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -106,7 +113,7 @@ type AlertCounts struct {
|
||||||
High int `json:"high"`
|
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
|
var c AlertCounts
|
||||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
return tx.QueryRow(ctx, `
|
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')
|
count(*) FILTER (WHERE severity = 'high' AND state = 'firing')
|
||||||
FROM alr.alerts
|
FROM alr.alerts
|
||||||
WHERE tenant_id = $1 AND state IN ('firing','acknowledged','suppressed')
|
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
|
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"`
|
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||||||
AgentID string `json:"agent_id,omitempty"`
|
AgentID string `json:"agent_id,omitempty"`
|
||||||
IfaceCount int `json:"interface_count"`
|
IfaceCount int `json:"interface_count"`
|
||||||
|
GroupIDs []string `json:"group_ids"`
|
||||||
|
// Writable каже інтерфейсу, чи показувати дії. Рахується з груп
|
||||||
|
// користувача, а не з ролі: роль дозволяє редагувати пристрої взагалі,
|
||||||
|
// групи — саме цей.
|
||||||
|
Writable bool `json:"writable"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDevices — інвентар для таблиці в UI.
|
// 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
|
var out []DeviceSummary
|
||||||
|
|
||||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
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
|
FROM inv.devices d
|
||||||
LEFT JOIN inv.sites st ON st.id = d.site_id
|
LEFT JOIN inv.sites st ON st.id = d.site_id
|
||||||
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
|
WHERE d.tenant_id = $1 AND d.deleted_at IS NULL
|
||||||
|
AND ($2::boolean OR d.id = ANY($3::uuid[]))
|
||||||
ORDER BY d.name
|
ORDER BY d.name
|
||||||
`, tenantID)
|
`, tenantID, sc.Unrestricted, nonNilIDs(sc.Readable))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -49,12 +59,28 @@ func (s *Store) ListDevices(ctx context.Context, tenantID string) ([]DeviceSumma
|
||||||
&d.IfaceCount); err != nil {
|
&d.IfaceCount); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
d.Writable = sc.CanWrite(d.ID)
|
||||||
|
d.GroupIDs = []string{}
|
||||||
out = append(out, d)
|
out = append(out, d)
|
||||||
}
|
}
|
||||||
return rows.Err()
|
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 {
|
type AgentSummary struct {
|
||||||
|
|
|
||||||
|
|
@ -20,14 +20,15 @@ import (
|
||||||
const RefreshTTL = 30 * 24 * time.Hour
|
const RefreshTTL = 30 * 24 * time.Hour
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrBadCredentials = errors.New("невірний email або пароль")
|
ErrBadCredentials = errors.New("невірний логін або пароль")
|
||||||
ErrNoMembership = errors.New("користувач не належить жодному тенанту")
|
ErrNoMembership = errors.New("користувач не належить жодному тенанту")
|
||||||
ErrSessionInvalid = errors.New("сесія недійсна")
|
ErrSessionInvalid = errors.New("сесія недійсна")
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
FullName string `json:"full_name,omitempty"`
|
FullName string `json:"full_name,omitempty"`
|
||||||
Locale string `json:"locale"`
|
Locale string `json:"locale"`
|
||||||
Timezone string `json:"timezone"`
|
Timezone string `json:"timezone"`
|
||||||
|
|
@ -46,7 +47,8 @@ type TenantMembership struct {
|
||||||
// TeamMember — рядок сторінки «Команда».
|
// TeamMember — рядок сторінки «Команда».
|
||||||
type TeamMember struct {
|
type TeamMember struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Email string `json:"email"`
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
FullName string `json:"full_name,omitempty"`
|
FullName string `json:"full_name,omitempty"`
|
||||||
RoleID string `json:"role_id"`
|
RoleID string `json:"role_id"`
|
||||||
RoleKey string `json:"role_key"`
|
RoleKey string `json:"role_key"`
|
||||||
|
|
@ -69,45 +71,56 @@ type Role struct {
|
||||||
// Вхід
|
// Вхід
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
// AuthenticateUser перевіряє email і пароль.
|
// AuthenticateUser перевіряє логін і пароль.
|
||||||
//
|
//
|
||||||
// Запити йдуть БЕЗ tenant-контексту: у момент входу тенант ще невідомий,
|
// Ідентифікатором є username, а не пошта: у мережевій інсталяції
|
||||||
// його називає саме членство. Політики з міграції 0012 дозволяють це
|
// половина облікових записів технічні (noc, monitoring, oncall) і
|
||||||
// рівно тоді, коли app.tenant_id порожній.
|
// скриньки не мають узагалі. Пошта лишається прийнятною — люди звикли
|
||||||
func (s *Store) AuthenticateUser(ctx context.Context, email, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) {
|
// вводити її, і відмовляти їм у вході через це було б причепливістю, —
|
||||||
|
// але вона більше не єдиний шлях і не обов'язкове поле.
|
||||||
|
func (s *Store) AuthenticateUser(ctx context.Context, login, password string, ip net.IP, userAgent string) (*User, []TenantMembership, error) {
|
||||||
var (
|
var (
|
||||||
u User
|
u User
|
||||||
hash *string
|
hash *string
|
||||||
|
email *string
|
||||||
)
|
)
|
||||||
|
|
||||||
err := s.pool.QueryRow(ctx, `
|
err := s.pool.QueryRow(ctx, `
|
||||||
SELECT id::text, email::text, COALESCE(full_name,''), locale, timezone,
|
SELECT id::text, username::text, email::text, COALESCE(full_name,''),
|
||||||
mfa_enabled, password_hash
|
locale, timezone, mfa_enabled, password_hash
|
||||||
FROM core.users
|
FROM core.users
|
||||||
WHERE email = $1 AND deleted_at IS NULL
|
WHERE (username = $1 OR email = $1) AND deleted_at IS NULL
|
||||||
`, email).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA, &hash)
|
-- Збіг за логіном виграє: пошта одного може випадково збігтися
|
||||||
|
-- з логіном іншого, і тоді пріоритет має мати той, кому цей
|
||||||
|
-- рядок належить як ідентифікатор.
|
||||||
|
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) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
// Спалюємо стільки ж часу, скільки справжня перевірка: інакше
|
// Спалюємо стільки ж часу, скільки справжня перевірка: інакше
|
||||||
// швидка відповідь видає, що такого email немає, і перебір
|
// швидка відповідь видає, що такого логіна немає, і перебір
|
||||||
// перетворюється на збір списку зареєстрованих адрес.
|
// перетворюється на збір списку зареєстрованих акаунтів.
|
||||||
auth.DummyVerify(password)
|
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
|
return nil, nil, ErrBadCredentials
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
if email != nil {
|
||||||
|
u.Email = *email
|
||||||
|
}
|
||||||
|
|
||||||
if hash == nil || *hash == "" {
|
if hash == nil || *hash == "" {
|
||||||
// Користувач лише через SSO — локального пароля немає.
|
// Користувач лише через SSO — локального пароля немає.
|
||||||
auth.DummyVerify(password)
|
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
|
return nil, nil, ErrBadCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := auth.VerifyPassword(*hash, password); err != nil {
|
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
|
return nil, nil, ErrBadCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,11 +129,11 @@ func (s *Store) AuthenticateUser(ctx context.Context, email, password string, ip
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if len(memberships) == 0 {
|
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
|
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,
|
if _, err := s.pool.Exec(ctx,
|
||||||
`UPDATE core.users SET last_login_at = now() WHERE id = $1`, u.ID); err != nil {
|
`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 {
|
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
rows, err := tx.Query(ctx, `
|
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,
|
r.id::text, r.key::text, r.name,
|
||||||
u.last_login_at, m.accepted_at, m.scope_group_ids::text[]
|
u.last_login_at, m.accepted_at, m.scope_group_ids::text[]
|
||||||
FROM core.memberships m
|
FROM core.memberships m
|
||||||
JOIN core.users u ON u.id = m.user_id
|
JOIN core.users u ON u.id = m.user_id
|
||||||
JOIN core.roles r ON r.id = m.role_id
|
JOIN core.roles r ON r.id = m.role_id
|
||||||
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
|
WHERE m.tenant_id = $1 AND u.deleted_at IS NULL
|
||||||
ORDER BY u.email
|
ORDER BY u.username
|
||||||
`, tenantID)
|
`, tenantID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -368,7 +382,7 @@ func (s *Store) ListTeam(ctx context.Context, tenantID string) ([]TeamMember, er
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t TeamMember
|
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.RoleID, &t.RoleKey, &t.RoleName,
|
||||||
&t.LastLogin, &t.AcceptedAt, &t.ScopeGroups); err != nil {
|
&t.LastLogin, &t.AcceptedAt, &t.ScopeGroups); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -417,7 +431,7 @@ func (s *Store) ListRoles(ctx context.Context, tenantID string) ([]Role, error)
|
||||||
// CreateUser заводить користувача й одразу дає йому членство.
|
// CreateUser заводить користувача й одразу дає йому членство.
|
||||||
//
|
//
|
||||||
// Використовується і CLI-початківцем (перший власник), і запрошеннями.
|
// Використовується і 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)
|
hash, err := auth.HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -426,17 +440,27 @@ func (s *Store) CreateUser(ctx context.Context, tenantID, email, password, fullN
|
||||||
var u User
|
var u User
|
||||||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||||
// Користувач глобальний: одна людина може працювати в кількох
|
// Користувач глобальний: одна людина може працювати в кількох
|
||||||
// тенантах (типово для MSP-провайдера).
|
// тенантах (типово для MSP-провайдера). Тому конфлікт за логіном
|
||||||
|
// означає «той самий чоловік», а не помилку — але пароль при
|
||||||
|
// цьому НЕ перезаписується: інакше адмін одного тенанта міняв би
|
||||||
|
// пароль людини в чужому.
|
||||||
|
var mail *string
|
||||||
err := tx.QueryRow(ctx, `
|
err := tx.QueryRow(ctx, `
|
||||||
INSERT INTO core.users (email, password_hash, full_name, email_verified_at)
|
INSERT INTO core.users (username, email, password_hash, full_name, email_verified_at)
|
||||||
VALUES ($1, $2, NULLIF($3,''), now())
|
VALUES ($1, NULLIF($2,'')::citext, $3, NULLIF($4,''),
|
||||||
ON CONFLICT (email) DO UPDATE
|
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)
|
SET full_name = COALESCE(NULLIF(EXCLUDED.full_name,''), core.users.full_name)
|
||||||
RETURNING id::text, email::text, COALESCE(full_name,''), locale, timezone, mfa_enabled
|
RETURNING id::text, username::text, email::text,
|
||||||
`, email, hash, fullName).Scan(&u.ID, &u.Email, &u.FullName, &u.Locale, &u.Timezone, &u.MFA)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("створення користувача: %w", err)
|
return fmt.Errorf("створення користувача: %w", err)
|
||||||
}
|
}
|
||||||
|
if mail != nil {
|
||||||
|
u.Email = *mail
|
||||||
|
}
|
||||||
|
|
||||||
var roleID string
|
var roleID string
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { AppShell } from './components/AppShell'
|
||||||
import { LoginPage } from './components/LoginPage'
|
import { LoginPage } from './components/LoginPage'
|
||||||
import { MapPage } from './pages/MapPage'
|
import { MapPage } from './pages/MapPage'
|
||||||
import { DevicesPage } from './pages/DevicesPage'
|
import { DevicesPage } from './pages/DevicesPage'
|
||||||
|
import { GroupsPage } from './pages/GroupsPage'
|
||||||
import { AlertsPage } from './pages/AlertsPage'
|
import { AlertsPage } from './pages/AlertsPage'
|
||||||
import { RulesPage } from './pages/RulesPage'
|
import { RulesPage } from './pages/RulesPage'
|
||||||
import { ChannelsPage } from './pages/ChannelsPage'
|
import { ChannelsPage } from './pages/ChannelsPage'
|
||||||
|
|
@ -49,6 +50,7 @@ export default function App() {
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
|
<Route path="/map" element={<Guard perm="maps:read" element={<MapPage />} />} />
|
||||||
<Route path="/devices" element={<Guard perm="devices:read" element={<DevicesPage />} />} />
|
<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="/alerts" element={<Guard perm="alerts:read" element={<AlertsPage />} />} />
|
||||||
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
|
<Route path="/rules" element={<Guard perm="alerts:read" element={<RulesPage />} />} />
|
||||||
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />
|
<Route path="/channels" element={<Guard perm="alerts:read" element={<ChannelsPage />} />} />
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { meFromLogin, session } from './session'
|
import { meFromLogin, session } from './session'
|
||||||
import type {
|
import type {
|
||||||
|
AccessLevel,
|
||||||
AgentSummary,
|
AgentSummary,
|
||||||
Alert,
|
Alert,
|
||||||
AlertCounts,
|
AlertCounts,
|
||||||
AlertRule,
|
AlertRule,
|
||||||
BuildResult,
|
BuildResult,
|
||||||
Channel,
|
Channel,
|
||||||
|
DeviceGroup,
|
||||||
DeviceSummary,
|
DeviceSummary,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
MapPatch,
|
MapPatch,
|
||||||
|
|
@ -14,9 +16,23 @@ import type {
|
||||||
PatchResult,
|
PatchResult,
|
||||||
Role,
|
Role,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
|
UserGroup,
|
||||||
UndoResult,
|
UndoResult,
|
||||||
} from '../types'
|
} 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-екранів, де входити нікому. Жива сесія людини має пріоритет.
|
// і NOC-екранів, де входити нікому. Жива сесія людини має пріоритет.
|
||||||
const STATIC_TOKEN = import.meta.env.VITE_API_TOKEN ?? ''
|
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 = {
|
export const api = {
|
||||||
// --- вхід ---
|
// --- вхід ---
|
||||||
|
|
||||||
login: (email: string, password: string, tenantID?: string) =>
|
login: (login: string, password: string, tenantID?: string) =>
|
||||||
raw<LoginResponse>('/api/v1/auth/login', {
|
raw<LoginResponse>('/api/v1/auth/login', {
|
||||||
method: 'POST',
|
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 ?? []),
|
roles: () => request<{ roles: Role[] }>('/api/v1/roles').then((r) => r.roles ?? []),
|
||||||
|
|
||||||
addMember: (email: string, password: string, roleKey: string, fullName = '') =>
|
addMember: (u: {
|
||||||
request<{ id: string }>('/api/v1/team', {
|
username: string
|
||||||
method: 'POST',
|
password: string
|
||||||
body: JSON.stringify({ email, password, role_key: roleKey, full_name: fullName }),
|
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) =>
|
setRole: (userID: string, roleID: string) =>
|
||||||
request<void>(`/api/v1/team/${userID}`, {
|
request<void>(`/api/v1/team/${userID}`, {
|
||||||
|
|
@ -195,6 +213,49 @@ export const api = {
|
||||||
listAgents: () =>
|
listAgents: () =>
|
||||||
request<{ agents: AgentSummary[] }>('/api/v1/agents').then((r) => r.agents ?? []),
|
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: () =>
|
listAlerts: () =>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type { Permission } from '../types'
|
||||||
|
|
||||||
export interface Me {
|
export interface Me {
|
||||||
userID?: string
|
userID?: string
|
||||||
|
username?: string
|
||||||
email?: string
|
email?: string
|
||||||
fullName?: string
|
fullName?: string
|
||||||
tokenName?: string
|
tokenName?: string
|
||||||
|
|
@ -59,13 +60,14 @@ export const session = {
|
||||||
|
|
||||||
/** Розбір відповіді входу в єдину форму. */
|
/** Розбір відповіді входу в єдину форму. */
|
||||||
export function meFromLogin(body: {
|
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
|
tenant?: { tenant_id: string; tenant_name?: string; role_name?: string } | null
|
||||||
permissions?: string[]
|
permissions?: string[]
|
||||||
tenant_id?: string
|
tenant_id?: string
|
||||||
}): Me {
|
}): Me {
|
||||||
return {
|
return {
|
||||||
userID: body.user?.id,
|
userID: body.user?.id,
|
||||||
|
username: body.user?.username,
|
||||||
email: body.user?.email,
|
email: body.user?.email,
|
||||||
fullName: body.user?.full_name,
|
fullName: body.user?.full_name,
|
||||||
tenantID: body.tenant?.tenant_id ?? body.tenant_id ?? '',
|
tenantID: body.tenant?.tenant_id ?? body.tenant_id ?? '',
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ interface NavItem {
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ to: '/map', label: 'Мапа', icon: '🗺', perm: 'maps:read' },
|
{ 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: '/alerts', label: 'Алерти', icon: '🔔', perm: 'alerts:read', badge: true },
|
||||||
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
|
{ to: '/rules', label: 'Правила', icon: '⚖', perm: 'alerts:read' },
|
||||||
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
|
{ to: '/channels', label: 'Сповіщення', icon: '📨', perm: 'alerts:read' },
|
||||||
|
|
@ -108,15 +109,15 @@ export function AppShell() {
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{me?.email && (
|
{(me?.username || me?.email) && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/profile"
|
to="/profile"
|
||||||
className="max-w-[9rem] truncate rounded border border-slate-700 px-2 py-1
|
className="max-w-[9rem] truncate rounded border border-slate-700 px-2 py-1
|
||||||
text-slate-400 hover:bg-slate-800"
|
text-slate-400 hover:bg-slate-800"
|
||||||
title={me.email}
|
title={me.email || me.username}
|
||||||
>
|
>
|
||||||
{me.email}
|
{me.username || me.email}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<button
|
<button
|
||||||
className="rounded border border-slate-700 px-2 py-1 text-slate-500 hover:bg-slate-800"
|
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
|
rtt?: number
|
||||||
loss?: number
|
loss?: number
|
||||||
icon?: string
|
icon?: string
|
||||||
|
/** Ширина з map_nodes.width; нижня межа лишається за замовчуванням. */
|
||||||
|
width?: number
|
||||||
|
/** Масштаб підпису: 'sm' | 'md' | 'lg'. */
|
||||||
|
size?: string
|
||||||
|
color?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeviceNodeType = Node<DeviceNodeData, 'device'>
|
export type DeviceNodeType = Node<DeviceNodeData, 'device'>
|
||||||
|
|
@ -34,14 +39,28 @@ const ICONS: Record<string, string> = {
|
||||||
other: '□',
|
other: '□',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEXT_SIZE: Record<string, string> = {
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-sm',
|
||||||
|
lg: 'text-base',
|
||||||
|
}
|
||||||
|
|
||||||
export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
export function DeviceNode({ data, selected }: NodeProps<DeviceNodeType>) {
|
||||||
const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown
|
const s = STATUS[data.status || 'unknown'] ?? STATUS.unknown
|
||||||
const icon = ICONS[data.icon ?? data.kind] ?? ICONS.other
|
const icon = ICONS[data.icon ?? data.kind] ?? ICONS.other
|
||||||
|
const textSize = TEXT_SIZE[data.size ?? 'md'] ?? TEXT_SIZE.md
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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' : ''}`}
|
${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="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" />
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`h-2 w-2 shrink-0 rounded-full ${s.dot}`} />
|
<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="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>
|
</div>
|
||||||
|
|
||||||
{/* Цифри показуємо лише коли вони є: порожній рядок «— мс»
|
{/* Цифри показуємо лише коли вони є: порожній рядок «— мс»
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,14 @@ import type { TenantMembership } from '../types'
|
||||||
*
|
*
|
||||||
* Свідомо мінімальна: одне поле помилки, жодних підказок на кшталт
|
* Свідомо мінімальна: одне поле помилки, жодних підказок на кшталт
|
||||||
* «такого користувача немає». Сервер і так відповідає однаково на
|
* «такого користувача немає». Сервер і так відповідає однаково на
|
||||||
* невідомий email і невірний пароль — показувати різне на клієнті
|
* невідомий логін і невірний пароль — показувати різне на клієнті
|
||||||
* означало б звести нанівець цей захист.
|
* означало б звести нанівець цей захист.
|
||||||
|
*
|
||||||
|
* Поле приймає і пошту: сервер шукає за обома. Людину, яка за звичкою
|
||||||
|
* ввела email, відхиляти було б причепливістю.
|
||||||
*/
|
*/
|
||||||
export function LoginPage({ onDone }: { onDone: () => void }) {
|
export function LoginPage({ onDone }: { onDone: () => void }) {
|
||||||
const [email, setEmail] = useState('')
|
const [login, setLogin] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [tenants, setTenants] = useState<TenantMembership[] | null>(null)
|
const [tenants, setTenants] = useState<TenantMembership[] | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
@ -22,7 +25,7 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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) {
|
if (!r.access_token && r.tenants && r.tenants.length > 0) {
|
||||||
|
|
@ -43,7 +46,7 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
|
||||||
? 'Забагато невдалих спроб. Спробуйте за 15 хвилин.'
|
? 'Забагато невдалих спроб. Спробуйте за 15 хвилин.'
|
||||||
: e.status === 403
|
: e.status === 403
|
||||||
? 'Користувача не додано до жодної організації'
|
? 'Користувача не додано до жодної організації'
|
||||||
: 'Невірний email або пароль',
|
: 'Невірний логін або пароль',
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
|
@ -89,16 +92,18 @@ export function LoginPage({ onDone }: { onDone: () => void }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label className="block">
|
<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
|
<input
|
||||||
type="email"
|
type="text"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full rounded border border-slate-700 bg-slate-800 px-3 py-2.5 text-sm
|
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"
|
text-slate-100 outline-none focus:border-sky-600"
|
||||||
value={email}
|
value={login}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setLogin(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ export function MapCanvas({ state, onPatch, onSelect, readOnly = false }: Props)
|
||||||
rtt: n.rtt_ms,
|
rtt: n.rtt_ms,
|
||||||
loss: n.loss_pct,
|
loss: n.loss_pct,
|
||||||
icon: n.style?.icon as string | undefined,
|
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],
|
[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
|
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-40
|
||||||
${checked ? 'bg-sky-600' : 'bg-slate-700'}`}
|
${checked ? 'bg-sky-600' : 'bg-slate-700'}`}
|
||||||
>
|
>
|
||||||
|
{/* left задано явно. Без нього ручка стає на статичну позицію, а
|
||||||
|
та у <button> зсунута типовим text-align: center — знайдено
|
||||||
|
живим виміром: left виходив 18px замість 2px, і ручка
|
||||||
|
вилазила на 14px за межі треку, накриваючи сусідню кнопку. */}
|
||||||
<span
|
<span
|
||||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform
|
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.5'}`}
|
${checked ? 'translate-x-4' : 'translate-x-0'}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useOutletContext } from 'react-router-dom'
|
import { useOutletContext } from 'react-router-dom'
|
||||||
import { api } from '../api/client'
|
import { api, type DeviceInput } from '../api/client'
|
||||||
import { session } from '../api/session'
|
import { session } from '../api/session'
|
||||||
import { liveEvents } from '../api/ws'
|
import { liveEvents } from '../api/ws'
|
||||||
import type { LiveAlerts } from '../hooks/useAlerts'
|
import type { LiveAlerts } from '../hooks/useAlerts'
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Button,
|
Button,
|
||||||
DataTable,
|
DataTable,
|
||||||
ErrorNote,
|
ErrorNote,
|
||||||
|
Field,
|
||||||
Modal,
|
Modal,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
|
@ -15,7 +16,7 @@ import {
|
||||||
ago,
|
ago,
|
||||||
inputClass,
|
inputClass,
|
||||||
} from '../components/ui'
|
} from '../components/ui'
|
||||||
import type { Alert, DeviceSummary } from '../types'
|
import type { AgentSummary, Alert, DeviceGroup, DeviceSummary } from '../types'
|
||||||
|
|
||||||
const statusLabel: Record<string, string> = {
|
const statusLabel: Record<string, string> = {
|
||||||
up: 'на зв’язку',
|
up: 'на зв’язку',
|
||||||
|
|
@ -25,6 +26,24 @@ const statusLabel: Record<string, string> = {
|
||||||
unknown: 'невідомо',
|
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() {
|
export function DevicesPage() {
|
||||||
// Алерти беремо з оболонки, а не тягнемо власним запитом: вона вже
|
// Алерти беремо з оболонки, а не тягнемо власним запитом: вона вже
|
||||||
// тримає їх живими для лічильника в шапці, і другий такий самий
|
// тримає їх живими для лічильника в шапці, і другий такий самий
|
||||||
|
|
@ -32,18 +51,30 @@ export function DevicesPage() {
|
||||||
const live = useOutletContext<LiveAlerts>()
|
const live = useOutletContext<LiveAlerts>()
|
||||||
|
|
||||||
const [devices, setDevices] = useState<DeviceSummary[]>([])
|
const [devices, setDevices] = useState<DeviceSummary[]>([])
|
||||||
|
const [groups, setGroups] = useState<DeviceGroup[]>([])
|
||||||
|
const [agents, setAgents] = useState<AgentSummary[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
const [groupFilter, setGroupFilter] = useState('')
|
||||||
const [onlyProblems, setOnlyProblems] = useState(false)
|
const [onlyProblems, setOnlyProblems] = useState(false)
|
||||||
const [detail, setDetail] = useState<DeviceSummary | null>(null)
|
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 canMute = session.can('alerts:ack')
|
||||||
const alerts = live.alerts
|
const alerts = live.alerts
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
try {
|
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)
|
setError(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
|
@ -56,11 +87,9 @@ export function DevicesPage() {
|
||||||
void reload()
|
void reload()
|
||||||
}, [reload])
|
}, [reload])
|
||||||
|
|
||||||
// Статус пристрою міняється не тут, а на зонді. Без підписки список
|
// Статус пристрою міняється не тут, а на зонді. Зміна застосовується
|
||||||
// показував би стан на момент відкриття сторінки й тихо старів.
|
// точково: це один рядок, і перечитувати заради нього весь список
|
||||||
//
|
// немає сенсу.
|
||||||
// Зміна застосовується точково: це один рядок, і перечитувати заради
|
|
||||||
// нього весь список немає сенсу.
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
liveEvents.subscribe((msg) => {
|
liveEvents.subscribe((msg) => {
|
||||||
|
|
@ -85,10 +114,20 @@ export function DevicesPage() {
|
||||||
return m
|
return m
|
||||||
}, [alerts])
|
}, [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 rows = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
return devices.filter((d) => {
|
return devices.filter((d) => {
|
||||||
if (onlyProblems && d.status !== 'down' && d.status !== 'warning') return false
|
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
|
if (!q) return true
|
||||||
return (
|
return (
|
||||||
d.name.toLowerCase().includes(q) ||
|
d.name.toLowerCase().includes(q) ||
|
||||||
|
|
@ -96,14 +135,14 @@ export function DevicesPage() {
|
||||||
(d.vendor ?? '').toLowerCase().includes(q)
|
(d.vendor ?? '').toLowerCase().includes(q)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [devices, query, onlyProblems])
|
}, [devices, query, onlyProblems, groupFilter])
|
||||||
|
|
||||||
const problems = devices.filter((d) => d.status === 'down' || d.status === 'warning').length
|
const problems = devices.filter((d) => d.status === 'down' || d.status === 'warning').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Пристрої"
|
title="Хости"
|
||||||
subtitle={
|
subtitle={
|
||||||
loading
|
loading
|
||||||
? undefined
|
? undefined
|
||||||
|
|
@ -114,18 +153,36 @@ export function DevicesPage() {
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
className={`${inputClass} w-40 md:w-56`}
|
className={`${inputClass} w-36 md:w-52`}
|
||||||
placeholder="пошук за іменем, IP…"
|
placeholder="пошук за іменем, IP…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
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
|
<Button
|
||||||
kind={onlyProblems ? 'primary' : 'default'}
|
kind={onlyProblems ? 'primary' : 'default'}
|
||||||
onClick={() => setOnlyProblems((v) => !v)}
|
onClick={() => setOnlyProblems((v) => !v)}
|
||||||
title="Показати лише недоступні й проблемні"
|
title="Показати лише недоступні й проблемні"
|
||||||
>
|
>
|
||||||
Тільки проблеми
|
Проблеми
|
||||||
</Button>
|
</Button>
|
||||||
|
{canWrite && (
|
||||||
|
<Button kind="primary" onClick={() => setCreating(true)}>
|
||||||
|
+ Хост
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -146,13 +203,13 @@ export function DevicesPage() {
|
||||||
onRowClick={setDetail}
|
onRowClick={setDetail}
|
||||||
empty={
|
empty={
|
||||||
devices.length === 0
|
devices.length === 0
|
||||||
? 'Пристроїв ще немає — їх заводить автовиявлення або імпорт'
|
? 'Хостів ще немає — додайте вручну або дочекайтесь автовиявлення'
|
||||||
: 'Нічого не знайдено за цим фільтром'
|
: 'Нічого не знайдено за цим фільтром'
|
||||||
}
|
}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
header: 'Пристрій',
|
header: 'Хост',
|
||||||
cell: (d) => (
|
cell: (d) => (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="truncate font-medium text-slate-100">{d.name}</span>
|
<span className="truncate font-medium text-slate-100">{d.name}</span>
|
||||||
|
|
@ -161,6 +218,11 @@ export function DevicesPage() {
|
||||||
{alertsByDevice.get(d.id)!.length}
|
{alertsByDevice.get(d.id)!.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!d.enabled && (
|
||||||
|
<span className="rounded bg-slate-800 px-1.5 text-[11px] text-slate-500">
|
||||||
|
вимкнено
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
@ -168,44 +230,81 @@ export function DevicesPage() {
|
||||||
key: 'status',
|
key: 'status',
|
||||||
header: 'Стан',
|
header: 'Стан',
|
||||||
cell: (d) => (
|
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: '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',
|
key: 'kind',
|
||||||
header: 'Тип',
|
header: 'Тип',
|
||||||
hideOnMobile: true,
|
hideOnMobile: true,
|
||||||
cell: (d) => (
|
cell: (d) => <span className="text-slate-400">{kindLabel[d.kind] ?? d.kind}</span>,
|
||||||
<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>,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'seen',
|
key: 'seen',
|
||||||
header: 'Востаннє',
|
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>
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<DeviceForm
|
||||||
|
groups={groups}
|
||||||
|
agents={agents}
|
||||||
|
onClose={() => setCreating(false)}
|
||||||
|
onSave={async (in_) => {
|
||||||
|
await api.createDevice(in_)
|
||||||
|
setCreating(false)
|
||||||
|
await reload()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{detail && (
|
{detail && (
|
||||||
<DeviceDetail
|
<DeviceDetail
|
||||||
device={detail}
|
device={detail}
|
||||||
|
groups={groups}
|
||||||
|
agents={agents}
|
||||||
alerts={alertsByDevice.get(detail.id) ?? []}
|
alerts={alertsByDevice.get(detail.id) ?? []}
|
||||||
canMute={canMute}
|
canWrite={canWrite && detail.writable}
|
||||||
onMute={async (minutes) => {
|
canMute={canMute && detail.writable}
|
||||||
await live.mute(detail.id, minutes)
|
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)}
|
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({
|
function DeviceDetail({
|
||||||
device,
|
device,
|
||||||
|
groups,
|
||||||
|
agents,
|
||||||
alerts,
|
alerts,
|
||||||
|
canWrite,
|
||||||
canMute,
|
canMute,
|
||||||
onMute,
|
onMute,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
device: DeviceSummary
|
device: DeviceSummary
|
||||||
|
groups: DeviceGroup[]
|
||||||
|
agents: AgentSummary[]
|
||||||
alerts: Alert[]
|
alerts: Alert[]
|
||||||
|
canWrite: boolean
|
||||||
canMute: boolean
|
canMute: boolean
|
||||||
onMute: (minutes: number) => Promise<void>
|
onMute: (minutes: number) => Promise<void>
|
||||||
|
onSave: (in_: DeviceInput) => Promise<void>
|
||||||
|
onDelete: () => Promise<void>
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [busy, setBusy] = useState(false)
|
if (canWrite) {
|
||||||
const [err, setErr] = useState<string | null>(null)
|
// Для того, хто має право писати, форма і є картка: окремий режим
|
||||||
|
// «перегляд» додав би зайвий клік до кожної правки.
|
||||||
|
return (
|
||||||
|
<DeviceForm
|
||||||
|
device={device}
|
||||||
|
groups={groups}
|
||||||
|
agents={agents}
|
||||||
|
onClose={onClose}
|
||||||
|
onSave={onSave}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title={device.name} onClose={onClose}>
|
<Modal title={device.name} onClose={onClose}>
|
||||||
<dl className="space-y-1.5 text-sm">
|
<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.address ?? '—'} />
|
||||||
<Pair k="Тип" v={device.kind} />
|
<Pair k="Тип" v={kindLabel[device.kind] ?? device.kind} />
|
||||||
<Pair k="Виробник" v={device.vendor ?? '—'} />
|
|
||||||
<Pair k="Інтерфейсів" v={String(device.interface_count)} />
|
<Pair k="Інтерфейсів" v={String(device.interface_count)} />
|
||||||
<Pair k="Востаннє на зв’язку" v={ago(device.last_seen_at)} />
|
<Pair k="Востаннє на зв’язку" v={ago(device.last_seen_at)} />
|
||||||
<Pair k="Опитується" v={device.enabled ? 'так' : 'ні'} />
|
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<h3 className="mb-2 mt-5 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
<h3 className="mb-2 mt-5 text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
|
@ -258,36 +560,21 @@ function DeviceDetail({
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{err && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<ErrorNote>{err}</ErrorNote>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canMute && (
|
{canMute && (
|
||||||
<div className="mt-5 flex flex-wrap gap-2">
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
{[60, 240, 1440].map((m) => (
|
{[60, 240, 1440].map((m) => (
|
||||||
<Button
|
<Button key={m} onClick={() => void onMute(m).then(onClose)}>
|
||||||
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)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Заглушити на {m >= 1440 ? '24 год' : m >= 240 ? '4 год' : '1 год'}
|
Заглушити на {m >= 1440 ? '24 год' : m >= 240 ? '4 год' : '1 год'}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!canWrite && (
|
||||||
|
<p className="mt-4 text-[11px] leading-snug text-slate-600">
|
||||||
|
Доступ лише на читання — редагувати цей хост дозволяє група доступу.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</Modal>
|
</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 { api } from '../api/client'
|
||||||
import { session } from '../api/session'
|
import { session } from '../api/session'
|
||||||
import { useLiveMap } from '../hooks/useLiveMap'
|
import { useLiveMap } from '../hooks/useLiveMap'
|
||||||
import { MapCanvas } from '../components/MapCanvas'
|
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'
|
import type { MapSummary } from '../types'
|
||||||
|
|
||||||
export function MapPage() {
|
export function MapPage() {
|
||||||
|
|
@ -11,20 +12,26 @@ export function MapPage() {
|
||||||
const [mapID, setMapID] = useState<string | null>(null)
|
const [mapID, setMapID] = useState<string | null>(null)
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const [listError, setListError] = useState<string | null>(null)
|
const [listError, setListError] = useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
const live = useLiveMap(mapID)
|
const live = useLiveMap(mapID)
|
||||||
const canWrite = session.can('maps:write')
|
const canWrite = session.can('maps:write')
|
||||||
|
|
||||||
useEffect(() => {
|
const loadMaps = useCallback(async (selectID?: string) => {
|
||||||
api
|
try {
|
||||||
.listMaps()
|
const m = await api.listMaps()
|
||||||
.then((m) => {
|
setMaps(m)
|
||||||
setMaps(m)
|
setMapID((cur) => selectID ?? cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
|
||||||
setMapID((cur) => cur ?? m.find((x) => x.is_default)?.id ?? m[0]?.id ?? null)
|
setListError(null)
|
||||||
})
|
} catch (e) {
|
||||||
.catch((e) => setListError(e instanceof Error ? e.message : String(e)))
|
setListError(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadMaps()
|
||||||
|
}, [loadMaps])
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const nodes = live.state?.nodes ?? []
|
const nodes = live.state?.nodes ?? []
|
||||||
return {
|
return {
|
||||||
|
|
@ -54,6 +61,9 @@ export function MapPage() {
|
||||||
|
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<>
|
<>
|
||||||
|
<Button onClick={() => setCreating(true)} title="Створити порожню мапу">
|
||||||
|
+ Мапа
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={!mapID}
|
disabled={!mapID}
|
||||||
onClick={() => void live.build()}
|
onClick={() => void live.build()}
|
||||||
|
|
@ -106,36 +116,28 @@ export function MapPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{node && (
|
{node && (
|
||||||
<div
|
<NodeInspector
|
||||||
className="shrink-0 border-t border-slate-800 bg-slate-900 px-3 py-2 text-xs
|
node={node}
|
||||||
md:absolute md:bottom-3 md:right-3 md:w-64 md:rounded-lg md:border"
|
readOnly={!canWrite}
|
||||||
>
|
onClose={() => setSelected(null)}
|
||||||
<div className="mb-1 flex items-center justify-between">
|
onPatch={(patch, comment) => live.patch({ nodes: { upsert: [patch] } }, comment)}
|
||||||
<span className="font-medium text-slate-100">{node.label}</span>
|
onDelete={() => {
|
||||||
<button
|
void live.patch({ nodes: { remove: [node.id] } }, 'видалення вузла')
|
||||||
className="px-2 text-slate-500 hover:text-slate-300"
|
setSelected(null)
|
||||||
onClick={() => setSelected(null)}
|
}}
|
||||||
>
|
/>
|
||||||
✕
|
)}
|
||||||
</button>
|
|
||||||
</div>
|
{creating && (
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 md:block">
|
<NewMapForm
|
||||||
<Row k="статус" v={node.status || 'unknown'} />
|
onClose={() => setCreating(false)}
|
||||||
{node.rtt_ms !== undefined && <Row k="RTT" v={`${node.rtt_ms.toFixed(3)} мс`} />}
|
onCreated={async (id) => {
|
||||||
{node.loss_pct !== undefined && <Row k="втрати" v={`${node.loss_pct}%`} />}
|
setCreating(false)
|
||||||
<Row k="координати" v={`${Math.round(node.x)}, ${Math.round(node.y)}`} />
|
await loadMaps(id)
|
||||||
</div>
|
}}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -180,3 +182,96 @@ function ConnectionBadge({
|
||||||
</span>
|
</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 { api } from '../api/client'
|
||||||
import { session } from '../api/session'
|
import { session } from '../api/session'
|
||||||
|
import { isAlertEvent, useLiveRefresh } from '../hooks/useLiveRefresh'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DataTable,
|
DataTable,
|
||||||
|
|
@ -62,7 +63,7 @@ export function RulesPage() {
|
||||||
|
|
||||||
const canWrite = session.can('alerts:write')
|
const canWrite = session.can('alerts:write')
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setRules(await api.listAlertRules())
|
setRules(await api.listAlertRules())
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
@ -71,11 +72,18 @@ export function RulesPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload()
|
void reload()
|
||||||
}, [])
|
}, [reload])
|
||||||
|
|
||||||
|
// Лічильник «активних» рахує сам сервер, і міняє його движок, а не ця
|
||||||
|
// сторінка. Після вимкнення правила його алерти закриються одразу, а
|
||||||
|
// от після повернення вони піднімуться лише наступним тіком — тобто
|
||||||
|
// за секунди ПІСЛЯ нашого перечитування. Без підписки таблиця так і
|
||||||
|
// лишалася б із нулями до перезавантаження сторінки.
|
||||||
|
useLiveRefresh(isAlertEvent, reload)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -84,9 +84,7 @@ export function TeamPage() {
|
||||||
header: 'Користувач',
|
header: 'Користувач',
|
||||||
cell: (m) => (
|
cell: (m) => (
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span className="truncate font-medium text-slate-100">
|
<span className="truncate font-medium text-slate-100">{m.username}</span>
|
||||||
{m.full_name || m.email}
|
|
||||||
</span>
|
|
||||||
{m.user_id === me?.userID && (
|
{m.user_id === me?.userID && (
|
||||||
<span className="rounded bg-slate-800 px-1.5 text-[11px] text-slate-400">
|
<span className="rounded bg-slate-800 px-1.5 text-[11px] text-slate-400">
|
||||||
це ви
|
це ви
|
||||||
|
|
@ -95,11 +93,17 @@ export function TeamPage() {
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: 'Імʼя',
|
||||||
|
hideOnMobile: true,
|
||||||
|
cell: (m) => <span className="text-slate-400">{m.full_name || '—'}</span>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'mail',
|
key: 'mail',
|
||||||
header: 'Email',
|
header: 'Email',
|
||||||
hideOnMobile: true,
|
hideOnMobile: true,
|
||||||
cell: (m) => <span className="text-slate-400">{m.email}</span>,
|
cell: (m) => <span className="text-slate-400">{m.email || '—'}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
key: 'role',
|
||||||
|
|
@ -203,6 +207,7 @@ function AddUserForm({
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved: () => Promise<void>
|
onSaved: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [fullName, setFullName] = useState('')
|
const [fullName, setFullName] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
|
@ -210,14 +215,32 @@ function AddUserForm({
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [err, setErr] = useState<string | null>(null)
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Той самий формат, що й у сервера й у БД. Перевіряти тут — щоб
|
||||||
|
// людина побачила проблему до того, як натисне «Створити».
|
||||||
|
const usernameOK = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/.test(username.trim())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Новий користувач" onClose={onClose}>
|
<Modal title="Новий користувач" onClose={onClose}>
|
||||||
<div className="space-y-3">
|
<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
|
<input
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
type="email"
|
type="email"
|
||||||
autoFocus
|
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
|
@ -255,19 +278,28 @@ function AddUserForm({
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{username.trim() !== '' && !usernameOK && (
|
||||||
|
<ErrorNote>Логін має бути 3–64 символи з латиниці, цифр, крапки, дефіса чи підкреслення</ErrorNote>
|
||||||
|
)}
|
||||||
<ErrorNote>{err}</ErrorNote>
|
<ErrorNote>{err}</ErrorNote>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
<Button onClick={onClose}>Скасувати</Button>
|
<Button onClick={onClose}>Скасувати</Button>
|
||||||
<Button
|
<Button
|
||||||
kind="primary"
|
kind="primary"
|
||||||
disabled={busy || !email.trim() || password.length < 8}
|
disabled={busy || !usernameOK || password.length < 8}
|
||||||
title={password.length < 8 ? 'Пароль щонайменше 8 символів' : undefined}
|
title={password.length < 8 ? 'Пароль щонайменше 8 символів' : undefined}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
try {
|
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()
|
await onSaved()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : String(e))
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,36 @@ export interface TenantMembership {
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
access_token?: string
|
access_token?: string
|
||||||
expires_in?: number
|
expires_in?: number
|
||||||
user?: { id: string; email: string; full_name?: string }
|
user?: { id: string; username: string; email?: string; full_name?: string }
|
||||||
tenant?: TenantMembership | null
|
tenant?: TenantMembership | null
|
||||||
tenants?: TenantMembership[]
|
tenants?: TenantMembership[]
|
||||||
permissions?: string[]
|
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 {
|
export interface TeamMember {
|
||||||
user_id: string
|
user_id: string
|
||||||
email: string
|
username: string
|
||||||
|
email?: string
|
||||||
full_name?: string
|
full_name?: string
|
||||||
role_id: string
|
role_id: string
|
||||||
role_key: string
|
role_key: string
|
||||||
|
|
@ -149,6 +170,9 @@ export interface DeviceSummary {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
last_seen_at?: string
|
last_seen_at?: string
|
||||||
interface_count: number
|
interface_count: number
|
||||||
|
group_ids: string[]
|
||||||
|
/** Чи можна редагувати саме цей хост — рахується з груп користувача. */
|
||||||
|
writable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentSummary {
|
export interface AgentSummary {
|
||||||
|
|
@ -177,6 +201,9 @@ export interface NodeInput {
|
||||||
label?: string
|
label?: string
|
||||||
x?: number
|
x?: number
|
||||||
y?: number
|
y?: number
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
locked?: boolean
|
||||||
style?: Record<string, unknown>
|
style?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue