Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.
0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.
Після закінчення ліцензії не вимикається нічого — замерзає лише ріст.
Моніторинг, що перестав моніторити через несплачений рахунок, це
аварія в мережі клієнта, спричинена нами.
0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.
0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.
ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
* healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
первинній ініціалізації Postgres слухає лише сокет — compose
вважав базу здоровою, migrate отримував connection refused. На
створеній базі цієї фази немає, тож вада чекала на першого клієнта;
* у білому переліку модулів API не було traps і filecfg — зонд із
приймачем трапів неможливо було зареєструвати взагалі.
ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
This commit is contained in:
parent
25da4ffea8
commit
ca143a616b
51 changed files with 14829 additions and 29 deletions
132
deploy/README.md
132
deploy/README.md
|
|
@ -50,6 +50,138 @@ docker compose run --rm --entrypoint netpulse-user cli \
|
|||
|
||||
Інтерфейс — на `https://<NETPULSE_DOMAIN>`.
|
||||
|
||||
## Пісочниця: повна установка, яку можна перевірити
|
||||
|
||||
Твердження «нова інсталяція піднімається сама» довго було доведене
|
||||
міркуванням, а не запуском. Сухий прогін проходив, окремі кроки
|
||||
перевірялись на живій базі — а повної установки з нуля не робив ніхто,
|
||||
бо ніде: єдина доступна машина була бойовим стендом на 4 ГБ, і другий
|
||||
повний стек поклав би робочу систему.
|
||||
|
||||
`./netpulse sandbox` ставить NetPulse **по-справжньому**, з тим самим
|
||||
`docker-compose.yml`, тими самими міграціями й тією самою
|
||||
самоперевіркою, але в окремому проєкті `compose` з власними томами й
|
||||
портами на `127.0.0.1`. Це не імітація: вона або справді піднімає стек і
|
||||
заходить у нього справжнім паролем, або зупиняється й каже, на чому.
|
||||
|
||||
```sh
|
||||
./netpulse sandbox # підняти й лишити, щоб подивитись
|
||||
./netpulse sandbox once # підняти, перевірити, знести все
|
||||
./netpulse sandbox check # та сама перевірка ще раз
|
||||
./netpulse sandbox status # що вона зараз займає
|
||||
./netpulse sandbox down # знести все, включно з томами
|
||||
./netpulse sandbox logs [служба] # журнали
|
||||
```
|
||||
|
||||
**Розробникові перед випуском** — `./netpulse sandbox once`. Він робить
|
||||
повну установку з нуля, проганяє всі твердження самоперевірки (вхід
|
||||
справжнім паролем, кабінет назвався, сім переліків із міграцій
|
||||
непорожні, дванадцять ендпоїнтів відповідають, зонд зареєструвався в
|
||||
колекторі) і прибирає за собою до останнього тому. Ненульовий код
|
||||
виходу означає, що цю збірку клієнтові віддавати не можна.
|
||||
|
||||
**Клієнтові — щоб подивитись до того, як ставити** — `./netpulse
|
||||
sandbox` без підкоманди. Після установки вона друкує адресу
|
||||
`https://localhost:<порт>`, логін і пароль; система жива, у ній є
|
||||
кабінет, локальний зонд і всі довідники. Подивились — `./netpulse
|
||||
sandbox down`, і на машині не лишається нічого.
|
||||
|
||||
### Скільки це коштує
|
||||
|
||||
| Що | Скільки |
|
||||
| -------------- | ------------------------------------------------------------ |
|
||||
| Пам'ять у роботі | ~550–900 МБ на всі шість служб |
|
||||
| Стеля пам'яті | ~2.05 ГБ — жорстке обмеження з накладки, вище не підніметься |
|
||||
| Пік при збірці | ще ~1–2 ГБ, поки збираються образи Go |
|
||||
| Диск: томи | ~250 МБ (порожня база зі схемою, внутрішній CA Caddy, посвідчення зонда) |
|
||||
| Диск: образи | ~2 ГБ, **спільні** з бойовою інсталяцією — якщо вони вже зібрані, пісочниця не додає нічого |
|
||||
| Порти | 18080, 18081, 18082 (або наступна вільна трійка), усі на `127.0.0.1` |
|
||||
|
||||
Точні цифри після запуску показує `./netpulse sandbox status` — вони
|
||||
виміряні, а не оцінені.
|
||||
|
||||
### Коли її запускати НЕ можна
|
||||
|
||||
Установник перевіряє це сам і **відмовляється**, а не пробує:
|
||||
|
||||
- **вільно менше 3 ГБ пам'яті** (4 ГБ, якщо образи ще треба зібрати).
|
||||
Міряється `MemAvailable`, а не вся пам'ять: на машині, де вже працює
|
||||
бойовий стек, «4 ГБ встановлено» не має жодного стосунку до того,
|
||||
скільки з них можна взяти;
|
||||
- **вільно менше 3 ГБ диска** (8 ГБ без готових образів);
|
||||
- **на машині працює бойова інсталяція NetPulse.** Пісочниця не зіпсує
|
||||
їй ані даних, ані портів — вона в іншому проєкті `compose`. Але
|
||||
пам'ять і диск у них спільні, і два Postgres не вміщуються там, де
|
||||
ледве вміщується один. Обійти можна прапорцем `--alongside`, і це
|
||||
свідоме рішення, а не формальність;
|
||||
- **docker compose старший за 2.24.4** — див. нижче;
|
||||
- **пісочниця вже стоїть.** Друга поверх першої поділила б із нею томи,
|
||||
і прогін «з нуля» перестав би бути прогоном з нуля.
|
||||
|
||||
Правильне місце для пісочниці — машина розробника або окрема віртуалка.
|
||||
|
||||
### Що буде при обриві
|
||||
|
||||
Пісочниця, яка лишила по собі том на 250 МБ і контейнер, що тримає
|
||||
порт, — це та сама шкода, від якої вона мала захистити. Тому:
|
||||
|
||||
- `Ctrl-C`, `SIGTERM`, `SIGHUP` перехоплюються і прибирають усе;
|
||||
- будь-яка зупинка установки (`ЗУПИНКА на кроці …`) теж прибирає все;
|
||||
- у накладці стоїть `restart: "no"` — забута пісочниця **не воскресає**
|
||||
після перезавантаження хоста;
|
||||
- прибирання йде трьома ешелонами: `compose down -v`, потім пряме
|
||||
`docker rm`/`docker volume rm` за міткою проєкту (ловить те, що
|
||||
лишилось від обірваного `up`), потім видалення `.env.sandbox`;
|
||||
- `--keep` лишає уламки для розбору журналів — але тільки при **невдачі
|
||||
установки**, не при сигналі: перерваний прогін лишає по собі не стенд,
|
||||
а половину стенду.
|
||||
|
||||
Чого перехопити неможливо: `kill -9` і зникнення живлення. Саме тому
|
||||
джерелом правди про залишки є не файл-позначка, а сам docker — і
|
||||
`./netpulse install` та `./netpulse check` при кожному запуску кажуть,
|
||||
якщо на машині висить забута пісочниця.
|
||||
|
||||
### Чого пісочниця НЕ доводить
|
||||
|
||||
Це найважливіший абзац розділу. Зелена перевірка доводить тільки те, що
|
||||
вона перевіряє — цей проєкт уже платив за протилежне припущення.
|
||||
|
||||
- **Let's Encrypt і DNS.** Пісочниця стоїть на `localhost` із
|
||||
самопідписаним сертифікатом і навмисно не читає `netpulse.conf`:
|
||||
справжній `DOMAIN` звідти відправив би її по сертифікат для адреси,
|
||||
яка веде на бойовий стенд, і витрачені спроби списались би з тижневої
|
||||
квоти домену.
|
||||
- **Прийом SNMP-трапів і правило `DOCKER-USER`.** Порт 162/udp назовні
|
||||
не виставляється взагалі: він не має автентифікації, і відкривати
|
||||
його заради перевірки означало б купити перевірку ціною дірки.
|
||||
- **Розрахунок `shared_buffers` із пам'яті хоста.** У пісочниці він
|
||||
заданий числом — це умова того, щоб вона нічого не поклала.
|
||||
- **Поведінка під навантаженням.** База порожня, хостів на моніторингу
|
||||
немає, історії немає.
|
||||
|
||||
### Чому потрібен compose 2.24.4
|
||||
|
||||
Списки `ports` при накладанні compose-файлів **додаються**, а не
|
||||
замінюються. Щоб зсунуті порти пісочниці не стали *додатком* до базових
|
||||
80/443/9443, накладка перевизначає їх тегом `!override`, а він
|
||||
з'явився у docker compose 2.24.4. На старішій версії пісочниця
|
||||
відмовляється працювати — бо мовчки зайняти порти бойового проксі
|
||||
гірше, ніж не запуститись.
|
||||
|
||||
Вимога знімається одним рядком у `docker-compose.yml`: якщо зробити
|
||||
базові порти змінними —
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTP:-80}:80"
|
||||
- "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTPS:-443}:443"
|
||||
- "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_GRPC:-9443}:9443"
|
||||
```
|
||||
|
||||
— то пісочниці вистачить власного `.env.sandbox`, накладка портів стане
|
||||
непотрібною, а мінімальна версія compose лишиться 2.0, як у решті
|
||||
установника.
|
||||
|
||||
## Підключення зонда
|
||||
|
||||
В інтерфейсі: **Зонди → Додати зонд**. Видане запрошення (`np_enr_…`)
|
||||
|
|
|
|||
118
deploy/docker-compose.sandbox.yml
Normal file
118
deploy/docker-compose.sandbox.yml
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Пісочниця NetPulse — накладка на docker-compose.yml.
|
||||
#
|
||||
# Це НЕ окремий стек і не спрощена копія. Це той самий docker-compose.yml,
|
||||
# накритий рівно тими правками, без яких повна установка на робочій
|
||||
# машині або конфліктує з наявною, або з'їдає машину. Усе інше —
|
||||
# порядок служб, міграції, ролі під RLS, зонд — лишається тим самим, бо
|
||||
# інакше пісочниця перевіряла б не установку, а власну вигадку.
|
||||
#
|
||||
# Запускати руками не треба: `./netpulse sandbox` підставляє і цей файл,
|
||||
# і ім'я проєкту, і окремий .env.sandbox.
|
||||
#
|
||||
# docker compose -p netpulse-sandbox \
|
||||
# -f docker-compose.yml -f deploy/docker-compose.sandbox.yml \
|
||||
# --env-file .env.sandbox <команда>
|
||||
#
|
||||
# ---------------------------------------------------------------------
|
||||
# ЩО САМЕ ТУТ МІНЯЄТЬСЯ І ЧОМУ
|
||||
#
|
||||
# 1. Порти. Базовий файл публікує 80, 443, 9443 і 162/udp числами. Друга
|
||||
# інсталяція на тій самій машині впирається в них першою ж командою
|
||||
# `up`, і виглядає це як «пісочниця поклала бойову систему» — рівно та
|
||||
# шкода, від якої вона мала захистити. Тому проксі публікується на
|
||||
# зсунуті порти І ЛИШЕ на 127.0.0.1: пісочниця не має бути видною з
|
||||
# мережі навіть випадково, у ній стоїть відомий пароль і самопідписаний
|
||||
# сертифікат.
|
||||
#
|
||||
# 2. Трапи. Зонд у пісочниці піднімається (без нього не перевірити
|
||||
# реєстрацію в колекторі — а це єдиний шлях, якого HTTP-перевірки не
|
||||
# бачать зовсім), але 162/udp назовні не виставляє взагалі. Порт без
|
||||
# автентифікації, відкритий заради перевірки, — це не перевірка.
|
||||
#
|
||||
# 3. Стелі пам'яті. Без них пісочниця конкурує з рештою машини на рівних
|
||||
# і виграє: Postgres резервує буфери одразу, а ядро вбиває не того,
|
||||
# хто попросив забагато, а того, хто підвернувся. Стеля перетворює
|
||||
# «машина стала» на «контейнер пісочниці впав», і друге видно одразу.
|
||||
# Цифри взяті з запасом: стеля тут — запобіжник, а не режим економії,
|
||||
# і впертись у неї на порожній базі не має ніщо. Якщо все ж уперлось,
|
||||
# `./netpulse sandbox status` це називає прямо, щоб причину не шукали
|
||||
# в коді.
|
||||
#
|
||||
# 4. restart: "no". У бойовому стеку `unless-stopped` правильний: служба
|
||||
# має пережити перезавантаження хоста. Для пісочниці це протилежність
|
||||
# потрібного — забута пісочниця не має воскресати після ребуту й
|
||||
# мовчки тримати порт і том. Побічний наслідок корисний: контейнер,
|
||||
# який падає в циклі, тут одразу видно як мертвий, а не як «щось
|
||||
# довго стартує».
|
||||
#
|
||||
# ---------------------------------------------------------------------
|
||||
# ЧОМУ ПОТРІБЕН COMPOSE 2.24.4
|
||||
#
|
||||
# Списки `ports` при накладанні файлів compose ДОДАЄ, а не замінює: без
|
||||
# явного тега `!override` базові «80:80» лишилися б на місці поруч зі
|
||||
# зсунутими, і пісочниця все одно вчепилась би в порт бойового проксі.
|
||||
# Тег `!override` з'явився у docker compose 2.24.4.
|
||||
#
|
||||
# Ця вимога зникає одним рядком у docker-compose.yml — якщо базові порти
|
||||
# зробити змінними зі значенням за замовчуванням:
|
||||
#
|
||||
# ports:
|
||||
# - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTP:-80}:80"
|
||||
# - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTPS:-443}:443"
|
||||
# - "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_GRPC:-9443}:9443"
|
||||
#
|
||||
# Тоді пісочниці вистачить .env.sandbox, накладка портів стає не
|
||||
# потрібна, а мінімальна версія compose лишається 2.0 — як у решті
|
||||
# установника. Поки цього немає, `./netpulse sandbox` перевіряє версію і
|
||||
# відмовляється працювати на старішій, а не мовчки бере не ті порти.
|
||||
|
||||
# Ім'я проєкту продубльовано тут навмисно. `./netpulse sandbox` і так
|
||||
# передає -p, але якщо цей файл колись запустять руками без -p, ізоляція
|
||||
# має лишитись: інше ім'я проєкту — це інші контейнери, інша мережа й,
|
||||
# найголовніше, ІНШІ ТОМИ. Без нього `down -v` у пісочниці знищив би
|
||||
# базу бойової інсталяції.
|
||||
name: netpulse-sandbox
|
||||
|
||||
services:
|
||||
db:
|
||||
mem_limit: 768m
|
||||
restart: "no"
|
||||
|
||||
cache:
|
||||
mem_limit: 320m
|
||||
restart: "no"
|
||||
|
||||
migrate:
|
||||
mem_limit: 256m
|
||||
|
||||
cli:
|
||||
mem_limit: 256m
|
||||
|
||||
api:
|
||||
mem_limit: 384m
|
||||
restart: "no"
|
||||
|
||||
collector:
|
||||
mem_limit: 256m
|
||||
restart: "no"
|
||||
|
||||
proxy:
|
||||
mem_limit: 128m
|
||||
restart: "no"
|
||||
# Порти пише установник у .env.sandbox, попередньо переконавшись, що
|
||||
# вони вільні. Значення за замовчуванням тут — щоб файл не був
|
||||
# непрацездатним сам по собі, а не щоб на них покладатись.
|
||||
ports: !override
|
||||
- "127.0.0.1:${NETPULSE_SB_HTTP:-18080}:80"
|
||||
- "127.0.0.1:${NETPULSE_SB_HTTPS:-18081}:443"
|
||||
- "127.0.0.1:${NETPULSE_SB_GRPC:-18082}:9443"
|
||||
|
||||
agent:
|
||||
mem_limit: 192m
|
||||
restart: "no"
|
||||
# Порожньо, а не інший порт: 162/udp не має автентифікації, і
|
||||
# відкривати його заради перевірки означало б купити перевірку ціною
|
||||
# дірки. Усе, що перевіряє зонд — обмін запрошення на токен, gRPC до
|
||||
# колектора, поява рядка в core.agents, — відбувається вихідними
|
||||
# з'єднаннями й публікації порту не потребує.
|
||||
ports: !override []
|
||||
|
|
@ -95,7 +95,21 @@ services:
|
|||
volumes:
|
||||
- db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U netpulse -d netpulse"]
|
||||
# -h 127.0.0.1 обов'язкове: без нього pg_isready йде unix-сокетом,
|
||||
# а споживачі — по TCP.
|
||||
#
|
||||
# Різниця видна рівно один раз у житті інсталяції, на ПЕРШОМУ
|
||||
# запуску. Образ Postgres під час ініціалізації піднімає тимчасовий
|
||||
# сервер БЕЗ TCP (listen_addresses=''), щоб виконати initdb і
|
||||
# скрипти. Сокетна перевірка в цю мить каже «готовий», compose
|
||||
# позначає службу здоровою, migrate стартує — і отримує
|
||||
# «connection refused», хоча база нібито здорова.
|
||||
#
|
||||
# На вже створеній базі цієї фази немає, тому вада не показувалась
|
||||
# роками: вона чекала не на нас, а на першого клієнта. Знайдена
|
||||
# пісочницею установника з першої ж спроби — це рівно те, заради
|
||||
# чого вона й писалась.
|
||||
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U netpulse -d netpulse"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
|
@ -267,13 +281,22 @@ services:
|
|||
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
# Порти й адреса прив'язки — змінними, з типовими значеннями, що
|
||||
# дають рівно попередню поведінку.
|
||||
#
|
||||
# Це не гнучкість заради гнучкості. Списки `ports` при накладанні
|
||||
# compose-файлів ДОДАЮТЬСЯ, а не замінюються: пісочниця, яка
|
||||
# оголосила б свої 18080/18443, отримала б їх НА ДОДАЧУ до 80 і 443
|
||||
# бойового проксі — тобто перехопила б робочий трафік. Обійти це
|
||||
# можна тегом `!override`, але він вимагає compose 2.24.4, а решта
|
||||
# установника працює на 2.0. Змінна знімає і те, і те.
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTP:-80}:80"
|
||||
- "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_HTTPS:-443}:443"
|
||||
# Окремий порт для зондів: вони говорять gRPC, а не HTTP, і
|
||||
# ділити з ним 443 означало б розрізняти протоколи за шляхом —
|
||||
# зайва крихкість там, де порт коштує нічого.
|
||||
- "9443:9443"
|
||||
- "${NETPULSE_BIND:-0.0.0.0}:${NETPULSE_PORT_GRPC:-9443}:9443"
|
||||
depends_on:
|
||||
- api
|
||||
- collector
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@
|
|||
# ./netpulse install
|
||||
#
|
||||
# Синтаксис: КЛЮЧ=значення, без лапок, без пробілів навколо «=».
|
||||
#
|
||||
# ПІСОЧНИЦЯ (`./netpulse sandbox`) цей файл НЕ читає, і це навмисно.
|
||||
# Вона ставить систему на localhost із самопідписаним сертифікатом,
|
||||
# а справжній DOMAIN звідси відправив би її по сертифікат для адреси,
|
||||
# яка веде на бойовий стенд: витрачені спроби списались би з тижневої
|
||||
# квоти домену, і платив би за перевірку той, кого перевіряли. З
|
||||
# TRAPS_FROM те саме — правило в DOCKER-USER це стан хоста, і прибрати
|
||||
# його разом із томами пісочниці не вийшло б.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@
|
|||
|
||||
set -u
|
||||
|
||||
# CDPATH= — не помилка з пробілом, а гасіння CDPATH: якщо він виставлений
|
||||
# в оточенні, cd мовчки піде не туди, і скрипт перевірятиме чужий каталог.
|
||||
# shellcheck disable=SC1007
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT" || exit 1
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@
|
|||
|
||||
set -u
|
||||
|
||||
# CDPATH= — не помилка з пробілом, а гасіння CDPATH: якщо він виставлений
|
||||
# в оточенні, cd мовчки піде не туди, і скрипт перевірятиме чужий каталог.
|
||||
# shellcheck disable=SC1007
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT" || exit 1
|
||||
|
||||
|
|
|
|||
|
|
@ -122,8 +122,55 @@ JWT — ні.
|
|||
| `GET` | `/api/v1/storage/retention` | строки зберігання за видами даних |
|
||||
| `POST` | `/api/v1/storage/retention/preview` | **що зникне** від запропонованих строків (`settings:write`) |
|
||||
| `PUT` | `/api/v1/storage/retention` | зберегти строки й накласти політики (`settings:write`) |
|
||||
| `GET` | `/api/v1/sla/targets` | цілі SLA (`devices:read`) |
|
||||
| `POST` | `/api/v1/sla/targets` | створити ціль (`settings:write`) |
|
||||
| `PUT` | `/api/v1/sla/targets/{id}` | замінити ціль (`settings:write`) |
|
||||
| `DELETE` | `/api/v1/sla/targets/{id}` | видалити ціль **разом із закритими звітами** (`settings:write`) |
|
||||
| `GET` | `/api/v1/sla/targets/{id}/report` | звіт за період, у який потрапляє `?date=YYYY-MM-DD` (типово — попередній) |
|
||||
| `GET` | `/api/v1/sla/targets/{id}/report.csv` | те саме вивантаженням |
|
||||
| `POST` | `/api/v1/sla/targets/{id}/close` | **закрити період**: порахувати раз і зберегти як факт (`settings:write`) |
|
||||
| `GET` | `/api/v1/ws` | WebSocket: події та завантаження каналів |
|
||||
|
||||
### Звіти SLA
|
||||
|
||||
Доступність рахується з `ts.icmp_1h` — годинних згорток ICMP. Не з сирих
|
||||
вимірів: 0005 дає їм 35 діб, тобто звіт за квартал, порахований по них,
|
||||
через два місяці мовчки дав би інше число. У годинних згорток строку
|
||||
немає взагалі, і саме їм `retention_policy.go` ставить нижню межу
|
||||
30 діб зі словами «місячні звіти читають саме звідси».
|
||||
|
||||
Період, який уже скінчився й устоявся (6 годин після кінця — стільки
|
||||
TimescaleDB рахує згортки), **закривається**: рахується один раз і лягає
|
||||
в `core.sla_periods` разом зі знімками умов. Далі його читають, а не
|
||||
рахують. Незакритий період позначено `closed: false` — це прикидка, яка
|
||||
змінюється щогодини.
|
||||
|
||||
Час періоду розкладено на чотири взаємно виключні частини, які в сумі
|
||||
дають `clock_sec`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"clock_sec": 2592000, // період у межах життя хоста
|
||||
"maintenance_sec": 7200, // вікна обслуговування: годинник зупинено
|
||||
"up_sec": 2577600, // виміряно, відповідав
|
||||
"downtime_sec": 900, // виміряно, не відповідав
|
||||
"unknown_sec": 6300, // НЕ виміряно нічим
|
||||
"uptime_pct": 99.965, // up / (up + down) — мовчання не в знаменнику
|
||||
"coverage_pct": 99.756, // (up + down) / (clock - maintenance)
|
||||
"insufficient": false, // покриття нижче за поріг цілі → вердикту немає
|
||||
"breached": false
|
||||
}
|
||||
```
|
||||
|
||||
`unknown_sec` ніколи не додається ні до `up_sec`, ні до `downtime_sec`.
|
||||
Поки `coverage_pct` нижче за `min_coverage_pct` цілі, `insufficient: true`
|
||||
і вердикт не виноситься: це **не** «виконано».
|
||||
|
||||
`warnings` — чого розрахунок не врахував: `rrule_ignored` (повторювані
|
||||
вікна обслуговування), `business_hours_ignored`, `beyond_horizon`
|
||||
(початок періоду старший за збережену історію), `unknown_tz`,
|
||||
`device_purged`.
|
||||
|
||||
### `POST /api/v1/auth/login` — вхід
|
||||
|
||||
```jsonc
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ func run() error {
|
|||
"скільки тримати закриті алерти до переносу в історію")
|
||||
gitRoot = flag.String("git-root", envOr("NETPULSE_GIT_ROOT", "/var/lib/netpulse/git"),
|
||||
"корінь сховища версій конфігів; порожньо — без Git")
|
||||
licensePub = flag.String("license-pubkey", os.Getenv("NETPULSE_LICENSE_PUBKEY"),
|
||||
"відкриті ключі перевірки ліцензій: kid=<hex|base64>[,...]; порожньо — ліцензії не перевіряються")
|
||||
licenseEvery = flag.Duration("license-interval", time.Hour,
|
||||
"як часто перевіряти стан ліцензії; 0 — лише при старті")
|
||||
privateHooks = flag.Bool("allow-private-webhooks",
|
||||
os.Getenv("NETPULSE_ALLOW_PRIVATE_WEBHOOKS") == "1",
|
||||
"дозволити вебхуки на внутрішні адреси — для self-hosted інсталяцій")
|
||||
|
|
@ -130,8 +134,18 @@ func run() error {
|
|||
log.Info("веб-інтерфейс вшито в бінарник")
|
||||
}
|
||||
|
||||
// Ліцензія. Порожній ключ — робочий стан: збірка без вшитого
|
||||
// відкритого ключа працює як працювала, ліміти беруться з тарифу, а
|
||||
// сторінка тарифу чесно каже, що перевірити ключ нічим.
|
||||
verifier, err := store.ParseLicenseVerifier(*licensePub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("-license-pubkey: %w", err)
|
||||
}
|
||||
api = api.WithLicense(verifier)
|
||||
|
||||
go api.Hub().Run(ctx)
|
||||
go pruneLoop(ctx, st, log, *pruneAge)
|
||||
go licenseLoop(ctx, st, verifier, log, *licenseEvery)
|
||||
|
||||
// Движок алертів живе тут, а не в netpulse-server, бо саме цей
|
||||
// процес уже читає БД для UI і має ключі для каналів доставки.
|
||||
|
|
@ -226,6 +240,67 @@ func pruneLoop(ctx context.Context, st *store.Store, log *slog.Logger, age time.
|
|||
}
|
||||
}
|
||||
|
||||
// ЧОМУ ПЕРЕВІРКА ЛІЦЕНЗІЇ — ЦИКЛ, А НЕ ОДИН КРОК ПРИ СТАРТІ
|
||||
//
|
||||
// Бо стан ліцензії міняється без жодної дії — просто від того, що минув
|
||||
// час. Інсталяцію, яку не перезавантажували чотири місяці (а це норма
|
||||
// для коробки в клієнта), перевірка при старті лишила б в active аж до
|
||||
// наступного оновлення: попередження «лишилось п'ять діб» людина
|
||||
// побачила б через півроку після того, як вони скінчились.
|
||||
//
|
||||
// ЧОМУ ПОМИЛКА ТУТ НЕ ЗУПИНЯЄ ПРОЦЕС
|
||||
//
|
||||
// Це головне рішення, і воно те саме, що й у решті ліцензійної частини.
|
||||
// Моніторинг, який не піднявся через ліцензію, — аварія в мережі
|
||||
// клієнта, спричинена нами: він не побачить падіння магістралі й
|
||||
// дізнається про нього від абонентів. Тому будь-яка невдача перевірки —
|
||||
// це рядок у журналі, а не код виходу. Найгірше, що з неї виходить, —
|
||||
// стелі лишаються такими, якими були.
|
||||
|
||||
// licenseLoop перевіряє ліцензію при старті й далі за тактом.
|
||||
func licenseLoop(ctx context.Context, st *store.Store, v *store.LicenseVerifier,
|
||||
log *slog.Logger, every time.Duration) {
|
||||
|
||||
check := func() {
|
||||
state, err := st.RefreshLicense(ctx, v)
|
||||
if err != nil {
|
||||
log.Warn("перевірка ліцензії", "err", err)
|
||||
return
|
||||
}
|
||||
// Рівень залежить від стану, і це не косметика: рядок про
|
||||
// пільговий період має бути помітним у потоці журналу, бо це
|
||||
// єдине попередження, яке отримає той, хто на сторінку тарифу
|
||||
// не заходить.
|
||||
switch state.State {
|
||||
case store.LicenseGrace:
|
||||
log.Warn("ліцензія: пільговий період", "діб", state.DaysLeft,
|
||||
"до", state.GraceUntil)
|
||||
case store.LicenseExpired:
|
||||
log.Warn("ліцензія протермінована — збір і сповіщення працюють, "+
|
||||
"нові хости й зонди не заводяться", "кому", state.IssuedTo)
|
||||
case store.LicenseInvalid:
|
||||
log.Error("ліцензія не перевіряється", "причина", state.Reason)
|
||||
default:
|
||||
log.Info("ліцензія", "стан", state.State, "інсталяція", state.InstallID)
|
||||
}
|
||||
}
|
||||
|
||||
check()
|
||||
if every <= 0 {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
check()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
|
|
|
|||
|
|
@ -172,6 +172,11 @@ func run() error {
|
|||
// й попередній: поруч із тим, хто ці дані створює, і подалі від
|
||||
// шляху запитів людини — див. storage_retention.go.
|
||||
go svc.SweepDataRetention(ctx)
|
||||
// Закриття періодів SLA. Тут із тієї ж причини, що й обидва
|
||||
// прибиральники, і ще з однієї: закривати період треба ВЧАСНО, поки
|
||||
// дані під ним ще є, а не тоді, коли хтось відкриє сторінку — див.
|
||||
// sla_close.go.
|
||||
go svc.CloseSLAPeriods(ctx)
|
||||
// Дзеркалення архіву конфігів на зовнішній Git. Окремий такт, а не
|
||||
// push після коміту: недоступний Forgejo не має коштувати жодного
|
||||
// бекапу — див. ncm_mirror.go.
|
||||
|
|
|
|||
100
server/internal/grpcapi/sla_close.go
Normal file
100
server/internal/grpcapi/sla_close.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package grpcapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// slaCloseLockKey — окреме advisory-блокування під закриття періодів SLA.
|
||||
//
|
||||
// Своє, а не спільне з прибиральником даних: обидва такти тікають
|
||||
// незалежно, і спільний ключ означав би, що довге закриття кварталу на
|
||||
// п'ятистах хостах заодно відкладає звільнення місця на диску.
|
||||
const slaCloseLockKey = 0x6e70_736c // "npsl"
|
||||
|
||||
// SLACloseInterval — як часто дивитись, чи не пора закривати період.
|
||||
//
|
||||
// Година. Найкоротший період — доба, а закривати його можна не раніше
|
||||
// ніж через SLASettleLag після кінця; частіше шукати нема чого. Рідше —
|
||||
// означало б, що звіт за минулий місяць стає остаточним «десь протягом
|
||||
// дня», а саме першого числа по нього й приходять.
|
||||
const SLACloseInterval = time.Hour
|
||||
|
||||
// ЧОМУ ЦЕ ФОНОВИЙ ТАКТ, А НЕ РОЗРАХУНОК ПРИ ВІДКРИТТІ СТОРІНКИ
|
||||
//
|
||||
// Спокуса зробити «закриємо, коли вперше відкриють» велика: коду менше,
|
||||
// такту немає. І вона тихо руйнує всю задачу.
|
||||
//
|
||||
// Період, який ніхто не відкрив вчасно, чекати не вміє — дані під ним
|
||||
// зникають за строками зберігання. Квартал, уперше відкритий через
|
||||
// півроку, закрився б назавжди по тому, що від нього лишилось: кілька
|
||||
// останніх тижнів замість трьох місяців, з мовчазними 100% на решті.
|
||||
// Тобто найгірший звіт вийшов би саме там, де його найдовше не дивились,
|
||||
// — а не дивляться найдовше рівно на ті мережі, де все спокійно.
|
||||
//
|
||||
// Тому закриття не залежить від уваги людини взагалі.
|
||||
//
|
||||
// Живе в колекторі, а не в REST-процесі, з тих самих міркувань, що й
|
||||
// прибиральник даних: поруч із тим, хто ці дані створює, і подалі від
|
||||
// шляху запитів людини. Кілька екземплярів колектора безпечні — тік бере
|
||||
// advisory-блокування, тож працює рівно один.
|
||||
|
||||
// CloseSLAPeriods закриває періоди, які вже можна закрити.
|
||||
func (s *Service) CloseSLAPeriods(ctx context.Context) {
|
||||
t := time.NewTicker(SLACloseInterval)
|
||||
defer t.Stop()
|
||||
|
||||
s.log.Info("закриття періодів SLA запущено", "інтервал", SLACloseInterval)
|
||||
|
||||
// Перший прохід одразу: після простою сервера є що добрати, і
|
||||
// чекати на нього годину означає віддати цю годину даним, які тим
|
||||
// часом можуть зникнути за строком зберігання.
|
||||
s.slaCloseTick(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.slaCloseTick(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) slaCloseTick(ctx context.Context) {
|
||||
conn, err := s.store.Pool().Acquire(ctx)
|
||||
if err != nil {
|
||||
s.log.Error("закриття SLA: з'єднання", "err", err)
|
||||
return
|
||||
}
|
||||
defer conn.Release()
|
||||
|
||||
var got bool
|
||||
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1)`,
|
||||
int64(slaCloseLockKey)).Scan(&got); err != nil {
|
||||
s.log.Error("закриття SLA: блокування", "err", err)
|
||||
return
|
||||
}
|
||||
if !got {
|
||||
// Закриває інший інстанс — штатний стан.
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(context.WithoutCancel(ctx),
|
||||
`SELECT pg_advisory_unlock($1)`, int64(slaCloseLockKey))
|
||||
}()
|
||||
|
||||
closed, err := s.store.CloseDueSLAPeriods(ctx)
|
||||
if err != nil {
|
||||
// Помилка не скасовує вже закритого: кожен період закривається
|
||||
// своєю транзакцією, і наступний тік продовжить з того ж місця.
|
||||
s.log.Error("закриття SLA: прохід", "err", err, "закрито_до_збою", len(closed))
|
||||
}
|
||||
for _, c := range closed {
|
||||
// Кожен закритий період — окремим рядком, а не лічильником.
|
||||
// Число «закрито 7» не відповідає на єдине питання, яке про
|
||||
// закриття взагалі ставлять: «чому в цьому звіті таке число».
|
||||
s.log.Info("період SLA закрито",
|
||||
"ціль", c.Name, "період", c.Period, "кабінет", c.TenantID)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,9 +11,23 @@ import (
|
|||
|
||||
// Модулі, які вміє зонд. Дублює перелік у бінарнику навмисно: увімкнути
|
||||
// в UI те, чого агент не вміє, означає мовчки не отримати даних.
|
||||
// validAgentModules — те, що зонд справді вміє.
|
||||
//
|
||||
// Перелік дублює реальність зонда, і тому легко відстає від неї. Саме
|
||||
// це й сталося з `traps`: модуль написали, у compose увімкнули, на
|
||||
// живому залізі перевірили — а сюди не дописали. Наслідок був не
|
||||
// «трапи не працюють», а «зонд неможливо зареєструвати взагалі»:
|
||||
// установник просив traps і отримував 400 на останньому кроці.
|
||||
// Знайшла пісочниця установника з другої спроби.
|
||||
//
|
||||
// Джерело правди — Key() модулів у agent/internal/modules/* плюс двоє,
|
||||
// які не є модулями реєстру (syslog і traps — вони лише слухають порт,
|
||||
// не мають ані задач, ані розкладу) і `ncm`, який живе в сесії зонда.
|
||||
// Дописуючи модуль на зонді, допишіть його тут.
|
||||
var validAgentModules = map[string]bool{
|
||||
"icmp": true, "snmp": true, "topology": true,
|
||||
"ncm": true, "syslog": true, "http": true,
|
||||
"traps": true, "filecfg": true,
|
||||
}
|
||||
|
||||
func (s *Server) handleListEnrollments(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
|
|
|
|||
243
server/internal/httpapi/billing.go
Normal file
243
server/internal/httpapi/billing.go
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Тариф, стелі й ліцензія.
|
||||
//
|
||||
// ПРО ПРАВА
|
||||
//
|
||||
// Дивитись — billing:read. Це право інженера, а не бухгалтера, і межа
|
||||
// проведена не там, де здається природним. Питання «чому не заводиться
|
||||
// шістнадцятий хост» ставить той, хто заводить хости, і відповідь на
|
||||
// нього мусить бути в нього перед очима ДО того, як він упреться. Тому
|
||||
// сторінка показує стелю разом із використаним, а не рахунок.
|
||||
//
|
||||
// Міняти — billing:manage. Воно є лише у власника: 0010 навмисно не
|
||||
// дала його навіть адміну. Це стосується й ліцензійного ключа —
|
||||
// застосування ключа міняє стелі всієї інсталяції, тобто це та сама дія
|
||||
// за наслідками, що й зміна тарифу.
|
||||
//
|
||||
// Обидва права були заведені 0010 і обидва досі значились у
|
||||
// store/roles.go серед dormantPerms: ключ у базі є, коду, який його
|
||||
// питає, немає. Цей файл — той код.
|
||||
//
|
||||
// ЧОГО ТУТ НЕМАЄ
|
||||
//
|
||||
// Вебхуків платіжки, інвойсів і черги usage-звітів. Не «поки що»: між
|
||||
// ними й цим файлом навмисно нема зв'язку. Усе, що читає перевірка
|
||||
// лімітів, — bill.entitlements; хто її заповнив, видно в колонці source
|
||||
// й нікого більше не обходить. Тому платіжку можна буде дописати
|
||||
// окремим файлом, не торкаючись ані цих обробників, ані тригерів у БД.
|
||||
|
||||
// handleBillingOverview — стан тарифу цілком.
|
||||
//
|
||||
// Одна відповідь на всю сторінку, а не чотири ручки, з тієї ж причини,
|
||||
// що й у черг: стелі, використане й стан ліцензії — це одне питання
|
||||
// («на чому я зараз і скільки лишилось»), і зібрана з трьох запитів у
|
||||
// різні секунди відповідь суперечила б сама собі — «14 з 15» поруч із
|
||||
// «15 з 15» на сусідній картці.
|
||||
func (s *Server) handleBillingOverview(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "billing:read") {
|
||||
return
|
||||
}
|
||||
|
||||
ent, err := s.store.TenantEntitlement(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "стелі тарифу", err)
|
||||
return
|
||||
}
|
||||
usage, err := s.store.TenantUsage(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "використання тарифу", err)
|
||||
return
|
||||
}
|
||||
plans, err := s.store.ListPlans(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "перелік тарифів", err)
|
||||
return
|
||||
}
|
||||
features, err := s.store.ListFeatures(r.Context())
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "каталог можливостей", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Стан ліцензії їде разом зі стелями, хоч він і рівня інсталяції.
|
||||
// Розділити їх означало б показати «дозволено 15 хостів» без
|
||||
// причини, чому саме 15, — а причина тут майже завжди в ліцензії.
|
||||
lic, err := s.store.LicenseStatus(r.Context(), s.license)
|
||||
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
s.writeStoreError(w, "стан ліцензії", err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"entitlement": ent,
|
||||
"usage": usage,
|
||||
"plans": plans,
|
||||
"features": features,
|
||||
"license": lic,
|
||||
// Чи вільно цій людині міняти тариф. Клієнт вивів би це й сам зі
|
||||
// свого переліку прав, але тоді правило жило б у двох місцях і
|
||||
// розійшлося б на першій же зміні. Те саме рішення, що на
|
||||
// сторінці сховища.
|
||||
"can_manage": p.Can("billing:manage"),
|
||||
})
|
||||
}
|
||||
|
||||
// handleSetBillingPlan переводить кабінет на тариф.
|
||||
func (s *Server) handleSetBillingPlan(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "billing:manage") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Plan string `json:"plan"`
|
||||
}
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
|
||||
// Знімок ДО зміни: у журнал має поїхати «з free на pro», а не
|
||||
// «змінили тариф». Читаємо до, бо після цієї відповіді вже немає.
|
||||
before, err := s.store.TenantEntitlement(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "стелі тарифу", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.SetTenantPlan(r.Context(), p.TenantID, in.Plan, p.UserID); err != nil {
|
||||
s.writeStoreError(w, "зміна тарифу", err)
|
||||
return
|
||||
}
|
||||
|
||||
s.auditBilling(r, p, store.AuditActionBillingPlan, store.AuditObjectBillingPlan,
|
||||
map[string]any{"from": before.PlanKey, "to": in.Plan})
|
||||
|
||||
s.handleBillingOverview(w, r, p)
|
||||
}
|
||||
|
||||
// handleGetLicense — стан ліцензії й ідентифікатор інсталяції.
|
||||
//
|
||||
// Окрема ручка попри те, що те саме їде в загальній відповіді:
|
||||
// install_id потрібен у мить замовлення ключа, а туди приходять із
|
||||
// порожньої інсталяції, де перелік тарифів ще нічого не означає.
|
||||
func (s *Server) handleGetLicense(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "billing:read") {
|
||||
return
|
||||
}
|
||||
st, err := s.store.LicenseStatus(r.Context(), s.license)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "стан ліцензії", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
// handleApplyLicense застосовує введений ключ.
|
||||
//
|
||||
// Відмова тут — 400 із ПРИЧИНОЮ, а не загальне «ключ недійсний».
|
||||
// Причин рівно три, вони не взаємозамінні, і кожна веде до різних дій
|
||||
// людини: формат (переплутали поле, обрізали при копіюванні), підпис
|
||||
// (ключ не наш або збірка не має відкритого ключа), інсталяція (ключ
|
||||
// правильний, але від сусіднього стенду). Звести їх до одного рядка
|
||||
// означало б перетворити кожне звернення в підтримку на вгадування.
|
||||
func (s *Server) handleApplyLicense(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "billing:manage") {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
|
||||
st, err := s.store.ApplyLicense(r.Context(), s.license, in.Key, p.UserID)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNoVerifier):
|
||||
// 501, а не 400: справа не в тому, що прислали, а в тому, що ця
|
||||
// збірка ліцензії перевіряти не вміє. Просити людину виправити
|
||||
// ключ у такій ситуації означало б послати її по колу.
|
||||
writeError(w, http.StatusNotImplemented, "no_verifier",
|
||||
"ця збірка не має відкритого ключа для перевірки ліцензій — "+
|
||||
"задайте NETPULSE_LICENSE_PUBKEY")
|
||||
return
|
||||
case errors.Is(err, store.ErrLicenseFormat):
|
||||
writeError(w, http.StatusBadRequest, "license_format",
|
||||
"ключ не розібрався: "+err.Error())
|
||||
return
|
||||
case errors.Is(err, store.ErrLicenseSignature):
|
||||
writeError(w, http.StatusBadRequest, "license_signature",
|
||||
"підпис ключа не сходиться: "+err.Error())
|
||||
return
|
||||
case errors.Is(err, store.ErrLicenseInstall):
|
||||
writeError(w, http.StatusBadRequest, "license_install", err.Error())
|
||||
return
|
||||
case err != nil:
|
||||
s.writeStoreError(w, "застосування ліцензії", err)
|
||||
return
|
||||
}
|
||||
|
||||
// У журнал іде ідентифікатор ліцензії й кому вона видана, але НЕ сам
|
||||
// ключ. Ключ — це секрет: той, хто його бачить, може підняти з ним
|
||||
// іншу інсталяцію. Журнал аудиту читає ширше коло людей, ніж те, що
|
||||
// має право керувати тарифом, і секретам там не місце — те саме
|
||||
// правило, що для паролів дзеркала Git.
|
||||
s.auditBilling(r, p, store.AuditActionLicenseApply, store.AuditObjectLicense,
|
||||
map[string]any{
|
||||
"license_id": st.LicenseID,
|
||||
"issued_to": st.IssuedTo,
|
||||
"plan": st.Plan,
|
||||
"state": st.State,
|
||||
"expires_at": st.ExpiresAt,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
// handleClearLicense знімає ключ із інсталяції.
|
||||
func (s *Server) handleClearLicense(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, "billing:manage") {
|
||||
return
|
||||
}
|
||||
before, _ := s.store.LicenseStatus(r.Context(), s.license)
|
||||
|
||||
if err := s.store.ClearLicense(r.Context(), p.UserID); err != nil {
|
||||
s.writeStoreError(w, "зняття ліцензії", err)
|
||||
return
|
||||
}
|
||||
s.auditBilling(r, p, store.AuditActionLicenseClear, store.AuditObjectLicense,
|
||||
map[string]any{"license_id": before.LicenseID, "issued_to": before.IssuedTo})
|
||||
|
||||
st, err := s.store.LicenseStatus(r.Context(), s.license)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "стан ліцензії", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
// auditBilling — спільний хвіст трьох обробників.
|
||||
//
|
||||
// Аудит пишеться ПІСЛЯ успішної дії й не скасовує її при збої: тариф
|
||||
// уже змінено, і мовчазне «не вдалось» тут гірше за рядок, якого немає
|
||||
// в журналі. Той самий порядок, що й у строків зберігання.
|
||||
func (s *Server) auditBilling(r *http.Request, p *Principal,
|
||||
action, object string, meta map[string]any) {
|
||||
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: action,
|
||||
ObjectType: object,
|
||||
Meta: meta,
|
||||
}); err != nil {
|
||||
s.log.Error("аудит білінгу", "дія", action, "err", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -91,9 +91,22 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request, p *P
|
|||
}
|
||||
id, err := s.store.CreateDevice(r.Context(), p.TenantID, in)
|
||||
if err != nil {
|
||||
if isPlanLimit(err) {
|
||||
writeError(w, http.StatusPaymentRequired, "plan_limit",
|
||||
"вичерпано ліміт пристроїв тарифу")
|
||||
if lim, ok := store.AsPlanLimit(err); ok {
|
||||
// Текст складає БД — там, де перевірка й спрацювала, і з
|
||||
// тими самими числами: «у тарифі Free дозволено 15 хостів,
|
||||
// зараз 15». Числа їдуть іще й окремими полями, щоб форма
|
||||
// могла показати їх поруч із кнопкою, а не лише в смужці
|
||||
// помилки.
|
||||
writeJSON(w, http.StatusPaymentRequired, map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "plan_limit",
|
||||
"message": lim.Error(),
|
||||
"limit": lim.Kind,
|
||||
"plan": lim.Plan,
|
||||
"allowed": lim.Allowed,
|
||||
"used": lim.Used,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "створення хоста", err)
|
||||
|
|
@ -264,9 +277,22 @@ func decodeDevice(w http.ResponseWriter, r *http.Request) (store.DeviceInput, de
|
|||
}, in, true
|
||||
}
|
||||
|
||||
func isPlanLimit(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "ліміт")
|
||||
}
|
||||
// isPlanLimit прибрано навмисно, і це варто пояснити, бо функція
|
||||
// виглядала робочою півроку.
|
||||
//
|
||||
// Вона перевіряла strings.Contains(err.Error(), "ліміт") — а тригер
|
||||
// 0009 підіймав 'device limit reached for tenant % (limit %)', тобто
|
||||
// англійською. Збігу не було ніколи. Тобто в мить, коли стеля вперше
|
||||
// спрацювала б, людина отримала б 500 «внутрішня помилка» замість 402 з
|
||||
// поясненням — рівно те, чого перевірка в БД мала не допустити.
|
||||
//
|
||||
// Помітити це неможливо було ніяк: bill.entitlements порожня на всіх
|
||||
// інсталяціях, стеля не спрацьовувала жодного разу, і зелений код нічого
|
||||
// про це не казав.
|
||||
//
|
||||
// Заміна (store.AsPlanLimit) впізнає відмову за HINT = 'upgrade_plan' —
|
||||
// за полем, яке ставить сама перевірка, а не за текстом, який
|
||||
// переписують, коли він погано читається.
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Групи користувачів
|
||||
|
|
|
|||
|
|
@ -194,6 +194,12 @@ func decodeBody(w http.ResponseWriter, r *http.Request, v any) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// isPlanLimitPg — коротка форма для switch вище.
|
||||
func isPlanLimitPg(err error) bool {
|
||||
_, ok := store.AsPlanLimit(err)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *Server) writeStoreError(w http.ResponseWriter, what string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
|
|
@ -208,6 +214,30 @@ func (s *Server) writeStoreError(w http.ResponseWriter, what string, err error)
|
|||
// 402 — саме те, що сталося: потрібен інший тариф.
|
||||
writeError(w, http.StatusPaymentRequired, "plan_limit", err.Error())
|
||||
|
||||
case isPlanLimitPg(err):
|
||||
// Та сама відмова, але сирою помилкою бази — так вона приходить
|
||||
// з усіх шляхів, крім правки мапи (єдиного, який її перекладав).
|
||||
//
|
||||
// Рядок стоїть саме тут, у СПІЛЬНОМУ обробнику помилок, а не в
|
||||
// кожному місці окремо, і це головне про нього. Стеля тепер
|
||||
// стоїть не лише на створенні хоста: вона спрацьовує на
|
||||
// поверненні з архіву, на масовому ввімкненні, на реєстрації
|
||||
// зонда, на додаванні людини в кабінет. Кожен із цих шляхів
|
||||
// віддав би 500 «внутрішня помилка» — і кожен довелося б
|
||||
// згадати окремо. Забути один із них нічого не коштує й нічим
|
||||
// не проявляється, доки хтось не впреться саме в нього.
|
||||
lim, _ := store.AsPlanLimit(err)
|
||||
writeJSON(w, http.StatusPaymentRequired, map[string]any{
|
||||
"error": map[string]any{
|
||||
"code": "plan_limit",
|
||||
"message": lim.Error(),
|
||||
"limit": lim.Kind,
|
||||
"plan": lim.Plan,
|
||||
"allowed": lim.Allowed,
|
||||
"used": lim.Used,
|
||||
},
|
||||
})
|
||||
|
||||
case errors.Is(err, store.ErrInvalid):
|
||||
writeError(w, http.StatusBadRequest, "invalid", err.Error())
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,12 @@ type Server struct {
|
|||
// коли порушення стає подією. nil означає, що движок алертів на
|
||||
// цій інсталяції вимкнено.
|
||||
events *alerting.EventSink
|
||||
|
||||
// Відкриті ключі, якими перевіряють ліцензії. nil — робочий стан:
|
||||
// збірка без вшитого ключа працює як була, а сторінка тарифу чесно
|
||||
// каже, що перевірити ключ нічим. Мовчазна відмова тут була б
|
||||
// гіршою за відсутність можливості.
|
||||
license *store.LicenseVerifier
|
||||
}
|
||||
|
||||
// New створює сервер. signer може бути nil лише в тестах, які не
|
||||
|
|
@ -79,6 +85,17 @@ func (s *Server) WithKeyring(ring *crypto.Keyring) *Server {
|
|||
return s
|
||||
}
|
||||
|
||||
// WithLicense дає серверу відкриті ключі перевірки ліцензій.
|
||||
//
|
||||
// Окремим методом, а не аргументом New, з тієї ж причини, що й ключі
|
||||
// шифрування: інсталяція без ліцензії — це не поламана інсталяція, а
|
||||
// звичайна. Вимагати ключ від тесту, від кіоску й від клієнта, який
|
||||
// щойно розгорнув систему й ще нічого не купив, було б безпідставно.
|
||||
func (s *Server) WithLicense(v *store.LicenseVerifier) *Server {
|
||||
s.license = v
|
||||
return s
|
||||
}
|
||||
|
||||
// Hub — доступ до трансляції для зовнішнього коду (тести, метрики).
|
||||
func (s *Server) Hub() *Hub { return s.hub }
|
||||
|
||||
|
|
@ -330,6 +347,31 @@ func (s *Server) Handler() http.Handler {
|
|||
mux.Handle("POST /api/v1/storage/retention/preview", s.authenticated(s.handleRetentionPreview))
|
||||
mux.Handle("PUT /api/v1/storage/retention", s.authenticated(s.handleSetRetentionSettings))
|
||||
|
||||
// Тариф, стелі й ліцензія. Дивитись — billing:read, і це право
|
||||
// інженера: «чому не заводиться шістнадцятий хост» питає той, хто
|
||||
// заводить хости. Міняти — billing:manage, і воно є лише у власника.
|
||||
// Ліцензійний ключ під тим самим правом, що й тариф: за наслідками
|
||||
// це та сама дія — вона задає стелі всієї інсталяції.
|
||||
mux.Handle("GET /api/v1/billing", s.authenticated(s.handleBillingOverview))
|
||||
mux.Handle("PUT /api/v1/billing/plan", s.authenticated(s.handleSetBillingPlan))
|
||||
mux.Handle("GET /api/v1/billing/license", s.authenticated(s.handleGetLicense))
|
||||
mux.Handle("POST /api/v1/billing/license", s.authenticated(s.handleApplyLicense))
|
||||
mux.Handle("DELETE /api/v1/billing/license", s.authenticated(s.handleClearLicense))
|
||||
|
||||
// Звіти SLA. Дивитись — devices:read (доступність своєї мережі бачить
|
||||
// кожен, хто бачить моніторинг), заводити цілі й закривати періоди —
|
||||
// settings:write. Див. sla.go.
|
||||
//
|
||||
// Закриття — POST, а не PUT, і це не формальність: воно не «зберігає
|
||||
// стан», а виконує незворотну дію — робить пораховане число фактом.
|
||||
mux.Handle("GET /api/v1/sla/targets", s.authenticated(s.handleListSLATargets))
|
||||
mux.Handle("POST /api/v1/sla/targets", s.authenticated(s.handleSaveSLATarget))
|
||||
mux.Handle("PUT /api/v1/sla/targets/{id}", s.authenticated(s.handleSaveSLATarget))
|
||||
mux.Handle("DELETE /api/v1/sla/targets/{id}", s.authenticated(s.handleDeleteSLATarget))
|
||||
mux.Handle("GET /api/v1/sla/targets/{id}/report", s.authenticated(s.handleSLAReport))
|
||||
mux.Handle("GET /api/v1/sla/targets/{id}/report.csv", s.authenticated(s.handleSLAReportCSV))
|
||||
mux.Handle("POST /api/v1/sla/targets/{id}/close", s.authenticated(s.handleCloseSLAPeriod))
|
||||
|
||||
mux.Handle("GET /api/v1/audit", s.authenticated(s.handleListAudit))
|
||||
mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta))
|
||||
mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent))
|
||||
|
|
|
|||
296
server/internal/httpapi/sla.go
Normal file
296
server/internal/httpapi/sla.go
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/netpulse/netpulse/server/internal/store"
|
||||
)
|
||||
|
||||
// Звіти SLA: доступність за період і вивантаження.
|
||||
//
|
||||
// ПРО ПРАВА
|
||||
//
|
||||
// Дивитись — devices:read. Доступність своєї мережі бачить кожен, хто
|
||||
// взагалі бачить моніторинг: ховати її немає від кого, а не побачити
|
||||
// наближення до порога вчасно коштує грошей за договором. Те саме
|
||||
// рішення, що й для сховища (0064) та дзеркала конфігів (0054).
|
||||
//
|
||||
// Заводити цілі й закривати періоди — settings:write. Ціль SLA — це
|
||||
// зобов'язання ОРГАНІЗАЦІЇ перед клієнтом, того самого класу, що й
|
||||
// строки зберігання: вона описує не один хост, а те, під чим
|
||||
// підписались. Закриття періоду ще й незворотне за наслідками: після
|
||||
// нього число стає фактом, який показують назовні.
|
||||
//
|
||||
// Окремих ключів sla:read / sla:write свідомо не заводимо. Кожен новий
|
||||
// ключ треба роздати ролям, показати на екрані прав і пояснити — тобто
|
||||
// взяти з людини плату за розрізнення, якого вона не просила. Завести
|
||||
// їх пізніше можна; забрати роздане право назад — уже ні.
|
||||
|
||||
// Ключі прав, які питають обробники цього файла.
|
||||
const (
|
||||
slaReadPerm = "devices:read"
|
||||
slaWritePerm = "settings:write"
|
||||
)
|
||||
|
||||
// slaAnchor — мить усередині періоду, який просять.
|
||||
//
|
||||
// Клієнт шле дату (?date=2026-04-15), а не межі періоду. Це навмисно:
|
||||
// межі кварталу — властивість цілі (її типу періоду й поясу), і давати
|
||||
// клієнту рахувати їх самому означає завести другу реалізацію
|
||||
// календаря, яка розійдеться з нашою на переході часу або на межі року.
|
||||
// Порожнє поле означає «попередній період»: саме його питають на аудиті,
|
||||
// і саме він єдиний, який уже можна закрити.
|
||||
func slaAnchor(r *http.Request) (time.Time, bool, error) {
|
||||
raw := r.URL.Query().Get("date")
|
||||
if raw == "" {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
// Полудень, а не опівніч. Дата приходить без поясу, тобто читається
|
||||
// як опівніч UTC; у поясі цілі це вже сусідня доба, а на межі місяця
|
||||
// — сусідній період. Полудень лишається тією самою добою в будь-якому
|
||||
// поясі Землі, тож звіт за 1 липня не виявиться звітом за червень.
|
||||
return t.Add(12 * time.Hour), true, nil
|
||||
}
|
||||
|
||||
// resolveSLAAnchor доводить якір до конкретної миті всередині періоду.
|
||||
func (s *Server) resolveSLAAnchor(r *http.Request, t store.SLATarget) (time.Time, error) {
|
||||
anchor, explicit, err := slaAnchor(r)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if explicit {
|
||||
return anchor, nil
|
||||
}
|
||||
loc, _ := t.Location()
|
||||
prev, err := store.SLAPrevPeriod(t.PeriodKind, time.Now(), loc)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return prev.From, nil
|
||||
}
|
||||
|
||||
// handleListSLATargets — перелік цілей.
|
||||
func (s *Server) handleListSLATargets(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, slaReadPerm) {
|
||||
return
|
||||
}
|
||||
targets, err := s.store.SLATargets(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "цілі SLA", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"targets": targets,
|
||||
// Правило «кому вільно міняти» живе на сервері в одному місці.
|
||||
// Клієнт міг би вивести його зі свого переліку прав, але тоді
|
||||
// воно жило б у двох і розійшлося б на першій же зміні.
|
||||
"can_edit": p.Can(slaWritePerm),
|
||||
// Скільки чекати після кінця періоду до закриття. Клієнт показує
|
||||
// це поруч із кнопкою: інакше «закрити» неактивна без пояснення.
|
||||
"settle_lag_sec": int(store.SLASettleLag.Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleSaveSLATarget заводить або оновлює ціль.
|
||||
func (s *Server) handleSaveSLATarget(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, slaWritePerm) {
|
||||
return
|
||||
}
|
||||
var in store.SLATargetInput
|
||||
if !decodeBody(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if id := r.PathValue("id"); id != "" {
|
||||
in.ID = id
|
||||
}
|
||||
|
||||
out, err := s.store.SaveSLATarget(r.Context(), p.TenantID, in)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "збереження цілі SLA", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionSLATargetSave,
|
||||
ObjectType: store.AuditObjectSLATarget,
|
||||
ObjectID: out.ID,
|
||||
Meta: map[string]any{
|
||||
"name": out.Name,
|
||||
"target_pct": out.TargetPct,
|
||||
"period_kind": out.PeriodKind,
|
||||
"tz": out.TZ,
|
||||
"enabled": out.Enabled,
|
||||
},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит цілі SLA", "err", err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleDeleteSLATarget видаляє ціль разом із її закритими періодами.
|
||||
func (s *Server) handleDeleteSLATarget(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, slaWritePerm) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
n, err := s.store.DeleteSLATarget(r.Context(), p.TenantID, id)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "видалення цілі SLA", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionSLATargetDelete,
|
||||
ObjectType: store.AuditObjectSLATarget,
|
||||
ObjectID: id,
|
||||
// Скільки закритих звітів зникло разом із ціллю. Саме це число
|
||||
// шукатимуть у журналі, коли спитають «а де торішній звіт».
|
||||
Meta: map[string]any{"periods_deleted": n},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит видалення цілі SLA", "err", err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"periods_deleted": n})
|
||||
}
|
||||
|
||||
// handleSLAReport — звіт цілі за період.
|
||||
func (s *Server) handleSLAReport(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, slaReadPerm) {
|
||||
return
|
||||
}
|
||||
rep, err := s.slaReport(r, p)
|
||||
if err != nil {
|
||||
s.writeSLAError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
}
|
||||
|
||||
// handleSLAReportCSV — те саме вивантаженням.
|
||||
//
|
||||
// Окремий обробник, а не параметр формату в попередньому: відповідь тут
|
||||
// не JSON, і плутати два типи вмісту в одному шляху означає, що клієнт
|
||||
// має вгадувати, як її читати. Квиток завантаження (0038) тут не
|
||||
// потрібен: звіт — це десятки кілобайтів, а не десятки мегабайтів, і
|
||||
// вкладка забере його вмить.
|
||||
func (s *Server) handleSLAReportCSV(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, slaReadPerm) {
|
||||
return
|
||||
}
|
||||
rep, err := s.slaReport(r, p)
|
||||
if err != nil {
|
||||
s.writeSLAError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition",
|
||||
`attachment; filename="`+store.SLAReportFileName(rep)+`"`)
|
||||
// Звіт за закритий період незмінний, але кешувати його однаково не
|
||||
// можна: попередній розрахунок за тим самим шляхом змінюється
|
||||
// щогодини, а розрізняти їх кешу нема чим.
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := store.SLAReportCSV(w, rep); err != nil {
|
||||
// Відповідь уже пішла — статус не змінити. Лишається журнал.
|
||||
s.log.Error("вивантаження звіту SLA", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCloseSLAPeriod закриває період: рахує раз і зберігає як факт.
|
||||
func (s *Server) handleCloseSLAPeriod(w http.ResponseWriter, r *http.Request, p *Principal) {
|
||||
if !requirePerm(w, p, slaWritePerm) {
|
||||
return
|
||||
}
|
||||
targets, err := s.store.SLATargets(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
s.writeStoreError(w, "цілі SLA", err)
|
||||
return
|
||||
}
|
||||
t, ok := slaFind(targets, r.PathValue("id"))
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "not_found", "цілі не знайдено")
|
||||
return
|
||||
}
|
||||
anchor, err := s.resolveSLAAnchor(r, t)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid", "дата має вигляд 2006-01-02")
|
||||
return
|
||||
}
|
||||
|
||||
// force — свідомий перерахунок уже закритого періоду. Окремим
|
||||
// параметром, а не мовчазним переписуванням: без нього повторний
|
||||
// виклик нічого не змінює, і фоновий такт може ходити сюди щогодини.
|
||||
force := r.URL.Query().Get("force") == "1"
|
||||
|
||||
rep, err := s.store.CloseSLAPeriod(r.Context(), p.TenantID, t.ID, anchor, force)
|
||||
if err != nil {
|
||||
s.writeSLAError(w, err)
|
||||
return
|
||||
}
|
||||
if err := s.store.WriteAudit(r.Context(), p.TenantID, store.AuditEntry{
|
||||
ActorUserID: p.UserID,
|
||||
ActorTokenID: p.TokenID,
|
||||
ActorIP: clientIP(r),
|
||||
Action: store.AuditActionSLAPeriodClose,
|
||||
ObjectType: store.AuditObjectSLATarget,
|
||||
ObjectID: t.ID,
|
||||
Meta: map[string]any{
|
||||
"name": t.Name,
|
||||
"period": rep.PeriodFrom + "/" + rep.PeriodTo,
|
||||
"revision": rep.Revision,
|
||||
"forced": force,
|
||||
"uptime_pct": rep.Totals.UptimePct,
|
||||
"hosts": len(rep.Rows),
|
||||
},
|
||||
}); err != nil {
|
||||
s.log.Error("аудит закриття періоду SLA", "err", err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rep)
|
||||
}
|
||||
|
||||
func (s *Server) slaReport(r *http.Request, p *Principal) (store.SLAReport, error) {
|
||||
targets, err := s.store.SLATargets(r.Context(), p.TenantID)
|
||||
if err != nil {
|
||||
return store.SLAReport{}, err
|
||||
}
|
||||
t, ok := slaFind(targets, r.PathValue("id"))
|
||||
if !ok {
|
||||
return store.SLAReport{}, store.ErrNotFound
|
||||
}
|
||||
anchor, err := s.resolveSLAAnchor(r, t)
|
||||
if err != nil {
|
||||
return store.SLAReport{}, store.ErrInvalid
|
||||
}
|
||||
return s.store.SLAReportFor(r.Context(), p.TenantID, t.ID, anchor)
|
||||
}
|
||||
|
||||
func slaFind(targets []store.SLATarget, id string) (store.SLATarget, bool) {
|
||||
for _, t := range targets {
|
||||
if t.ID == id {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return store.SLATarget{}, false
|
||||
}
|
||||
|
||||
// writeSLAError відрізняє «ще рано» від решти.
|
||||
//
|
||||
// Окремий код, бо це не помилка клієнта й не поломка: період справді
|
||||
// існує, просто згортки під ним ще рахуються. Клієнт показує цей текст
|
||||
// біля кнопки замість загальної смужки помилки нагорі.
|
||||
func (s *Server) writeSLAError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, store.ErrSLANotSettled) {
|
||||
writeError(w, http.StatusConflict, "sla_not_settled", err.Error())
|
||||
return
|
||||
}
|
||||
s.writeStoreError(w, "звіт SLA", err)
|
||||
}
|
||||
|
|
@ -71,6 +71,14 @@ const (
|
|||
AuditActionTelegramUnlink = "core.telegram.unlink"
|
||||
|
||||
AuditActionRetentionSettings = "core.retention.update"
|
||||
|
||||
AuditActionSLATargetSave = "core.sla_target.save"
|
||||
AuditActionSLATargetDelete = "core.sla_target.delete"
|
||||
AuditActionSLAPeriodClose = "core.sla_period.close"
|
||||
|
||||
AuditActionBillingPlan = "bill.plan.update"
|
||||
AuditActionLicenseApply = "bill.license.apply"
|
||||
AuditActionLicenseClear = "bill.license.clear"
|
||||
)
|
||||
|
||||
// Ключі типів об'єктів — те, НАД ЧИМ зроблено дію.
|
||||
|
|
@ -85,6 +93,9 @@ const (
|
|||
AuditObjectRole = "core.role"
|
||||
AuditObjectTelegram = "core.telegram_account"
|
||||
AuditObjectRetention = "core.retention_settings"
|
||||
AuditObjectSLATarget = "core.sla_target"
|
||||
AuditObjectBillingPlan = "bill.plan"
|
||||
AuditObjectLicense = "bill.license"
|
||||
)
|
||||
|
||||
// AuditActionInfo — те, що словник знає про дію.
|
||||
|
|
@ -236,6 +247,53 @@ var auditActions = []AuditActionInfo{
|
|||
// саме за цим полем шукатимуть того, після кого зникла історія.
|
||||
Label: "Зміна строків зберігання даних",
|
||||
},
|
||||
{
|
||||
Key: AuditActionSLATargetSave, Group: "Адміністрування",
|
||||
Label: "Зміна цілі SLA",
|
||||
},
|
||||
{
|
||||
Key: AuditActionSLATargetDelete, Group: "Адміністрування",
|
||||
// Тут Destructive стоїть, на відміну від строків зберігання, і
|
||||
// різниця саме в миті: видалення цілі каскадом зносить УСІ її
|
||||
// закриті періоди негайно, у цій же транзакції. Скільки саме —
|
||||
// у meta.periods_deleted, бо через рік це буде єдине місце, де
|
||||
// видно, що торішній звіт колись існував.
|
||||
Label: "Видалення цілі SLA", Destructive: true,
|
||||
},
|
||||
{
|
||||
Key: AuditActionSLAPeriodClose, Group: "Адміністрування",
|
||||
// Закриття не руйнівне: воно, навпаки, робить число незмінним.
|
||||
// А от meta.forced і meta.revision — те, за чим шукатимуть
|
||||
// відповідь на «чому в мене два роздруки з різними числами».
|
||||
Label: "Закриття періоду SLA",
|
||||
},
|
||||
{
|
||||
Key: AuditActionBillingPlan, Group: "Тариф і ліцензія",
|
||||
// Зміна тарифу нічого не стирає в мить збереження, але саме
|
||||
// вона задає стелі — тобто з неї починається кожне «а чому в
|
||||
// нас перестали заводитись хости». Питання ставлять за тиждень
|
||||
// після події, і без цього рядка відповідь на нього не має де
|
||||
// взятись: bill.entitlements переписується цілком, попереднього
|
||||
// стану там не лишається. Тому в meta їдуть обидва тарифи, «з»
|
||||
// і «на».
|
||||
Label: "Зміна тарифу",
|
||||
},
|
||||
{
|
||||
Key: AuditActionLicenseApply, Group: "Тариф і ліцензія",
|
||||
// Найважливіший рядок розділу. Ключ задає стелі ВСІЄЇ
|
||||
// інсталяції, включно з чужими кабінетами на спільному
|
||||
// хостингу, і робить це одним натисканням. Самого ключа в meta
|
||||
// немає й не буде — це секрет; там лише його ідентифікатор,
|
||||
// кому виданий і до якої дати.
|
||||
Label: "Застосування ліцензійного ключа",
|
||||
},
|
||||
{
|
||||
Key: AuditActionLicenseClear, Group: "Тариф і ліцензія",
|
||||
// Destructive не ставимо: рядок у bill.license_keys лишається,
|
||||
// зникає лише те, що діє. Але наслідок відчутний — стелі
|
||||
// повертаються до «не задані», — і саме тому дія в журналі.
|
||||
Label: "Зняття ліцензійного ключа",
|
||||
},
|
||||
}
|
||||
|
||||
var auditActionByKey = func() map[string]AuditActionInfo {
|
||||
|
|
@ -270,6 +328,9 @@ var auditObjectTypes = []AuditActionInfo{
|
|||
{Key: AuditObjectRole, Label: "Роль"},
|
||||
{Key: AuditObjectTelegram, Label: "Прив'язка Telegram"},
|
||||
{Key: AuditObjectRetention, Label: "Строки зберігання даних"},
|
||||
{Key: AuditObjectSLATarget, Label: "Ціль SLA"},
|
||||
{Key: AuditObjectBillingPlan, Label: "Тариф"},
|
||||
{Key: AuditObjectLicense, Label: "Ліцензія інсталяції"},
|
||||
}
|
||||
|
||||
var auditObjectTypeByKey = func() map[string]string {
|
||||
|
|
@ -318,6 +379,8 @@ func auditActionGroupFromKey(key string) string {
|
|||
return "Сповіщення"
|
||||
case "core":
|
||||
return "Адміністрування"
|
||||
case "bill":
|
||||
return "Тариф і ліцензія"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
843
server/internal/store/billing.go
Normal file
843
server/internal/store/billing.go
Normal file
|
|
@ -0,0 +1,843 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Тарифи, стелі й підписка: те, що показують людині, і те, що читає
|
||||
// перевірка лімітів.
|
||||
//
|
||||
// ЩО ТУТ Є ДЖЕРЕЛОМ ПРАВДИ
|
||||
//
|
||||
// Рівно одна таблиця — bill.entitlements. Її читають тригери в БД, її
|
||||
// читає сторінка, з неї беруться числа у відмові «дозволено 15, зараз
|
||||
// 15». Усе інше (тариф, ліцензійний ключ, майбутня підписка Stripe) —
|
||||
// це джерела, які в неї ПИШУТЬ, і жодне з них не має права бути
|
||||
// прочитаним у мить перевірки.
|
||||
//
|
||||
// Це не стилістика. Перевірка ліміту виконується на кожній вставці
|
||||
// хоста, а вставок буває тисяча за прогін автовиявлення; JOIN по
|
||||
// чотирьох таблицях із розбором JSON у кожній із них там неприпустимий.
|
||||
// І друге, важливіше: перевірка, яка знає про Stripe, ламається разом зі
|
||||
// Stripe. Той, хто дописуватиме платіжку, не повинен мати способу
|
||||
// зачепити відповідь на питання «чи можна завести хост».
|
||||
//
|
||||
// ЩО РОБИТЬ ЦЕЙ ФАЙЛ
|
||||
//
|
||||
// Читає стан для сторінки й перекладає відмову БД у щось, що можна
|
||||
// показати людині. Перерахунок стель із ліцензії живе поруч, у
|
||||
// billing_license.go — там, де й решта ліцензійної логіки.
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Відмова через ліміт
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// ЧОМУ ВІДМОВА ЧЕРЕЗ СТЕЛЮ — ТИП, А НЕ РЯДОК
|
||||
//
|
||||
// Тому що клієнт має отримати числа, а не фразу. «Вичерпано ліміт
|
||||
// пристроїв тарифу» — це те, що людина вже й так зрозуміла з того, що
|
||||
// нічого не створилось; корисне починається з «дозволено 15, зараз 15»,
|
||||
// бо саме воно каже, що робити далі, і саме воно доводить, що система
|
||||
// не зламалась, а відмовила свідомо.
|
||||
//
|
||||
// Числа приходять із DETAIL відмови (див. bill.deny_limit у 0069) саме
|
||||
// тому, що вони мають прийти З ТІЄЇ Ж транзакції, яка відмовила.
|
||||
// Порахувати їх другим запитом означало б показати інше число, ніж те,
|
||||
// на якому спрацювала перевірка.
|
||||
|
||||
// PlanLimitError — стеля тарифу вичерпана.
|
||||
type PlanLimitError struct {
|
||||
// Kind — що саме скінчилось: devices | maps | map_nodes | agents | users.
|
||||
Kind string `json:"limit"`
|
||||
Plan string `json:"plan"`
|
||||
Allowed int `json:"allowed"`
|
||||
Used int `json:"used"`
|
||||
|
||||
// message — фраза, яку склала сама БД, українською й з числами.
|
||||
// Складати її вдруге в Go означало б мати два формулювання однієї
|
||||
// відмови: одне в логах Postgres, інше на екрані.
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *PlanLimitError) Error() string {
|
||||
if e.message != "" {
|
||||
return e.message
|
||||
}
|
||||
return "досягнуто ліміт тарифу"
|
||||
}
|
||||
|
||||
// Is прив'язує тип до наявного вартового ErrPlanLimit.
|
||||
//
|
||||
// Щоб код, який уже питає errors.Is(err, ErrPlanLimit) (шлях правки
|
||||
// мапи, де відмову перекладає mapPgError), продовжував працювати й тоді,
|
||||
// коли до нього дійде вже типізована помилка.
|
||||
//
|
||||
// Чого це НЕ робить, і про це варто сказати вголос: сира помилка бази
|
||||
// вартовому не відповідає — у її ланцюжку лежить *pgconn.PgError, а не
|
||||
// цей тип. Тому спільний обробник помилок питає ще й AsPlanLimit
|
||||
// окремим рядком (httpapi/maps_write.go). Спокуса покластись на одну
|
||||
// перевірку тут коштувала б 500 на всіх шляхах, крім мап.
|
||||
func (e *PlanLimitError) Is(target error) bool { return target == ErrPlanLimit }
|
||||
|
||||
// AsPlanLimit впізнає відмову через стелю в будь-якій обгортці.
|
||||
//
|
||||
// Впізнавання йде за HINT = 'upgrade_plan', а НЕ за текстом помилки.
|
||||
// Так було в maps_write.go з самого початку, і так правильно: текст
|
||||
// відмови — це те, що переписують, коли він погано читається. Саме на
|
||||
// тексті й зламалась попередня перевірка (httpapi/groups.go шукав слово
|
||||
// «ліміт» у повідомленні, яке 0009 писала англійською), і зламалась
|
||||
// мовчки — до першого спрацювання стелі ніхто б і не дізнався.
|
||||
func AsPlanLimit(err error) (*PlanLimitError, bool) {
|
||||
if err == nil {
|
||||
return nil, false
|
||||
}
|
||||
var already *PlanLimitError
|
||||
if errors.As(err, &already) {
|
||||
return already, true
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) || pgErr.Hint != "upgrade_plan" {
|
||||
return nil, false
|
||||
}
|
||||
out := &PlanLimitError{message: pgErr.Message}
|
||||
// Подробиця може не розібратись — наприклад, на інсталяції, де
|
||||
// 0069 ще не накотилась і тригер лишився з 0009. Це не привід
|
||||
// втрачати саму відмову: без чисел вона гірша, але вона є, і 402
|
||||
// має поїхати замість 500 у будь-якому разі.
|
||||
if pgErr.Detail != "" {
|
||||
_ = json.Unmarshal([]byte(pgErr.Detail), out)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Тарифи
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Plan — тариф так, як його бачить сторінка.
|
||||
type Plan struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
BasePriceCents int `json:"base_price_cents"`
|
||||
PerDeviceCents int `json:"per_device_cents"`
|
||||
Currency string `json:"currency"`
|
||||
BillingPeriod string `json:"billing_period"`
|
||||
PlanLimits
|
||||
Features []string `json:"features"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
}
|
||||
|
||||
// Feature — можливість із каталогу bill.features.
|
||||
type Feature struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ListPlans — усі тарифи, разом із непублічними.
|
||||
//
|
||||
// Непублічні теж, бо один із них (self_hosted) майже напевно й буде
|
||||
// поточним: сторінка мусить уміти назвати те, що зараз діє. Ховає їх зі
|
||||
// СПИСКУ ПРОПОЗИЦІЙ уже інтерфейс — за прапорцем is_public, а не за
|
||||
// відсутністю рядка.
|
||||
func (s *Store) ListPlans(ctx context.Context) ([]Plan, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT key, name, COALESCE(description,''), base_price_cents, per_device_cents,
|
||||
currency, billing_period,
|
||||
max_devices, max_maps, max_map_nodes, max_agents, max_users,
|
||||
metric_retention_days, features, is_public
|
||||
FROM bill.plans ORDER BY sort_order, key
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Plan{}
|
||||
for rows.Next() {
|
||||
var p Plan
|
||||
if err := rows.Scan(&p.Key, &p.Name, &p.Description, &p.BasePriceCents,
|
||||
&p.PerDeviceCents, &p.Currency, &p.BillingPeriod,
|
||||
&p.MaxDevices, &p.MaxMaps, &p.MaxMapNodes, &p.MaxAgents, &p.MaxUsers,
|
||||
&p.RetentionDays, &p.Features, &p.IsPublic); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListFeatures — каталог можливостей.
|
||||
func (s *Store) ListFeatures(ctx context.Context) ([]Feature, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT key, name, COALESCE(description,'') FROM bill.features ORDER BY key`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Feature{}
|
||||
for rows.Next() {
|
||||
var f Feature
|
||||
if err := rows.Scan(&f.Key, &f.Name, &f.Description); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Стелі кабінету
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Entitlement — чинні стелі кабінету.
|
||||
type Entitlement struct {
|
||||
PlanKey string `json:"plan_key"`
|
||||
PlanName string `json:"plan_name"`
|
||||
PlanLimits
|
||||
Features []string `json:"features"`
|
||||
Source string `json:"source"`
|
||||
Reason string `json:"reason"`
|
||||
ValidUntil *time.Time `json:"valid_until,omitempty"`
|
||||
GraceUntil *time.Time `json:"grace_until,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Has — чи входить можливість у стелі кабінету.
|
||||
func (e *Entitlement) Has(feature string) bool {
|
||||
for _, f := range e.Features {
|
||||
if f == feature {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ЧОМУ СТЕЛІ ЗАВОДЯТЬСЯ ЛІНИВО, А НЕ ТРИГЕРОМ НА core.tenants
|
||||
//
|
||||
// 0069 завела рядок кожному кабінету, який існував на мить накочування.
|
||||
// Кабінети, створені ПІСЛЯ (netpulse-user, майбутня реєстрація),
|
||||
// лишаються без рядка — і це нормальний стан, а не поломка: стелі там
|
||||
// немає, тригери пропускають усе, продукт працює.
|
||||
//
|
||||
// Але сторінка тарифу в такому кабінеті мусить щось показати, і
|
||||
// показати вона мусить правду: «стелі не задані». Заводити рядок звідси
|
||||
// дешевше й чесніше, ніж вішати ще один тригер на створення кабінету:
|
||||
// тригер спрацював би в мить, коли про тариф ще ніхто не питав, і його
|
||||
// довелося б тримати в синхроні з переліком колонок ще в одному місці.
|
||||
|
||||
// TenantEntitlement читає стелі, заводячи їх за потреби.
|
||||
func (s *Store) TenantEntitlement(ctx context.Context, tenantID string) (*Entitlement, error) {
|
||||
e, err := s.readEntitlement(ctx, tenantID)
|
||||
if err == nil || !errors.Is(err, ErrNotFound) {
|
||||
return e, err
|
||||
}
|
||||
if err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
INSERT INTO bill.entitlements
|
||||
(tenant_id, plan_key, max_devices, max_maps, max_map_nodes, max_agents,
|
||||
max_users, metric_retention_days, features, source, reason)
|
||||
SELECT $1, p.key, p.max_devices, p.max_maps, p.max_map_nodes, p.max_agents,
|
||||
p.max_users, p.metric_retention_days, p.features, 'license_key', 'migration'
|
||||
FROM bill.plans p WHERE p.key = 'self_hosted'
|
||||
ON CONFLICT (tenant_id) DO NOTHING
|
||||
`, tenantID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.readEntitlement(ctx, tenantID)
|
||||
}
|
||||
|
||||
func (s *Store) readEntitlement(ctx context.Context, tenantID string) (*Entitlement, error) {
|
||||
var e Entitlement
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
return tx.QueryRow(ctx, `
|
||||
SELECT e.plan_key, COALESCE(p.name, e.plan_key),
|
||||
e.max_devices, e.max_maps, e.max_map_nodes, e.max_agents, e.max_users,
|
||||
e.metric_retention_days, e.features, e.source::text, e.reason,
|
||||
e.valid_until, e.grace_until, e.updated_at
|
||||
FROM bill.entitlements e
|
||||
LEFT JOIN bill.plans p ON p.key = e.plan_key
|
||||
WHERE e.tenant_id = $1
|
||||
`, tenantID).Scan(&e.PlanKey, &e.PlanName,
|
||||
&e.MaxDevices, &e.MaxMaps, &e.MaxMapNodes, &e.MaxAgents, &e.MaxUsers,
|
||||
&e.RetentionDays, &e.Features, &e.Source, &e.Reason,
|
||||
&e.ValidUntil, &e.GraceUntil, &e.UpdatedAt)
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.Features == nil {
|
||||
e.Features = []string{}
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// TenantUsage — скільки слотів зайнято.
|
||||
//
|
||||
// Питається в БД тією самою функцією, яку викликають тригери
|
||||
// (bill.usage_now), а не власним count(*). Друге визначення «зайнятого
|
||||
// хоста» неминуче розійшлося б із першим — і розійшлося б саме там, де
|
||||
// це найдорожче: сторінка казала б «14 з 15», а вставка відмовляла б.
|
||||
func (s *Store) TenantUsage(ctx context.Context, tenantID string) (Usage, error) {
|
||||
var u Usage
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
return tx.QueryRow(ctx,
|
||||
`SELECT devices, maps, agents, users FROM bill.usage_now($1)`, tenantID).
|
||||
Scan(&u.Devices, &u.Maps, &u.Agents, &u.Users)
|
||||
})
|
||||
return u, err
|
||||
}
|
||||
|
||||
// SetTenantPlan переводить кабінет на тариф.
|
||||
//
|
||||
// Ручна дія власника — те, чим користуються, поки платіжки немає, і те,
|
||||
// чим користуватимуться після неї для enterprise-домовленостей. Пише в
|
||||
// bill.entitlements напряму, з source = 'manual': видно, що стелі
|
||||
// поставила людина, а не ключ і не провайдер.
|
||||
//
|
||||
// Стеля нижча за зайняте не забороняється, і це свідомо. Власник має
|
||||
// право перевести кабінет на менший тариф, маючи 40 хостів при стелі
|
||||
// 15: наявні 40 лишаються під наглядом (тригер стоїть на появі нового,
|
||||
// не на існуванні), нові не заводяться, а на сторінці стоїть «40 з 15»
|
||||
// — тобто рівно та правда, за якою вирішують, що робити далі.
|
||||
// Заборонити перехід означало б вимагати спершу видалити 25 хостів,
|
||||
// щоб дізнатись, чи влаштує дешевший тариф.
|
||||
func (s *Store) SetTenantPlan(ctx context.Context, tenantID, planKey, userID string) error {
|
||||
return s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE bill.entitlements e SET
|
||||
plan_key = p.key,
|
||||
max_devices = p.max_devices, max_maps = p.max_maps,
|
||||
max_map_nodes = p.max_map_nodes, max_agents = p.max_agents,
|
||||
max_users = p.max_users,
|
||||
metric_retention_days = p.metric_retention_days,
|
||||
features = p.features,
|
||||
source = 'manual', reason = 'plan', license_id = NULL,
|
||||
valid_until = NULL, grace_until = NULL,
|
||||
updated_at = now()
|
||||
FROM bill.plans p
|
||||
WHERE e.tenant_id = $1 AND p.key = $2
|
||||
`, tenantID, planKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("%w: тариф %q не існує або кабінету ще не заведено стель",
|
||||
ErrInvalid, planKey)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Ліцензія інсталяції
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// instanceRow — сирий рядок bill.instance.
|
||||
type instanceRow struct {
|
||||
InstallID string
|
||||
LicenseKey string
|
||||
Payload []byte
|
||||
State string
|
||||
InvalidReason string
|
||||
ClockMaxSeen time.Time
|
||||
ClockWarpedAt *time.Time
|
||||
CheckedAt *time.Time
|
||||
AppliedAt *time.Time
|
||||
}
|
||||
|
||||
// readInstance читає рядок інсталяції.
|
||||
//
|
||||
// Пулом воркера, як і core.storage_config: таблиця рівня інсталяції,
|
||||
// tenant_id у неї немає за побудовою, і RLS до неї не застосовна.
|
||||
func (s *Store) readInstance(ctx context.Context) (*instanceRow, error) {
|
||||
var r instanceRow
|
||||
var key, reason *string
|
||||
err := s.bg.QueryRow(ctx, `
|
||||
SELECT install_id::text, license_key, payload, state, invalid_reason,
|
||||
clock_max_seen, clock_warped_at, checked_at, applied_at
|
||||
FROM bill.instance WHERE id
|
||||
`).Scan(&r.InstallID, &key, &r.Payload, &r.State, &reason,
|
||||
&r.ClockMaxSeen, &r.ClockWarpedAt, &r.CheckedAt, &r.AppliedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if key != nil {
|
||||
r.LicenseKey = *key
|
||||
}
|
||||
if reason != nil {
|
||||
r.InvalidReason = *reason
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// InstallID — ідентифікатор цієї інсталяції.
|
||||
//
|
||||
// Потрібен людині: саме його вона надсилає, замовляючи ключ.
|
||||
func (s *Store) InstallID(ctx context.Context) (string, error) {
|
||||
r, err := s.readInstance(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.InstallID, nil
|
||||
}
|
||||
|
||||
// LicenseStatus — поточний стан без перерахунку.
|
||||
//
|
||||
// Дешеве читання для сторінки. Стан перераховує такт (RefreshLicense);
|
||||
// сторінка показує те, що він порахував, і поруч — коли саме. Рахувати
|
||||
// на кожному відкритті означало б, що стан залежить від того, чи хтось
|
||||
// дивиться.
|
||||
func (s *Store) LicenseStatus(ctx context.Context, v *LicenseVerifier) (LicenseState, error) {
|
||||
r, err := s.readInstance(ctx)
|
||||
if err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
st := LicenseState{
|
||||
State: r.State,
|
||||
Reason: r.InvalidReason,
|
||||
InstallID: r.InstallID,
|
||||
CheckedAt: r.CheckedAt,
|
||||
AppliedAt: r.AppliedAt,
|
||||
ClockWarped: r.ClockWarpedAt != nil,
|
||||
ClockMaxSeen: &r.ClockMaxSeen,
|
||||
VerifierEmpty: v.Empty(),
|
||||
}
|
||||
if len(r.Payload) == 0 {
|
||||
return st, nil
|
||||
}
|
||||
var p LicensePayload
|
||||
if err := json.Unmarshal(r.Payload, &p); err != nil {
|
||||
return st, nil
|
||||
}
|
||||
live := EvaluateLicense(&p, nowFor(r))
|
||||
// Стан беремо з перерахованого, а не з колонки: колонка відстає
|
||||
// рівно на час до наступного такту, і в добу переходу active→grace
|
||||
// сторінка показувала б учорашню правду.
|
||||
//
|
||||
// Виняток — invalid: він означає, що payload не пройшов перевірку
|
||||
// підпису, і будувати з нього строк не можна взагалі.
|
||||
if r.State != LicenseInvalid {
|
||||
live.InstallID = st.InstallID
|
||||
live.CheckedAt, live.AppliedAt = st.CheckedAt, st.AppliedAt
|
||||
live.ClockWarped, live.ClockMaxSeen = st.ClockWarped, st.ClockMaxSeen
|
||||
live.VerifierEmpty = st.VerifierEmpty
|
||||
return live, nil
|
||||
}
|
||||
st.LicenseID, st.IssuedTo, st.Plan = p.LicenseID, p.IssuedTo, p.Plan
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// nowFor — час, за яким рахувати строк для цього рядка інсталяції.
|
||||
func nowFor(r *instanceRow) time.Time {
|
||||
t, _ := effectiveNow(time.Now().UTC(), r.ClockMaxSeen)
|
||||
return t
|
||||
}
|
||||
|
||||
// ApplyLicense застосовує введений ключ.
|
||||
//
|
||||
// Порядок: перевірити підпис → перевірити прив'язку до інсталяції →
|
||||
// записати → перерахувати стелі. Перший крок, що не вдався, лишає все
|
||||
// як було: інсталяція з чинним ключем не має втратити його від того,
|
||||
// що хтось вставив у поле сторонній рядок.
|
||||
func (s *Store) ApplyLicense(ctx context.Context, v *LicenseVerifier, key, userID string) (LicenseState, error) {
|
||||
inst, err := s.readInstance(ctx)
|
||||
if err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
|
||||
p, perr := v.ParseLicense(key)
|
||||
if perr != nil {
|
||||
return LicenseState{}, perr
|
||||
}
|
||||
// Прив'язка. Порожній InstallID у ключі — ключ «на пред'явника»:
|
||||
// його випустили до розгортання, і забирає його той, хто ввів
|
||||
// першим. Непорожній мусить збігтися дослівно.
|
||||
if p.InstallID != "" && p.InstallID != inst.InstallID {
|
||||
return LicenseState{}, fmt.Errorf("%w: ключ виданий для %s, ця інсталяція — %s",
|
||||
ErrLicenseInstall, p.InstallID, inst.InstallID)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
st := EvaluateLicense(p, now)
|
||||
|
||||
if _, err := s.bg.Exec(ctx, `
|
||||
UPDATE bill.instance SET
|
||||
license_key = $1, payload = $2::jsonb,
|
||||
license_id = NULLIF($3,'')::uuid, issued_to = NULLIF($4,''),
|
||||
state = $5, invalid_reason = NULL,
|
||||
expires_at = $6, grace_until = $7,
|
||||
applied_at = now(), applied_by = $8, checked_at = now(),
|
||||
clock_max_seen = GREATEST(clock_max_seen, now())
|
||||
WHERE id
|
||||
`, key, string(body), licenseUUID(p.LicenseID), p.IssuedTo, st.State,
|
||||
st.ExpiresAt, st.GraceUntil, nullUUID(userID)); err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
|
||||
// Слід у bill.license_keys — не дублювання, а відповідь на питання
|
||||
// «які ключі тут бували». Ключ, який замінили місяць тому, з
|
||||
// bill.instance зникає безслідно, і з'ясувати, чи був він взагалі,
|
||||
// стає ніяк. tenant_id лишається порожнім навмисно: ліцензія
|
||||
// належить інсталяції, а не кабінету (див. 0069).
|
||||
if err := s.recordLicenseKey(ctx, p, key, inst.InstallID); err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
|
||||
if err := s.applyEntitlements(ctx, p, st.State); err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
return s.LicenseStatus(ctx, v)
|
||||
}
|
||||
|
||||
// ClearLicense знімає ключ.
|
||||
//
|
||||
// Не «видаляє ліцензію»: рядок у bill.license_keys лишається, зникає
|
||||
// лише те, що діє. Кабінети повертаються до self_hosted, тобто до
|
||||
// стану «стелі не задані», а не до найменшого тарифу — зняття ключа не
|
||||
// має бути дією, після якої система перестає приймати хости.
|
||||
func (s *Store) ClearLicense(ctx context.Context, userID string) error {
|
||||
if _, err := s.bg.Exec(ctx, `
|
||||
UPDATE bill.instance SET
|
||||
license_key = NULL, payload = NULL, license_id = NULL, issued_to = NULL,
|
||||
state = 'unlicensed', invalid_reason = NULL,
|
||||
expires_at = NULL, grace_until = NULL,
|
||||
applied_at = now(), applied_by = $1, checked_at = now()
|
||||
WHERE id
|
||||
`, nullUUID(userID)); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.applyEntitlements(ctx, nil, LicenseUnlicensed)
|
||||
}
|
||||
|
||||
// ЧОМУ СТАН ЛІЦЕНЗІЇ ПЕРЕРАХОВУЄТЬСЯ ТАКТОМ, А НЕ РАЗ ПРИ СТАРТІ
|
||||
//
|
||||
// Бо стан ліцензії міняється БЕЗ ЖОДНОЇ ДІЇ — просто від того, що минув
|
||||
// час. Інсталяція, яку не перезавантажували чотири місяці (а це норма),
|
||||
// перейшла б із active в expired і не помітила б цього до наступного
|
||||
// перезапуску. Тобто попередження «лишилось 5 діб» людина побачила б
|
||||
// через півроку після того, як вони скінчились.
|
||||
//
|
||||
// Такт же робить три речі: рухає монотонний годинник, перечитує підпис
|
||||
// (ключ міг стати невалідним через ротацію ключів підпису у збірці) і
|
||||
// переписує стелі, якщо стан змінився.
|
||||
|
||||
// RefreshLicense — такт перевірки в рантаймі.
|
||||
func (s *Store) RefreshLicense(ctx context.Context, v *LicenseVerifier) (LicenseState, error) {
|
||||
inst, err := s.readInstance(ctx)
|
||||
if err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
eff, warped := effectiveNow(now, inst.ClockMaxSeen)
|
||||
|
||||
if inst.LicenseKey == "" {
|
||||
if _, err := s.bg.Exec(ctx, `
|
||||
UPDATE bill.instance SET checked_at = now(),
|
||||
clock_max_seen = GREATEST(clock_max_seen, now()) WHERE id`); err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
return s.LicenseStatus(ctx, v)
|
||||
}
|
||||
|
||||
p, perr := v.ParseLicense(inst.LicenseKey)
|
||||
if perr != nil {
|
||||
// Ключ був прийнятий раніше, а тепер не перевіряється. Найчастіша
|
||||
// причина — збірку оновили, і ключ підпису, яким його видали,
|
||||
// з неї прибрали.
|
||||
//
|
||||
// Стан invalid, стелі НЕ чіпаємо. Це принципово: ми не знаємо,
|
||||
// чи справді ключ підроблений, чи це ми загубили відкритий ключ,
|
||||
// — і в другому випадку відібрати в клієнта стелі означало б
|
||||
// покарати його за нашу помилку розгортання. Видно це на
|
||||
// сторінці, і полагодити має людина, а не тиха дія коду.
|
||||
if _, err := s.bg.Exec(ctx, `
|
||||
UPDATE bill.instance SET state = 'invalid', invalid_reason = $1,
|
||||
checked_at = now(), clock_max_seen = GREATEST(clock_max_seen, now())
|
||||
WHERE id`, perr.Error()); err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
return s.LicenseStatus(ctx, v)
|
||||
}
|
||||
|
||||
st := EvaluateLicense(p, eff)
|
||||
|
||||
if _, err := s.bg.Exec(ctx, `
|
||||
UPDATE bill.instance SET
|
||||
state = $1, invalid_reason = NULL,
|
||||
expires_at = $2, grace_until = $3,
|
||||
checked_at = now(),
|
||||
clock_max_seen = GREATEST(clock_max_seen, now()),
|
||||
clock_warped_at = CASE WHEN $4 THEN now() ELSE clock_warped_at END
|
||||
WHERE id
|
||||
`, st.State, st.ExpiresAt, st.GraceUntil, warped); err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
|
||||
// Стелі переписуються щотакту, а не лише на зміні стану.
|
||||
//
|
||||
// Дорожче на один UPDATE на кабінет за годину — і рівно це закриває
|
||||
// найтихішу з поломок: рядок entitlements, зіпсований чужою правкою
|
||||
// руками в базі, інакше лишався б чинним нескінченно, бо «стан не
|
||||
// змінився».
|
||||
if err := s.applyEntitlements(ctx, p, st.State); err != nil {
|
||||
return LicenseState{}, err
|
||||
}
|
||||
return s.LicenseStatus(ctx, v)
|
||||
}
|
||||
|
||||
// recordLicenseKey лишає слід про застосований ключ.
|
||||
func (s *Store) recordLicenseKey(ctx context.Context, p *LicensePayload, key, installID string) error {
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prefix := key
|
||||
if len(prefix) > 16 {
|
||||
prefix = prefix[:16]
|
||||
}
|
||||
// Підпис лягає окремою колонкою, хоч він і є всередині key.
|
||||
//
|
||||
// Не заради місця, а заради відповіді на питання, яке ставлять раз
|
||||
// на кілька років і завжди в поганий день: «яким саме ключем підпису
|
||||
// це підписано і чи сходиться воно досі». Дістати підпис регулярним
|
||||
// виразом із текстового поля посеред розслідування — не те, чим
|
||||
// хочеться займатись; колонка bytea для цього в 0009 і заведена.
|
||||
// Первинний ключ рядка генерує база, а не lid із payload.
|
||||
//
|
||||
// Спокуса взяти lid очевидна — «нехай ідентифікатор ліцензії буде
|
||||
// ідентифікатором рядка». Але lid пише той, хто випускає ключ, і
|
||||
// продовження ліцензії з тим самим lid (звичайна практика) уперлось
|
||||
// би в первинний ключ. Наслідок був би найгіршим із можливих:
|
||||
// дійсний оплачений ключ не застосовується, а людина бачить
|
||||
// «внутрішню помилку». Тотожність рядка тримає хеш самого ключа —
|
||||
// саме він і є тим, що не повторюється.
|
||||
_, err = s.bg.Exec(ctx, `
|
||||
INSERT INTO bill.license_keys
|
||||
(plan_key, key_hash, key_prefix, payload, signature, signing_key_id,
|
||||
status, max_devices, max_map_nodes, features, bound_install_id, issued_to,
|
||||
activated_at, expires_at)
|
||||
VALUES (COALESCE((SELECT key FROM bill.plans WHERE key = $1), 'self_hosted'),
|
||||
digest($2, 'sha256'), $3, $4::jsonb, $5, $6,
|
||||
'active', $7, $8, $9, $10::uuid, NULLIF($11,''),
|
||||
now(), $12)
|
||||
ON CONFLICT (key_hash) DO UPDATE SET
|
||||
status = 'active', activated_at = now(),
|
||||
bound_install_id = EXCLUDED.bound_install_id
|
||||
`, p.Plan, key, prefix, string(body),
|
||||
licenseSignatureBytes(key), p.KeyID,
|
||||
p.MaxDevices, p.MaxMapNodes, featuresOrEmpty(p.Features), installID,
|
||||
p.IssuedTo, timeOrNil(p.ExpiresAt))
|
||||
return err
|
||||
}
|
||||
|
||||
// licenseSignatureBytes дістає підпис із уже перевіреного ключа.
|
||||
//
|
||||
// Порожній зріз замість помилки: сюди потрапляє лише ключ, який щойно
|
||||
// пройшов ParseLicense, тобто розкодувався напевно. Але падати на
|
||||
// збереженні СЛІДУ через несподіванку в розборі не можна — ліцензія при
|
||||
// цьому вже застосована, і відкотити її частково гірше, ніж лишити
|
||||
// колонку порожньою.
|
||||
func licenseSignatureBytes(key string) []byte {
|
||||
parts := strings.Split(strings.TrimSpace(key), ".")
|
||||
if len(parts) != 3 {
|
||||
return []byte{}
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return []byte{}
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// licenseUUID пропускає далі лише те, що справді схоже на uuid.
|
||||
//
|
||||
// lid у payload пише той, хто випускає ключ, і поле це вільне. Рядок,
|
||||
// який не є uuid, доїхав би до `::uuid` у запиті й перетворився б на
|
||||
// 500 «внутрішня помилка» — тобто дійсний за підписом ключ не
|
||||
// застосувався б через косметичне поле. Порожній рядок означає «нехай
|
||||
// база згенерує свій», і це нормальний стан.
|
||||
func licenseUUID(v string) string {
|
||||
if len(v) != 36 {
|
||||
return ""
|
||||
}
|
||||
for i, c := range v {
|
||||
switch i {
|
||||
case 8, 13, 18, 23:
|
||||
if c != '-' {
|
||||
return ""
|
||||
}
|
||||
default:
|
||||
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||
if !isHex {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func featuresOrEmpty(f []string) []string {
|
||||
if f == nil {
|
||||
return []string{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// timeOrNil — вказівник, а не any з nil усередині.
|
||||
//
|
||||
// Порожній інтерфейс із nil доїжджає до драйвера як «значення
|
||||
// невідомого типу», і колонка timestamptz на нього реагує по-різному
|
||||
// залежно від версії. Типізований nil-вказівник однозначний завжди.
|
||||
func timeOrNil(unix int64) *time.Time {
|
||||
if unix == 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(unix, 0).UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
// applyEntitlements переписує стелі всім кабінетам, які живуть із
|
||||
// ліцензії.
|
||||
//
|
||||
// САМЕ «ЯКІ ЖИВУТЬ ІЗ ЛІЦЕНЗІЇ» — це та межа, за яку не протікає
|
||||
// платіжка. Кабінет, стелі якого прийшли з підписки (source = 'stripe')
|
||||
// або поставлені руками (source = 'manual'), ключ інсталяції не чіпає.
|
||||
// Без цієї умови перший же ключ, застосований на спільному хостингу,
|
||||
// переписав би чужі оплачені стелі своїми.
|
||||
func (s *Store) applyEntitlements(ctx context.Context, p *LicensePayload, state string) error {
|
||||
planKey := "self_hosted"
|
||||
if p != nil && p.Plan != "" {
|
||||
planKey = p.Plan
|
||||
}
|
||||
|
||||
base, features, err := s.planLimits(ctx, planKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits := LimitsFromPayload(base, p)
|
||||
features = FeaturesFromPayload(features, p)
|
||||
|
||||
reason := "license"
|
||||
switch state {
|
||||
case LicenseUnlicensed, LicenseInvalid:
|
||||
reason = "migration"
|
||||
case LicenseExpired:
|
||||
reason = "license_expired"
|
||||
}
|
||||
|
||||
ids, err := s.licensedTenants(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
// Стелі рахуються ОКРЕМО для кожного кабінету, бо «не нижче
|
||||
// зайнятого» — це про конкретні числа конкретного кабінету, а
|
||||
// не про інсталяцію загалом.
|
||||
used, err := s.TenantUsage(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eff := EffectiveLimits(limits, state, used)
|
||||
|
||||
var validUntil, graceUntil *time.Time
|
||||
if p != nil {
|
||||
v, g := p.Expires(), p.GraceUntil()
|
||||
validUntil, graceUntil = &v, &g
|
||||
}
|
||||
if err := s.InTenantTx(ctx, id, func(tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx, `
|
||||
UPDATE bill.entitlements SET
|
||||
plan_key = $2,
|
||||
max_devices = $3, max_maps = $4, max_map_nodes = $5,
|
||||
max_agents = $6, max_users = $7,
|
||||
metric_retention_days = $8, features = $9,
|
||||
source = 'license_key', reason = $10,
|
||||
valid_until = $11, grace_until = $12,
|
||||
updated_at = now()
|
||||
WHERE tenant_id = $1 AND source = 'license_key'
|
||||
`, id, planKey, eff.MaxDevices, eff.MaxMaps, eff.MaxMapNodes,
|
||||
eff.MaxAgents, eff.MaxUsers, eff.RetentionDays, features,
|
||||
reason, validUntil, graceUntil)
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// planLimits — стелі й фічі тарифу.
|
||||
func (s *Store) planLimits(ctx context.Context, key string) (PlanLimits, []string, error) {
|
||||
var l PlanLimits
|
||||
var f []string
|
||||
err := s.bg.QueryRow(ctx, `
|
||||
SELECT max_devices, max_maps, max_map_nodes, max_agents, max_users,
|
||||
metric_retention_days, features
|
||||
FROM bill.plans WHERE key = $1
|
||||
`, key).Scan(&l.MaxDevices, &l.MaxMaps, &l.MaxMapNodes, &l.MaxAgents,
|
||||
&l.MaxUsers, &l.RetentionDays, &f)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Ключ називає тариф, якого в цій збірці немає. Не помилка
|
||||
// застосування: ключ дійсний, підпис сходиться, просто план
|
||||
// новіший за інсталяцію. Стелі беремо з самого ключа, а базою
|
||||
// стає «без обмежень» — бо клієнт заплатив, і відмовляти йому
|
||||
// через незнайоме слово в полі plan ми не маємо права.
|
||||
return PlanLimits{RetentionDays: 400}, []string{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return l, nil, err
|
||||
}
|
||||
if f == nil {
|
||||
f = []string{}
|
||||
}
|
||||
return l, f, nil
|
||||
}
|
||||
|
||||
// licensedTenants — кабінети, стелі яких задає ліцензія інсталяції.
|
||||
func (s *Store) licensedTenants(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.bg.Query(ctx, `
|
||||
SELECT e.tenant_id::text
|
||||
FROM bill.entitlements e
|
||||
JOIN core.tenants t ON t.id = e.tenant_id AND t.deleted_at IS NULL
|
||||
WHERE e.source = 'license_key'
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
517
server/internal/store/billing_license.go
Normal file
517
server/internal/store/billing_license.go
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ліцензійні ключі для self-hosted.
|
||||
//
|
||||
// ЧОМУ ЦЕ ВАЖЛИВІШЕ ЗА STRIPE
|
||||
//
|
||||
// Бо продукт ставлять клієнту НА ЙОГО ЗАЛІЗО, і найчастіше — у сегмент
|
||||
// без виходу назовні. Це не рідкість і не окремий випадок: мережа, за
|
||||
// якою треба стежити, зазвичай і є та мережа, з якої нікуди не
|
||||
// достукатись. Ліцензія, яка вимагає зателефонувати додому, у такій
|
||||
// інсталяції не працює взагалі — не «працює гірше», а не працює.
|
||||
//
|
||||
// Звідси головна вимога до формату: ключ мусить нести всю правду про
|
||||
// себе САМ. Строк, стелі, набір фіч, кому виданий, до якої інсталяції
|
||||
// прив'язаний — усе всередині, під підписом. Перевірка — це перевірка
|
||||
// підпису над байтами, які вже лежать перед нами, і більше нічого.
|
||||
//
|
||||
// ФОРМАТ
|
||||
//
|
||||
// NP1.<base64url(payload)>.<base64url(підпис)>
|
||||
//
|
||||
// Три частини, крапка як роздільник — та сама форма, що в JWT, і це
|
||||
// свідомо: її впізнають, її не ламають поштові клієнти, вона копіюється
|
||||
// подвійним кліком. Але це НЕ JWT: там алгоритм оголошено в самому
|
||||
// токені, і найвідоміша вразливість формату — саме через це («alg:
|
||||
// none»). Тут алгоритм один, він у префіксі NP1 і не узгоджується ні з
|
||||
// ким.
|
||||
//
|
||||
// Підпис Ed25519. Обґрунтування вибору проти RSA-4096 з 0009 —
|
||||
// у міграції 0069; коротко: 64 байти проти 512 у ключі, який людина
|
||||
// вводить руками, і жодного параметра, який можна виставити неправильно.
|
||||
//
|
||||
// ПІДПИСУЄМО САМЕ ТІ БАЙТИ, ЯКІ ВЕЗЕМО
|
||||
//
|
||||
// Підпис рахується над base64-розкодованим payload дослівно, а не над
|
||||
// «канонічним виглядом структури». Це прибирає цілий клас поломок:
|
||||
// будь-яка канонізація (порядок полів, пропуск порожніх, формат чисел)
|
||||
// — це домовленість між тим, хто підписав, і тим, хто перевіряє, і
|
||||
// розходиться вона мовчки, через рік, після нейтральної на вигляд зміни
|
||||
// структури. Байти не розходяться ніколи.
|
||||
//
|
||||
// Наслідок, з яким треба жити: невідомі поля в payload переживають
|
||||
// перевірку. Це правильно — ключ, виданий новішою збіркою генератора,
|
||||
// має лишатись дійсним на старішій інсталяції, а не ставати
|
||||
// «недійсним» через поле, про яке вона не знає.
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Формат ключа
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const licensePrefix = "NP1"
|
||||
|
||||
var (
|
||||
// ErrLicenseFormat — рядок не схожий на ключ узагалі.
|
||||
ErrLicenseFormat = errors.New("це не схоже на ліцензійний ключ")
|
||||
// ErrLicenseSignature — підпис не сходиться або ключ підпису невідомий.
|
||||
ErrLicenseSignature = errors.New("підпис ліцензії не сходиться")
|
||||
// ErrLicenseInstall — ключ виданий іншій інсталяції.
|
||||
ErrLicenseInstall = errors.New("ключ виданий іншій інсталяції")
|
||||
// ErrNoVerifier — на цій збірці немає жодного відкритого ключа
|
||||
// підпису, тобто перевірити ліцензію нічим.
|
||||
ErrNoVerifier = errors.New("відкритий ключ підпису ліцензій не налаштовано")
|
||||
)
|
||||
|
||||
// LicensePayload — те, що лежить під підписом.
|
||||
//
|
||||
// Стелі — вказівники, і саме тому. Відсутнє поле означає «без
|
||||
// обмеження», нуль — «нуль штук». Для ліцензії ця різниця вирішальна:
|
||||
// ключ, у якому забули max_devices, має відкривати необмежену
|
||||
// інсталяцію, а не блокувати перший же хост. Помилка в цей бік
|
||||
// коштувала б клієнту робочого дня, а нам — звернення о шостій ранку.
|
||||
type LicensePayload struct {
|
||||
V int `json:"v"`
|
||||
LicenseID string `json:"lid"`
|
||||
KeyID string `json:"kid"`
|
||||
|
||||
// InstallID порожній — ключ не прив'язаний до інсталяції.
|
||||
//
|
||||
// Так виглядає ключ, виданий ДО розгортання: install_id заводить
|
||||
// сама інсталяція при першому старті, і на момент продажу його ще
|
||||
// не існує. Прив'язка відбувається при застосуванні (bound_install_id
|
||||
// у bill.license_keys) — тобто перший, хто ввів ключ, і забирає його
|
||||
// собі.
|
||||
InstallID string `json:"install,omitempty"`
|
||||
|
||||
Plan string `json:"plan"`
|
||||
IssuedTo string `json:"to,omitempty"`
|
||||
|
||||
IssuedAt int64 `json:"iat"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
|
||||
// GraceDays — скільки діб після строку все лишається як було.
|
||||
//
|
||||
// Не «скільки терпіти неплатника»: пільговий період існує проти
|
||||
// НАШОЇ ж помилки — лист із рахунком у спамі, відпустка людини, яка
|
||||
// підписує, оплата, що йшла три дні. Нуль тут означав би, що
|
||||
// продукт починає обмежувати клієнта в день, коли той іще навіть не
|
||||
// знає про проблему.
|
||||
GraceDays int `json:"grace,omitempty"`
|
||||
|
||||
MaxDevices *int `json:"max_devices,omitempty"`
|
||||
MaxMaps *int `json:"max_maps,omitempty"`
|
||||
MaxMapNodes *int `json:"max_map_nodes,omitempty"`
|
||||
MaxAgents *int `json:"max_agents,omitempty"`
|
||||
MaxUsers *int `json:"max_users,omitempty"`
|
||||
RetentionDays *int `json:"retention_days,omitempty"`
|
||||
Features []string `json:"features,omitempty"`
|
||||
}
|
||||
|
||||
// Expires — строк дії як час.
|
||||
func (p *LicensePayload) Expires() time.Time { return time.Unix(p.ExpiresAt, 0).UTC() }
|
||||
|
||||
// GraceUntil — до якої миті все лишається як було.
|
||||
func (p *LicensePayload) GraceUntil() time.Time {
|
||||
return p.Expires().AddDate(0, 0, p.GraceDays)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Перевірка підпису
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// LicenseVerifier — відкриті ключі, якими перевіряють ліцензії.
|
||||
//
|
||||
// Кілька ключів одночасно, як у crypto.Keyring, і з тієї ж причини:
|
||||
// ключ підпису колись доведеться змінити, а вже видані ліцензії мають
|
||||
// лишитись дійсними до кінця свого строку. Тому payload несе kid, а не
|
||||
// покладається на «ключ у нас один».
|
||||
type LicenseVerifier struct {
|
||||
keys map[string]ed25519.PublicKey
|
||||
}
|
||||
|
||||
// ParseLicenseVerifier читає ключі з рядка `kid=<hex|base64>[,kid2=…]`.
|
||||
//
|
||||
// Той самий синтаксис, що й у -dek: два різні формати для двох наборів
|
||||
// ключів в одному продукті — це два способи помилитись.
|
||||
//
|
||||
// Порожній рядок дає порожній набір, а не помилку. Це робочий стан:
|
||||
// збірка без вшитого ключа підпису — це збірка для розробки й для
|
||||
// клієнта, який ліцензію не купував. Вона працює; просто ключ у ній
|
||||
// перевірити нічим, і будь-який введений ключ отримає стан invalid із
|
||||
// причиною, названою вголос.
|
||||
func ParseLicenseVerifier(spec string) (*LicenseVerifier, error) {
|
||||
v := &LicenseVerifier{keys: map[string]ed25519.PublicKey{}}
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
kid, raw, ok := strings.Cut(part, "=")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("очікується kid=ключ, отримано %q", part)
|
||||
}
|
||||
key, err := decodeKeyBytes(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ключ %s: %w", kid, err)
|
||||
}
|
||||
if len(key) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("ключ %s: очікується %d байтів, отримано %d",
|
||||
kid, ed25519.PublicKeySize, len(key))
|
||||
}
|
||||
v.keys[strings.TrimSpace(kid)] = ed25519.PublicKey(key)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// NewLicenseVerifier — набір із готових ключів (для тестів і генератора).
|
||||
func NewLicenseVerifier(keys map[string]ed25519.PublicKey) *LicenseVerifier {
|
||||
cp := make(map[string]ed25519.PublicKey, len(keys))
|
||||
for k, v := range keys {
|
||||
cp[k] = v
|
||||
}
|
||||
return &LicenseVerifier{keys: cp}
|
||||
}
|
||||
|
||||
// Empty — чи є чим перевіряти.
|
||||
func (v *LicenseVerifier) Empty() bool { return v == nil || len(v.keys) == 0 }
|
||||
|
||||
func decodeKeyBytes(raw string) ([]byte, error) {
|
||||
if b, err := hex.DecodeString(raw); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.StdEncoding.DecodeString(raw); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
if b, err := base64.RawURLEncoding.DecodeString(raw); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return nil, errors.New("не hex і не base64")
|
||||
}
|
||||
|
||||
// ParseLicense розбирає ключ і перевіряє підпис.
|
||||
//
|
||||
// Порядок кроків не косметичний: спершу форма, потім підпис, і лише
|
||||
// потім будь-що про зміст. Розбирати payload до перевірки підпису
|
||||
// означало б довіряти числам, які написав той, кого вони обмежують.
|
||||
func (v *LicenseVerifier) ParseLicense(key string) (*LicensePayload, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
// Ключ їздить у листах і в чатах, звідки повертається з переносами
|
||||
// рядків і пробілами всередині. Прибрати їх тут дешевше, ніж
|
||||
// пояснювати людині, що саме в її ключі не так.
|
||||
key = strings.NewReplacer("\n", "", "\r", "", " ", "", "\t", "").Replace(key)
|
||||
|
||||
parts := strings.Split(key, ".")
|
||||
if len(parts) != 3 || parts[0] != licensePrefix {
|
||||
return nil, ErrLicenseFormat
|
||||
}
|
||||
body, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, ErrLicenseFormat
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return nil, ErrLicenseFormat
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return nil, ErrLicenseFormat
|
||||
}
|
||||
|
||||
// kid лежить у payload, тобто читати його доводиться ДО перевірки
|
||||
// підпису — інакше невідомо, чим перевіряти. Це не дірка: підроблений
|
||||
// kid не дає нічого, бо вказує лише на те, ЯКИМ відкритим ключем
|
||||
// пробувати, а ключі всі наші. Найгірше, що дає брехня тут, —
|
||||
// «невідомий ключ підпису».
|
||||
var head LicensePayload
|
||||
if err := json.Unmarshal(body, &head); err != nil {
|
||||
return nil, ErrLicenseFormat
|
||||
}
|
||||
if v.Empty() {
|
||||
return nil, ErrNoVerifier
|
||||
}
|
||||
pub, ok := v.keys[head.KeyID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: невідомий ключ підпису %q", ErrLicenseSignature, head.KeyID)
|
||||
}
|
||||
if !ed25519.Verify(pub, body, sig) {
|
||||
return nil, ErrLicenseSignature
|
||||
}
|
||||
// Версія перевіряється ПІСЛЯ підпису: чужому числу до перевірки віри
|
||||
// немає, а після — це вже наше власне число.
|
||||
if head.V != 1 {
|
||||
return nil, fmt.Errorf("%w: версія ключа %d не підтримується цією збіркою",
|
||||
ErrLicenseFormat, head.V)
|
||||
}
|
||||
return &head, nil
|
||||
}
|
||||
|
||||
// SignLicense збирає ключ. Живе поруч із перевіркою навмисно.
|
||||
//
|
||||
// Генератор ліцензій — окремий інструмент і в цьому бінарнику не
|
||||
// потрібен. Але формат, у якому підписують, і формат, у якому
|
||||
// перевіряють, — це одна річ, і рознесені по різних репозиторіях вони
|
||||
// розійдуться. Тут же на цій функції стоять тести: перевірити, що
|
||||
// підпис сходиться, інакше нічим.
|
||||
func SignLicense(priv ed25519.PrivateKey, p LicensePayload) (string, error) {
|
||||
if p.V == 0 {
|
||||
p.V = 1
|
||||
}
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sig := ed25519.Sign(priv, body)
|
||||
return licensePrefix + "." +
|
||||
base64.RawURLEncoding.EncodeToString(body) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Стан ліцензії
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Стани. Рядки збігаються з CHECK у bill.instance.
|
||||
const (
|
||||
LicenseUnlicensed = "unlicensed"
|
||||
LicenseActive = "active"
|
||||
LicenseGrace = "grace"
|
||||
LicenseExpired = "expired"
|
||||
LicenseInvalid = "invalid"
|
||||
)
|
||||
|
||||
// LicenseState — усе, що інтерфейс і такт знають про ліцензію.
|
||||
type LicenseState struct {
|
||||
State string `json:"state"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
|
||||
InstallID string `json:"install_id"`
|
||||
LicenseID string `json:"license_id,omitempty"`
|
||||
IssuedTo string `json:"issued_to,omitempty"`
|
||||
Plan string `json:"plan,omitempty"`
|
||||
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
GraceUntil *time.Time `json:"grace_until,omitempty"`
|
||||
// DaysLeft — скільки діб лишилось до кінця пільгового періоду.
|
||||
// Від'ємне після нього; для unlicensed не заповнюється.
|
||||
DaysLeft *int `json:"days_left,omitempty"`
|
||||
|
||||
// ClockWarped — системний час пішов назад проти побаченого раніше.
|
||||
//
|
||||
// Показується, але нічого не вимикає: годинник з'їжджає й сам.
|
||||
ClockWarped bool `json:"clock_warped,omitempty"`
|
||||
ClockMaxSeen *time.Time `json:"clock_max_seen,omitempty"`
|
||||
CheckedAt *time.Time `json:"checked_at,omitempty"`
|
||||
AppliedAt *time.Time `json:"applied_at,omitempty"`
|
||||
VerifierEmpty bool `json:"verifier_empty,omitempty"`
|
||||
}
|
||||
|
||||
// EvaluateLicense — стан ключа на заданий момент.
|
||||
//
|
||||
// Чиста функція: жодної бази, жодного time.Now(). Саме тому її можна
|
||||
// перевірити тестом на всіх межах, включно з тими, які в житті настають
|
||||
// раз на рік і на які ніхто не дивиться.
|
||||
//
|
||||
// now сюди подають НЕ як time.Now(), а як max(time.Now(), останній
|
||||
// побачений час) — див. RefreshLicense. Годинник належить тому, кого
|
||||
// ліцензія обмежує, і рахувати строк за ним просто так не можна.
|
||||
func EvaluateLicense(p *LicensePayload, now time.Time) LicenseState {
|
||||
if p == nil {
|
||||
return LicenseState{State: LicenseUnlicensed}
|
||||
}
|
||||
st := LicenseState{
|
||||
State: LicenseActive,
|
||||
LicenseID: p.LicenseID,
|
||||
IssuedTo: p.IssuedTo,
|
||||
Plan: p.Plan,
|
||||
}
|
||||
exp, grace := p.Expires(), p.GraceUntil()
|
||||
st.ExpiresAt, st.GraceUntil = &exp, &grace
|
||||
|
||||
// Округлення вгору навмисно: «лишилось 0 діб» людина читає як «уже
|
||||
// все», хоча насправді ще є частина доби. Показувати 1 доти, доки
|
||||
// строк справді не минув, чесніше за арифметично точний нуль.
|
||||
days := int(grace.Sub(now).Hours() / 24)
|
||||
if grace.After(now) {
|
||||
days++
|
||||
}
|
||||
st.DaysLeft = &days
|
||||
|
||||
switch {
|
||||
case now.Before(exp):
|
||||
st.State = LicenseActive
|
||||
case now.Before(grace):
|
||||
st.State = LicenseGrace
|
||||
st.Reason = "строк дії минув, триває пільговий період"
|
||||
default:
|
||||
st.State = LicenseExpired
|
||||
st.Reason = "строк дії та пільговий період минули"
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Стелі, які дає ліцензія
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// PlanLimits — набір стель. Вказівник = «без обмеження».
|
||||
type PlanLimits struct {
|
||||
MaxDevices *int `json:"max_devices"`
|
||||
MaxMaps *int `json:"max_maps"`
|
||||
MaxMapNodes *int `json:"max_map_nodes"`
|
||||
MaxAgents *int `json:"max_agents"`
|
||||
MaxUsers *int `json:"max_users"`
|
||||
RetentionDays int `json:"metric_retention_days"`
|
||||
}
|
||||
|
||||
// Usage — скільки слотів зайнято. Дзеркало bill.usage_now.
|
||||
type Usage struct {
|
||||
Devices int `json:"devices"`
|
||||
Maps int `json:"maps"`
|
||||
Agents int `json:"agents"`
|
||||
Users int `json:"users"`
|
||||
}
|
||||
|
||||
// ЩО РОБИТЬ ПРОСТРОЧЕНА ЛІЦЕНЗІЯ І ЧОМУ САМЕ ЦЕ
|
||||
//
|
||||
// Не вимикає нічого. Ні збору, ні алертів, ні сповіщень, ні доступу до
|
||||
// зібраного. Замерзає рівно одне — РІСТ: не з'являється новий хост,
|
||||
// зонд, користувач, мапа.
|
||||
//
|
||||
// Це не поблажливість, це єдина відповідь, яка витримує перевірку
|
||||
// наслідками. Моніторинг, що перестав моніторити через несплачений
|
||||
// рахунок, — аварія в мережі клієнта, спричинена нами. Він не побачить
|
||||
// падіння магістралі й дізнається про нього від абонентів; ми при цьому
|
||||
// грошей не отримаємо, а отримаємо звернення й репутацію продукту, який
|
||||
// тихо перестав працювати саме тоді, коли був потрібен. Продавати
|
||||
// прилад, який сам себе вимикає, можна рівно один раз.
|
||||
//
|
||||
// Друга частина того самого рішення, і без неї перша нічого не варта:
|
||||
// стеля ніколи не опускається нижче ФАКТИЧНО зайнятого. Інсталяція з
|
||||
// п'ятьмастами хостами на протермінованому ключі лишається
|
||||
// п'ятисотхостовою, а не падає до п'ятнадцяти. Інакше «нічого не
|
||||
// вимикаємо» було б брехнею: стеля нижча за використане означає, що
|
||||
// перший же перезапис entitlements зробив би 485 хостів такими, які не
|
||||
// можна повернути з архіву, а тригер на UPDATE — тими, які не можна
|
||||
// ввімкнути назад.
|
||||
//
|
||||
// Так само поводиться стан unlicensed: інсталяція, яку щойно розгорнули
|
||||
// й ще не купили, має показувати продукт, а не стіну.
|
||||
|
||||
// EffectiveLimits — стелі, які насправді лягають у bill.entitlements.
|
||||
func EffectiveLimits(plan PlanLimits, state string, used Usage) PlanLimits {
|
||||
if state != LicenseExpired {
|
||||
return plan
|
||||
}
|
||||
out := plan
|
||||
out.MaxDevices = notBelow(plan.MaxDevices, used.Devices)
|
||||
out.MaxMaps = notBelow(plan.MaxMaps, used.Maps)
|
||||
out.MaxAgents = notBelow(plan.MaxAgents, used.Agents)
|
||||
out.MaxUsers = notBelow(plan.MaxUsers, used.Users)
|
||||
// MaxMapNodes свідомо лишається як є: він рахується НА МАПУ, а не
|
||||
// на кабінет, і «скільки вузлів на найбільшій мапі» — не те число,
|
||||
// яке тут відоме. Наслідок м'який і у правильний бік: наявні вузли
|
||||
// не зникають (тригер стоїть на вставці), а домалювати новий на вже
|
||||
// переповненій мапі не вийде — що після прострочення й правильно.
|
||||
return out
|
||||
}
|
||||
|
||||
// notBelow не дає стелі опуститись нижче вже зайнятого.
|
||||
//
|
||||
// nil (без обмеження) лишається nil: «без стелі» вже не нижче за будь-що.
|
||||
func notBelow(limit *int, used int) *int {
|
||||
if limit == nil {
|
||||
return nil
|
||||
}
|
||||
if *limit >= used {
|
||||
return limit
|
||||
}
|
||||
v := used
|
||||
return &v
|
||||
}
|
||||
|
||||
// LimitsFromPayload — стелі, записані в самому ключі.
|
||||
//
|
||||
// Ключ перекриває тариф, а не навпаки, і це не дрібниця: домовленість
|
||||
// («вам 250 хостів, хоча в тарифі 100») живе саме тут, у підписаному
|
||||
// рядку, а не в чиємусь листі. Поле, якого в ключі немає, лишається
|
||||
// таким, яким його задає тариф.
|
||||
func LimitsFromPayload(base PlanLimits, p *LicensePayload) PlanLimits {
|
||||
if p == nil {
|
||||
return base
|
||||
}
|
||||
out := base
|
||||
if p.MaxDevices != nil {
|
||||
out.MaxDevices = p.MaxDevices
|
||||
}
|
||||
if p.MaxMaps != nil {
|
||||
out.MaxMaps = p.MaxMaps
|
||||
}
|
||||
if p.MaxMapNodes != nil {
|
||||
out.MaxMapNodes = p.MaxMapNodes
|
||||
}
|
||||
if p.MaxAgents != nil {
|
||||
out.MaxAgents = p.MaxAgents
|
||||
}
|
||||
if p.MaxUsers != nil {
|
||||
out.MaxUsers = p.MaxUsers
|
||||
}
|
||||
if p.RetentionDays != nil {
|
||||
out.RetentionDays = *p.RetentionDays
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FeaturesFromPayload — набір можливостей.
|
||||
//
|
||||
// Порожній перелік у ключі означає «як у тарифі», а не «жодної».
|
||||
// Різниця та сама, що між nil і нулем у стелях, і ціна помилки та сама:
|
||||
// ключ без переліку фіч закрив би клієнтові все, за що він заплатив.
|
||||
func FeaturesFromPayload(base []string, p *LicensePayload) []string {
|
||||
if p == nil || len(p.Features) == 0 {
|
||||
return base
|
||||
}
|
||||
out := append([]string(nil), p.Features...)
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Монотонний годинник
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// clockSkewTolerance — наскільки час має піти назад, щоб це вважалось
|
||||
// зсувом, а не звичайним життям.
|
||||
//
|
||||
// Година, а не хвилина. Дрібні стрибки назад — норма: поправка NTP
|
||||
// після довгого простою, віртуалка, яку зняли з паузи, перехід на
|
||||
// зимовий час у системі, де хтось тримає локальний час замість UTC.
|
||||
// Позначати їх зсувом означало б показувати попередження щотижня, а
|
||||
// попередження, яке блимає щотижня, перестають читати.
|
||||
const clockSkewTolerance = time.Hour
|
||||
|
||||
// effectiveNow — час, за яким рахують строк ліцензії.
|
||||
//
|
||||
// Максимум із поточного й найпізнішого побаченого. Годинник назад не
|
||||
// йде; якщо now менший за seen, його рухали — і рахувати строк за ним
|
||||
// означало б віддати керування строком тому, кого строк обмежує.
|
||||
//
|
||||
// Це не криптографія й не заважає рішучому обходу: досить стерти рядок
|
||||
// у базі. Воно ловить дешеву спробу, коштує одного UPDATE на годину й
|
||||
// нічого не ламає, коли годинник з'їхав чесно.
|
||||
func effectiveNow(now, seen time.Time) (time.Time, bool) {
|
||||
if seen.IsZero() || !now.Before(seen) {
|
||||
return now, false
|
||||
}
|
||||
return seen, now.Before(seen.Add(-clockSkewTolerance))
|
||||
}
|
||||
464
server/internal/store/billing_license_test.go
Normal file
464
server/internal/store/billing_license_test.go
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Перевірки ліцензійного механізму без бази.
|
||||
//
|
||||
// ЩО САМЕ ТУТ ПЕРЕВІРЯЄТЬСЯ І ЧОМУ САМЕ ЦЕ
|
||||
//
|
||||
// Урок цього проєкту, дослівно: зелена перевірка доводить рівно те, що
|
||||
// вона перевіряє. Тест ізоляції RLS був зелений і пропустив зламаний
|
||||
// вхід, бо перевіряв «чи не видно чужого», коли зламалось «чи видно
|
||||
// своє».
|
||||
//
|
||||
// У ліцензії симетрична пастка, і вона тут головна. Легко написати
|
||||
// набір «ключ підроблено — відмовили», «строк минув — стан expired», і
|
||||
// весь він буде зелений на реалізації, яка після прострочення вимикає
|
||||
// збір даних. Тобто перевірка доводила б, що механізм СПРАЦЬОВУЄ, і
|
||||
// мовчала б про те, що він спрацьовує руйнівно.
|
||||
//
|
||||
// Тому половина перевірок нижче — про те, що НЕ має статись:
|
||||
// протермінована ліцензія не опускає стель нижче зайнятого, порожні
|
||||
// поля в ключі не закривають того, що дає тариф, зсув годинника нічого
|
||||
// не вимикає, а невідоме поле в payload не робить чужий ключ недійсним.
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Допоміжне
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func testKeypair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("генерація ключа: %v", err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
func intp(v int) *int { return &v }
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Підпис
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestLicenseRoundTrip(t *testing.T) {
|
||||
pub, priv := testKeypair(t)
|
||||
v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub})
|
||||
|
||||
exp := time.Now().AddDate(1, 0, 0).Unix()
|
||||
key, err := SignLicense(priv, LicensePayload{
|
||||
LicenseID: "11111111-1111-1111-1111-111111111111",
|
||||
KeyID: "k1",
|
||||
Plan: "enterprise",
|
||||
IssuedTo: "ТОВ «Мережа»",
|
||||
ExpiresAt: exp,
|
||||
GraceDays: 30,
|
||||
// Кирилиця в issued_to тут не для краси: назва клієнта
|
||||
// українською — звичайний випадок, і base64url над UTF-8 має
|
||||
// пережити подорож ключа туди й назад без втрат.
|
||||
MaxDevices: intp(250),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("підпис: %v", err)
|
||||
}
|
||||
|
||||
got, err := v.ParseLicense(key)
|
||||
if err != nil {
|
||||
t.Fatalf("перевірка щойно підписаного ключа: %v", err)
|
||||
}
|
||||
if got.IssuedTo != "ТОВ «Мережа»" {
|
||||
t.Errorf("issued_to не пережив кодування: %q", got.IssuedTo)
|
||||
}
|
||||
if got.MaxDevices == nil || *got.MaxDevices != 250 {
|
||||
t.Errorf("max_devices не пережив кодування: %v", got.MaxDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// Ключ їздить у листах і повертається з переносами рядків.
|
||||
//
|
||||
// Це не педантизм: половина звернень «ключ не приймається» — саме про
|
||||
// це. Перевірка стоїть тут, бо мовчазна відмова на пробілі виглядає
|
||||
// точно так само, як відмова на підробці, і людина шукатиме не там.
|
||||
func TestLicenseSurvivesCopyPasteDamage(t *testing.T) {
|
||||
pub, priv := testKeypair(t)
|
||||
v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub})
|
||||
key, _ := SignLicense(priv, LicensePayload{
|
||||
KeyID: "k1", Plan: "pro", ExpiresAt: time.Now().AddDate(1, 0, 0).Unix(),
|
||||
})
|
||||
|
||||
damaged := " " + key[:20] + "\n" + key[20:40] + " \r\n" + key[40:] + "\t"
|
||||
if _, err := v.ParseLicense(damaged); err != nil {
|
||||
t.Fatalf("ключ із листа не прийнявся: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseRejectsTampering(t *testing.T) {
|
||||
pub, priv := testKeypair(t)
|
||||
v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub})
|
||||
|
||||
key, _ := SignLicense(priv, LicensePayload{
|
||||
KeyID: "k1", Plan: "free", MaxDevices: intp(15),
|
||||
ExpiresAt: time.Now().AddDate(1, 0, 0).Unix(),
|
||||
})
|
||||
parts := strings.Split(key, ".")
|
||||
|
||||
// Підмінений payload зі своїм, більш щедрим, вмістом і старим
|
||||
// підписом — це і є та атака, проти якої весь механізм.
|
||||
forged, _ := SignLicense(priv, LicensePayload{
|
||||
KeyID: "k1", Plan: "enterprise", MaxDevices: intp(100000),
|
||||
ExpiresAt: time.Now().AddDate(10, 0, 0).Unix(),
|
||||
})
|
||||
forgedParts := strings.Split(forged, ".")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{"чужий payload зі старим підписом", parts[0] + "." + forgedParts[1] + "." + parts[2]},
|
||||
{"порожній підпис", parts[0] + "." + parts[1] + "."},
|
||||
{"не той префікс", "NP2." + parts[1] + "." + parts[2]},
|
||||
{"дві частини замість трьох", parts[0] + "." + parts[1]},
|
||||
{"сміття", "просто рядок"},
|
||||
{"порожньо", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := v.ParseLicense(c.key); err == nil {
|
||||
t.Fatal("ключ прийнято, хоча він недійсний")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseRejectsForeignSigner(t *testing.T) {
|
||||
ourPub, _ := testKeypair(t)
|
||||
_, theirPriv := testKeypair(t)
|
||||
|
||||
v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": ourPub})
|
||||
// Ключ, підписаний чужим приватним ключем, але з НАШИМ kid: саме так
|
||||
// виглядала б спроба видати собі ліцензію самотужки.
|
||||
key, _ := SignLicense(theirPriv, LicensePayload{
|
||||
KeyID: "k1", Plan: "enterprise",
|
||||
ExpiresAt: time.Now().AddDate(1, 0, 0).Unix(),
|
||||
})
|
||||
if _, err := v.ParseLicense(key); err == nil {
|
||||
t.Fatal("прийнято ключ, підписаний чужим ключем")
|
||||
}
|
||||
}
|
||||
|
||||
// Ключ, виданий новішим генератором, має лишатись дійсним.
|
||||
//
|
||||
// Перевірка про те, чого не має статись: невідоме поле в payload не
|
||||
// робить ключ недійсним. Інакше перше ж розширення формату
|
||||
// перетворило б усі видані ключі на «підпис не сходиться» — у клієнтів,
|
||||
// які нічого не робили.
|
||||
func TestLicenseKeepsUnknownFields(t *testing.T) {
|
||||
pub, priv := testKeypair(t)
|
||||
v := NewLicenseVerifier(map[string]ed25519.PublicKey{"k1": pub})
|
||||
|
||||
body := []byte(`{"v":1,"kid":"k1","plan":"pro","exp":` +
|
||||
strconv.FormatInt(time.Now().AddDate(1, 0, 0).Unix(), 10) +
|
||||
`,"майбутнє_поле":{"a":[1,2,3]}}`)
|
||||
enc := base64.RawURLEncoding
|
||||
key := licensePrefix + "." +
|
||||
enc.EncodeToString(body) + "." + enc.EncodeToString(ed25519.Sign(priv, body))
|
||||
|
||||
got, err := v.ParseLicense(key)
|
||||
if err != nil {
|
||||
t.Fatalf("ключ із невідомим полем відхилено: %v", err)
|
||||
}
|
||||
if got.Plan != "pro" {
|
||||
t.Errorf("plan = %q, очікувалось pro", got.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
// Збірка без відкритого ключа не вдає, що перевірила.
|
||||
//
|
||||
// Найгірший із можливих варіантів тут — мовчазне «ключ прийнято»: тоді
|
||||
// будь-який рядок відкривав би будь-які стелі, і виявилось би це ніколи.
|
||||
func TestLicenseWithoutVerifierRefuses(t *testing.T) {
|
||||
v, err := ParseLicenseVerifier("")
|
||||
if err != nil {
|
||||
t.Fatalf("порожній набір ключів має бути робочим станом: %v", err)
|
||||
}
|
||||
if !v.Empty() {
|
||||
t.Fatal("порожній рядок дав непорожній набір")
|
||||
}
|
||||
_, priv := testKeypair(t)
|
||||
key, _ := SignLicense(priv, LicensePayload{KeyID: "k1", Plan: "enterprise"})
|
||||
if _, err := v.ParseLicense(key); err != ErrNoVerifier {
|
||||
t.Fatalf("очікувалось ErrNoVerifier, отримано %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Стан за часом
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestEvaluateLicenseStates(t *testing.T) {
|
||||
exp := time.Date(2026, 3, 1, 12, 0, 0, 0, time.UTC)
|
||||
p := &LicensePayload{ExpiresAt: exp.Unix(), GraceDays: 30}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
want string
|
||||
}{
|
||||
{"задовго до строку", exp.AddDate(0, -6, 0), LicenseActive},
|
||||
{"за секунду до строку", exp.Add(-time.Second), LicenseActive},
|
||||
{"за секунду після строку", exp.Add(time.Second), LicenseGrace},
|
||||
{"в середині пільгового", exp.AddDate(0, 0, 15), LicenseGrace},
|
||||
{"за секунду до кінця пільгового", exp.AddDate(0, 0, 30).Add(-time.Second), LicenseGrace},
|
||||
{"одразу після пільгового", exp.AddDate(0, 0, 30).Add(time.Second), LicenseExpired},
|
||||
{"через рік після", exp.AddDate(1, 0, 0), LicenseExpired},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := EvaluateLicense(p, c.now); got.State != c.want {
|
||||
t.Fatalf("стан = %q, очікувалось %q", got.State, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Ключ без пільгового періоду переходить у expired одразу. Окремо,
|
||||
// бо нуль тут — це справді нуль, а не «не задано»: інакше
|
||||
// GraceDays=0 мовчки давав би місяць понад строк.
|
||||
zero := &LicensePayload{ExpiresAt: exp.Unix()}
|
||||
if got := EvaluateLicense(zero, exp.Add(time.Second)); got.State != LicenseExpired {
|
||||
t.Fatalf("без пільгового: стан = %q, очікувалось %q", got.State, LicenseExpired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateLicenseNil(t *testing.T) {
|
||||
if got := EvaluateLicense(nil, time.Now()); got.State != LicenseUnlicensed {
|
||||
t.Fatalf("без ключа стан = %q, очікувалось %q", got.State, LicenseUnlicensed)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Монотонний годинник
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestClockRollbackDoesNotExtendLicense(t *testing.T) {
|
||||
seen := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Годинник перевели на рік назад — строк має рахуватись за
|
||||
// побаченим, а не за тим, що показує машина.
|
||||
back := seen.AddDate(-1, 0, 0)
|
||||
eff, warped := effectiveNow(back, seen)
|
||||
if !eff.Equal(seen) {
|
||||
t.Fatalf("час рахується за переведеним годинником: %v", eff)
|
||||
}
|
||||
if !warped {
|
||||
t.Fatal("зсув на рік назад не позначено")
|
||||
}
|
||||
|
||||
// Дрібний стрибок назад — норма: поправка NTP, віртуалка з паузи.
|
||||
// Позначати його зсувом означало б блимати попередженням щотижня.
|
||||
small := seen.Add(-10 * time.Minute)
|
||||
eff, warped = effectiveNow(small, seen)
|
||||
if !eff.Equal(seen) {
|
||||
t.Fatalf("дрібний стрибок має все одно рахуватись за побаченим: %v", eff)
|
||||
}
|
||||
if warped {
|
||||
t.Fatal("десять хвилин назад позначено як зсув — попередження блиматиме на рівному місці")
|
||||
}
|
||||
|
||||
// Час іде вперед — звичайне життя, нічого не позначаємо.
|
||||
fwd := seen.Add(time.Hour)
|
||||
eff, warped = effectiveNow(fwd, seen)
|
||||
if !eff.Equal(fwd) || warped {
|
||||
t.Fatalf("нормальний хід часу: eff=%v warped=%v", eff, warped)
|
||||
}
|
||||
}
|
||||
|
||||
// Зсув годинника нічого не вимикає — він лише позначається.
|
||||
//
|
||||
// Перевірка про те, чого не має статись. Спокуса «побачили обман —
|
||||
// заблокували» тут особливо сильна, і вона хибна: годинник з'їжджає й
|
||||
// сам (сів CMOS, зник NTP після переїзду в ізольований сегмент), а
|
||||
// покарати за несправність означає вимкнути моніторинг тому, хто нічого
|
||||
// не порушував.
|
||||
func TestClockWarpDoesNotChangeLimits(t *testing.T) {
|
||||
plan := PlanLimits{MaxDevices: intp(100), RetentionDays: 90}
|
||||
used := Usage{Devices: 40}
|
||||
|
||||
// Стан лишається тим, який дала EvaluateLicense; годинник впливає
|
||||
// лише на те, ЯКИЙ момент їй передали, а не на самі стелі.
|
||||
got := EffectiveLimits(plan, LicenseActive, used)
|
||||
if got.MaxDevices == nil || *got.MaxDevices != 100 {
|
||||
t.Fatalf("стелю змінено без причини: %v", got.MaxDevices)
|
||||
}
|
||||
if got.RetentionDays != 90 {
|
||||
t.Fatalf("строк зберігання змінено без причини: %d", got.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Стелі після прострочення — головна перевірка файлу
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Протермінована ліцензія не опускає стелю нижче зайнятого.
|
||||
//
|
||||
// Це і є те «не спрацювало як слід», яке найдорожче: якби стеля впала
|
||||
// до тарифної, 485 із 500 хостів стали б такими, яких не можна повернути
|
||||
// з архіву й не можна ввімкнути назад — тобто продукт відібрав би в
|
||||
// клієнта те, що вже працює, за несплачений рахунок.
|
||||
func TestExpiredLicenseFreezesCeilingAtUsage(t *testing.T) {
|
||||
plan := PlanLimits{
|
||||
MaxDevices: intp(15), MaxMaps: intp(1),
|
||||
MaxAgents: intp(1), MaxUsers: intp(3),
|
||||
RetentionDays: 7,
|
||||
}
|
||||
used := Usage{Devices: 500, Maps: 12, Agents: 4, Users: 9}
|
||||
|
||||
got := EffectiveLimits(plan, LicenseExpired, used)
|
||||
|
||||
check := func(name string, got *int, want int) {
|
||||
t.Helper()
|
||||
if got == nil {
|
||||
t.Fatalf("%s: стеля зникла зовсім", name)
|
||||
}
|
||||
if *got != want {
|
||||
t.Fatalf("%s: стеля = %d, очікувалось %d", name, *got, want)
|
||||
}
|
||||
}
|
||||
check("хости", got.MaxDevices, 500)
|
||||
check("мапи", got.MaxMaps, 12)
|
||||
check("зонди", got.MaxAgents, 4)
|
||||
check("користувачі", got.MaxUsers, 9)
|
||||
|
||||
// Строк зберігання прострочення НЕ чіпає. Скоротити його означало б
|
||||
// знищити вже зібрану історію — тобто вчинити з даними клієнта те,
|
||||
// чого не робить навіть свідоме зменшення тарифу.
|
||||
if got.RetentionDays != 7 {
|
||||
t.Fatalf("строк зберігання = %d, ліцензія не має його чіпати", got.RetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
// А от РОСТИ протермінована ліцензія не дає: стеля дорівнює зайнятому,
|
||||
// тобто наступний хост уже не влізе. Без цієї перевірки попередня
|
||||
// доводила б лише те, що ми нічого не обмежуємо взагалі.
|
||||
func TestExpiredLicenseStopsGrowth(t *testing.T) {
|
||||
plan := PlanLimits{MaxDevices: intp(15)}
|
||||
got := EffectiveLimits(plan, LicenseExpired, Usage{Devices: 500})
|
||||
if *got.MaxDevices != 500 {
|
||||
t.Fatalf("стеля = %d, очікувалось рівно зайняте (500)", *got.MaxDevices)
|
||||
}
|
||||
// Стеля 500 при 500 зайнятих — це саме «нічого нового»: тригер у БД
|
||||
// відмовляє на cnt >= lim.
|
||||
if *got.MaxDevices > 500 {
|
||||
t.Fatal("стеля вища за зайняте — прострочена ліцензія дозволяє рости")
|
||||
}
|
||||
}
|
||||
|
||||
// Ліцензія без обмеження лишається без обмеження й після прострочення.
|
||||
func TestExpiredUnlimitedStaysUnlimited(t *testing.T) {
|
||||
got := EffectiveLimits(PlanLimits{}, LicenseExpired, Usage{Devices: 500})
|
||||
if got.MaxDevices != nil {
|
||||
t.Fatalf("необмежена стеля стала %d", *got.MaxDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// До прострочення стеля дорівнює купленій, а не зайнятому.
|
||||
//
|
||||
// Симетрична перевірка до попередніх: якби «не нижче зайнятого» діяло
|
||||
// завжди, клієнт, який якось завів 500 хостів на тарифі в 15, назавжди
|
||||
// лишався б із 500 — тобто стеля не діяла б узагалі.
|
||||
func TestActiveLicenseKeepsPlanCeiling(t *testing.T) {
|
||||
plan := PlanLimits{MaxDevices: intp(15)}
|
||||
for _, state := range []string{LicenseActive, LicenseGrace, LicenseUnlicensed} {
|
||||
got := EffectiveLimits(plan, state, Usage{Devices: 500})
|
||||
if got.MaxDevices == nil || *got.MaxDevices != 15 {
|
||||
t.Fatalf("стан %s: стеля = %v, очікувалось 15", state, got.MaxDevices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Стелі з самого ключа
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Порожні поля ключа не забирають того, що дає тариф.
|
||||
//
|
||||
// Ще одна перевірка «чого не має статись», і ціна помилки тут — робочий
|
||||
// день клієнта: ключ, у якому не заповнили max_devices, мусить лишити
|
||||
// тарифну стелю, а не заблокувати перший же хост нулем.
|
||||
func TestLicenseEmptyFieldsFallBackToPlan(t *testing.T) {
|
||||
base := PlanLimits{
|
||||
MaxDevices: intp(100), MaxMaps: intp(10), MaxMapNodes: intp(50),
|
||||
MaxAgents: intp(5), MaxUsers: intp(25), RetentionDays: 90,
|
||||
}
|
||||
got := LimitsFromPayload(base, &LicensePayload{Plan: "pro"})
|
||||
|
||||
if *got.MaxDevices != 100 || *got.MaxMaps != 10 || *got.MaxMapNodes != 50 ||
|
||||
*got.MaxAgents != 5 || *got.MaxUsers != 25 || got.RetentionDays != 90 {
|
||||
t.Fatalf("ключ без стель перекрив тариф: %+v", got)
|
||||
}
|
||||
|
||||
// А заповнене — перекриває: саме тут живе домовленість «вам 250,
|
||||
// хоча в тарифі 100».
|
||||
got = LimitsFromPayload(base, &LicensePayload{MaxDevices: intp(250)})
|
||||
if *got.MaxDevices != 250 {
|
||||
t.Fatalf("стеля з ключа не застосувалась: %v", got.MaxDevices)
|
||||
}
|
||||
if *got.MaxUsers != 25 {
|
||||
t.Fatalf("незадане поле ключа зачепило сусіднє: %v", got.MaxUsers)
|
||||
}
|
||||
|
||||
// Нуль у ключі — це справді нуль, а не «не задано». Різницю тримає
|
||||
// вказівник, і саме її ця перевірка й стереже.
|
||||
got = LimitsFromPayload(base, &LicensePayload{MaxAgents: intp(0)})
|
||||
if got.MaxAgents == nil || *got.MaxAgents != 0 {
|
||||
t.Fatalf("нуль у ключі прочитано як «не задано»: %v", got.MaxAgents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLicenseFeaturesFallBackToPlan(t *testing.T) {
|
||||
base := []string{"icmp", "snmp", "ncm_git"}
|
||||
|
||||
if got := FeaturesFromPayload(base, &LicensePayload{}); len(got) != 3 {
|
||||
t.Fatalf("порожній перелік у ключі закрив фічі тарифу: %v", got)
|
||||
}
|
||||
got := FeaturesFromPayload(base, &LicensePayload{Features: []string{"snmp", "icmp"}})
|
||||
if len(got) != 2 || got[0] != "icmp" || got[1] != "snmp" {
|
||||
t.Fatalf("перелік із ключа не застосувався або не впорядкований: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Набір ключів підпису
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestParseLicenseVerifier(t *testing.T) {
|
||||
pub, _ := testKeypair(t)
|
||||
spec := "k1=" + hex.EncodeToString(pub)
|
||||
|
||||
v, err := ParseLicenseVerifier(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("розбір набору: %v", err)
|
||||
}
|
||||
if v.Empty() {
|
||||
t.Fatal("набір порожній після розбору непорожнього рядка")
|
||||
}
|
||||
|
||||
bad := []string{
|
||||
"безЗнакаРівності",
|
||||
"k1=не-hex-і-не-base64",
|
||||
"k1=" + hex.EncodeToString([]byte{1, 2, 3}), // коротший за ed25519.PublicKeySize
|
||||
}
|
||||
for _, s := range bad {
|
||||
if _, err := ParseLicenseVerifier(s); err == nil {
|
||||
t.Errorf("прийнято некоректний набір %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
325
server/internal/store/billing_limits_db_test.go
Normal file
325
server/internal/store/billing_limits_db_test.go
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Стеля тарифу проти СПРАВЖНЬОЇ бази.
|
||||
//
|
||||
// ЧОМУ ТУТ НЕ ОБІЙТИСЬ ЧИТАННЯМ SQL ОЧИМА
|
||||
//
|
||||
// Бо перевірка стелі — це тригер, і вся її суть у тому, на яких саме
|
||||
// операціях він стоїть. 0009 повісила його лише на INSERT, і читанням
|
||||
// коду це виглядало вичерпним: хост з'являється вставкою, стеля рахує
|
||||
// хости, все сходиться. Не сходилось: хост з'являється ще й UPDATE-ом —
|
||||
// поверненням з архіву й простим увімкненням. Стелю в 15 обходили за
|
||||
// чотири дії, і жодна перевірка про це не знала.
|
||||
//
|
||||
// ГОЛОВНЕ ПРО ЦЕЙ ФАЙЛ
|
||||
//
|
||||
// Половина перевірок нижче — про те, що стеля НЕ спрацювала там, де не
|
||||
// має. Це не симетрія заради симетрії: тест, який доводить лише
|
||||
// «шістнадцятий не створився», лишається зеленим і на реалізації, яка
|
||||
// не дає створити ЖОДНОГО хоста, перейменувати наявний або повернути
|
||||
// вимкнений. Помилка в цей бік не проявляється помилкою — вона
|
||||
// проявляється тим, що продукт перестає працювати, і скаржиться на неї
|
||||
// клієнт, а не перевірка.
|
||||
//
|
||||
// Пропускається мовчки без NETPULSE_TEST_DSN. Запускати на ОДНОРАЗОВІЙ
|
||||
// базі з накоченими міграціями:
|
||||
//
|
||||
// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \
|
||||
// go test ./internal/store/ -run PlanLimit
|
||||
|
||||
func TestPlanLimitDevices(t *testing.T) {
|
||||
s, tenantID := billingTestTenant(t)
|
||||
ctx := context.Background()
|
||||
|
||||
setDeviceCeiling(t, s, tenantID, 2)
|
||||
|
||||
mk := func(name string, enabled bool) (string, error) {
|
||||
return s.CreateDevice(ctx, tenantID, DeviceInput{
|
||||
Name: name, Address: nextAddr(), Kind: "switch", Enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
// --- стеля не заважає працювати до неї -----------------------------
|
||||
first, err := mk("host-1", true)
|
||||
if err != nil {
|
||||
t.Fatalf("перший хост при стелі 2 не створився: %v", err)
|
||||
}
|
||||
if _, err := mk("host-2", true); err != nil {
|
||||
t.Fatalf("другий хост при стелі 2 не створився: %v", err)
|
||||
}
|
||||
|
||||
// --- стеля спрацьовує ---------------------------------------------
|
||||
_, err = mk("host-3", true)
|
||||
lim, ok := AsPlanLimit(err)
|
||||
if !ok {
|
||||
t.Fatalf("третій хост при стелі 2 створився або відмова не впізнана: %v", err)
|
||||
}
|
||||
if lim.Kind != "devices" || lim.Allowed != 2 || lim.Used != 2 {
|
||||
t.Fatalf("відмова без правильних чисел: %+v (%s)", lim, lim.Error())
|
||||
}
|
||||
// Найважливіше в самій відмові — щоб її можна було показати людині.
|
||||
// Порожній або англійський текст тут означає, що на екран поїде або
|
||||
// нічого, або мова розробника.
|
||||
if lim.Error() == "" || !containsCyrillic(lim.Error()) {
|
||||
t.Fatalf("текст відмови не для людини: %q", lim.Error())
|
||||
}
|
||||
|
||||
// --- ДІРА 0009 №1: повернення з архіву -----------------------------
|
||||
//
|
||||
// Заархівований хост звільняє слот — це правильно й це перевіряється
|
||||
// нижче. Але повернути його, коли слот уже зайняв інший, не можна:
|
||||
// інакше стеля обходиться парою «заархівував — завів новий —
|
||||
// повернув старий».
|
||||
if err := s.DeleteDevice(ctx, tenantID, first); err != nil {
|
||||
t.Fatalf("архівування: %v", err)
|
||||
}
|
||||
if _, err := mk("host-4", true); err != nil {
|
||||
t.Fatalf("слот не звільнився після архівування: %v", err)
|
||||
}
|
||||
_, rerr := s.RestoreDevices(ctx, tenantID, Scope{Unrestricted: true}, []string{first})
|
||||
if _, ok := AsPlanLimit(rerr); !ok {
|
||||
t.Fatalf("повернення з архіву обійшло стелю (діра 0009): %v", rerr)
|
||||
}
|
||||
|
||||
// --- ДІРА 0009 №2: увімкнення вимкненого ---------------------------
|
||||
//
|
||||
// Вимкнений хост слота не займає — його МОЖНА завести навіть під
|
||||
// стелею (так заводять хост «про запас» перед переїздом). Але
|
||||
// ввімкнути його, коли слотів немає, не можна.
|
||||
off, err := mk("host-off", false)
|
||||
if err != nil {
|
||||
t.Fatalf("вимкнений хост під стелею не створився — "+
|
||||
"стеля спрацювала там, де не має: %v", err)
|
||||
}
|
||||
uerr := s.UpdateDevice(ctx, tenantID, off, DeviceInput{
|
||||
Name: "host-off", Address: nextAddr(), Kind: "switch", Enabled: true,
|
||||
})
|
||||
if _, ok := AsPlanLimit(uerr); !ok {
|
||||
t.Fatalf("увімкнення вимкненого обійшло стелю (діра 0009): %v", uerr)
|
||||
}
|
||||
}
|
||||
|
||||
// Стеля не має заважати правити те, що вже є.
|
||||
//
|
||||
// Перевірка «чого не має статись», і без неї попередня доводила б
|
||||
// лише, що ми навчились відмовляти. Тригер на UPDATE — саме та зміна, у
|
||||
// якій легко заборонити перейменування хоста на переповненому кабінеті,
|
||||
// і виявилось би це не тут, а в клієнта, який не може виправити
|
||||
// друкарську помилку в назві.
|
||||
func TestPlanLimitDoesNotBlockEditing(t *testing.T) {
|
||||
s, tenantID := billingTestTenant(t)
|
||||
ctx := context.Background()
|
||||
|
||||
setDeviceCeiling(t, s, tenantID, 1)
|
||||
|
||||
id, err := s.CreateDevice(ctx, tenantID, DeviceInput{
|
||||
Name: "єдиний", Address: nextAddr(), Kind: "switch", Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("хост при стелі 1: %v", err)
|
||||
}
|
||||
|
||||
// Кабінет рівно на стелі. Кожна з правок нижче лишає кількість
|
||||
// увімкнених хостів такою ж — отже, жодна не має впертись.
|
||||
steps := []struct {
|
||||
name string
|
||||
in DeviceInput
|
||||
}{
|
||||
{"перейменування", DeviceInput{Name: "єдиний-2", Address: "10.77.0.1", Kind: "switch", Enabled: true}},
|
||||
{"зміна адреси", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "switch", Enabled: true}},
|
||||
{"зміна типу", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "router", Enabled: true}},
|
||||
{"вимкнення", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "router", Enabled: false}},
|
||||
{"повторне ввімкнення", DeviceInput{Name: "єдиний-2", Address: "10.77.0.2", Kind: "router", Enabled: true}},
|
||||
}
|
||||
for _, st := range steps {
|
||||
if err := s.UpdateDevice(ctx, tenantID, id, st.in); err != nil {
|
||||
t.Fatalf("%s під стелею впало: %v", st.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Стеля, якої немає, не має вигадуватись.
|
||||
//
|
||||
// Це стан КОЖНОЇ інсталяції, яка накотила 0069: план self_hosted, усі
|
||||
// стелі порожні. Якби тут щось спрацьовувало, оновлення відібрало б у
|
||||
// клієнта можливість заводити хости — і саме проти цього написана
|
||||
// перша половина 0069.
|
||||
func TestNoCeilingNoRefusal(t *testing.T) {
|
||||
s, tenantID := billingTestTenant(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
UPDATE bill.entitlements SET max_devices = NULL, max_maps = NULL,
|
||||
max_agents = NULL, max_users = NULL WHERE tenant_id = $1`, tenantID); err != nil {
|
||||
t.Fatalf("зняття стель: %v", err)
|
||||
}
|
||||
|
||||
for i := 0; i < 25; i++ {
|
||||
if _, err := s.CreateDevice(ctx, tenantID, DeviceInput{
|
||||
Name: fmt.Sprintf("вільний-%d", i), Address: nextAddr(),
|
||||
Kind: "switch", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("хост %d при знятих стелях: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Кабінет без рядка в bill.entitlements працює й показує стан.
|
||||
//
|
||||
// Так виглядає кабінет, створений ПІСЛЯ 0069 (netpulse-user його
|
||||
// заводить, стель не заводить). Перевірка на два боки: стеля не
|
||||
// вигадується, а сторінка тарифу все одно має що показати.
|
||||
func TestEntitlementCreatedLazily(t *testing.T) {
|
||||
s, tenantID := billingTestTenant(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`DELETE FROM bill.entitlements WHERE tenant_id = $1`, tenantID); err != nil {
|
||||
t.Fatalf("прибирання стель: %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.CreateDevice(ctx, tenantID, DeviceInput{
|
||||
Name: "без-стель", Address: nextAddr(), Kind: "switch", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("хост у кабінеті без стель: %v", err)
|
||||
}
|
||||
|
||||
ent, err := s.TenantEntitlement(ctx, tenantID)
|
||||
if err != nil {
|
||||
t.Fatalf("стелі не завелись ліниво: %v", err)
|
||||
}
|
||||
if ent.PlanKey != "self_hosted" {
|
||||
t.Fatalf("тариф = %q, очікувався self_hosted", ent.PlanKey)
|
||||
}
|
||||
if ent.MaxDevices != nil {
|
||||
t.Fatalf("у щойно заведених стелях узялась стеля хостів: %d", *ent.MaxDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// Використане, яке показує сторінка, і використане, на якому спрацьовує
|
||||
// тригер, — одне й те саме число.
|
||||
//
|
||||
// Розходження тут не ламає нічого технічно й тому не помічається: обидва
|
||||
// шляхи «працюють». Помічає його людина, у якої на екрані «14 з 15», а
|
||||
// шістнадцятий хост не заводиться.
|
||||
func TestUsageMatchesTrigger(t *testing.T) {
|
||||
s, tenantID := billingTestTenant(t)
|
||||
ctx := context.Background()
|
||||
|
||||
setDeviceCeiling(t, s, tenantID, 3)
|
||||
|
||||
var ids []string
|
||||
for i := 0; i < 3; i++ {
|
||||
id, err := s.CreateDevice(ctx, tenantID, DeviceInput{
|
||||
Name: fmt.Sprintf("рахунок-%d", i), Address: nextAddr(),
|
||||
Kind: "switch", Enabled: i < 2, // третій вимкнений
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("хост %d: %v", i, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
u, err := s.TenantUsage(ctx, tenantID)
|
||||
if err != nil {
|
||||
t.Fatalf("використання: %v", err)
|
||||
}
|
||||
// Вимкнений хост слота не займає — так каже bill.usage_now, і так
|
||||
// само має рахувати тригер.
|
||||
if u.Devices != 2 {
|
||||
t.Fatalf("сторінка рахує %d хостів, очікувалось 2 (вимкнений не рахується)", u.Devices)
|
||||
}
|
||||
|
||||
// Доводимо, що тригер рахує так само: при стелі 3 і двох увімкнених
|
||||
// третій увімкнений має пройти, а четвертий — ні.
|
||||
if _, err := s.CreateDevice(ctx, tenantID, DeviceInput{
|
||||
Name: "рахунок-3", Address: nextAddr(), Kind: "switch", Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("тригер порахував вимкнений хост як зайнятий слот: %v", err)
|
||||
}
|
||||
_, err = s.CreateDevice(ctx, tenantID, DeviceInput{
|
||||
Name: "рахунок-4", Address: nextAddr(), Kind: "switch", Enabled: true,
|
||||
})
|
||||
if _, ok := AsPlanLimit(err); !ok {
|
||||
t.Fatalf("стеля не спрацювала на четвертому увімкненому: %v", err)
|
||||
}
|
||||
_ = ids
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Оснастка
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func billingTestTenant(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("NETPULSE_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
s, err := New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("підключення: %v", err)
|
||||
}
|
||||
t.Cleanup(s.Close)
|
||||
|
||||
var tenantID string
|
||||
slug := fmt.Sprintf("bill-test-%d", time.Now().UnixNano())
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO core.tenants (slug, name) VALUES ($1, 'Тест білінгу') RETURNING id::text
|
||||
`, slug).Scan(&tenantID); err != nil {
|
||||
t.Fatalf("кабінет: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_, _ = s.pool.Exec(context.Background(),
|
||||
`DELETE FROM core.tenants WHERE id = $1`, tenantID)
|
||||
})
|
||||
|
||||
if _, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO bill.entitlements
|
||||
(tenant_id, plan_key, metric_retention_days, features, source)
|
||||
VALUES ($1, 'self_hosted', 400, '{}', 'license_key')
|
||||
ON CONFLICT (tenant_id) DO NOTHING`, tenantID); err != nil {
|
||||
t.Fatalf("стелі: %v", err)
|
||||
}
|
||||
return s, tenantID
|
||||
}
|
||||
|
||||
func setDeviceCeiling(t *testing.T, s *Store, tenantID string, n int) {
|
||||
t.Helper()
|
||||
if _, err := s.pool.Exec(context.Background(),
|
||||
`UPDATE bill.entitlements SET plan_key = 'free', max_devices = $2
|
||||
WHERE tenant_id = $1`, tenantID, n); err != nil {
|
||||
t.Fatalf("стеля хостів: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// nextAddr дає неповторну адресу.
|
||||
//
|
||||
// Адреси мусять різнитись не через стелю, а через unique-індекс на
|
||||
// (tenant_id, address): падіння на ньому виглядало б як падіння стелі,
|
||||
// і півгодини пішло б не туди.
|
||||
var addrN = 0
|
||||
|
||||
func nextAddr() string {
|
||||
addrN++
|
||||
return fmt.Sprintf("10.66.%d.%d", addrN/250, addrN%250+1)
|
||||
}
|
||||
|
||||
func containsCyrillic(s string) bool {
|
||||
for _, r := range s {
|
||||
if r >= 'А' && r <= 'я' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
146
server/internal/store/billing_limits_test.go
Normal file
146
server/internal/store/billing_limits_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Розпізнавання відмови через стелю — без бази.
|
||||
//
|
||||
// ЧОМУ ЦЕ ОКРЕМА ПЕРЕВІРКА, А НЕ ЧАСТИНА ТЕСТУ ПРОТИ БАЗИ
|
||||
//
|
||||
// Тому що зламана тут ланка була саме такою: перевірка в БД працювала
|
||||
// бездоганно, а Go не впізнавав її відмову. httpapi/groups.go шукав у
|
||||
// тексті помилки слово «ліміт», а тригер 0009 писав англійською — і
|
||||
// збігу не було ніколи. Тобто в мить, коли стеля вперше спрацювала б,
|
||||
// людина отримала б 500 «внутрішня помилка» замість пояснення.
|
||||
//
|
||||
// Тест проти бази цього НЕ ловить: там відмова справді сталася, рядок
|
||||
// не створився, перевірка зелена. Ловить лише окрема перевірка того, що
|
||||
// саме код зробив із цією відмовою далі.
|
||||
//
|
||||
// Тому нижче — не «стеля спрацювала», а «відмову впізнано, і з неї
|
||||
// дістали числа, які можна показати людині».
|
||||
|
||||
// planLimitPgError збирає відмову рівно такою, якою її підіймає
|
||||
// bill.deny_limit із 0069.
|
||||
func planLimitPgError(msg, detail string) *pgconn.PgError {
|
||||
return &pgconn.PgError{
|
||||
Severity: "ERROR",
|
||||
Code: "23514", // check_violation
|
||||
Message: msg,
|
||||
Detail: detail,
|
||||
Hint: "upgrade_plan",
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsPlanLimitReadsNumbers(t *testing.T) {
|
||||
raw := planLimitPgError(
|
||||
"у тарифі Free дозволено 15 хостів, зараз 15",
|
||||
`{"limit":"devices","plan":"free","allowed":15,"used":15}`)
|
||||
|
||||
// Обгортка навмисно подвійна: у житті помилка проходить через
|
||||
// fmt.Errorf у CreateDevice і ще раз через шар транзакції.
|
||||
err := fmt.Errorf("створення хоста: %w", fmt.Errorf("tx: %w", raw))
|
||||
|
||||
lim, ok := AsPlanLimit(err)
|
||||
if !ok {
|
||||
t.Fatal("відмову через стелю не впізнано в обгортках")
|
||||
}
|
||||
if lim.Kind != "devices" || lim.Plan != "free" || lim.Allowed != 15 || lim.Used != 15 {
|
||||
t.Fatalf("числа не дістались: %+v", lim)
|
||||
}
|
||||
if lim.Error() != "у тарифі Free дозволено 15 хостів, зараз 15" {
|
||||
t.Fatalf("текст відмови загублено: %q", lim.Error())
|
||||
}
|
||||
|
||||
// Типізована помилка відповідає наявному вартовому — на це
|
||||
// спирається шлях правки мапи, де відмову перекладає mapPgError.
|
||||
if !errors.Is(lim, ErrPlanLimit) {
|
||||
t.Fatal("errors.Is(PlanLimitError, ErrPlanLimit) не спрацював")
|
||||
}
|
||||
|
||||
// А СИРА помилка бази вартовому не відповідає, і це не недогляд, а
|
||||
// факт, який мусить бути записаний перевіркою: у її ланцюжку лежить
|
||||
// *pgconn.PgError, і жоден errors.Is його з ErrPlanLimit не зведе.
|
||||
// Саме тому спільний обробник помилок питає AsPlanLimit окремим
|
||||
// рядком. Якби хтось «спростив» його назад до одного errors.Is,
|
||||
// відмова через стелю знову перетворилась би на 500 — на всіх
|
||||
// шляхах, крім мап, і мовчки.
|
||||
if errors.Is(err, ErrPlanLimit) {
|
||||
t.Fatal("сира помилка бази раптом відповідає ErrPlanLimit — " +
|
||||
"перевірте, чи не зайвий тепер AsPlanLimit у writeStoreError")
|
||||
}
|
||||
}
|
||||
|
||||
// Регресія на ту саму поломку, з якої почалась ця робота.
|
||||
//
|
||||
// Стара перевірка шукала слово «ліміт» у тексті. Відмова англійською —
|
||||
// саме те, що підіймала 0009, і саме те, що вона пропускала. Тепер
|
||||
// впізнавання йде за HINT, тобто текст може бути будь-яким, включно з
|
||||
// таким, який ще не переклали.
|
||||
func TestAsPlanLimitDoesNotDependOnWording(t *testing.T) {
|
||||
raw := planLimitPgError("device limit reached for tenant abc (limit 15)", "")
|
||||
lim, ok := AsPlanLimit(fmt.Errorf("створення хоста: %w", raw))
|
||||
if !ok {
|
||||
t.Fatal("англійська відмова 0009 не впізнана — та сама поломка, що й була")
|
||||
}
|
||||
// Чисел немає — стара міграція їх не передавала. Відмова від цього
|
||||
// не перестає бути відмовою: 402 має поїхати в будь-якому разі.
|
||||
if lim.Allowed != 0 || lim.Used != 0 {
|
||||
t.Fatalf("узялись числа, яких у відмові не було: %+v", lim)
|
||||
}
|
||||
if lim.Error() == "" {
|
||||
t.Fatal("текст відмови порожній — людині нічого показати")
|
||||
}
|
||||
}
|
||||
|
||||
// Перевірка протилежного боку: сторонні помилки бази не мають
|
||||
// перетворюватись на «вичерпано тариф».
|
||||
//
|
||||
// Це те, що ламається тихо: обробник, який на будь-яку помилку віддає
|
||||
// 402 «змініть тариф», сховає справжню поломку за пропозицією заплатити,
|
||||
// і розбиратись у ній ніхто не піде.
|
||||
func TestAsPlanLimitIgnoresOtherErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"звичайна помилка", errors.New("щось пішло не так")},
|
||||
{"nil", nil},
|
||||
{"порушення унікальності", fmt.Errorf("x: %w", &pgconn.PgError{
|
||||
Code: "23505", Message: "duplicate key value violates unique constraint",
|
||||
})},
|
||||
{"check без підказки", fmt.Errorf("x: %w", &pgconn.PgError{
|
||||
Code: "23514", Message: "new row violates check constraint",
|
||||
})},
|
||||
{"чужа підказка", fmt.Errorf("x: %w", &pgconn.PgError{
|
||||
Code: "23514", Message: "щось", Hint: "GRANT SELECT ...",
|
||||
})},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, ok := AsPlanLimit(c.err); ok {
|
||||
t.Fatal("сторонню помилку прийнято за вичерпаний тариф")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Зіпсована подробиця не має губити саму відмову.
|
||||
//
|
||||
// Інсталяція зі свіжим Go й старою схемою (0069 ще не накотилась) —
|
||||
// звичайний стан під час оновлення, і в ньому DETAIL порожній або
|
||||
// чужого формату. Втратити через це 402 означало б показати «внутрішню
|
||||
// помилку» саме тоді, коли причина відома точно.
|
||||
func TestAsPlanLimitSurvivesBrokenDetail(t *testing.T) {
|
||||
for _, detail := range []string{"", "не json", `{"allowed":"багато"}`, `[1,2,3]`} {
|
||||
err := fmt.Errorf("x: %w", planLimitPgError("стеля", detail))
|
||||
if _, ok := AsPlanLimit(err); !ok {
|
||||
t.Fatalf("відмову втрачено через подробицю %q", detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
729
server/internal/store/ncm_compliance_builtin_test.go
Normal file
729
server/internal/store/ncm_compliance_builtin_test.go
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
schema "github.com/netpulse/netpulse/server/migrations"
|
||||
)
|
||||
|
||||
// Перевірка вбудованих правил відповідності з міграції 0071.
|
||||
//
|
||||
// НАВІЩО ЦЕЙ ФАЙЛ ІСНУЄ. Правило «не має бути», чий зразок не збігається
|
||||
// НІКОЛИ, повідомляє «порушень немає» — тобто виглядає ідеально й
|
||||
// нічого не перевіряє. Відрізнити його від справного правила за
|
||||
// результатом прогону неможливо в принципі: обидва дають нуль
|
||||
// порушень. Єдиний спосіб — показати конфіг, на якому зразок мусить
|
||||
// спрацювати, і конфіг, на якому не мусить.
|
||||
//
|
||||
// ЗВІДКИ БЕРУТЬСЯ ЗРАЗКИ. З самої міграції, через schema.Files. Копії
|
||||
// виразів у тесті немає навмисно: два описи одного й того самого
|
||||
// розійдуться на першій же правці, і тест почав би підтверджувати
|
||||
// власну копію замість того, що поїде клієнтові. Та сама причина, з
|
||||
// якої db/profiles/build.py породжує SQL, а не дублює його.
|
||||
//
|
||||
// ЩО ТЕСТ ГАРАНТУЄ, а що ні. Гарантує: кожен зразок хоч на одному
|
||||
// справжньому конфізі спрацював і хоч на одному не спрацював, і для
|
||||
// КОЖНОГО вендора з переліку правила є конфіг цього вендора з
|
||||
// очікуваним висновком. Не гарантує: що зразок покриває всі прошивки
|
||||
// цієї родини — конфіги збирались із документації й типових
|
||||
// вивантажень, а не з усього парку.
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Читання правил із міграції
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const builtinMigration = "0071_builtin_compliance_prototypes.sql"
|
||||
|
||||
// Рядок довідника має жорстку форму саме заради цього розбору:
|
||||
//
|
||||
// ('ключ', 'серйозність', 'вид', 'тип конфігу', NULL | string_to_array('Вендор,Вендор', ','),
|
||||
// $rx$зразок$rx$,
|
||||
//
|
||||
// Доларові лапки навколо зразка — не оздоба: у виразі живуть зворотні
|
||||
// скісні риски й апострофи, і саме вони роблять межі зразка
|
||||
// однозначними для цього виразу.
|
||||
var builtinRuleRe = regexp.MustCompile(
|
||||
`(?s)\('([a-z0-9-]+)',\s*'([a-z]+)',\s*'([a-z_]+)',\s*'([a-z]+)',\s*` +
|
||||
`(?:NULL|string_to_array\('([^']*)', ','\)),\s*\$rx\$(.*?)\$rx\$`)
|
||||
|
||||
type builtinRule struct {
|
||||
Key string
|
||||
Severity string
|
||||
Kind string
|
||||
ConfigType string
|
||||
Vendors []string
|
||||
Pattern string
|
||||
}
|
||||
|
||||
func loadBuiltinRules(t *testing.T) map[string]builtinRule {
|
||||
t.Helper()
|
||||
|
||||
body, err := schema.Files.ReadFile(builtinMigration)
|
||||
if err != nil {
|
||||
t.Fatalf("міграція %s не читається: %v", builtinMigration, err)
|
||||
}
|
||||
|
||||
out := map[string]builtinRule{}
|
||||
for _, m := range builtinRuleRe.FindAllStringSubmatch(string(body), -1) {
|
||||
r := builtinRule{
|
||||
Key: m[1], Severity: m[2], Kind: m[3], ConfigType: m[4], Pattern: m[6],
|
||||
}
|
||||
if m[5] != "" {
|
||||
r.Vendors = strings.Split(m[5], ",")
|
||||
}
|
||||
if _, dup := out[r.Key]; dup {
|
||||
t.Fatalf("ключ %q у довіднику двічі", r.Key)
|
||||
}
|
||||
out[r.Key] = r
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("з %s не вийнято жодного правила — розбір розійшовся з форматом файлу",
|
||||
builtinMigration)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Зразки конфігів
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Конфіг і вендор, під яким цей хост стоїть в inv.devices.
|
||||
//
|
||||
// Вендор тут не прикраса: селектор вбудованого правила — це
|
||||
// {"vendors":[...]}, який selectorSQL перетворює на `d.vendor = ANY(...)`,
|
||||
// тобто рівно на порівняння з цим рядком. Пари «правило + вендор», для
|
||||
// якої немає конфігу, тест не пропускає — інакше вендора можна було б
|
||||
// дописати в правило, не перевіривши на ньому нічого.
|
||||
type cfgSample struct {
|
||||
Vendor string
|
||||
Body string
|
||||
}
|
||||
|
||||
var samples = map[string]cfgSample{
|
||||
|
||||
// --- Cisco IOS ---------------------------------------------------
|
||||
|
||||
"cisco-bad": {"Cisco", `Building configuration...
|
||||
|
||||
Current configuration : 4021 bytes
|
||||
!
|
||||
version 15.2
|
||||
service timestamps debug datetime msec
|
||||
no service password-encryption
|
||||
!
|
||||
hostname kyiv-acc-sw01
|
||||
!
|
||||
enable password cisco123
|
||||
!
|
||||
username admin privilege 15 password 0 admin123
|
||||
!
|
||||
interface Vlan1
|
||||
ip address 10.10.0.11 255.255.255.0
|
||||
!
|
||||
snmp-server community public RO
|
||||
snmp-server community private RW
|
||||
!
|
||||
line con 0
|
||||
password cisco
|
||||
line vty 0 4
|
||||
transport input telnet ssh
|
||||
login local
|
||||
line vty 5 15
|
||||
transport input all
|
||||
!
|
||||
end
|
||||
`},
|
||||
|
||||
"cisco-good": {"Cisco", `Building configuration...
|
||||
!
|
||||
version 15.2
|
||||
service password-encryption
|
||||
!
|
||||
hostname kyiv-core-sw01
|
||||
!
|
||||
enable secret 5 $1$mERr$X1s0Kk9yq2mA3cCf1uJvS/
|
||||
!
|
||||
username noc privilege 15 secret 5 $1$hM3z$8QeJm0Wl2sN6tR4vB7xYc.
|
||||
!
|
||||
aaa new-model
|
||||
!
|
||||
ip access-list standard MGMT-ACL
|
||||
permit 10.20.30.0 0.0.0.255
|
||||
!
|
||||
snmp-server community NP-ro-2024 RO MGMT-ACL
|
||||
!
|
||||
logging host 10.20.30.15
|
||||
logging trap informational
|
||||
!
|
||||
ntp server 10.20.30.10 prefer
|
||||
ntp server 10.20.30.11
|
||||
!
|
||||
line con 0
|
||||
exec-timeout 5 0
|
||||
line vty 0 15
|
||||
access-class MGMT-ACL in
|
||||
transport input ssh
|
||||
login local
|
||||
!
|
||||
end
|
||||
`},
|
||||
|
||||
// --- Eltex MES ---------------------------------------------------
|
||||
//
|
||||
// Свідомо змішаний: telnet відкритий, решта в порядку. Саме такий
|
||||
// конфіг і ловить помилку «зразок написаний під Cisco, а вендор у
|
||||
// переліку є» — на бездоганному конфізі її не видно.
|
||||
|
||||
"eltex-mixed": {"Eltex", `!
|
||||
version 4.0.14
|
||||
!
|
||||
hostname odesa-acc-sw03
|
||||
!
|
||||
username admin password encrypted 5f4dcc3b5aa765d61d8327deb882cf99 privilege 15
|
||||
!
|
||||
ip telnet server
|
||||
!
|
||||
snmp-server community NP-ro-2024 ro
|
||||
!
|
||||
logging host 10.20.30.15
|
||||
!
|
||||
sntp unicast client enable
|
||||
sntp server 10.20.30.10
|
||||
!
|
||||
line telnet
|
||||
exec-timeout 10
|
||||
line ssh
|
||||
exec-timeout 10
|
||||
!
|
||||
management access-list MGMT
|
||||
permit ip-source 10.20.30.0 mask 255.255.255.0
|
||||
management access-class MGMT
|
||||
!
|
||||
end
|
||||
`},
|
||||
|
||||
// --- ZTE ZXR10 ---------------------------------------------------
|
||||
|
||||
"zte-mixed": {"ZTE", `!
|
||||
!<Configuration Data Version 2.0>
|
||||
!
|
||||
hostname kharkiv-agg-zte01
|
||||
!
|
||||
username zteadmin password 0 zte123 privilege 15
|
||||
!
|
||||
snmp-server community public view AllView ro
|
||||
snmp-server enable
|
||||
!
|
||||
ntp server 10.20.30.10
|
||||
!
|
||||
logging server 10.20.30.15
|
||||
!
|
||||
line telnet idle-timeout 10
|
||||
!
|
||||
line vty 0 4
|
||||
transport input telnet ssh
|
||||
!
|
||||
end
|
||||
`},
|
||||
|
||||
// --- Huawei VRP --------------------------------------------------
|
||||
|
||||
"huawei-bad": {"Huawei", `!Last configuration was updated at 2026-03-11 09:12:44+02:00
|
||||
#
|
||||
sysname lviv-agg-sw02
|
||||
#
|
||||
telnet server enable
|
||||
#
|
||||
snmp-agent
|
||||
snmp-agent community read public
|
||||
snmp-agent community write private
|
||||
snmp-agent sys-info version v2c
|
||||
#
|
||||
aaa
|
||||
local-user admin password simple Huawei@123
|
||||
local-user admin service-type telnet ssh
|
||||
#
|
||||
user-interface vty 0 4
|
||||
authentication-mode password
|
||||
set authentication password simple Admin@123
|
||||
protocol inbound all
|
||||
#
|
||||
return
|
||||
`},
|
||||
|
||||
"huawei-good": {"Huawei", `#
|
||||
sysname lviv-core-sw01
|
||||
#
|
||||
undo telnet server enable
|
||||
undo telnet ipv6 server enable
|
||||
#
|
||||
snmp-agent
|
||||
snmp-agent community read cipher %^%#NP2024ro%^%#
|
||||
snmp-agent sys-info version v3
|
||||
#
|
||||
info-center enable
|
||||
info-center loghost 10.20.30.15
|
||||
#
|
||||
ntp-service unicast-server 10.20.30.10
|
||||
ntp-service unicast-server 10.20.30.11
|
||||
#
|
||||
aaa
|
||||
local-user noc password irreversible-cipher $1c$KJHGfdsa
|
||||
local-user noc service-type ssh
|
||||
#
|
||||
user-interface vty 0 4
|
||||
acl 2000 inbound
|
||||
authentication-mode aaa
|
||||
protocol inbound ssh
|
||||
#
|
||||
return
|
||||
`},
|
||||
|
||||
// --- H3C Comware -------------------------------------------------
|
||||
|
||||
"h3c-bad": {"H3C", `#
|
||||
version 7.1.070, Release 6127P02
|
||||
#
|
||||
sysname dnipro-acc-h3c01
|
||||
#
|
||||
telnet server enable
|
||||
#
|
||||
snmp-agent
|
||||
snmp-agent community read public
|
||||
#
|
||||
local-user admin class manage
|
||||
password simple H3C@1234
|
||||
service-type telnet ssh
|
||||
#
|
||||
line vty 0 15
|
||||
authentication-mode scheme
|
||||
protocol inbound all
|
||||
#
|
||||
return
|
||||
`},
|
||||
|
||||
"h3c-good": {"H3C", `#
|
||||
sysname dnipro-core-h3c01
|
||||
#
|
||||
undo telnet server enable
|
||||
#
|
||||
info-center loghost 10.20.30.15
|
||||
#
|
||||
ntp-service unicast-server 10.20.30.10
|
||||
#
|
||||
snmp-agent community read cipher $c$3$Nq8kL2Rt
|
||||
#
|
||||
local-user noc class manage
|
||||
password hash $h$6$OqRsTuVw
|
||||
service-type ssh
|
||||
#
|
||||
line vty 0 15
|
||||
acl 2000 inbound
|
||||
protocol inbound ssh
|
||||
#
|
||||
return
|
||||
`},
|
||||
|
||||
// --- D-Link ------------------------------------------------------
|
||||
|
||||
"dlink-bad": {"D-Link", `#-------------------------------------------------------------------
|
||||
# DGS-1210-28 Gigabit Ethernet Switch
|
||||
# Configuration
|
||||
#-------------------------------------------------------------------
|
||||
# BASIC
|
||||
config serial_port baud_rate 115200 auto_logout 10_minutes
|
||||
enable telnet 23
|
||||
enable web 80
|
||||
# ACCOUNT
|
||||
create account admin admin
|
||||
# SNMP
|
||||
enable snmp
|
||||
create snmp community public view CommunityView read_only
|
||||
create snmp community private view CommunityView read_write
|
||||
# TIME AND SNTP
|
||||
disable sntp
|
||||
# SYSLOG
|
||||
disable syslog
|
||||
`},
|
||||
|
||||
"dlink-good": {"D-Link", `#-------------------------------------------------------------------
|
||||
# DGS-3420-28SC Gigabit Ethernet Switch
|
||||
# Configuration
|
||||
#-------------------------------------------------------------------
|
||||
# BASIC
|
||||
disable telnet
|
||||
enable ssh
|
||||
config ssh authmode password enable
|
||||
# SNMP
|
||||
enable snmp
|
||||
create snmp community NP-ro-2024 view CommunityView read_only
|
||||
# TIME AND SNTP
|
||||
config sntp primary 10.20.30.10 secondary 10.20.30.11 poll-interval 720
|
||||
enable sntp
|
||||
# SYSLOG
|
||||
create syslog host 1 ipaddress 10.20.30.15 severity informational facility local0 udp_port 514 state enable
|
||||
enable syslog
|
||||
# ACCESS
|
||||
create trusted_host network 10.20.30.0/24
|
||||
`},
|
||||
|
||||
// --- MikroTik RouterOS -------------------------------------------
|
||||
|
||||
"mikrotik-bad": {"MikroTik", `# mar/11/2026 09:12:44 by RouterOS 7.14.3
|
||||
# software id = ABCD-1234
|
||||
#
|
||||
/interface bridge
|
||||
add name=bridge1
|
||||
/ip service
|
||||
set telnet disabled=no port=23
|
||||
set ftp disabled=no
|
||||
set www disabled=no
|
||||
set ssh disabled=no
|
||||
set winbox disabled=no
|
||||
/snmp community
|
||||
set [ find default=yes ] name=public addresses=0.0.0.0/0
|
||||
/snmp
|
||||
set enabled=yes
|
||||
/system clock
|
||||
set time-zone-name=Europe/Kyiv
|
||||
/system logging
|
||||
add topics=info
|
||||
/user
|
||||
add name=noc password=Str0ngPass group=full
|
||||
`},
|
||||
|
||||
"mikrotik-good": {"MikroTik", `# mar/11/2026 09:12:44 by RouterOS 7.14.3
|
||||
#
|
||||
/interface bridge
|
||||
add name=bridge1
|
||||
/ip service
|
||||
set telnet disabled=yes
|
||||
set ftp disabled=yes
|
||||
set www disabled=yes
|
||||
set ssh address=10.20.30.0/24 disabled=no
|
||||
set winbox address=10.20.30.0/24 disabled=no
|
||||
/snmp community
|
||||
set [ find default=yes ] name=NP-ro-2024 addresses=10.20.30.0/24
|
||||
/snmp
|
||||
set enabled=yes trap-version=2
|
||||
/system ntp client
|
||||
set enabled=yes servers=10.20.30.10,10.20.30.11
|
||||
/system logging action
|
||||
add name=remote target=remote remote=10.20.30.15
|
||||
/system logging
|
||||
add action=remote topics=info,error,warning
|
||||
/system clock
|
||||
set time-zone-name=Europe/Kyiv
|
||||
`},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Очікування
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// true — правило пройдено (порушення немає), false — знайдено порушення.
|
||||
//
|
||||
// Заповнено для КОЖНОЇ пари «правило + зразок вендора з його переліку».
|
||||
// Пропуск — це не «нам байдуже», а помилка тесту: саме через пропущену
|
||||
// пару вендор потрапляє в правило неперевіреним.
|
||||
var expect = map[string]map[string]bool{
|
||||
|
||||
// Єдине правило без прив'язки до вендора — тому перелічені всі
|
||||
// зразки набору.
|
||||
"snmp-default-community": {
|
||||
"cisco-bad": false, "cisco-good": true,
|
||||
"eltex-mixed": true, "zte-mixed": false,
|
||||
"huawei-bad": false, "huawei-good": true,
|
||||
"h3c-bad": false, "h3c-good": true,
|
||||
"dlink-bad": false, "dlink-good": true,
|
||||
"mikrotik-bad": false, "mikrotik-good": true,
|
||||
},
|
||||
|
||||
"no-telnet-ios": {
|
||||
// transport input telnet / transport input all
|
||||
"cisco-bad": false, "cisco-good": true,
|
||||
// ip telnet server — форма Eltex, якої в синтаксисі Cisco немає
|
||||
"eltex-mixed": false,
|
||||
"zte-mixed": false,
|
||||
},
|
||||
"no-telnet-vrp": {
|
||||
"huawei-bad": false, "huawei-good": true,
|
||||
"h3c-bad": false, "h3c-good": true,
|
||||
},
|
||||
"no-telnet-dlink": {
|
||||
"dlink-bad": false, "dlink-good": true,
|
||||
},
|
||||
"no-telnet-routeros": {
|
||||
"mikrotik-bad": false, "mikrotik-good": true,
|
||||
},
|
||||
|
||||
"plaintext-password-ios": {
|
||||
// no service password-encryption + enable password + password 0
|
||||
"cisco-bad": false, "cisco-good": true,
|
||||
// password encrypted <md5> — незворотна схема, не порушення
|
||||
"eltex-mixed": true,
|
||||
// password 0 <відкрито>
|
||||
"zte-mixed": false,
|
||||
},
|
||||
"plaintext-password-vrp": {
|
||||
"huawei-bad": false, "huawei-good": true,
|
||||
"h3c-bad": false, "h3c-good": true,
|
||||
},
|
||||
"plaintext-password-routeros": {
|
||||
"mikrotik-bad": false, "mikrotik-good": true,
|
||||
},
|
||||
|
||||
"syslog-target-ios": {
|
||||
"cisco-bad": false, "cisco-good": true,
|
||||
"eltex-mixed": true,
|
||||
// logging server <адреса> — форма ZTE
|
||||
"zte-mixed": true,
|
||||
},
|
||||
"syslog-target-vrp": {
|
||||
"huawei-bad": false, "huawei-good": true,
|
||||
"h3c-bad": false, "h3c-good": true,
|
||||
},
|
||||
"syslog-target-dlink": {
|
||||
"dlink-bad": false, "dlink-good": true,
|
||||
},
|
||||
"syslog-target-routeros": {
|
||||
// /system logging без action — це журнал у пам'ять
|
||||
"mikrotik-bad": false, "mikrotik-good": true,
|
||||
},
|
||||
|
||||
"ntp-source-ios": {
|
||||
"cisco-bad": false, "cisco-good": true,
|
||||
// sntp server — форма Eltex
|
||||
"eltex-mixed": true,
|
||||
"zte-mixed": true,
|
||||
},
|
||||
"ntp-source-vrp": {
|
||||
"huawei-bad": false, "huawei-good": true,
|
||||
"h3c-bad": false, "h3c-good": true,
|
||||
},
|
||||
"ntp-source-dlink": {
|
||||
// disable sntp
|
||||
"dlink-bad": false, "dlink-good": true,
|
||||
},
|
||||
"ntp-source-routeros": {
|
||||
"mikrotik-bad": false, "mikrotik-good": true,
|
||||
},
|
||||
|
||||
"mgmt-acl-ios": {
|
||||
"cisco-bad": false, "cisco-good": true,
|
||||
// management access-class — форма Eltex
|
||||
"eltex-mixed": true,
|
||||
// на ZTE список доступу до vty не заведений
|
||||
"zte-mixed": false,
|
||||
},
|
||||
"mgmt-acl-vrp": {
|
||||
"huawei-bad": false, "huawei-good": true,
|
||||
"h3c-bad": false, "h3c-good": true,
|
||||
},
|
||||
"mgmt-acl-dlink": {
|
||||
"dlink-bad": false, "dlink-good": true,
|
||||
},
|
||||
"mgmt-acl-routeros": {
|
||||
"mikrotik-bad": false, "mikrotik-good": true,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Самі перевірки
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Склад набору звіряється поіменно: правило, яке з'явилось у міграції й
|
||||
// не має очікувань, інакше проїхало б повз тест непоміченим — тобто
|
||||
// найдорожчим способом.
|
||||
func TestBuiltinRulesAllCovered(t *testing.T) {
|
||||
rules := loadBuiltinRules(t)
|
||||
|
||||
var missing []string
|
||||
for key := range rules {
|
||||
if _, ok := expect[key]; !ok {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("правила без очікувань у тесті: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
var stale []string
|
||||
for key := range expect {
|
||||
if _, ok := rules[key]; !ok {
|
||||
stale = append(stale, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(stale)
|
||||
if len(stale) > 0 {
|
||||
t.Errorf("очікування для правил, яких у міграції немає: %s", strings.Join(stale, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// Кожне правило звіряється зі зразками конфігів усіх своїх вендорів.
|
||||
func TestBuiltinRulesAgainstRealConfigs(t *testing.T) {
|
||||
rules := loadBuiltinRules(t)
|
||||
|
||||
for key, br := range rules {
|
||||
want, ok := expect[key]
|
||||
if !ok {
|
||||
continue // про це вже сказав TestBuiltinRulesAllCovered
|
||||
}
|
||||
|
||||
re, err := regexp.Compile(br.Pattern)
|
||||
if err != nil {
|
||||
t.Errorf("%s: зразок не компілюється: %v", key, err)
|
||||
continue
|
||||
}
|
||||
rule := ComplianceRule{Kind: br.Kind, Pattern: br.Pattern}
|
||||
|
||||
for name, s := range samples {
|
||||
// Правило застосовне до цього конфігу, якщо вендорів у
|
||||
// нього немає взагалі або цей вендор у переліку.
|
||||
if !applies(br.Vendors, s.Vendor) {
|
||||
continue
|
||||
}
|
||||
exp, has := want[name]
|
||||
if !has {
|
||||
t.Errorf("%s: для вендора %s (зразок %s) немає очікування — "+
|
||||
"вендор у правилі є, а перевіреного конфігу немає",
|
||||
key, s.Vendor, name)
|
||||
continue
|
||||
}
|
||||
|
||||
got, line, num := checkCompliance(rule, re, s.Body)
|
||||
if got != exp {
|
||||
t.Errorf("%s на %s: очікували %s, отримали %s (рядок %d: %q)",
|
||||
key, name, verdict(exp), verdict(got), num, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Найважливіша перевірка файлу.
|
||||
//
|
||||
// Зразок, який не збігається НІКОЛИ, дає «порушень немає» на всьому
|
||||
// парку — тобто ідеальний вигляд і нуль користі. Зразок, який
|
||||
// збігається ЗАВЖДИ, дає суцільне червоне, після якого на розділ
|
||||
// перестають дивитись. Обидва стани перевіряються однаково: у наборі
|
||||
// зразків має бути хоч один конфіг, де правило спрацювало, і хоч один,
|
||||
// де ні.
|
||||
func TestBuiltinRulesAreNotAlwaysGreen(t *testing.T) {
|
||||
rules := loadBuiltinRules(t)
|
||||
|
||||
for key, br := range rules {
|
||||
re, err := regexp.Compile(br.Pattern)
|
||||
if err != nil {
|
||||
continue // про це вже сказав попередній тест
|
||||
}
|
||||
rule := ComplianceRule{Kind: br.Kind, Pattern: br.Pattern}
|
||||
|
||||
var passed, failed int
|
||||
for _, s := range samples {
|
||||
if !applies(br.Vendors, s.Vendor) {
|
||||
continue
|
||||
}
|
||||
if ok, _, _ := checkCompliance(rule, re, s.Body); ok {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
t.Errorf("%s: зразок не спрацював ЖОДНОГО разу на %d конфігах своїх вендорів — "+
|
||||
"таке правило показує «порушень немає» завжди", key, passed)
|
||||
}
|
||||
if passed == 0 {
|
||||
t.Errorf("%s: зразок спрацював на ВСІХ %d конфігах своїх вендорів — "+
|
||||
"правило, яке ніколи не буває зеленим, перестають читати", key, failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Кожен вендор, названий у правилах, мусить мати конфіг у наборі.
|
||||
//
|
||||
// Без цієї перевірки вендора можна дописати в перелік правила, не
|
||||
// показавши жодного його конфігу, — і правило мовчки поширилось би на
|
||||
// синтаксис, якого ніхто не бачив.
|
||||
func TestBuiltinRuleVendorsHaveSamples(t *testing.T) {
|
||||
rules := loadBuiltinRules(t)
|
||||
|
||||
have := map[string]bool{}
|
||||
for _, s := range samples {
|
||||
have[s.Vendor] = true
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for key, br := range rules {
|
||||
for _, v := range br.Vendors {
|
||||
if !have[v] && !seen[key+v] {
|
||||
seen[key+v] = true
|
||||
t.Errorf("%s: вендор %q названий у правилі, а конфігу цього вендора в наборі немає",
|
||||
key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Тип конфігу заданий явно в кожному правилі (0056), і це саме
|
||||
// 'running': жодне з правил набору не описує конфіг-файли сервера.
|
||||
func TestBuiltinRulesDeclareConfigType(t *testing.T) {
|
||||
for key, br := range loadBuiltinRules(t) {
|
||||
if br.ConfigType != "running" {
|
||||
t.Errorf("%s: тип конфігу %q — набір писався під конфіги заліза", key, br.ConfigType)
|
||||
}
|
||||
if !complianceKinds[br.Kind] {
|
||||
t.Errorf("%s: вид правила %q рушій не знає", key, br.Kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Прототипи з цієї ж міграції мають бути вимкнені.
|
||||
//
|
||||
// Правило проєкту: оновлення не вмикає нічого, що заводить чеки.
|
||||
// Ввімкнений прототип у вбудованому шаблоні заводить snmp.walk і
|
||||
// snmp.get на кожному хості, якому шаблон причеплений, — мовчки, під
|
||||
// час накату. Перевіряти це очима на рев'ю вже двічі не вийшло.
|
||||
func TestBuiltinPrototypesShipDisabled(t *testing.T) {
|
||||
body, err := schema.Files.ReadFile(builtinMigration)
|
||||
if err != nil {
|
||||
t.Fatalf("міграція %s не читається: %v", builtinMigration, err)
|
||||
}
|
||||
text := string(body)
|
||||
|
||||
// Хвіст рядка INSERT у tpl.item_prototypes: discovery_sec,
|
||||
// interval_sec, max_rows, enabled.
|
||||
tail := regexp.MustCompile(`(?m)^\s*\d+,\s*\d+,\s*\d+,\s*(true|false)\)`)
|
||||
hits := tail.FindAllStringSubmatch(text, -1)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("очікували 2 прототипи в міграції, знайшли %d", len(hits))
|
||||
}
|
||||
for _, h := range hits {
|
||||
if h[1] != "false" {
|
||||
t.Errorf("прототип заведено ввімкненим: %q", strings.TrimSpace(h[0]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applies(vendors []string, vendor string) bool {
|
||||
if len(vendors) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, v := range vendors {
|
||||
if v == vendor {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func verdict(passed bool) string {
|
||||
if passed {
|
||||
return "«порушень немає»"
|
||||
}
|
||||
return "«порушення»"
|
||||
}
|
||||
|
|
@ -144,8 +144,8 @@ var dormantPerms = map[string]bool{
|
|||
"devices:control": true, // ручний запуск перевірок іде під devices:write
|
||||
"maps:publish": true, // публічні посилання є лише в дашбордів
|
||||
"ncm:rollback": true, // відкат на пристрій ще не реалізовано
|
||||
"billing:read": true, // сторінки тарифу ще немає
|
||||
"billing:manage": true,
|
||||
// billing:read і billing:manage звідси прибрано: сторінка тарифу
|
||||
// з'явилась, і обидва права тепер питає httpapi/billing.go.
|
||||
}
|
||||
|
||||
// PermissionCatalog — усі права, згруповані, з описом із бази.
|
||||
|
|
|
|||
487
server/internal/store/sla.go
Normal file
487
server/internal/store/sla.go
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Розрахунок доступності за період: арифметика без бази.
|
||||
//
|
||||
// ЧОМУ ЦЕ ОКРЕМИЙ ФАЙЛ БЕЗ ЖОДНОГО ЗАПИТУ
|
||||
//
|
||||
// Усе, що робить звіт SLA спірним, — це не SQL, а домовленості: що таке
|
||||
// «недоступний», куди подіти вікно обслуговування, як рахувати хост,
|
||||
// заведений посеред кварталу. Перевіряти домовленості на живій базі
|
||||
// означає перевіряти їх утричі дорожче й на порядок рідше — тобто не
|
||||
// перевіряти. Тому весь спір винесено в чисті функції, а база лишається
|
||||
// тим, чим має бути: постачальником рядків.
|
||||
//
|
||||
// ЩО ТАКЕ «НЕДОСТУПНИЙ» — ЧОТИРИ РІШЕННЯ, ЯКІ ТУТ ПРИЙНЯТО
|
||||
//
|
||||
// 1. Час, про який немає ЖОДНОГО виміру, не є ані доступністю, ані
|
||||
// недоступністю. Він рахується окремо (UnknownSec) і не додається
|
||||
// нікуди. Це головне рішення файлу.
|
||||
//
|
||||
// Обидві альтернативи гірші, і кожна по-своєму. Порахувати мовчання
|
||||
// як «працювало» означає, що місяць із мертвим зондом дає 100% — а
|
||||
// саме такий звіт і показують, не перевіряючи, бо він гарний.
|
||||
// Порахувати мовчання як «лежало» означає, що годинна перерва в
|
||||
// роботі САМОГО МОНІТОРИНГУ стає аварією в мережі клієнта, за яку
|
||||
// хтось платить.
|
||||
//
|
||||
// Тому третій варіант: сказати вголос, скільки саме ми знаємо.
|
||||
// CoveragePct — це частка періоду, підперта виміром, і поки вона
|
||||
// нижча за поріг цілі, вердикт не виноситься взагалі: період
|
||||
// позначається як «недостатньо даних». Зелений висновок із діри в
|
||||
// даних тут неможливий за побудовою.
|
||||
//
|
||||
// 2. Вікно обслуговування з exclude_from_sla зупиняє годинник. Не
|
||||
// «зараховується як доступність» — саме зупиняє: ці секунди виходять
|
||||
// і з чисельника, і зі знаменника. Різниця видна на межі: двогодинні
|
||||
// роботи, зараховані як uptime, ПОКРАЩИЛИ б місяць, у якому була
|
||||
// аварія. Робота, про яку домовились заздалегідь, не має права
|
||||
// покращувати звіт — лише не псувати його.
|
||||
//
|
||||
// 3. Зонд offline — це випадок 1, і він вирішується сам. Вимірів немає,
|
||||
// годинних відер немає, час падає в UnknownSec. Окремої обробки не
|
||||
// потрібно, і саме тому джерелом узято ts.icmp_1h, а не історію
|
||||
// станів: історія станів пише рядок лише при ЗМІНІ, тож мовчання
|
||||
// зонда вона зображує як незмінне «up».
|
||||
//
|
||||
// 4. Хост, вимкнений людиною (inv.devices.enabled = false), теж падає в
|
||||
// UnknownSec — і це компроміс, який треба назвати вголос. Правильно
|
||||
// було б зупинити годинник, як на обслуговуванні. Зробити цього не
|
||||
// можна: історії поля enabled у продукті немає, тобто відповіді на
|
||||
// питання «коли саме його вимкнули» не існує ніде. Вигадати її
|
||||
// означало б підмішати здогад у документ для аудитора. Тому вимкнений
|
||||
// хост псує ПОКРИТТЯ, а не доступність, і це видно в звіті числом.
|
||||
|
||||
// SLAPeriodKind — типи періодів, які вміє різати цей файл.
|
||||
//
|
||||
// Місяць і квартал — те, що просять на аудиті. Доба й тиждень коштували
|
||||
// рівно один рядок у switch і потрібні для іншого: на них видно, що
|
||||
// розрахунок узагалі працює, не чекаючи місяця.
|
||||
const (
|
||||
SLAPeriodDaily = "daily"
|
||||
SLAPeriodWeekly = "weekly"
|
||||
SLAPeriodMonthly = "monthly"
|
||||
SLAPeriodQuarterly = "quarterly"
|
||||
)
|
||||
|
||||
// slaBucketSec — довжина одного відра ts.icmp_1h.
|
||||
//
|
||||
// Година, і це стеля точності всього звіту: аварія на п'ять хвилин
|
||||
// всередині години видна як частка втрачених спроб, а не як п'ять
|
||||
// хвилин. Для «99.9% за квартал» (це 2 години 12 хвилин допустимого
|
||||
// простою) такої точності досить; для «99.99%» — уже ні, і про це
|
||||
// сказано людині на самій сторінці.
|
||||
const slaBucketSec = 3600
|
||||
|
||||
// SLASpan — напіввідкритий відрізок часу [From, To).
|
||||
//
|
||||
// Напіввідкритий навмисно: сусідні періоди мають стикатися без щілини й
|
||||
// без нахлесту, інакше секунда опівночі 1 липня потрапила б і в другий
|
||||
// квартал, і в третій.
|
||||
type SLASpan struct {
|
||||
From time.Time
|
||||
To time.Time
|
||||
}
|
||||
|
||||
// SLAHourBucket — один рядок ts.icmp_1h, зведений до того, що потрібно
|
||||
// доступності.
|
||||
//
|
||||
// Samples — скільки спроб зробили за годину, Down — скільки з них не
|
||||
// відповіли. Саме ця пара й відрізняє «не відповідав» від «не питали»:
|
||||
// відра, якого немає, немає, а не «нуль втрат».
|
||||
type SLAHourBucket struct {
|
||||
Start time.Time
|
||||
Samples int64
|
||||
Down int64
|
||||
}
|
||||
|
||||
// SLAInput — усе, що потрібно, щоб порахувати один хост за один період.
|
||||
type SLAInput struct {
|
||||
// Межі періоду в абсолютному часі. Ріже їх SLAPeriodBounds у поясі
|
||||
// цілі; далі пояс не потрібен — уся арифметика йде в секундах
|
||||
// абсолютного часу. Наслідок, який варто розуміти: доба переходу на
|
||||
// літній час має 23 або 25 годин, і місяць із нею довший чи коротший
|
||||
// сам собою, без жодної окремої гілки.
|
||||
From time.Time
|
||||
To time.Time
|
||||
|
||||
// Коли хост з'явився і, якщо вже архівований, коли зник. Період
|
||||
// звужується до їх перетину: хост, заведений 20 травня, не має
|
||||
// «недоступності» за 1–19 травня — його не було.
|
||||
CreatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
|
||||
// Вікна обслуговування, які вже відібрано селектором. Можуть
|
||||
// перетинатися — тут вони зводяться в непересічні.
|
||||
Maintenance []SLASpan
|
||||
|
||||
// Годинні відра, що потрапляють у період. Порядок не важливий.
|
||||
Buckets []SLAHourBucket
|
||||
|
||||
// Проти чого міряємо. Обидва числа беруться з цілі й кладуться в
|
||||
// закритий період знімком: ціль живе далі й може змінитись.
|
||||
TargetPct float64
|
||||
MinCoveragePct float64
|
||||
}
|
||||
|
||||
// SLAOutcome — розкладений період.
|
||||
//
|
||||
// Чотири часові величини взаємно виключні й у сумі дають ClockSec рівно.
|
||||
// Це не педантизм: звіт, у якому частини не сходяться з цілим, читач
|
||||
// перевіряє один раз, не сходить — і далі не вірить жодному числу.
|
||||
type SLAOutcome struct {
|
||||
ClockSec int64 `json:"clock_sec"`
|
||||
MaintenanceSec int64 `json:"maintenance_sec"`
|
||||
UpSec int64 `json:"up_sec"`
|
||||
DownSec int64 `json:"downtime_sec"`
|
||||
UnknownSec int64 `json:"unknown_sec"`
|
||||
|
||||
// UptimePct — з ВИМІРЯНОГО часу, а не з періоду. Знаменник —
|
||||
// UpSec+DownSec; мовчання в нього не входить (див. рішення 1).
|
||||
UptimePct float64 `json:"uptime_pct"`
|
||||
// CoveragePct — яку частку годинника ми взагалі бачили.
|
||||
CoveragePct float64 `json:"coverage_pct"`
|
||||
|
||||
// Incidents — скільки окремих смуг недоступності. Точність — година:
|
||||
// це кількість непересічних серій годин, у яких була хоч одна
|
||||
// невдала спроба. Дві аварії всередині однієї години рахуються як
|
||||
// одна; годину без даних посеред аварії серія переживає, бо
|
||||
// відсутність виміру не є доказом відновлення.
|
||||
Incidents int `json:"incidents"`
|
||||
|
||||
// Insufficient — даних замало для вердикту. Тоді Breached завжди
|
||||
// false, і це НЕ означає «виконано»: означає «не знаємо».
|
||||
Insufficient bool `json:"insufficient"`
|
||||
Breached bool `json:"breached"`
|
||||
}
|
||||
|
||||
// ComputeSLA розкладає період одного хоста.
|
||||
func ComputeSLA(in SLAInput) SLAOutcome {
|
||||
var out SLAOutcome
|
||||
|
||||
// 1. Годинник: період, звужений життям хоста.
|
||||
live, ok := slaClip(SLASpan{From: in.From, To: in.To}, slaLifetime(in))
|
||||
if !ok {
|
||||
// Хоста в цьому періоді не існувало. Нулі скрізь, вердикту
|
||||
// немає. Показати тут «0% доступності» означало б винайти
|
||||
// аварію з нічого.
|
||||
out.Insufficient = true
|
||||
return out
|
||||
}
|
||||
clock := slaSeconds(live)
|
||||
out.ClockSec = int64(math.Round(clock))
|
||||
|
||||
// 2. Обслуговування: об'єднуємо перед відніманням.
|
||||
//
|
||||
// Об'єднання — не оптимізація. Два вікна 02:00–04:00 і 03:00–05:00
|
||||
// дають три години зупиненого годинника, а сума тривалостей дала б
|
||||
// чотири — і доступність вийшла б кращою, ніж є, тихо й на кожному
|
||||
// звіті.
|
||||
maint := slaMerge(slaClipAll(in.Maintenance, live))
|
||||
billable := slaSubtract(live, maint)
|
||||
billableF := slaSecondsAll(billable)
|
||||
|
||||
// 3. Відра, накладені на оплачуваний час.
|
||||
var upF, downF, observedF float64
|
||||
buckets := append([]SLAHourBucket(nil), in.Buckets...)
|
||||
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Start.Before(buckets[j].Start) })
|
||||
|
||||
for _, b := range buckets {
|
||||
if b.Samples <= 0 {
|
||||
// Відро без жодної спроби — це те саме мовчання, лише
|
||||
// записане рядком. Рахувати його як «втрат не було» —
|
||||
// найтихіший спосіб зіпсувати звіт.
|
||||
continue
|
||||
}
|
||||
ov := slaOverlapAll(SLASpan{From: b.Start, To: b.Start.Add(slaBucketSec * time.Second)}, billable)
|
||||
if ov <= 0 {
|
||||
continue
|
||||
}
|
||||
observedF += ov
|
||||
// Втрати всередині години розподіляються рівномірно. Це
|
||||
// припущення, і воно єдине можливе: у відрі немає позначки, коли
|
||||
// саме впало. Похибка обмежена годиною й не накопичується —
|
||||
// сума часток дає ту саму суму спроб.
|
||||
d := ov * float64(b.Down) / float64(b.Samples)
|
||||
downF += d
|
||||
upF += ov - d
|
||||
}
|
||||
if observedF > billableF {
|
||||
// Може статись на межі округлення чи якщо відро частково
|
||||
// накрило вікно обслуговування. Виміряного часу не буває більше
|
||||
// за оплачуваний.
|
||||
observedF = billableF
|
||||
}
|
||||
|
||||
// 4. Округлення так, щоб частини сходились із цілим.
|
||||
//
|
||||
// Спершу дві виміряні величини, далі мовчання добирається різницею,
|
||||
// а обслуговування — залишком годинника. Порядок саме такий, бо
|
||||
// підганяти під ціле треба найменш точну величину, а не ту, на яку
|
||||
// дивиться клієнт.
|
||||
out.UpSec = int64(math.Round(upF))
|
||||
out.DownSec = int64(math.Round(downF))
|
||||
billableSec := int64(math.Round(billableF))
|
||||
if out.UpSec+out.DownSec > billableSec {
|
||||
out.DownSec = billableSec - out.UpSec
|
||||
if out.DownSec < 0 {
|
||||
out.DownSec = 0
|
||||
out.UpSec = billableSec
|
||||
}
|
||||
}
|
||||
out.UnknownSec = billableSec - out.UpSec - out.DownSec
|
||||
if out.UnknownSec < 0 {
|
||||
out.UnknownSec = 0
|
||||
}
|
||||
out.MaintenanceSec = out.ClockSec - out.UpSec - out.DownSec - out.UnknownSec
|
||||
if out.MaintenanceSec < 0 {
|
||||
out.MaintenanceSec = 0
|
||||
}
|
||||
|
||||
// 5. Висновки.
|
||||
measured := out.UpSec + out.DownSec
|
||||
if measured > 0 {
|
||||
out.UptimePct = slaRound3(100 * float64(out.UpSec) / float64(measured))
|
||||
}
|
||||
if billableSec > 0 {
|
||||
out.CoveragePct = slaRound3(100 * float64(measured) / float64(billableSec))
|
||||
}
|
||||
out.Incidents = slaIncidents(buckets, billable)
|
||||
|
||||
// Вердикт виноситься лише при достатньому покритті. Три способи
|
||||
// його не мати: хоста не було, весь період — обслуговування, або
|
||||
// вимірів менше за поріг цілі. У всіх трьох випадках єдина чесна
|
||||
// відповідь — «не знаємо», і вона НЕ дорівнює «виконано».
|
||||
out.Insufficient = billableSec == 0 || measured == 0 ||
|
||||
out.CoveragePct < in.MinCoveragePct
|
||||
if !out.Insufficient {
|
||||
out.Breached = out.UptimePct < in.TargetPct
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// slaIncidents рахує смуги недоступності.
|
||||
//
|
||||
// Смуга починається на годині з невдалими спробами й закінчується на
|
||||
// першій ПОВНІСТЮ вдалій годині. Година без даних смугу не закриває:
|
||||
// відсутність виміру — не доказ того, що хост піднявся, і закривати нею
|
||||
// аварію означало б розбивати одну добову аварію на десяток «інцидентів»
|
||||
// щоразу, коли мовчав зонд.
|
||||
func slaIncidents(buckets []SLAHourBucket, billable []SLASpan) int {
|
||||
n := 0
|
||||
inRun := false
|
||||
for _, b := range buckets {
|
||||
if b.Samples <= 0 {
|
||||
continue
|
||||
}
|
||||
if slaOverlapAll(SLASpan{From: b.Start, To: b.Start.Add(slaBucketSec * time.Second)}, billable) <= 0 {
|
||||
continue
|
||||
}
|
||||
if b.Down > 0 {
|
||||
if !inRun {
|
||||
n++
|
||||
inRun = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
inRun = false
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// slaLifetime — відрізок, у якому хост існував.
|
||||
func slaLifetime(in SLAInput) SLASpan {
|
||||
s := SLASpan{From: in.CreatedAt, To: in.To}
|
||||
if in.DeletedAt != nil && in.DeletedAt.Before(in.To) {
|
||||
s.To = *in.DeletedAt
|
||||
}
|
||||
// Хост без дати створення (теоретично неможливо, практично —
|
||||
// відновлення з чужого дампа) не має звужувати період: краще
|
||||
// порахувати весь, ніж мовчки нічого.
|
||||
if s.From.IsZero() {
|
||||
s.From = in.From
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Арифметика відрізків
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func slaSeconds(s SLASpan) float64 {
|
||||
d := s.To.Sub(s.From).Seconds()
|
||||
if d < 0 {
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func slaSecondsAll(list []SLASpan) float64 {
|
||||
var t float64
|
||||
for _, s := range list {
|
||||
t += slaSeconds(s)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// slaClip — перетин двох відрізків. ok=false, якщо перетину немає.
|
||||
func slaClip(a, b SLASpan) (SLASpan, bool) {
|
||||
out := SLASpan{From: a.From, To: a.To}
|
||||
if b.From.After(out.From) {
|
||||
out.From = b.From
|
||||
}
|
||||
if b.To.Before(out.To) {
|
||||
out.To = b.To
|
||||
}
|
||||
if !out.From.Before(out.To) {
|
||||
return SLASpan{}, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func slaClipAll(list []SLASpan, within SLASpan) []SLASpan {
|
||||
out := make([]SLASpan, 0, len(list))
|
||||
for _, s := range list {
|
||||
if c, ok := slaClip(s, within); ok {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// slaMerge зводить перекриття в непересічні відрізки.
|
||||
func slaMerge(list []SLASpan) []SLASpan {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
cp := append([]SLASpan(nil), list...)
|
||||
sort.Slice(cp, func(i, j int) bool { return cp[i].From.Before(cp[j].From) })
|
||||
|
||||
out := []SLASpan{cp[0]}
|
||||
for _, s := range cp[1:] {
|
||||
last := &out[len(out)-1]
|
||||
// Стик (s.From == last.To) теж об'єднуємо: два суміжні вікна —
|
||||
// одна перерва, і показувати їх окремо немає кому.
|
||||
if !s.From.After(last.To) {
|
||||
if s.To.After(last.To) {
|
||||
last.To = s.To
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// slaSubtract віднімає непересічні cut від base.
|
||||
func slaSubtract(base SLASpan, cut []SLASpan) []SLASpan {
|
||||
out := []SLASpan{base}
|
||||
for _, c := range cut {
|
||||
next := make([]SLASpan, 0, len(out)+1)
|
||||
for _, s := range out {
|
||||
ovl, ok := slaClip(s, c)
|
||||
if !ok {
|
||||
next = append(next, s)
|
||||
continue
|
||||
}
|
||||
if s.From.Before(ovl.From) {
|
||||
next = append(next, SLASpan{From: s.From, To: ovl.From})
|
||||
}
|
||||
if ovl.To.Before(s.To) {
|
||||
next = append(next, SLASpan{From: ovl.To, To: s.To})
|
||||
}
|
||||
}
|
||||
out = next
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func slaOverlapAll(s SLASpan, list []SLASpan) float64 {
|
||||
var t float64
|
||||
for _, b := range list {
|
||||
if c, ok := slaClip(s, b); ok {
|
||||
t += slaSeconds(c)
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func slaRound3(v float64) float64 {
|
||||
return math.Round(v*1000) / 1000
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Календарні межі
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// SLAPeriodBounds — межі періоду, у який потрапляє anchor.
|
||||
//
|
||||
// Ріже в поясі loc, а не в UTC. «Квартал» для клієнта в Києві — це три
|
||||
// календарні місяці за київським календарем; зсунуті на дві-три години
|
||||
// межі здаються дрібницею рівно доти, доки в цих годинах не станеться
|
||||
// аварія, яка потрапить не в той квартал.
|
||||
func SLAPeriodBounds(kind string, anchor time.Time, loc *time.Location) (SLASpan, error) {
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
t := anchor.In(loc)
|
||||
y, m, d := t.Date()
|
||||
midnight := func(yy int, mm time.Month, dd int) time.Time {
|
||||
return time.Date(yy, mm, dd, 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case SLAPeriodDaily:
|
||||
f := midnight(y, m, d)
|
||||
return SLASpan{From: f, To: f.AddDate(0, 0, 1)}, nil
|
||||
case SLAPeriodWeekly:
|
||||
// Понеділок — перший день тижня. time.Weekday рахує від неділі,
|
||||
// тож зсув, а не віднімання: інакше кожна неділя опинялась би в
|
||||
// наступному тижні.
|
||||
shift := (int(t.Weekday()) + 6) % 7
|
||||
f := midnight(y, m, d).AddDate(0, 0, -shift)
|
||||
return SLASpan{From: f, To: f.AddDate(0, 0, 7)}, nil
|
||||
case SLAPeriodMonthly:
|
||||
f := midnight(y, m, 1)
|
||||
return SLASpan{From: f, To: f.AddDate(0, 1, 0)}, nil
|
||||
case SLAPeriodQuarterly:
|
||||
f := midnight(y, time.Month((int(m)-1)/3*3+1), 1)
|
||||
return SLASpan{From: f, To: f.AddDate(0, 3, 0)}, nil
|
||||
}
|
||||
return SLASpan{}, fmt.Errorf("%w: невідомий тип періоду %q", ErrInvalid, kind)
|
||||
}
|
||||
|
||||
// SLAPrevPeriod — межі періоду, що передує тому, у якому now.
|
||||
//
|
||||
// Потрібне рівно для закриття: закривати можна лише те, що вже
|
||||
// скінчилось.
|
||||
func SLAPrevPeriod(kind string, now time.Time, loc *time.Location) (SLASpan, error) {
|
||||
cur, err := SLAPeriodBounds(kind, now, loc)
|
||||
if err != nil {
|
||||
return SLASpan{}, err
|
||||
}
|
||||
// Відступ усередину попереднього періоду, а не віднімання довжини:
|
||||
// довжина місяця й кварталу різна, а секунда перед початком поточного
|
||||
// гарантовано належить попередньому.
|
||||
return SLAPeriodBounds(kind, cur.From.Add(-time.Second), loc)
|
||||
}
|
||||
|
||||
// SLASettleLag — скільки чекати після кінця періоду перед закриттям.
|
||||
//
|
||||
// Шість годин. Причина в 0005: ts.icmp_1h рахується з ts.icmp_5m
|
||||
// політикою з end_offset 1 година й розкладом раз на годину, а сама
|
||||
// ts.icmp_5m — з відставанням у 5 хвилин. Тобто останні години періоду
|
||||
// матеріалізуються не раніше ніж за дві години після його кінця.
|
||||
//
|
||||
// Шість замість двох — це запас на зупинений на ніч сервер і на
|
||||
// відкладені фонові задачі TimescaleDB. Ціна запасу — звіт за минулий
|
||||
// місяць стає остаточним об 06:00 першого числа, а не опівночі. Ціна
|
||||
// його відсутності — закритий назавжди період із недорахованим хвостом,
|
||||
// і виправити його можна лише руками.
|
||||
const SLASettleLag = 6 * time.Hour
|
||||
538
server/internal/store/sla_compute.go
Normal file
538
server/internal/store/sla_compute.go
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Живий розрахунок і закриття періоду.
|
||||
//
|
||||
// ЧОМУ ЗАКРИТИЙ ПЕРІОД НЕ ПЕРЕРАХОВУЮТЬ
|
||||
//
|
||||
// Звіт про доступність — це документ, який показують назовні: клієнту за
|
||||
// договором, аудитору на перевірці. У такого документа є одна властивість,
|
||||
// без якої він не документ: два роздруки того самого кварталу, зроблені в
|
||||
// різні дні, мають збігатися.
|
||||
//
|
||||
// Розрахунок «на льоту» цієї властивості не має й мати не може. Під ним
|
||||
// лежать дані зі строком зберігання, вікна обслуговування, які хтось
|
||||
// може дозаповнити заднім числом, перелік хостів, який змінюється щодня,
|
||||
// і сама ціль, яку можна відредагувати. Кожна з цих речей рухається
|
||||
// незалежно від нас, і жодна не питає дозволу.
|
||||
//
|
||||
// Тому щойно період скінчився й згортки під ним устоялись (SLASettleLag),
|
||||
// він рахується РАЗ і лягає в core.sla_periods разом зі знімками умов:
|
||||
// обіцяний відсоток, поріг покриття, пояс, ім'я хоста. Далі це вже не
|
||||
// дані, а висновок, і читається він як висновок. Перерахувати можна —
|
||||
// але лише свідомо, і кожен перерахунок видно в revision самого рядка.
|
||||
|
||||
// slaBackfillPeriods — скільки закритих періодів назад добирає фоновий
|
||||
// такт.
|
||||
//
|
||||
// Три. Не «усі»: інсталяція, яку підняли після року простою, інакше
|
||||
// порахувала б дванадцять місяців по даних, яких за той рік уже немає, і
|
||||
// закрила б їх назавжди порожніми. Три періоди назад покривають звичайну
|
||||
// причину пропуску — сервер стояв вихідні — і не дотягуються туди, де
|
||||
// закривати вже нема чого.
|
||||
const slaBackfillPeriods = 3
|
||||
|
||||
// slaDevice — хост і межі його життя.
|
||||
type slaDevice struct {
|
||||
ID string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
// slaSelectDevices — хости цілі, які ІСНУВАЛИ хоч частину періоду.
|
||||
//
|
||||
// М'яко видалені сюди входять навмисно. Хост, який прибрали з переліку
|
||||
// 20 травня, у травні працював, і звіт за травень без нього показав би
|
||||
// кращу картину, ніж була, — просто тому, що найгірший учасник зник зі
|
||||
// списку разом із причиною, через яку його прибрали.
|
||||
func (s *Store) slaSelectDevices(ctx context.Context, tx pgx.Tx, tenantID string,
|
||||
sel Selector, span SLASpan) ([]slaDevice, error) {
|
||||
|
||||
a := &args{}
|
||||
pTenant := a.add(tenantID)
|
||||
pFrom := a.add(span.From)
|
||||
pTo := a.add(span.To)
|
||||
where, err := s.selectorSQL(sel, a, "d.id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := fmt.Sprintf(`
|
||||
SELECT d.id::text, d.name, d.created_at, d.deleted_at
|
||||
FROM inv.devices d
|
||||
WHERE d.tenant_id = %s
|
||||
AND d.created_at < %s
|
||||
AND (d.deleted_at IS NULL OR d.deleted_at > %s)
|
||||
%s
|
||||
ORDER BY d.name
|
||||
`, pTenant, pTo, pFrom, where)
|
||||
|
||||
rows, err := tx.Query(ctx, q, a.vals...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []slaDevice
|
||||
for rows.Next() {
|
||||
var d slaDevice
|
||||
if err := rows.Scan(&d.ID, &d.Name, &d.CreatedAt, &d.DeletedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// slaBuckets — годинні відра всіх хостів за період, одним запитом.
|
||||
//
|
||||
// Один запит, а не по хосту: квартал на 500 хостів — це близько мільйона
|
||||
// відер, і мільйон запитів по одному ряду перетворив би звіт на
|
||||
// півгодинну операцію. Предикат tenant_id тут ОБОВ'ЯЗКОВИЙ і не
|
||||
// дублюється RLS: 0011 не вмикає політик на гіпертаблицях і безперервних
|
||||
// агрегатах, тобто другого рубежу для ts.icmp_1h не існує.
|
||||
func (s *Store) slaBuckets(ctx context.Context, tx pgx.Tx, tenantID string,
|
||||
deviceIDs []string, span SLASpan) (map[string][]SLAHourBucket, error) {
|
||||
|
||||
out := map[string][]SLAHourBucket{}
|
||||
if len(deviceIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT device_id::text, bucket, samples, down_samples
|
||||
FROM ts.icmp_1h
|
||||
WHERE tenant_id = $1 AND device_id = ANY($2::uuid[])
|
||||
AND bucket >= $3 AND bucket < $4
|
||||
ORDER BY device_id, bucket
|
||||
`, tenantID, deviceIDs, span.From, span.To)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var b SLAHourBucket
|
||||
if err := rows.Scan(&id, &b.Start, &b.Samples, &b.Down); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = append(out[id], b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// slaMaintenance — вікна обслуговування, розкладені по хостах.
|
||||
//
|
||||
// Селектор вікна розбирається тим самим selectorSQL, що й придушення
|
||||
// алертів. Розійтись їм не можна: вікно, під яким алерт придушено, а SLA
|
||||
// зіпсовано, — це найгірший із можливих станів, бо про нього дізнаються
|
||||
// не тоді, коли він стався, а через квартал, з чужої претензії.
|
||||
func (s *Store) slaMaintenance(ctx context.Context, tx pgx.Tx, tenantID string,
|
||||
devices []slaDevice, span SLASpan) (map[string][]SLASpan, bool, error) {
|
||||
|
||||
out := map[string][]SLASpan{}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT COALESCE(selector::text, '{}'), lower(period), upper(period),
|
||||
(rrule IS NOT NULL)
|
||||
FROM alr.maintenance_windows
|
||||
WHERE tenant_id = $1 AND exclude_from_sla
|
||||
AND period && tstzrange($2, $3, '[)')
|
||||
`, tenantID, span.From, span.To)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
type window struct {
|
||||
sel Selector
|
||||
empty bool
|
||||
from, to time.Time
|
||||
}
|
||||
var windows []window
|
||||
hasRRule := false
|
||||
for rows.Next() {
|
||||
var raw string
|
||||
var from, to *time.Time
|
||||
var rr bool
|
||||
if err := rows.Scan(&raw, &from, &to, &rr); err != nil {
|
||||
rows.Close()
|
||||
return nil, false, err
|
||||
}
|
||||
var w window
|
||||
if err := json.Unmarshal([]byte(raw), &w.sel); err != nil {
|
||||
// Вікно з нерозбірливим селектором ігнорувати не можна: воно
|
||||
// б мовчки перетворилось на простій. Трактуємо як «весь
|
||||
// кабінет» — бік помилки, що не псує звіт клієнту.
|
||||
w.sel = Selector{}
|
||||
}
|
||||
// Вікно без селектора накриває весь кабінет: так виглядає
|
||||
// «вимикаємо все на 30 хвилин». Та сама умова, що й у
|
||||
// LoadSuppression.
|
||||
w.empty = len(w.sel.DeviceIDs) == 0 && len(w.sel.GroupIDs) == 0 &&
|
||||
len(w.sel.SiteIDs) == 0 && len(w.sel.Tags) == 0 &&
|
||||
len(w.sel.Kinds) == 0 && len(w.sel.Vendors) == 0 &&
|
||||
len(w.sel.TemplateIDs) == 0
|
||||
// Незамкнений діапазон ('[2026-01-01,)') означає «і далі»;
|
||||
// обрізаємо періодом, а не відкидаємо вікно.
|
||||
w.from, w.to = span.From, span.To
|
||||
if from != nil && from.After(w.from) {
|
||||
w.from = *from
|
||||
}
|
||||
if to != nil && to.Before(w.to) {
|
||||
w.to = *to
|
||||
}
|
||||
if rr {
|
||||
hasRRule = true
|
||||
}
|
||||
windows = append(windows, w)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(windows) == 0 {
|
||||
return out, false, nil
|
||||
}
|
||||
|
||||
// Порожній селектор накриває всіх — резолвити нічого.
|
||||
inSet := map[string]bool{}
|
||||
for _, d := range devices {
|
||||
inSet[d.ID] = true
|
||||
}
|
||||
for _, w := range windows {
|
||||
if w.empty {
|
||||
for _, d := range devices {
|
||||
out[d.ID] = append(out[d.ID], SLASpan{From: w.from, To: w.to})
|
||||
}
|
||||
continue
|
||||
}
|
||||
ids, err := s.slaSelectDevices(ctx, tx, tenantID, w.sel, span)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
for _, d := range ids {
|
||||
if !inSet[d.ID] {
|
||||
continue
|
||||
}
|
||||
out[d.ID] = append(out[d.ID], SLASpan{From: w.from, To: w.to})
|
||||
}
|
||||
}
|
||||
return out, hasRRule, nil
|
||||
}
|
||||
|
||||
// slaComputeRows — живий розрахунок цілі за період.
|
||||
func (s *Store) slaComputeRows(ctx context.Context, tx pgx.Tx, tenantID string,
|
||||
t SLATarget, span SLASpan) ([]SLARow, []string, error) {
|
||||
|
||||
devices, err := s.slaSelectDevices(ctx, tx, tenantID, t.Selector, span)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ids := make([]string, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
ids = append(ids, d.ID)
|
||||
}
|
||||
buckets, err := s.slaBuckets(ctx, tx, tenantID, ids, span)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
maint, hasRRule, err := s.slaMaintenance(ctx, tx, tenantID, devices, span)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var warn []string
|
||||
if hasRRule {
|
||||
warn = append(warn, SLAWarnRRule)
|
||||
}
|
||||
|
||||
rows := make([]SLARow, 0, len(devices))
|
||||
for _, d := range devices {
|
||||
r := SLARow{DeviceID: d.ID, DeviceName: d.Name, Warnings: []string{}}
|
||||
r.SLAOutcome = ComputeSLA(SLAInput{
|
||||
From: span.From,
|
||||
To: span.To,
|
||||
CreatedAt: d.CreatedAt,
|
||||
DeletedAt: d.DeletedAt,
|
||||
Maintenance: maint[d.ID],
|
||||
Buckets: buckets[d.ID],
|
||||
TargetPct: t.TargetPct,
|
||||
MinCoveragePct: t.MinCoveragePct,
|
||||
})
|
||||
if hasRRule && len(maint[d.ID]) > 0 {
|
||||
r.Warnings = append(r.Warnings, SLAWarnRRule)
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
return rows, warn, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Закриття
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// ErrSLANotSettled — період ще не можна закривати.
|
||||
//
|
||||
// Окрема помилка, а не загальний ErrInvalid: клієнт має показати не
|
||||
// «некоректний запит», а причину — «зачекайте, згортки під періодом ще
|
||||
// рахуються». Ці два тексти ведуть людину в різні боки.
|
||||
var ErrSLANotSettled = fmt.Errorf("%w: період ще не завершився або згортки під ним не встоялись", ErrInvalid)
|
||||
|
||||
// CloseSLAPeriod рахує період один раз і зберігає як факт.
|
||||
//
|
||||
// Ідемпотентний: повторний виклик на вже закритому періоді нічого не
|
||||
// переписує й повертає збережене. Саме через це фоновий такт може
|
||||
// сміливо ходити по тих самих періодах щогодини.
|
||||
//
|
||||
// force переписує закрите — і це єдиний шлях це зробити. Він піднімає
|
||||
// revision, тобто лишає слід у самому звіті: два роздруки того самого
|
||||
// кварталу з різними числами інакше неможливо розрізнити.
|
||||
func (s *Store) CloseSLAPeriod(ctx context.Context, tenantID, targetID string,
|
||||
anchor time.Time, force bool) (SLAReport, error) {
|
||||
|
||||
var rep SLAReport
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
t, err := s.slaTarget(ctx, tx, tenantID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rep, err = s.slaCloseTx(ctx, tx, tenantID, t, anchor, force)
|
||||
return err
|
||||
})
|
||||
return rep, err
|
||||
}
|
||||
|
||||
func (s *Store) slaCloseTx(ctx context.Context, tx pgx.Tx, tenantID string,
|
||||
t SLATarget, anchor time.Time, force bool) (SLAReport, error) {
|
||||
|
||||
loc, tzOK := t.Location()
|
||||
span, err := SLAPeriodBounds(t.PeriodKind, anchor, loc)
|
||||
if err != nil {
|
||||
return SLAReport{}, err
|
||||
}
|
||||
// Раніше строку не закриваємо НІКОЛИ, навіть на вимогу.
|
||||
//
|
||||
// force дозволяє переписати вже пораховане, а не порахувати
|
||||
// недорахованим. Різниця вирішальна: закритий на добу раніше
|
||||
// квартал недорахував би останні години назавжди, і жодна кнопка
|
||||
// цього вже не помітила б.
|
||||
if time.Now().Before(span.To.Add(SLASettleLag)) {
|
||||
return SLAReport{}, ErrSLANotSettled
|
||||
}
|
||||
|
||||
stored, err := s.slaStoredRows(ctx, tx, tenantID, t.ID, span, loc)
|
||||
if err != nil {
|
||||
return SLAReport{}, err
|
||||
}
|
||||
if stored.Closed && !force {
|
||||
rep, err := s.slaReportTx(ctx, tx, tenantID, t, anchor)
|
||||
return rep, err
|
||||
}
|
||||
|
||||
rows, warn, err := s.slaComputeRows(ctx, tx, tenantID, t, span)
|
||||
if err != nil {
|
||||
return SLAReport{}, err
|
||||
}
|
||||
if !tzOK {
|
||||
warn = slaAppendUnique(warn, SLAWarnUnknownTZ)
|
||||
}
|
||||
if len(t.BusinessHours) > 0 {
|
||||
warn = slaAppendUnique(warn, SLAWarnBusinessHours)
|
||||
}
|
||||
horizon, err := s.slaHorizon(ctx, tx, tenantID)
|
||||
if err != nil {
|
||||
return SLAReport{}, err
|
||||
}
|
||||
if slaBeyondHorizon(horizon, span.From) {
|
||||
// Найважливіше попередження з усіх: частина періоду не має даних
|
||||
// не тому, що їх не збирали, а тому, що їх уже видалили. Воно
|
||||
// вмерзає в рядок разом із числом — інакше через рік звіт
|
||||
// виглядав би як звичайна недостатність покриття.
|
||||
warn = slaAppendUnique(warn, SLAWarnBeyondHorizon)
|
||||
}
|
||||
|
||||
revision := 1
|
||||
if stored.Closed {
|
||||
revision = stored.Revision + 1
|
||||
// Дозвіл на правку закритого — рівно на цю транзакцію.
|
||||
if _, err := tx.Exec(ctx, `SELECT set_config('app.sla_reopen', 'on', true)`); err != nil {
|
||||
return SLAReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
from := span.From.In(loc).Format("2006-01-02")
|
||||
to := span.To.In(loc).Format("2006-01-02")
|
||||
|
||||
// Повне перезаписування, а не UPSERT по хостах: за час між
|
||||
// розрахунками хост міг випасти з селектора, і залишений рядок
|
||||
// показував би в звіті когось, кого ціль уже не стосується.
|
||||
if _, err := tx.Exec(ctx, `
|
||||
DELETE FROM core.sla_periods
|
||||
WHERE tenant_id = $1 AND sla_target_id = $2
|
||||
AND period = daterange($3::date, $4::date)
|
||||
`, tenantID, t.ID, from, to); err != nil {
|
||||
return SLAReport{}, err
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
w := slaAppendUnique(append([]string(nil), r.Warnings...), warn...)
|
||||
raw, _ := json.Marshal(w)
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO core.sla_periods
|
||||
(tenant_id, sla_target_id, device_id, device_name, period,
|
||||
uptime_pct, downtime_sec, maintenance_sec, up_sec, unknown_sec,
|
||||
clock_sec, coverage_pct, incidents, breached,
|
||||
target_pct, min_coverage_pct, tz, source, closed, revision,
|
||||
warnings, computed_at)
|
||||
VALUES ($1, $2, $3, $4, daterange($5::date, $6::date),
|
||||
$7, $8, $9, $10, $11, $12, $13, $14, $15,
|
||||
$16, $17, $18, 'icmp_1h', true, $19, $20::jsonb, now())
|
||||
`, tenantID, t.ID, r.DeviceID, r.DeviceName, from, to,
|
||||
r.UptimePct, r.DownSec, r.MaintenanceSec, r.UpSec, r.UnknownSec,
|
||||
r.ClockSec, r.CoveragePct, r.Incidents, r.Breached,
|
||||
t.TargetPct, t.MinCoveragePct, t.TZ, revision, string(raw)); err != nil {
|
||||
return SLAReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return SLAReport{
|
||||
Target: t,
|
||||
From: span.From,
|
||||
To: span.To,
|
||||
PeriodFrom: from,
|
||||
PeriodTo: to,
|
||||
Closed: true,
|
||||
Closable: true,
|
||||
ComputedAt: &now,
|
||||
Revision: revision,
|
||||
Rows: rows,
|
||||
Horizon: horizon,
|
||||
Warnings: warn,
|
||||
Totals: slaTotals(rows, t.TargetPct, t.MinCoveragePct),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SLAClosed — скільки періодів закрив прохід і за якими цілями.
|
||||
type SLAClosed struct {
|
||||
TenantID string
|
||||
TargetID string
|
||||
Name string
|
||||
Period string
|
||||
}
|
||||
|
||||
// CloseDueSLAPeriods закриває все, що вже можна закрити.
|
||||
//
|
||||
// Фоновий такт, а не «порахуємо, коли відкриють сторінку». Причина
|
||||
// проста: періоду, який ніхто не відкрив вчасно, дані під ним не
|
||||
// дочекаються. Квартал, уперше відкритий через півроку, закрився б
|
||||
// назавжди по тому, що від нього лишилось, — і саме цей рядок відрізняє
|
||||
// звіт, який можна показати, від звіту, який можна лише пояснити.
|
||||
func (s *Store) CloseDueSLAPeriods(ctx context.Context) ([]SLAClosed, error) {
|
||||
tenants, err := s.TenantIDs(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var done []SLAClosed
|
||||
for _, tenantID := range tenants {
|
||||
targets, err := s.slaEnabledTargets(ctx, tenantID)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
for _, t := range targets {
|
||||
loc, _ := t.Location()
|
||||
// Від найстарішого до найсвіжішого: якщо на середині щось
|
||||
// упаде, уже закрите лишиться закритим, а наступний тік
|
||||
// продовжить з того ж місця.
|
||||
for back := slaBackfillPeriods; back >= 1; back-- {
|
||||
span, err := slaPeriodBack(t.PeriodKind, time.Now(), loc, back)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if time.Now().Before(span.To.Add(SLASettleLag)) {
|
||||
continue
|
||||
}
|
||||
closed, err := s.slaCloseIfMissing(ctx, tenantID, t, span)
|
||||
if err != nil {
|
||||
return done, err
|
||||
}
|
||||
if closed {
|
||||
done = append(done, SLAClosed{
|
||||
TenantID: tenantID, TargetID: t.ID, Name: t.Name,
|
||||
Period: span.From.In(loc).Format("2006-01-02"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return done, nil
|
||||
}
|
||||
|
||||
// slaCloseIfMissing — окрема транзакція на кожен період.
|
||||
//
|
||||
// Не одна на все: закриття кварталу на 500 хостів — це 500 вставок, і
|
||||
// тримати їх в одній транзакції з усіма іншими цілями означало б, що
|
||||
// збій на останній скасовує роботу за всі попередні.
|
||||
func (s *Store) slaCloseIfMissing(ctx context.Context, tenantID string, t SLATarget, span SLASpan) (bool, error) {
|
||||
loc, _ := t.Location()
|
||||
closed := false
|
||||
err := s.InTenantTxBG(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
var n int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM core.sla_periods
|
||||
WHERE tenant_id = $1 AND sla_target_id = $2
|
||||
AND period = daterange($3::date, $4::date) AND closed
|
||||
`, tenantID, t.ID, span.From.In(loc).Format("2006-01-02"),
|
||||
span.To.In(loc).Format("2006-01-02")).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
// anchor — будь-яка мить усередині періоду; беремо початок.
|
||||
if _, err := s.slaCloseTx(ctx, tx, tenantID, t, span.From, false); err != nil {
|
||||
return err
|
||||
}
|
||||
closed = true
|
||||
return nil
|
||||
})
|
||||
return closed, err
|
||||
}
|
||||
|
||||
func (s *Store) slaEnabledTargets(ctx context.Context, tenantID string) ([]SLATarget, error) {
|
||||
all, err := s.SLATargets(ctx, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := all[:0]
|
||||
for _, t := range all {
|
||||
if t.Enabled {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// slaPeriodBack — межі періоду, що передує поточному на back кроків.
|
||||
func slaPeriodBack(kind string, now time.Time, loc *time.Location, back int) (SLASpan, error) {
|
||||
span, err := SLAPeriodBounds(kind, now, loc)
|
||||
if err != nil {
|
||||
return SLASpan{}, err
|
||||
}
|
||||
for i := 0; i < back; i++ {
|
||||
// Секунда перед початком періоду завжди належить попередньому —
|
||||
// незалежно від того, скільки в ньому діб. Віднімання фіксованої
|
||||
// довжини тут дало б збій на лютому й на переході часу.
|
||||
span, err = SLAPeriodBounds(kind, span.From.Add(-time.Second), loc)
|
||||
if err != nil {
|
||||
return SLASpan{}, err
|
||||
}
|
||||
}
|
||||
return span, nil
|
||||
}
|
||||
248
server/internal/store/sla_csv.go
Normal file
248
server/internal/store/sla_csv.go
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Вивантаження звіту SLA.
|
||||
//
|
||||
// ЧОМУ CSV ЗБИРАЄ СЕРВЕР, А НЕ БРАУЗЕР
|
||||
//
|
||||
// Спокуса зібрати його в TypeScript велика: рядки вже на екрані,
|
||||
// лишається склеїти. Але саме цей файл кладуть у папку до договору, і
|
||||
// саме його потім звіряють із тим, що показує сторінка. Дві реалізації
|
||||
// одного округлення розійдуться на третьому знаку — і розбіжність
|
||||
// побачить не той, хто її зробив, а клієнт.
|
||||
//
|
||||
// ЧОМУ КРАПКА, А НЕ КОМА, У ДРОБАХ
|
||||
//
|
||||
// Український Excel читає крапку як текст, і 99.912 стає рядком.
|
||||
// Спокусливо писати кому — і тоді той самий файл ламається в усьому
|
||||
// іншому: у csv кома вже є роздільником полів, у Python, jq, Grafana й
|
||||
// будь-якому імпортері кома в числі — це помилка розбору.
|
||||
//
|
||||
// Тому крапка, а різницю бере на себе перший рядок файла: заголовок
|
||||
// sep=; змушує Excel читати роздільником крапку з комою, і тоді числа з
|
||||
// крапкою вже нікуди не з'їжджають. Це не наша вигадка, а домовленість
|
||||
// самого Excel; решта світу цей рядок пропускає як коментар.
|
||||
|
||||
// SLAReportCSV пише звіт у потік.
|
||||
//
|
||||
// Один рядок на хост плюс підсумковий. Підсумок саме в тому ж файлі, а
|
||||
// не окремим: людина, яка відкрила вивантаження, шукає в ньому головне
|
||||
// число, і змушувати її додавати стовпчик руками означає віддати їй
|
||||
// шанс помилитись у тому, заради чого файл і робився.
|
||||
func SLAReportCSV(w io.Writer, rep SLAReport) error {
|
||||
// BOM: без нього український Excel читає UTF-8 як cp1251, і всі
|
||||
// назви хостів перетворюються на кракозябри. Решта інструментів BOM
|
||||
// пропускає.
|
||||
if _, err := io.WriteString(w, "\ufeff"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.WriteString(w, "sep=;\r\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
cw.Comma = ';'
|
||||
// CRLF — те, чого чекає Excel. Для решти світу різниці немає.
|
||||
cw.UseCRLF = true
|
||||
|
||||
// Шапка з умовами звіту. Вона й є те, що робить файл документом:
|
||||
// без неї «99.912» не відповідає на питання «за що і проти чого».
|
||||
head := [][]string{
|
||||
{"Ціль", rep.Target.Name},
|
||||
{"Період", rep.PeriodFrom + " — " + rep.PeriodTo},
|
||||
{"Тип періоду", slaPeriodLabel(rep.Target.PeriodKind)},
|
||||
{"Часовий пояс", rep.Target.TZ},
|
||||
{"Ціль доступності, %", slaNum(rep.Target.TargetPct)},
|
||||
{"Поріг покриття, %", slaNum(rep.Target.MinCoveragePct)},
|
||||
{"Джерело даних", rep.Horizon.Relation},
|
||||
{"Стан звіту", slaStateLabel(rep)},
|
||||
}
|
||||
if rep.ComputedAt != nil {
|
||||
head = append(head, []string{"Розраховано", rep.ComputedAt.Format(time.RFC3339)})
|
||||
}
|
||||
if rep.Revision > 1 {
|
||||
head = append(head, []string{"Перерахунків", strconv.Itoa(rep.Revision)})
|
||||
}
|
||||
// Строк зберігання джерела — у шапці, а не в примітці. Це та
|
||||
// властивість, через яку звіт узагалі може змінитись, і читач має
|
||||
// побачити її поруч із числами.
|
||||
head = append(head, []string{"Строк зберігання джерела", slaKeepLabel(rep.Horizon.KeepDays)})
|
||||
for _, code := range rep.Warnings {
|
||||
head = append(head, []string{"Застереження", SLAWarningLabel(code)})
|
||||
}
|
||||
for _, row := range head {
|
||||
if err := cw.Write(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := cw.Write(nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cw.Write([]string{
|
||||
"Хост",
|
||||
"Доступність, %",
|
||||
"Покриття, %",
|
||||
"Вердикт",
|
||||
"Простій, с",
|
||||
"Доступно, с",
|
||||
"Немає даних, с",
|
||||
"Обслуговування, с",
|
||||
"Тривалість періоду, с",
|
||||
"Смуг недоступності",
|
||||
"Застереження",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range rep.Rows {
|
||||
if err := cw.Write(slaCSVRow(r.DeviceName, r.SLAOutcome, r.Warnings)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := cw.Write(slaCSVRow("РАЗОМ", rep.Totals, nil)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cw.Flush()
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
func slaCSVRow(name string, o SLAOutcome, warnings []string) []string {
|
||||
// Доступність порожня, а не «0», коли її не виміряли. Нуль у цій
|
||||
// клітинці читається як «лежало весь період» — рівно навпаки до
|
||||
// того, що сталось насправді.
|
||||
uptime := ""
|
||||
if !o.Insufficient {
|
||||
uptime = slaNum(o.UptimePct)
|
||||
}
|
||||
labels := make([]string, 0, len(warnings))
|
||||
for _, c := range warnings {
|
||||
labels = append(labels, SLAWarningLabel(c))
|
||||
}
|
||||
return []string{
|
||||
name,
|
||||
uptime,
|
||||
slaNum(o.CoveragePct),
|
||||
slaVerdict(o),
|
||||
strconv.FormatInt(o.DownSec, 10),
|
||||
strconv.FormatInt(o.UpSec, 10),
|
||||
strconv.FormatInt(o.UnknownSec, 10),
|
||||
strconv.FormatInt(o.MaintenanceSec, 10),
|
||||
strconv.FormatInt(o.ClockSec, 10),
|
||||
strconv.Itoa(o.Incidents),
|
||||
strings.Join(labels, "; "),
|
||||
}
|
||||
}
|
||||
|
||||
// slaVerdict — три стани, а не два.
|
||||
//
|
||||
// «Недостатньо даних» мусить бути окремим словом. Звести його до
|
||||
// «виконано» означає збрехати, до «порушено» — звинуватити мережу в
|
||||
// тому, що зламався моніторинг.
|
||||
func slaVerdict(o SLAOutcome) string {
|
||||
switch {
|
||||
case o.Insufficient:
|
||||
return "недостатньо даних"
|
||||
case o.Breached:
|
||||
return "порушено"
|
||||
default:
|
||||
return "виконано"
|
||||
}
|
||||
}
|
||||
|
||||
// SLAVerdict — той самий висновок для інших форматів.
|
||||
func SLAVerdict(o SLAOutcome) string { return slaVerdict(o) }
|
||||
|
||||
func slaStateLabel(rep SLAReport) string {
|
||||
if rep.Closed {
|
||||
return "закритий період (перерахунку не підлягає)"
|
||||
}
|
||||
if rep.Closable {
|
||||
return "попередній розрахунок; період можна закрити"
|
||||
}
|
||||
return "попередній розрахунок; період ще триває"
|
||||
}
|
||||
|
||||
func slaKeepLabel(days *int) string {
|
||||
if days == nil {
|
||||
return "без строку"
|
||||
}
|
||||
return strconv.Itoa(*days) + " діб"
|
||||
}
|
||||
|
||||
func slaPeriodLabel(kind string) string {
|
||||
switch kind {
|
||||
case SLAPeriodDaily:
|
||||
return "доба"
|
||||
case SLAPeriodWeekly:
|
||||
return "тиждень"
|
||||
case SLAPeriodMonthly:
|
||||
return "місяць"
|
||||
case SLAPeriodQuarterly:
|
||||
return "квартал"
|
||||
}
|
||||
return kind
|
||||
}
|
||||
|
||||
// SLAPeriodLabel — назва типу періоду для інтерфейсу й журналу.
|
||||
func SLAPeriodLabel(kind string) string { return slaPeriodLabel(kind) }
|
||||
|
||||
// slaNum — число з трьома знаками й крапкою, без хвостових нулів.
|
||||
//
|
||||
// Без хвостових нулів навмисно: «100» і «100.000» в одному стовпчику
|
||||
// читаються як різні за точністю виміри, хоч це те саме число.
|
||||
func slaNum(v float64) string {
|
||||
s := strconv.FormatFloat(v, 'f', 3, 64)
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimSuffix(s, ".")
|
||||
if s == "" || s == "-" {
|
||||
return "0"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SLAReportFileName — ім'я файла вивантаження.
|
||||
//
|
||||
// Назва цілі в імені навмисно: у папці «Договори» лежатиме десяток таких
|
||||
// файлів, і «sla.csv» серед них не означає нічого.
|
||||
func SLAReportFileName(rep SLAReport) string {
|
||||
name := slaSafeName(rep.Target.Name)
|
||||
return fmt.Sprintf("sla-%s-%s.csv", name, rep.PeriodFrom)
|
||||
}
|
||||
|
||||
func slaSafeName(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '-' || r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
// Кирилиця й пробіли зводяться до дефіса: ім'я файла їде в
|
||||
// заголовок Content-Disposition, а він у частині проксі й
|
||||
// старих браузерів не переживає ані не-ASCII, ані пробілу.
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "-")
|
||||
for strings.Contains(out, "--") {
|
||||
out = strings.ReplaceAll(out, "--", "-")
|
||||
}
|
||||
if out == "" {
|
||||
return "report"
|
||||
}
|
||||
if len(out) > 40 {
|
||||
out = strings.Trim(out[:40], "-")
|
||||
}
|
||||
return out
|
||||
}
|
||||
242
server/internal/store/sla_db_test.go
Normal file
242
server/internal/store/sla_db_test.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Перевірка SLA ПРОТИ БАЗИ.
|
||||
//
|
||||
// Чиста арифметика покрита в sla_test.go, і це головна половина. Друга
|
||||
// половина арифметикою не перевіряється взагалі, бо вона не в коді:
|
||||
//
|
||||
// - закритий період справді не переписується — це властивість тригера
|
||||
// на таблиці, а не гілки в Go, і тест без бази про неї нічого не
|
||||
// каже, скільки б зеленого не показував;
|
||||
// - закритий період переживає ПОВНЕ видалення хоста (0057) — це
|
||||
// властивість знятого зовнішнього ключа;
|
||||
// - повторне закриття нічого не міняє — це властивість трьох запитів
|
||||
// разом, а не функції.
|
||||
//
|
||||
// Мовчки пропускається без NETPULSE_TEST_DSN: `go test ./...` не має
|
||||
// вимагати бази. Запускати ЛИШЕ на одноразовій базі — тест створює
|
||||
// кабінет і видаляє його з усім вмістом:
|
||||
//
|
||||
// docker run --rm -d --name np-test -e POSTGRES_PASSWORD=x \
|
||||
// -e POSTGRES_DB=np timescale/timescaledb:2.17.2-pg16
|
||||
// NETPULSE_DSN=postgres://postgres:x@localhost/np go run ./cmd/netpulse-migrate
|
||||
// NETPULSE_TEST_DSN=postgres://postgres:x@localhost/np \
|
||||
// go test ./internal/store/ -run SLAAgainstDB -v
|
||||
func TestSLAAgainstDB(t *testing.T) {
|
||||
dsn := os.Getenv("NETPULSE_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("NETPULSE_TEST_DSN не задано — перевірка проти бази пропускається")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
st, err := New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("підключення: %v", err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
|
||||
slug := "sla-" + strings.ReplaceAll(time.Now().Format("150405.000"), ".", "")
|
||||
var tenantID string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO core.tenants (slug, name, timezone) VALUES ($1, $2, 'UTC')
|
||||
RETURNING id::text
|
||||
`, slug, "Перевірка SLA").Scan(&tenantID); err != nil {
|
||||
t.Fatalf("кабінет: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
bg := context.Background()
|
||||
// Тригер незмінності стоїть і на DELETE: без дозволу каскад від
|
||||
// кабінету не змів би закритих періодів, і прибирання тесту
|
||||
// лишило б за собою кабінет.
|
||||
_, _ = st.pool.Exec(bg, `SET app.sla_reopen = 'on'`)
|
||||
_, _ = st.pool.Exec(bg, `DELETE FROM core.tenants WHERE id = $1`, tenantID)
|
||||
_, _ = st.pool.Exec(bg, `RESET app.sla_reopen`)
|
||||
})
|
||||
|
||||
// Період — позаминула доба: вона вже скінчилась і давно «встоялась».
|
||||
day := time.Now().UTC().AddDate(0, 0, -2).Truncate(24 * time.Hour)
|
||||
|
||||
var deviceID string
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
INSERT INTO inv.devices (tenant_id, name, address, kind, created_at)
|
||||
VALUES ($1, $2, '10.88.0.1', 'switch', $3) RETURNING id::text
|
||||
`, tenantID, slug+"-sw", day.AddDate(0, 0, -10)).Scan(&deviceID); err != nil {
|
||||
t.Fatalf("хост: %v", err)
|
||||
}
|
||||
|
||||
// Дані кладемо ПРЯМО в годинну згортку, а не в сирі виміри.
|
||||
//
|
||||
// Не з лінощів: у бойовій базі ts.icmp_1h наповнює політика
|
||||
// TimescaleDB, і чекати на неї в тесті довелось би реальну годину.
|
||||
// А перевіряємо тут не роботу згорток (вона перевірена 0005), а
|
||||
// поведінку звіту над ними.
|
||||
mat := st.slaMaterializedHypertable(ctx, t, "ts.icmp_1h")
|
||||
for i := 0; i < 24; i++ {
|
||||
down := int64(0)
|
||||
if i == 5 {
|
||||
down = 30 // чверть години простою
|
||||
}
|
||||
if _, err := st.pool.Exec(ctx, `
|
||||
INSERT INTO `+mat+` (bucket, device_id, tenant_id, samples, down_samples)
|
||||
VALUES ($1, $2, $3, 120, $4)
|
||||
`, day.Add(time.Duration(i)*time.Hour), deviceID, tenantID, down); err != nil {
|
||||
t.Skipf("не вдалось покласти відро напряму в матеріалізовану таблицю (%v) — "+
|
||||
"перевірка потребує доступу до внутрішньої схеми TimescaleDB", err)
|
||||
}
|
||||
}
|
||||
|
||||
target, err := st.SaveSLATarget(ctx, tenantID, SLATargetInput{
|
||||
Name: "Ядро",
|
||||
PeriodKind: SLAPeriodDaily,
|
||||
TargetPct: 99.9,
|
||||
TZ: "UTC",
|
||||
MinCoveragePct: 95,
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ціль: %v", err)
|
||||
}
|
||||
|
||||
anchor := day.Add(12 * time.Hour)
|
||||
|
||||
// --- 1. Живий розрахунок бачить дані.
|
||||
rep, err := st.SLAReportFor(ctx, tenantID, target.ID, anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("звіт: %v", err)
|
||||
}
|
||||
if rep.Closed {
|
||||
t.Fatal("незакритий період не має видавати себе за закритий")
|
||||
}
|
||||
if len(rep.Rows) != 1 {
|
||||
t.Fatalf("рядків у звіті: %d, очікували 1", len(rep.Rows))
|
||||
}
|
||||
if rep.Rows[0].DownSec != 900 {
|
||||
t.Fatalf("простій: %d с, очікували 900", rep.Rows[0].DownSec)
|
||||
}
|
||||
|
||||
// --- 2. Закриття робить число фактом.
|
||||
closed, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, anchor, false)
|
||||
if err != nil {
|
||||
t.Fatalf("закриття: %v", err)
|
||||
}
|
||||
if !closed.Closed || closed.Revision != 1 {
|
||||
t.Fatalf("закритий період: closed=%v revision=%d", closed.Closed, closed.Revision)
|
||||
}
|
||||
|
||||
// --- 3. Повторне закриття нічого не міняє.
|
||||
again, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, anchor, false)
|
||||
if err != nil {
|
||||
t.Fatalf("повторне закриття: %v", err)
|
||||
}
|
||||
if again.Revision != 1 {
|
||||
t.Fatalf("повторне закриття підняло ревізію до %d", again.Revision)
|
||||
}
|
||||
|
||||
// --- 4. ГОЛОВНЕ: дані зникли, а звіт лишився тим самим.
|
||||
//
|
||||
// Саме це й моделює строк зберігання: за два місяці відер під
|
||||
// кварталом не буде. Наївний розрахунок після цього дав би інше
|
||||
// число; закритий період має віддати те саме.
|
||||
if _, err := st.pool.Exec(ctx,
|
||||
`DELETE FROM `+mat+` WHERE tenant_id = $1`, tenantID); err != nil {
|
||||
t.Fatalf("видалення відер: %v", err)
|
||||
}
|
||||
after, err := st.SLAReportFor(ctx, tenantID, target.ID, anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("звіт після втрати даних: %v", err)
|
||||
}
|
||||
if !after.Closed {
|
||||
t.Fatal("після втрати даних звіт перестав бути закритим")
|
||||
}
|
||||
if after.Rows[0].DownSec != closed.Rows[0].DownSec ||
|
||||
after.Totals.UptimePct != closed.Totals.UptimePct {
|
||||
t.Fatalf("закритий звіт змінився після зникнення даних: було %v%%, стало %v%%",
|
||||
closed.Totals.UptimePct, after.Totals.UptimePct)
|
||||
}
|
||||
|
||||
// --- 5. Тригер не дає переписати закритий рядок повз наш код.
|
||||
if _, err := st.pool.Exec(ctx, `
|
||||
UPDATE core.sla_periods SET uptime_pct = 100
|
||||
WHERE tenant_id = $1 AND sla_target_id = $2
|
||||
`, tenantID, target.ID); err == nil {
|
||||
t.Fatal("закритий період переписався звичайним UPDATE — тригер незмінності не працює")
|
||||
}
|
||||
|
||||
// --- 6. Свідомий перерахунок піднімає ревізію.
|
||||
forced, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, anchor, true)
|
||||
if err != nil {
|
||||
t.Fatalf("свідомий перерахунок: %v", err)
|
||||
}
|
||||
if forced.Revision != 2 {
|
||||
t.Fatalf("ревізія після перерахунку: %d, очікували 2", forced.Revision)
|
||||
}
|
||||
// Дані ми щойно знесли, тож перерахунок ЧЕСНО дає невідомість, а не
|
||||
// ті самі числа. Це і є ціна свідомого перерахунку, і саме тому він
|
||||
// лишає слід.
|
||||
if !forced.Totals.Insufficient {
|
||||
t.Fatal("перерахунок по зниклих даних мусить дати «недостатньо даних»")
|
||||
}
|
||||
|
||||
// --- 7. Закритий період переживає ПОВНЕ видалення хоста.
|
||||
if _, err := st.pool.Exec(ctx,
|
||||
`DELETE FROM inv.devices WHERE id = $1`, deviceID); err != nil {
|
||||
t.Fatalf("видалення хоста: %v", err)
|
||||
}
|
||||
var left int
|
||||
if err := st.pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM core.sla_periods WHERE tenant_id = $1
|
||||
`, tenantID).Scan(&left); err != nil {
|
||||
t.Fatalf("перелік періодів: %v", err)
|
||||
}
|
||||
if left == 0 {
|
||||
t.Fatal("повне видалення хоста забрало з собою закритий звіт — " +
|
||||
"зовнішній ключ із CASCADE повернувся")
|
||||
}
|
||||
survived, err := st.SLAReportFor(ctx, tenantID, target.ID, anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("звіт після видалення хоста: %v", err)
|
||||
}
|
||||
if len(survived.Rows) == 0 || survived.Rows[0].DeviceName == "" {
|
||||
t.Fatal("у звіті лишився рядок без імені хоста — знімок імені не працює")
|
||||
}
|
||||
found := false
|
||||
for _, w := range survived.Rows[0].Warnings {
|
||||
if w == SLAWarnDevicePurged {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("рядок видаленого хоста не позначено попередженням")
|
||||
}
|
||||
|
||||
// --- 8. Незавершений період закрити не можна навіть на вимогу.
|
||||
if _, err := st.CloseSLAPeriod(ctx, tenantID, target.ID, time.Now(), true); err == nil {
|
||||
t.Fatal("поточну добу вдалось закрити — недорахований звіт став би фактом")
|
||||
}
|
||||
}
|
||||
|
||||
// slaMaterializedHypertable — фізична таблиця під безперервним агрегатом.
|
||||
//
|
||||
// Писати у вигляд не можна, а політика наповнить його не раніше ніж за
|
||||
// годину. Та сама асиметрія, що описана в 0064: політики й розміри
|
||||
// живуть на матеріалізованій таблиці, а не на вигляді.
|
||||
func (s *Store) slaMaterializedHypertable(ctx context.Context, t *testing.T, view string) string {
|
||||
t.Helper()
|
||||
var schema, name string
|
||||
if err := s.pool.QueryRow(ctx, `
|
||||
SELECT materialization_hypertable_schema, materialization_hypertable_name
|
||||
FROM timescaledb_information.continuous_aggregates
|
||||
WHERE view_schema = split_part($1, '.', 1) AND view_name = split_part($1, '.', 2)
|
||||
`, view).Scan(&schema, &name); err != nil {
|
||||
t.Skipf("не знайдено матеріалізованої таблиці для %s: %v", view, err)
|
||||
}
|
||||
return schema + "." + name
|
||||
}
|
||||
600
server/internal/store/sla_store.go
Normal file
600
server/internal/store/sla_store.go
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Цілі SLA і закриті періоди: усе, що ходить у базу.
|
||||
//
|
||||
// ЧОМУ ДЖЕРЕЛО — ts.icmp_1h, А НЕ СИРІ ВИМІРИ
|
||||
//
|
||||
// Це не питання швидкості, а питання того, чи буде звіт тим самим через
|
||||
// два місяці. Строки з 0005, як їх бачить 0064:
|
||||
//
|
||||
// ts.icmp_samples 35 діб
|
||||
// ts.icmp_5m 400 діб
|
||||
// ts.icmp_1h строку немає
|
||||
//
|
||||
// Квартал — 90–92 доби. Порахований по сирих вимірах, він СЬОГОДНІ
|
||||
// правильний, а через два місяці той самий запит на той самий квартал
|
||||
// дасть інше число: рядків під ним просто не буде, а «немає рядка» й
|
||||
// «втрат не було» запит не розрізняє. Звіт мовчки поїде до 100%.
|
||||
//
|
||||
// ts.icmp_5m покриває квартал, але 400 діб — це політика, яку 0064 дала
|
||||
// міняти з веб-форми. ts.icmp_1h — єдиний рівень, якому продукт уже
|
||||
// пообіцяв жити: 0005 не заводить йому політики зовсім, а
|
||||
// retention_policy.go ставить нижню межу 30 діб зі словами «місячні
|
||||
// звіти читають саме звідси».
|
||||
//
|
||||
// І головне — навіть цього замало. Тому закритий період не читає дані
|
||||
// взагалі: він читає сам себе. Див. CloseSLAPeriod.
|
||||
|
||||
// SLATarget — ціль доступності.
|
||||
type SLATarget struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Selector Selector `json:"selector"`
|
||||
// TargetPct — обіцяний відсоток. 99.9 за квартал — це 2 год 12 хв
|
||||
// допустимого простою.
|
||||
TargetPct float64 `json:"target_pct"`
|
||||
PeriodKind string `json:"period_kind"`
|
||||
TZ string `json:"tz"`
|
||||
MinCoveragePct float64 `json:"min_coverage_pct"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// BusinessHours лежить у схемі з 0008 і НЕ реалізований. Поле
|
||||
// віддається як є, щоб форма могла показати його заповненим і
|
||||
// поруч — попередження: розрахунок його не звужує.
|
||||
BusinessHours json.RawMessage `json:"business_hours,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Location — пояс цілі, розібраний.
|
||||
//
|
||||
// Невідоме ім'я поясу не є приводом упасти: у контейнері без tzdata
|
||||
// LoadLocation не знайде нічого взагалі, і тоді звіт має вийти в UTC із
|
||||
// попередженням, а не не вийти зовсім.
|
||||
func (t SLATarget) Location() (*time.Location, bool) {
|
||||
if t.TZ == "" {
|
||||
return time.UTC, true
|
||||
}
|
||||
loc, err := time.LoadLocation(t.TZ)
|
||||
if err != nil {
|
||||
return time.UTC, false
|
||||
}
|
||||
return loc, true
|
||||
}
|
||||
|
||||
// Коди попереджень. Короткі ключі, бо лежать у jsonb закритого періоду й
|
||||
// читаються через рік; людські формулювання — у slaWarningLabels.
|
||||
const (
|
||||
// Серед вікон обслуговування трапилось повторюване (rrule). RFC 5545
|
||||
// у продукті не розгортає ніхто — ані придушення алертів, ані цей
|
||||
// розрахунок. Враховано лише базове входження вікна.
|
||||
SLAWarnRRule = "rrule_ignored"
|
||||
// У цілі заповнені робочі години. Розрахунок їх не звужує.
|
||||
SLAWarnBusinessHours = "business_hours_ignored"
|
||||
// Період починається раніше, ніж сягає збережена історія годинних
|
||||
// згорток. Частина періоду не має даних НЕ тому, що їх не збирали.
|
||||
SLAWarnBeyondHorizon = "beyond_horizon"
|
||||
// Пояс цілі не впізнано, рахували в UTC.
|
||||
SLAWarnUnknownTZ = "unknown_tz"
|
||||
// Хоста вже немає: рядок пережив повне видалення (0057). Ім'я
|
||||
// показується зі знімка.
|
||||
SLAWarnDevicePurged = "device_purged"
|
||||
)
|
||||
|
||||
var slaWarningLabels = map[string]string{
|
||||
SLAWarnRRule: "повторювані вікна обслуговування враховано лише першим входженням",
|
||||
SLAWarnBusinessHours: "робочі години задано, але розрахунок їх не звужує",
|
||||
SLAWarnBeyondHorizon: "початок періоду старший за збережену історію годинних згорток",
|
||||
SLAWarnUnknownTZ: "часовий пояс цілі не впізнано, рахували в UTC",
|
||||
SLAWarnDevicePurged: "хост видалено назавжди; показано ім'я на момент розрахунку",
|
||||
}
|
||||
|
||||
// SLAWarningLabel — людське формулювання коду попередження.
|
||||
func SLAWarningLabel(code string) string {
|
||||
if s, ok := slaWarningLabels[code]; ok {
|
||||
return s
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
// SLARow — один хост за один період.
|
||||
type SLARow struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
SLAOutcome
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
// SLAHorizon — скільки історії РЕАЛЬНО є під цим звітом.
|
||||
//
|
||||
// Їде в кожній відповіді, а не лежить у довідці. Питання «чому за
|
||||
// березень порожньо» має відповідь у тому самому вікні, де видно
|
||||
// порожнечу, — інакше на нього відповідає підтримка, і щоразу заново.
|
||||
type SLAHorizon struct {
|
||||
// Relation — з чого рахували.
|
||||
Relation string `json:"relation"`
|
||||
// KeepDays — строк на це відношення; nil означає «не видаляється».
|
||||
KeepDays *int `json:"keep_days"`
|
||||
// Oldest — найстаріше годинне відро, яке зараз є в базі для цього
|
||||
// кабінету. Це і є фактичний горизонт, на відміну від наміру.
|
||||
Oldest *time.Time `json:"oldest_bucket,omitempty"`
|
||||
}
|
||||
|
||||
// slaBeyondHorizon — чи починається період раніше, ніж сягають дані.
|
||||
//
|
||||
// Окрема функція, а не умова по місцю, рівно з однієї причини: це
|
||||
// найважче помітна з усіх помилок звіту. Період, половина якого просто
|
||||
// видалена за строком зберігання, виглядає точнісінько як період, у
|
||||
// якому півмережі не опитувалось, — і обидва дають те саме низьке
|
||||
// покриття. Різницю знає лише ця умова, і перевіряти її треба тестом, а
|
||||
// не читанням.
|
||||
func slaBeyondHorizon(h SLAHorizon, from time.Time) bool {
|
||||
if h.Oldest == nil {
|
||||
// Даних немає взагалі. Це не «за межею горизонту», а порожня
|
||||
// база: горизонту, який можна перетнути, ще не існує.
|
||||
return false
|
||||
}
|
||||
return h.Oldest.After(from)
|
||||
}
|
||||
|
||||
// SLAReport — звіт цілі за один період.
|
||||
type SLAReport struct {
|
||||
Target SLATarget `json:"target"`
|
||||
|
||||
// Межі періоду. From/To — абсолютний час, PeriodFrom/PeriodTo —
|
||||
// календарні дати в поясі цілі (те, що читає людина й що лежить у
|
||||
// daterange).
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
PeriodFrom string `json:"period_from"`
|
||||
PeriodTo string `json:"period_to"`
|
||||
|
||||
// Closed — рядки прочитано з core.sla_periods, а не пораховано.
|
||||
// Саме це відрізняє звіт від прикидки, і саме це має бачити той,
|
||||
// хто збирається його роздрукувати.
|
||||
Closed bool `json:"closed"`
|
||||
ComputedAt *time.Time `json:"computed_at,omitempty"`
|
||||
Revision int `json:"revision,omitempty"`
|
||||
|
||||
// Closable — період уже скінчився й згортки під ним устоялись.
|
||||
Closable bool `json:"closable"`
|
||||
|
||||
Rows []SLARow `json:"rows"`
|
||||
Horizon SLAHorizon `json:"horizon"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Totals SLAOutcome `json:"totals"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Цілі
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// SLATargets — усі цілі кабінету.
|
||||
func (s *Store) SLATargets(ctx context.Context, tenantID string) ([]SLATarget, error) {
|
||||
var out []SLATarget
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT id::text, name, selector::text, target_pct, period_kind, tz,
|
||||
min_coverage_pct, enabled,
|
||||
COALESCE(business_hours::text, ''), created_at
|
||||
FROM core.sla_targets
|
||||
WHERE tenant_id = $1
|
||||
ORDER BY name
|
||||
`, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var t SLATarget
|
||||
var sel, bh string
|
||||
if err := rows.Scan(&t.ID, &t.Name, &sel, &t.TargetPct, &t.PeriodKind,
|
||||
&t.TZ, &t.MinCoveragePct, &t.Enabled, &bh, &t.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
// Зіпсований селектор не має ховати всю ціль: порожній
|
||||
// селектор означає «весь кабінет», і це видно на екрані.
|
||||
_ = json.Unmarshal([]byte(sel), &t.Selector)
|
||||
if bh != "" && bh != "null" {
|
||||
t.BusinessHours = json.RawMessage(bh)
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return rows.Err()
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Store) slaTarget(ctx context.Context, tx pgx.Tx, tenantID, id string) (SLATarget, error) {
|
||||
var t SLATarget
|
||||
var sel, bh string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT id::text, name, selector::text, target_pct, period_kind, tz,
|
||||
min_coverage_pct, enabled, COALESCE(business_hours::text, ''), created_at
|
||||
FROM core.sla_targets WHERE tenant_id = $1 AND id = $2
|
||||
`, tenantID, id).Scan(&t.ID, &t.Name, &sel, &t.TargetPct, &t.PeriodKind, &t.TZ,
|
||||
&t.MinCoveragePct, &t.Enabled, &bh, &t.CreatedAt)
|
||||
if err == pgx.ErrNoRows {
|
||||
return t, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(sel), &t.Selector)
|
||||
if bh != "" && bh != "null" {
|
||||
t.BusinessHours = json.RawMessage(bh)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// SLATargetInput — те, що приходить із форми.
|
||||
type SLATargetInput struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Selector Selector `json:"selector"`
|
||||
TargetPct float64 `json:"target_pct"`
|
||||
PeriodKind string `json:"period_kind"`
|
||||
TZ string `json:"tz"`
|
||||
MinCoveragePct float64 `json:"min_coverage_pct"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// ValidateSLATarget перевіряє ціль до того, як вона щось порахує.
|
||||
//
|
||||
// Окрема функція, а не перевірки по місцю: рівно ті самі умови треба
|
||||
// прикласти і в REST, і у фоновому такті, а розійшовшись, вони дали б
|
||||
// ціль, яку форма приймає, а розрахунок не розуміє.
|
||||
func ValidateSLATarget(in SLATargetInput) error {
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return fmt.Errorf("%w: ціль без назви", ErrInvalid)
|
||||
}
|
||||
switch in.PeriodKind {
|
||||
case SLAPeriodDaily, SLAPeriodWeekly, SLAPeriodMonthly, SLAPeriodQuarterly:
|
||||
default:
|
||||
return fmt.Errorf("%w: невідомий тип періоду %q", ErrInvalid, in.PeriodKind)
|
||||
}
|
||||
// Нижня межа не нуль: ціль «0% доступності» не означає нічого, а
|
||||
// набирається однією помилкою в порожньому полі.
|
||||
if in.TargetPct <= 0 || in.TargetPct > 100 {
|
||||
return fmt.Errorf("%w: ціль доступності має бути в межах (0; 100]", ErrInvalid)
|
||||
}
|
||||
if in.MinCoveragePct < 0 || in.MinCoveragePct > 100 {
|
||||
return fmt.Errorf("%w: поріг покриття має бути в межах [0; 100]", ErrInvalid)
|
||||
}
|
||||
if in.TZ != "" {
|
||||
if _, err := time.LoadLocation(in.TZ); err != nil {
|
||||
return fmt.Errorf("%w: невідомий часовий пояс %q", ErrInvalid, in.TZ)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveSLATarget заводить або оновлює ціль.
|
||||
//
|
||||
// Пояс і тип періоду ЗМІНЮВАТИ можна, і це навмисно: помилку в них
|
||||
// інакше довелось би виправляти видаленням цілі разом з усіма закритими
|
||||
// періодами. Наслідок, який знімається знімками в самому періоді: старі
|
||||
// закриті рядки лишаються порахованими за старим поясом і старою ціллю,
|
||||
// і в них це записано.
|
||||
func (s *Store) SaveSLATarget(ctx context.Context, tenantID string, in SLATargetInput) (SLATarget, error) {
|
||||
if err := ValidateSLATarget(in); err != nil {
|
||||
return SLATarget{}, err
|
||||
}
|
||||
sel, err := json.Marshal(in.Selector)
|
||||
if err != nil {
|
||||
return SLATarget{}, err
|
||||
}
|
||||
tz := in.TZ
|
||||
if tz == "" {
|
||||
tz = "UTC"
|
||||
}
|
||||
|
||||
var out SLATarget
|
||||
err = s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
var id string
|
||||
if in.ID != "" {
|
||||
err := tx.QueryRow(ctx, `
|
||||
UPDATE core.sla_targets
|
||||
SET name = $3, selector = $4::jsonb, target_pct = $5,
|
||||
period_kind = $6, tz = $7, min_coverage_pct = $8, enabled = $9
|
||||
WHERE tenant_id = $1 AND id = $2
|
||||
RETURNING id::text
|
||||
`, tenantID, in.ID, in.Name, string(sel), in.TargetPct,
|
||||
in.PeriodKind, tz, in.MinCoveragePct, in.Enabled).Scan(&id)
|
||||
if err == pgx.ErrNoRows {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO core.sla_targets
|
||||
(tenant_id, name, selector, target_pct, period_kind, tz,
|
||||
min_coverage_pct, enabled)
|
||||
VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8)
|
||||
RETURNING id::text
|
||||
`, tenantID, in.Name, string(sel), in.TargetPct, in.PeriodKind, tz,
|
||||
in.MinCoveragePct, in.Enabled).Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
out, err = s.slaTarget(ctx, tx, tenantID, id)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// DeleteSLATarget видаляє ціль РАЗОМ із закритими періодами.
|
||||
//
|
||||
// Каскад тут лишається навмисно, на відміну від хоста: період без цілі
|
||||
// не має сенсу — у ньому немає ані обіцяного відсотка, ані переліку
|
||||
// хостів. А от кнопка мусить казати це вголос, тому кількість періодів,
|
||||
// які зникнуть, повертається викликачеві до підтвердження.
|
||||
func (s *Store) DeleteSLATarget(ctx context.Context, tenantID, id string) (int, error) {
|
||||
var n int
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT count(*)::int FROM core.sla_periods
|
||||
WHERE tenant_id = $1 AND sla_target_id = $2
|
||||
`, tenantID, id).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
// Тригер незмінності стоїть і на DELETE: без явного дозволу
|
||||
// каскад від цілі не зніс би жодного закритого періоду, і
|
||||
// видалення падало б із незрозумілою помилкою.
|
||||
if _, err := tx.Exec(ctx, `SELECT set_config('app.sla_reopen', 'on', true)`); err != nil {
|
||||
return err
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM core.sla_targets WHERE tenant_id = $1 AND id = $2
|
||||
`, tenantID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Звіт
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// SLAReportFor — звіт цілі за період, у який потрапляє anchor.
|
||||
//
|
||||
// Порядок дій тут і є все рішення задачі:
|
||||
//
|
||||
// 1. Якщо період ЗАКРИТО — читаємо збережені рядки й більше нічого не
|
||||
// рахуємо. Дані під ними могли вже зникнути; висновок лишився.
|
||||
// 2. Інакше рахуємо по ts.icmp_1h і віддаємо з позначкою «попередній».
|
||||
func (s *Store) SLAReportFor(ctx context.Context, tenantID, targetID string, anchor time.Time) (SLAReport, error) {
|
||||
var rep SLAReport
|
||||
err := s.InTenantTx(ctx, tenantID, func(tx pgx.Tx) error {
|
||||
t, err := s.slaTarget(ctx, tx, tenantID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rep, err = s.slaReportTx(ctx, tx, tenantID, t, anchor)
|
||||
return err
|
||||
})
|
||||
return rep, err
|
||||
}
|
||||
|
||||
func (s *Store) slaReportTx(ctx context.Context, tx pgx.Tx, tenantID string,
|
||||
t SLATarget, anchor time.Time) (SLAReport, error) {
|
||||
|
||||
loc, tzOK := t.Location()
|
||||
span, err := SLAPeriodBounds(t.PeriodKind, anchor, loc)
|
||||
if err != nil {
|
||||
return SLAReport{}, err
|
||||
}
|
||||
|
||||
rep := SLAReport{
|
||||
Target: t,
|
||||
From: span.From,
|
||||
To: span.To,
|
||||
PeriodFrom: span.From.In(loc).Format("2006-01-02"),
|
||||
PeriodTo: span.To.In(loc).Format("2006-01-02"),
|
||||
Closable: !time.Now().Before(span.To.Add(SLASettleLag)),
|
||||
}
|
||||
if !tzOK {
|
||||
rep.Warnings = append(rep.Warnings, SLAWarnUnknownTZ)
|
||||
}
|
||||
if len(t.BusinessHours) > 0 {
|
||||
rep.Warnings = append(rep.Warnings, SLAWarnBusinessHours)
|
||||
}
|
||||
if rep.Horizon, err = s.slaHorizon(ctx, tx, tenantID); err != nil {
|
||||
return rep, err
|
||||
}
|
||||
if slaBeyondHorizon(rep.Horizon, span.From) {
|
||||
rep.Warnings = append(rep.Warnings, SLAWarnBeyondHorizon)
|
||||
}
|
||||
|
||||
// 1. Уже закритий період читається, а не рахується.
|
||||
stored, err := s.slaStoredRows(ctx, tx, tenantID, t.ID, span, loc)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
if len(stored.Rows) > 0 && stored.Closed {
|
||||
rep.Closed = true
|
||||
rep.Rows = stored.Rows
|
||||
rep.ComputedAt = stored.ComputedAt
|
||||
rep.Revision = stored.Revision
|
||||
// Знімки з рядка перекривають поточну ціль: звіт має показувати
|
||||
// умови, за якими його виносили, а не сьогоднішні.
|
||||
if stored.TargetPct > 0 {
|
||||
rep.Target.TargetPct = stored.TargetPct
|
||||
}
|
||||
if stored.TZ != "" {
|
||||
rep.Target.TZ = stored.TZ
|
||||
}
|
||||
// Підсумок теж рахується проти ЗНІМКІВ, а не проти сьогоднішньої
|
||||
// цілі: інакше піднятий сьогодні поріг покриття заднім числом
|
||||
// зробив би торішній вердикт «невідомим».
|
||||
rep.Totals = slaTotals(rep.Rows, stored.TargetPct, stored.MinCoverage)
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// 2. Живий розрахунок.
|
||||
rows, warn, err := s.slaComputeRows(ctx, tx, tenantID, t, span)
|
||||
if err != nil {
|
||||
return rep, err
|
||||
}
|
||||
rep.Rows = rows
|
||||
rep.Warnings = slaAppendUnique(rep.Warnings, warn...)
|
||||
rep.Totals = slaTotals(rows, t.TargetPct, t.MinCoveragePct)
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// slaHorizon — фактичний горизонт даних під звітом.
|
||||
func (s *Store) slaHorizon(ctx context.Context, tx pgx.Tx, tenantID string) (SLAHorizon, error) {
|
||||
h := SLAHorizon{Relation: "ts.icmp_1h"}
|
||||
// Строк читаємо з core.retention_settings, а не з політики
|
||||
// TimescaleDB: саме він показує НАМІР людини, і саме його вона
|
||||
// щойно могла змінити. NULL тут — «не видаляється», і це поточний
|
||||
// стан продукту.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT keep_days FROM core.retention_settings WHERE relation = 'ts.icmp_1h'
|
||||
`).Scan(&h.KeepDays); err != nil && err != pgx.ErrNoRows {
|
||||
return h, err
|
||||
}
|
||||
// А це — факт: найстаріше відро, яке справді лежить. Розходження
|
||||
// між ним і строком означає, що дані молодші за політику (нова
|
||||
// інсталяція) — і саме воно пояснює порожній звіт за минулий рік.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT min(bucket) FROM ts.icmp_1h WHERE tenant_id = $1
|
||||
`, tenantID).Scan(&h.Oldest); err != nil && err != pgx.ErrNoRows {
|
||||
return h, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
type slaStored struct {
|
||||
Closed bool
|
||||
Rows []SLARow
|
||||
ComputedAt *time.Time
|
||||
Revision int
|
||||
// Знімки умов, за яких виносився вердикт. Читаються з рядка, а не з
|
||||
// цілі: ціль живе далі й могла змінитись, а торішній «виконано» не
|
||||
// має ставати «порушено» без жодної події в мережі.
|
||||
TargetPct float64
|
||||
MinCoverage float64
|
||||
TZ string
|
||||
}
|
||||
|
||||
func (s *Store) slaStoredRows(ctx context.Context, tx pgx.Tx, tenantID, targetID string,
|
||||
span SLASpan, loc *time.Location) (slaStored, error) {
|
||||
|
||||
var out slaStored
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT COALESCE(p.device_id::text, ''), p.device_name,
|
||||
p.clock_sec, p.maintenance_sec, p.up_sec, p.downtime_sec, p.unknown_sec,
|
||||
p.uptime_pct, p.coverage_pct, p.incidents, p.breached,
|
||||
p.warnings::text, p.closed, p.revision, p.computed_at,
|
||||
COALESCE(p.target_pct, 0), COALESCE(p.min_coverage_pct, 0), p.tz,
|
||||
(d.id IS NULL) AS purged
|
||||
FROM core.sla_periods p
|
||||
LEFT JOIN inv.devices d ON d.id = p.device_id
|
||||
WHERE p.tenant_id = $1 AND p.sla_target_id = $2
|
||||
AND p.period = daterange($3::date, $4::date)
|
||||
ORDER BY p.device_name
|
||||
`, tenantID, targetID, span.From.In(loc).Format("2006-01-02"), span.To.In(loc).Format("2006-01-02"))
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var r SLARow
|
||||
var warn string
|
||||
var closed bool
|
||||
var rev int
|
||||
var at time.Time
|
||||
var tgt, cov float64
|
||||
var tz string
|
||||
var purged bool
|
||||
if err := rows.Scan(&r.DeviceID, &r.DeviceName,
|
||||
&r.ClockSec, &r.MaintenanceSec, &r.UpSec, &r.DownSec, &r.UnknownSec,
|
||||
&r.UptimePct, &r.CoveragePct, &r.Incidents, &r.Breached,
|
||||
&warn, &closed, &rev, &at, &tgt, &cov, &tz, &purged); err != nil {
|
||||
return out, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(warn), &r.Warnings)
|
||||
if purged && r.DeviceID != "" {
|
||||
r.Warnings = slaAppendUnique(r.Warnings, SLAWarnDevicePurged)
|
||||
}
|
||||
// Недостатність відновлюється зі знімка порога, а не з поточної
|
||||
// цілі: інакше піднятий сьогодні поріг заднім числом зробив би
|
||||
// торішній вердикт «невідомим».
|
||||
r.Insufficient = r.ClockSec-r.MaintenanceSec == 0 ||
|
||||
r.UpSec+r.DownSec == 0 || r.CoveragePct < cov
|
||||
out.Rows = append(out.Rows, r)
|
||||
out.Closed = closed
|
||||
out.Revision = rev
|
||||
out.ComputedAt = &at
|
||||
out.TargetPct = tgt
|
||||
out.MinCoverage = cov
|
||||
out.TZ = tz
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// slaTotals — підсумок по цілі.
|
||||
//
|
||||
// Підсумкова доступність рахується з СУМИ секунд, а не як середнє
|
||||
// відсотків по хостах. Різниця не косметична: середнє відсотків дає
|
||||
// однакову вагу хосту, що прожив у періоді добу, і хосту, що прожив
|
||||
// квартал, — тобто новий хост, заведений 30 числа й одразу впалий,
|
||||
// зіпсував би місяць усій групі.
|
||||
func slaTotals(rows []SLARow, targetPct, minCoverage float64) SLAOutcome {
|
||||
var t SLAOutcome
|
||||
for _, r := range rows {
|
||||
t.ClockSec += r.ClockSec
|
||||
t.MaintenanceSec += r.MaintenanceSec
|
||||
t.UpSec += r.UpSec
|
||||
t.DownSec += r.DownSec
|
||||
t.UnknownSec += r.UnknownSec
|
||||
t.Incidents += r.Incidents
|
||||
}
|
||||
measured := t.UpSec + t.DownSec
|
||||
billable := t.ClockSec - t.MaintenanceSec
|
||||
if measured > 0 {
|
||||
t.UptimePct = slaRound3(100 * float64(t.UpSec) / float64(measured))
|
||||
}
|
||||
if billable > 0 {
|
||||
t.CoveragePct = slaRound3(100 * float64(measured) / float64(billable))
|
||||
}
|
||||
t.Insufficient = billable == 0 || measured == 0 || t.CoveragePct < minCoverage
|
||||
if !t.Insufficient {
|
||||
t.Breached = t.UptimePct < targetPct
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func slaAppendUnique(dst []string, add ...string) []string {
|
||||
for _, a := range add {
|
||||
found := false
|
||||
for _, d := range dst {
|
||||
if d == a {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
dst = append(dst, a)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
640
server/internal/store/sla_test.go
Normal file
640
server/internal/store/sla_test.go
Normal file
|
|
@ -0,0 +1,640 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Перевірки розрахунку доступності.
|
||||
//
|
||||
// ЩО САМЕ ТУТ ПЕРЕВІРЯЄТЬСЯ, І ЧОМУ САМЕ ЦЕ
|
||||
//
|
||||
// Не «формула ділить правильно» — вона ділить правильно з першого разу.
|
||||
// Перевіряються МЕЖІ, тобто ті випадки, у яких звіт мовчки бреше замість
|
||||
// того, щоб упасти:
|
||||
//
|
||||
// - період, у якому взагалі немає даних;
|
||||
// - період, частина якого лежить за строком зберігання;
|
||||
// - хост, заведений посеред періоду;
|
||||
// - хост, видалений посеред періоду;
|
||||
// - вікно обслуговування на межі доби.
|
||||
//
|
||||
// Спільна риса всіх п'яти: жоден із них не дає помилки. Кожен дає число,
|
||||
// і саме тому їх треба перевіряти числом, а не оком.
|
||||
|
||||
func slaTime(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// slaHours робить рівний ряд відер: n годин від start, по samples спроб,
|
||||
// з яких down невдалих.
|
||||
func slaHours(start time.Time, n int, samples, down int64) []SLAHourBucket {
|
||||
out := make([]SLAHourBucket, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, SLAHourBucket{
|
||||
Start: start.Add(time.Duration(i) * time.Hour),
|
||||
Samples: samples,
|
||||
Down: down,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Базовий випадок, від якого рахуються всі інші: доба, повний ряд відер,
|
||||
// жодної втрати.
|
||||
func TestSLAFullDayNoLoss(t *testing.T) {
|
||||
from := slaTime("2026-04-01T00:00:00Z")
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: from.AddDate(0, 0, 1),
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
Buckets: slaHours(from, 24, 120, 0),
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 95,
|
||||
})
|
||||
if out.ClockSec != 86400 {
|
||||
t.Fatalf("годинник: %d, очікували 86400", out.ClockSec)
|
||||
}
|
||||
if out.UpSec != 86400 || out.DownSec != 0 || out.UnknownSec != 0 {
|
||||
t.Fatalf("розклад часу: up=%d down=%d unknown=%d", out.UpSec, out.DownSec, out.UnknownSec)
|
||||
}
|
||||
if out.UptimePct != 100 || out.CoveragePct != 100 {
|
||||
t.Fatalf("доступність %v при покритті %v", out.UptimePct, out.CoveragePct)
|
||||
}
|
||||
if out.Insufficient || out.Breached {
|
||||
t.Fatal("повна доба без втрат не може бути ні порушенням, ні невідомістю")
|
||||
}
|
||||
}
|
||||
|
||||
// МЕЖА 1: даних немає ЖОДНИХ.
|
||||
//
|
||||
// Найдорожчий випадок у всій задачі. Наївний розрахунок дає тут 100% —
|
||||
// «серед вимірів не було невдалих», — і саме цей звіт роздрукують, бо
|
||||
// він гарний. Правильна відповідь — «не знаємо», і вона не має жодного
|
||||
// шансу зійти за «виконано».
|
||||
func TestSLAEmptyPeriodIsNotHundredPercent(t *testing.T) {
|
||||
from := slaTime("2026-04-01T00:00:00Z")
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: from.AddDate(0, 0, 1),
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 95,
|
||||
})
|
||||
if out.UptimePct != 0 {
|
||||
t.Fatalf("доступність без жодного виміру має лишитись нулем, а не %v", out.UptimePct)
|
||||
}
|
||||
if out.CoveragePct != 0 {
|
||||
t.Fatalf("покриття: %v, очікували 0", out.CoveragePct)
|
||||
}
|
||||
if out.UnknownSec != 86400 {
|
||||
t.Fatalf("невідомий час: %d, очікували цілу добу", out.UnknownSec)
|
||||
}
|
||||
if out.UpSec != 0 {
|
||||
t.Fatalf("порожній період дав %d секунд доступності — саме та помилка, від якої все це написано", out.UpSec)
|
||||
}
|
||||
if !out.Insufficient {
|
||||
t.Fatal("період без даних мусить бути позначений як недостатній")
|
||||
}
|
||||
if out.Breached {
|
||||
t.Fatal("недостатність даних — це не порушення SLA")
|
||||
}
|
||||
if v := SLAVerdict(out); v != "недостатньо даних" {
|
||||
t.Fatalf("вердикт %q замість «недостатньо даних»", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Половина доби без даних: покриття 50%, і вердикту немає.
|
||||
//
|
||||
// Тут і видно, навіщо покриття окремим числом. Доступність із виміряної
|
||||
// половини — усі 100%, і без порога вона поїхала б у звіт як «виконано».
|
||||
func TestSLAHalfSilenceBlocksVerdict(t *testing.T) {
|
||||
from := slaTime("2026-04-01T00:00:00Z")
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: from.AddDate(0, 0, 1),
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
Buckets: slaHours(from, 12, 120, 0),
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 95,
|
||||
})
|
||||
if out.CoveragePct != 50 {
|
||||
t.Fatalf("покриття: %v, очікували 50", out.CoveragePct)
|
||||
}
|
||||
if out.UptimePct != 100 {
|
||||
t.Fatalf("доступність із виміряного: %v, очікували 100", out.UptimePct)
|
||||
}
|
||||
if !out.Insufficient {
|
||||
t.Fatal("покриття 50% нижче за поріг 95% — вердикту бути не має")
|
||||
}
|
||||
if out.UnknownSec != 43200 {
|
||||
t.Fatalf("невідомий час: %d, очікували півдоби", out.UnknownSec)
|
||||
}
|
||||
}
|
||||
|
||||
// Втрати всередині години розкладаються часткою спроб.
|
||||
func TestSLAPartialLossInsideHour(t *testing.T) {
|
||||
from := slaTime("2026-04-01T00:00:00Z")
|
||||
buckets := slaHours(from, 24, 120, 0)
|
||||
// Одна година: чверть спроб не відповіла — це 15 хвилин простою.
|
||||
buckets[3].Down = 30
|
||||
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: from.AddDate(0, 0, 1),
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
Buckets: buckets,
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 95,
|
||||
})
|
||||
if out.DownSec != 900 {
|
||||
t.Fatalf("простій: %d с, очікували 900", out.DownSec)
|
||||
}
|
||||
if out.UpSec+out.DownSec+out.UnknownSec+out.MaintenanceSec != out.ClockSec {
|
||||
t.Fatalf("частини не сходяться з цілим: %d+%d+%d+%d != %d",
|
||||
out.UpSec, out.DownSec, out.UnknownSec, out.MaintenanceSec, out.ClockSec)
|
||||
}
|
||||
if !out.Breached {
|
||||
t.Fatalf("15 хвилин простою за добу — це %v%%, нижче за ціль 99.9", out.UptimePct)
|
||||
}
|
||||
}
|
||||
|
||||
// МЕЖА 2: хост заведено посеред періоду.
|
||||
//
|
||||
// Без цієї межі новий хост, який з'явився 20 числа, отримав би 19 діб
|
||||
// «недоступності» — і зіпсував би місяць усій групі рівно тим, що його
|
||||
// завели.
|
||||
func TestSLADeviceCreatedMidPeriod(t *testing.T) {
|
||||
from := slaTime("2026-04-01T00:00:00Z")
|
||||
to := from.AddDate(0, 1, 0)
|
||||
created := slaTime("2026-04-21T00:00:00Z")
|
||||
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: to,
|
||||
CreatedAt: created,
|
||||
// Дані є рівно з дня заведення й до кінця місяця: 10 діб.
|
||||
Buckets: slaHours(created, 10*24, 120, 0),
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 95,
|
||||
})
|
||||
if want := int64(10 * 86400); out.ClockSec != want {
|
||||
t.Fatalf("годинник: %d, очікували %d (лише з дня заведення)", out.ClockSec, want)
|
||||
}
|
||||
if out.UnknownSec != 0 {
|
||||
t.Fatalf("до заведення хоста часу бути не має, а є %d с невідомого", out.UnknownSec)
|
||||
}
|
||||
if out.CoveragePct != 100 || out.UptimePct != 100 {
|
||||
t.Fatalf("покриття %v, доступність %v — очікували по 100", out.CoveragePct, out.UptimePct)
|
||||
}
|
||||
if out.Insufficient {
|
||||
t.Fatal("десять повністю виміряних діб — це достатньо для вердикту")
|
||||
}
|
||||
}
|
||||
|
||||
// МЕЖА 3: хост видалено посеред періоду.
|
||||
//
|
||||
// Дзеркальна помилка: без звуження годинника архівований 10 числа хост
|
||||
// показав би 20 діб мовчання й потягнув би покриття групи вниз — при
|
||||
// тому що після 10 числа його просто не існувало.
|
||||
func TestSLADeviceDeletedMidPeriod(t *testing.T) {
|
||||
from := slaTime("2026-04-01T00:00:00Z")
|
||||
to := from.AddDate(0, 1, 0)
|
||||
deleted := slaTime("2026-04-11T00:00:00Z")
|
||||
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: to,
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
DeletedAt: &deleted,
|
||||
Buckets: slaHours(from, 10*24, 120, 0),
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 95,
|
||||
})
|
||||
if want := int64(10 * 86400); out.ClockSec != want {
|
||||
t.Fatalf("годинник: %d, очікували %d (лише до видалення)", out.ClockSec, want)
|
||||
}
|
||||
if out.CoveragePct != 100 {
|
||||
t.Fatalf("покриття: %v, очікували 100", out.CoveragePct)
|
||||
}
|
||||
}
|
||||
|
||||
// Хоста в періоді не існувало зовсім — рядок є, чисел немає.
|
||||
func TestSLADeviceOutsidePeriodEntirely(t *testing.T) {
|
||||
from := slaTime("2026-04-01T00:00:00Z")
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: from.AddDate(0, 1, 0),
|
||||
CreatedAt: slaTime("2026-06-01T00:00:00Z"),
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 95,
|
||||
})
|
||||
if out.ClockSec != 0 || out.UnknownSec != 0 {
|
||||
t.Fatalf("хоста не було: годинник %d, невідомо %d — очікували нулі",
|
||||
out.ClockSec, out.UnknownSec)
|
||||
}
|
||||
if !out.Insufficient || out.Breached {
|
||||
t.Fatal("неіснуючий хост не порушує SLA й не виконує його")
|
||||
}
|
||||
}
|
||||
|
||||
// МЕЖА 4: вікно обслуговування на межі доби.
|
||||
//
|
||||
// Вікно 23:00–01:00 не належить жодній добі цілком. Дві типові помилки:
|
||||
// приписати його одній добі повністю (тоді друга доба псується) або
|
||||
// відкинути як «не вміщається» (тоді псуються обидві).
|
||||
func TestSLAMaintenanceAcrossMidnight(t *testing.T) {
|
||||
from := slaTime("2026-04-02T00:00:00Z")
|
||||
to := from.AddDate(0, 0, 1)
|
||||
|
||||
buckets := slaHours(from, 24, 120, 0)
|
||||
// Перша година доби — суцільна недоступність, але вона накрита
|
||||
// вікном обслуговування, що почалось учора о 23:00.
|
||||
buckets[0].Down = 120
|
||||
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: to,
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
Maintenance: []SLASpan{{
|
||||
From: slaTime("2026-04-01T23:00:00Z"),
|
||||
To: slaTime("2026-04-02T01:00:00Z"),
|
||||
}},
|
||||
Buckets: buckets,
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 95,
|
||||
})
|
||||
if out.MaintenanceSec != 3600 {
|
||||
t.Fatalf("обслуговування: %d с, очікували 3600 — у цю добу потрапила лише його половина",
|
||||
out.MaintenanceSec)
|
||||
}
|
||||
if out.DownSec != 0 {
|
||||
t.Fatalf("простій під вікном обслуговування не рахується, а нарахувало %d с", out.DownSec)
|
||||
}
|
||||
if out.UptimePct != 100 {
|
||||
t.Fatalf("доступність: %v, очікували 100", out.UptimePct)
|
||||
}
|
||||
// Годинник зупинявся, тому знаменник покриття — 23 години, а не 24.
|
||||
if out.CoveragePct != 100 {
|
||||
t.Fatalf("покриття: %v — вікно має виходити й зі знаменника теж", out.CoveragePct)
|
||||
}
|
||||
if out.UpSec+out.DownSec+out.UnknownSec+out.MaintenanceSec != out.ClockSec {
|
||||
t.Fatal("частини не сходяться з цілим")
|
||||
}
|
||||
}
|
||||
|
||||
// Вікна, що перетинаються, не рахуються двічі.
|
||||
func TestSLAOverlappingMaintenanceCountedOnce(t *testing.T) {
|
||||
from := slaTime("2026-04-02T00:00:00Z")
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: from.AddDate(0, 0, 1),
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
Maintenance: []SLASpan{
|
||||
{From: slaTime("2026-04-02T02:00:00Z"), To: slaTime("2026-04-02T04:00:00Z")},
|
||||
{From: slaTime("2026-04-02T03:00:00Z"), To: slaTime("2026-04-02T05:00:00Z")},
|
||||
},
|
||||
Buckets: slaHours(from, 24, 120, 0),
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 95,
|
||||
})
|
||||
if out.MaintenanceSec != 3*3600 {
|
||||
t.Fatalf("обслуговування: %d с, очікували 10800 (02:00–05:00 без подвійного рахунку)",
|
||||
out.MaintenanceSec)
|
||||
}
|
||||
}
|
||||
|
||||
// Обслуговування не має ПОКРАЩУВАТИ звіт.
|
||||
//
|
||||
// Спокуслива помилка: зарахувати вікно як доступність. Тоді місяць, у
|
||||
// якому була година аварії й десять годин планових робіт, вийшов би
|
||||
// кращим за місяць без робіт узагалі.
|
||||
func TestSLAMaintenanceDoesNotImproveUptime(t *testing.T) {
|
||||
from := slaTime("2026-04-02T00:00:00Z")
|
||||
buckets := slaHours(from, 24, 120, 0)
|
||||
buckets[10].Down = 120 // година справжньої аварії
|
||||
|
||||
base := SLAInput{
|
||||
From: from,
|
||||
To: from.AddDate(0, 0, 1),
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
Buckets: buckets,
|
||||
TargetPct: 99,
|
||||
MinCoveragePct: 95,
|
||||
}
|
||||
without := ComputeSLA(base)
|
||||
|
||||
base.Maintenance = []SLASpan{{
|
||||
From: slaTime("2026-04-02T20:00:00Z"),
|
||||
To: slaTime("2026-04-02T22:00:00Z"),
|
||||
}}
|
||||
with := ComputeSLA(base)
|
||||
|
||||
if with.UptimePct > without.UptimePct {
|
||||
t.Fatalf("планові роботи покращили звіт: %v проти %v", with.UptimePct, without.UptimePct)
|
||||
}
|
||||
if with.DownSec != without.DownSec {
|
||||
t.Fatalf("аварія поза вікном змінилась через вікно: %d проти %d",
|
||||
with.DownSec, without.DownSec)
|
||||
}
|
||||
}
|
||||
|
||||
// Смуги недоступності: година мовчання посеред аварії не ділить її надвоє.
|
||||
func TestSLAIncidentsSurviveDataGap(t *testing.T) {
|
||||
from := slaTime("2026-04-02T00:00:00Z")
|
||||
buckets := slaHours(from, 24, 120, 0)
|
||||
buckets[5].Down = 120
|
||||
buckets[7].Down = 120
|
||||
// Шоста година — без даних узагалі: відро прибрано.
|
||||
buckets = append(buckets[:6], buckets[7:]...)
|
||||
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: from.AddDate(0, 0, 1),
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
Buckets: buckets,
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 50,
|
||||
})
|
||||
if out.Incidents != 1 {
|
||||
t.Fatalf("смуг недоступності: %d, очікували 1 — діра в даних не є відновленням",
|
||||
out.Incidents)
|
||||
}
|
||||
}
|
||||
|
||||
// Дві аварії, розділені справді робочою годиною, — це дві смуги.
|
||||
func TestSLAIncidentsSplitByGoodHour(t *testing.T) {
|
||||
from := slaTime("2026-04-02T00:00:00Z")
|
||||
buckets := slaHours(from, 24, 120, 0)
|
||||
buckets[5].Down = 120
|
||||
buckets[7].Down = 120
|
||||
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: from.AddDate(0, 0, 1),
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
Buckets: buckets,
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 50,
|
||||
})
|
||||
if out.Incidents != 2 {
|
||||
t.Fatalf("смуг недоступності: %d, очікували 2", out.Incidents)
|
||||
}
|
||||
}
|
||||
|
||||
// Відро з нулем спроб — це мовчання, а не «втрат не було».
|
||||
func TestSLAZeroSampleBucketIsSilence(t *testing.T) {
|
||||
from := slaTime("2026-04-02T00:00:00Z")
|
||||
out := ComputeSLA(SLAInput{
|
||||
From: from,
|
||||
To: from.Add(2 * time.Hour),
|
||||
CreatedAt: slaTime("2026-01-01T00:00:00Z"),
|
||||
Buckets: []SLAHourBucket{{Start: from, Samples: 0}},
|
||||
TargetPct: 99.9,
|
||||
MinCoveragePct: 95,
|
||||
})
|
||||
if out.UpSec != 0 {
|
||||
t.Fatalf("порожнє відро дало %d с доступності", out.UpSec)
|
||||
}
|
||||
if out.UnknownSec != 7200 {
|
||||
t.Fatalf("невідомий час: %d, очікували 7200", out.UnknownSec)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Календар
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Квартал ріжеться в поясі цілі, а не в UTC.
|
||||
//
|
||||
// Різниця — три години на кожній межі. Аварія о 01:00 1 липня за
|
||||
// київським часом належить третьому кварталу; порахована в UTC, вона
|
||||
// потрапила б у другий, тобто у ВЖЕ ЗАКРИТИЙ звіт.
|
||||
func TestSLAQuarterBoundsUseTargetTimezone(t *testing.T) {
|
||||
kyiv, err := time.LoadLocation("Europe/Kyiv")
|
||||
if err != nil {
|
||||
t.Skip("у системі немає бази часових поясів")
|
||||
}
|
||||
span, err := SLAPeriodBounds(SLAPeriodQuarterly, slaTime("2026-05-15T10:00:00Z"), kyiv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := span.From.In(kyiv).Format(time.RFC3339); got != "2026-04-01T00:00:00+03:00" {
|
||||
t.Fatalf("початок кварталу: %s", got)
|
||||
}
|
||||
if got := span.To.In(kyiv).Format(time.RFC3339); got != "2026-07-01T00:00:00+03:00" {
|
||||
t.Fatalf("кінець кварталу: %s", got)
|
||||
}
|
||||
// Межі напіввідкриті: 1 липня о 00:00 належить уже наступному
|
||||
// кварталу, а не обом одразу.
|
||||
next, err := SLAPeriodBounds(SLAPeriodQuarterly, span.To, kyiv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !next.From.Equal(span.To) {
|
||||
t.Fatalf("між кварталами щілина або нахлест: %s проти %s", next.From, span.To)
|
||||
}
|
||||
}
|
||||
|
||||
// Місяць рахується календарем, а не «тридцятьма добами».
|
||||
func TestSLAMonthBoundsFollowCalendar(t *testing.T) {
|
||||
span, err := SLAPeriodBounds(SLAPeriodMonthly, slaTime("2026-02-14T12:00:00Z"), time.UTC)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := span.To.Sub(span.From).Hours() / 24; got != 28 {
|
||||
t.Fatalf("лютий 2026 вийшов %v діб", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Попередній період — це попередній календарний, а не «мінус довжина».
|
||||
//
|
||||
// Різниця видно на січні: віднявши 31 добу від 1 березня, отримаєш
|
||||
// 29 січня, тобто взагалі не той місяць.
|
||||
func TestSLAPrevPeriodCrossesYear(t *testing.T) {
|
||||
span, err := SLAPrevPeriod(SLAPeriodMonthly, slaTime("2026-01-10T00:00:00Z"), time.UTC)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := span.From.Format("2006-01-02"); got != "2025-12-01" {
|
||||
t.Fatalf("попередній місяць до січня 2026: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Тиждень починається понеділком, і неділя належить попередньому.
|
||||
func TestSLAWeekStartsOnMonday(t *testing.T) {
|
||||
// 2026-04-05 — неділя.
|
||||
span, err := SLAPeriodBounds(SLAPeriodWeekly, slaTime("2026-04-05T12:00:00Z"), time.UTC)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := span.From.Format("2006-01-02"); got != "2026-03-30" {
|
||||
t.Fatalf("початок тижня для неділі 5 квітня: %s, очікували 2026-03-30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSLAUnknownPeriodKindIsRefused(t *testing.T) {
|
||||
if _, err := SLAPeriodBounds("yearly", time.Now(), time.UTC); err == nil {
|
||||
t.Fatal("невідомий тип періоду мусить бути помилкою, а не порожнім звітом")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Горизонт зберігання
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// МЕЖА 5: період частково лежить за строком зберігання.
|
||||
//
|
||||
// Найтихіша з усіх помилок. Такий період виглядає точнісінько як період,
|
||||
// у якому півмережі не опитувалось: те саме низьке покриття, ті самі
|
||||
// невідомі секунди. Різницю знає лише ця умова.
|
||||
func TestSLABeyondHorizonDetected(t *testing.T) {
|
||||
oldest := slaTime("2026-05-10T00:00:00Z")
|
||||
h := SLAHorizon{Relation: "ts.icmp_1h", Oldest: &oldest}
|
||||
|
||||
if !slaBeyondHorizon(h, slaTime("2026-04-01T00:00:00Z")) {
|
||||
t.Fatal("квартал, що починається до найстарішого відра, має підняти попередження")
|
||||
}
|
||||
if slaBeyondHorizon(h, slaTime("2026-06-01T00:00:00Z")) {
|
||||
t.Fatal("період усередині горизонту попередження піднімати не має")
|
||||
}
|
||||
}
|
||||
|
||||
// Порожня база — це не «за межею горизонту».
|
||||
//
|
||||
// Різниця важлива: у першому випадку дані видалили, у другому їх ще не
|
||||
// збирали. Порада людині в цих двох випадках протилежна.
|
||||
func TestSLAEmptyDatabaseIsNotBeyondHorizon(t *testing.T) {
|
||||
if slaBeyondHorizon(SLAHorizon{Relation: "ts.icmp_1h"}, time.Now()) {
|
||||
t.Fatal("база без жодного відра не має скаржитись на строк зберігання")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Підсумок і вивантаження
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Підсумок групи рахується з секунд, а не як середнє відсотків.
|
||||
//
|
||||
// Різниця видна саме там, де вона найдорожча: новий хост, що прожив у
|
||||
// періоді годину й ту пролежав, у середньому відсотків важить стільки ж,
|
||||
// скільки хост, що пропрацював цілий місяць.
|
||||
func TestSLATotalsWeighByTime(t *testing.T) {
|
||||
rows := []SLARow{
|
||||
{DeviceName: "старий", SLAOutcome: SLAOutcome{
|
||||
ClockSec: 30 * 86400, UpSec: 30 * 86400, CoveragePct: 100, UptimePct: 100,
|
||||
}},
|
||||
{DeviceName: "новий", SLAOutcome: SLAOutcome{
|
||||
ClockSec: 3600, DownSec: 3600, CoveragePct: 100,
|
||||
}},
|
||||
}
|
||||
tot := slaTotals(rows, 99.9, 95)
|
||||
|
||||
// Середнє відсотків дало б 50%. Правильна відповідь — 99.86%.
|
||||
if tot.UptimePct < 99.8 {
|
||||
t.Fatalf("підсумкова доступність: %v — схоже на середнє відсотків, а не на суму секунд",
|
||||
tot.UptimePct)
|
||||
}
|
||||
if tot.ClockSec != 30*86400+3600 {
|
||||
t.Fatalf("сумарний годинник: %d", tot.ClockSec)
|
||||
}
|
||||
}
|
||||
|
||||
// Підсумок групи, у якій нікого не виміряли, теж не є «виконано».
|
||||
func TestSLATotalsInsufficientWhenNothingMeasured(t *testing.T) {
|
||||
rows := []SLARow{
|
||||
{DeviceName: "мовчун", SLAOutcome: SLAOutcome{ClockSec: 86400, UnknownSec: 86400}},
|
||||
}
|
||||
tot := slaTotals(rows, 99.9, 95)
|
||||
if !tot.Insufficient || tot.Breached {
|
||||
t.Fatalf("група без вимірів: insufficient=%v breached=%v", tot.Insufficient, tot.Breached)
|
||||
}
|
||||
}
|
||||
|
||||
// У вивантаженні клітинка доступності порожня, а не нульова, коли її не
|
||||
// виміряли: нуль читається як «лежало весь період».
|
||||
func TestSLACSVLeavesUptimeBlankWhenUnknown(t *testing.T) {
|
||||
var b strings.Builder
|
||||
rep := SLAReport{
|
||||
Target: SLATarget{Name: "Ядро", TargetPct: 99.9, PeriodKind: SLAPeriodMonthly, TZ: "UTC"},
|
||||
PeriodFrom: "2026-04-01", PeriodTo: "2026-05-01",
|
||||
Rows: []SLARow{{DeviceName: "sw-1", SLAOutcome: SLAOutcome{
|
||||
ClockSec: 86400, UnknownSec: 86400, Insufficient: true,
|
||||
}}},
|
||||
Horizon: SLAHorizon{Relation: "ts.icmp_1h"},
|
||||
}
|
||||
if err := SLAReportCSV(&b, rep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := b.String()
|
||||
if !strings.Contains(out, "sw-1;;0;недостатньо даних") {
|
||||
t.Fatalf("рядок хоста без вимірів виглядає не так:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "без строку") {
|
||||
t.Fatal("у шапці має стояти строк зберігання джерела")
|
||||
}
|
||||
if !strings.Contains(out, "sep=;") {
|
||||
t.Fatal("без sep=; український Excel розкладе файл в один стовпчик")
|
||||
}
|
||||
}
|
||||
|
||||
// Ім'я файла не тягне кирилицю в заголовок Content-Disposition.
|
||||
func TestSLAReportFileNameIsASCII(t *testing.T) {
|
||||
name := SLAReportFileName(SLAReport{
|
||||
Target: SLATarget{Name: "Ядро мережі / Київ"},
|
||||
PeriodFrom: "2026-04-01",
|
||||
})
|
||||
for _, r := range name {
|
||||
if r > 127 {
|
||||
t.Fatalf("в імені файла лишилась не-ASCII: %q", name)
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(name, "-2026-04-01.csv") {
|
||||
t.Fatalf("ім'я файла без періоду: %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Перевірка цілі
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
func TestValidateSLATargetRejectsNonsense(t *testing.T) {
|
||||
base := SLATargetInput{
|
||||
Name: "Ядро", PeriodKind: SLAPeriodMonthly, TargetPct: 99.9, MinCoveragePct: 95,
|
||||
}
|
||||
if err := ValidateSLATarget(base); err != nil {
|
||||
t.Fatalf("нормальна ціль відхилена: %v", err)
|
||||
}
|
||||
|
||||
bad := base
|
||||
bad.Name = " "
|
||||
if err := ValidateSLATarget(bad); err == nil {
|
||||
t.Error("ціль без назви приймається")
|
||||
}
|
||||
|
||||
bad = base
|
||||
bad.PeriodKind = "yearly"
|
||||
if err := ValidateSLATarget(bad); err == nil {
|
||||
t.Error("невідомий тип періоду приймається")
|
||||
}
|
||||
|
||||
// Нуль — не «без цілі», а «0% доступності». Набирається однією
|
||||
// помилкою в порожньому полі, після чого жоден звіт ніколи не
|
||||
// покаже порушення.
|
||||
bad = base
|
||||
bad.TargetPct = 0
|
||||
if err := ValidateSLATarget(bad); err == nil {
|
||||
t.Error("ціль 0% приймається")
|
||||
}
|
||||
|
||||
bad = base
|
||||
bad.TZ = "Middle/Earth"
|
||||
if err := ValidateSLATarget(bad); err == nil {
|
||||
t.Error("вигаданий часовий пояс приймається")
|
||||
}
|
||||
}
|
||||
641
server/migrations/0069_billing.sql
Normal file
641
server/migrations/0069_billing.sql
Normal file
|
|
@ -0,0 +1,641 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0069_billing.sql
|
||||
-- Тарифи, ліміти й ліцензії: те, що 0009 описала, але чим ніхто ніколи
|
||||
-- не скористався.
|
||||
--
|
||||
-- ЩО З 0009 ЖИВЕ, А ЩО ЛЕЖИТЬ МЕРТВИМ
|
||||
--
|
||||
-- Це перше, що треба знати, бо будувати поверх 0009 без цієї звірки
|
||||
-- означає добудовувати те, чого немає. Звірка зроблена grep-ом по
|
||||
-- всьому дереву: `bill.` згадується поза самою 0009 рівно в семи
|
||||
-- місцях, і жодне з них не є кодом застосунку.
|
||||
--
|
||||
-- ЖИВЕ рівно три речі:
|
||||
--
|
||||
-- 1. bill.plans і bill.features — заповнені 0010 (три плани,
|
||||
-- дев'ятнадцять фіч), читаються політикою read_all з 0011. Тобто
|
||||
-- дані є й видимі. Жоден рядок Go їх не читає.
|
||||
--
|
||||
-- 2. Тригери bill.assert_device_limit і bill.assert_map_node_limit —
|
||||
-- справді висять на INSERT і справді виконуються на КОЖНІЙ вставці
|
||||
-- хоста й вузла мапи. Але перший їхній рядок — SELECT max_devices
|
||||
-- FROM bill.entitlements, а bill.entitlements порожня на кожній
|
||||
-- інсталяції, що існує: заповнює її лише db/tests/smoke.sql.
|
||||
-- lim IS NULL → RETURN NEW. Тобто механізм працює, а ефекту не має
|
||||
-- ніде, крім смоук-тесту.
|
||||
--
|
||||
-- 3. store/maps_write.go::mapPgError перекладає HINT='upgrade_plan' у
|
||||
-- ErrPlanLimit, а httpapi віддає 402. Це єдина справді робоча
|
||||
-- ланка — і вона є лише на шляху правки мапи.
|
||||
--
|
||||
-- МЕРТВЕ — усе інше. bill.subscriptions, bill.entitlements (як місце,
|
||||
-- куди хтось пише), bill.usage_daily, bill.usage_reports, bill.invoices,
|
||||
-- bill.invoice_lines, bill.payment_events, bill.license_keys,
|
||||
-- bill.license_checkins, усі чотири ENUM-и. Жодного INSERT, жодного
|
||||
-- SELECT з коду. Права billing:read і billing:manage заведені 0010 і
|
||||
-- перелічені в store/roles.go у dormantPerms як «сторінки тарифу ще
|
||||
-- немає».
|
||||
--
|
||||
-- І одна ланка ЗЛАМАНА, що гірше за мертву. httpapi/groups.go на
|
||||
-- створенні хоста робить isPlanLimit(err) — а та перевіряє
|
||||
-- strings.Contains(err.Error(), "ліміт"). Тригер 0009 підіймає
|
||||
-- 'device limit reached for tenant % (limit %)', тобто англійською.
|
||||
-- Збігу немає ніколи. Отже в мить, коли ліміт УПЕРШЕ спрацював би,
|
||||
-- людина отримала б не «вичерпано ліміт тарифу», а 500 «внутрішня
|
||||
-- помилка» — рівно те, чого перевірка в БД мала не допустити.
|
||||
--
|
||||
-- ДЕ ЛІМІТ НЕ СПРАЦЬОВУЄ, ХОЧА МАВ БИ
|
||||
--
|
||||
-- Це небезпечніший бік, ніж хибне спрацювання: хибне видно одразу й
|
||||
-- скаржаться на нього того ж дня, а пропущене не проявляється ніяк.
|
||||
--
|
||||
-- Тригер стоїть лише на INSERT. Хост, повернутий з архіву
|
||||
-- (RestoreDevices — UPDATE deleted_at = NULL), і хост, який просто
|
||||
-- ввімкнули (UPDATE enabled = true), проходять повз перевірку цілком.
|
||||
-- Тобто стелю в 15 хостів обходить будь-хто: завести 15, заархівувати
|
||||
-- десять, завести ще десять, повернути з архіву. Отримуємо 25 під
|
||||
-- наглядом і план, який каже «до 15».
|
||||
--
|
||||
-- max_maps, max_agents, max_users і metric_retention_days з
|
||||
-- bill.plans не перевіряє НІЩО й ніде. Це не колонки про запас — це
|
||||
-- те, чим три плани в 0010 відрізняються один від одного.
|
||||
--
|
||||
-- ЩО РОБИТЬ ЦЯ МІГРАЦІЯ
|
||||
--
|
||||
-- 1. Заводить bill.entitlements КОЖНОМУ наявному кабінету — але з
|
||||
-- планом, у якого всі стелі порожні. Пояснення нижче; коротко:
|
||||
-- оновлення не має права нічого відібрати.
|
||||
-- 2. Переписує перевірку лімітів: одна функція замість двох, робота
|
||||
-- на INSERT і на UPDATE, повідомлення українською й машиночитна
|
||||
-- подробиця, з якої застосунок збирає фразу «у тарифі Х дозволено
|
||||
-- N хостів, зараз N».
|
||||
-- 3. Заводить стан ліцензії на рівні інсталяції (bill.instance):
|
||||
-- install_id, сам ключ, перевірений payload, строк, пільговий
|
||||
-- період і монотонний годинник.
|
||||
-- 4. Відкриває bill.license_keys для ключів, не прив'язаних до
|
||||
-- кабінету, — під RLS з 0011 такий рядок не видно нікому взагалі.
|
||||
--
|
||||
-- ЧОГО ЦЯ МІГРАЦІЯ НЕ РОБИТЬ
|
||||
--
|
||||
-- Нічого зі Stripe. Таблиці 0009 (subscriptions, invoices,
|
||||
-- payment_events, usage_reports) лишаються як є й лишаються порожніми.
|
||||
-- Це свідомо: платіжка не має права протікати в перевірку лімітів.
|
||||
-- Єдине, що читають тригери й застосунок, — bill.entitlements; хто саме
|
||||
-- її заповнив (ліцензійний ключ, Stripe, домовленість руками), видно в
|
||||
-- колонці source й нікого більше не обходить. Тому інтеграцію з
|
||||
-- платіжкою можна дописати пізніше, не торкаючись жодного рядка нижче.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1. План, з якого нічого не ламається
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Найнебезпечніший рядок усієї задачі — той, яким наявним кабінетам
|
||||
-- уперше видають entitlements. Досі стеля не діяла НІДЕ; будь-яке
|
||||
-- значення, крім «немає стелі», означає, що після накочування цієї
|
||||
-- міграції інсталяція з двомастами хостами перестає приймати
|
||||
-- двісті перший. Тобто оновлення, яке нічого не питало, відібрало б
|
||||
-- у клієнта продукт — і виявилось би це не тут, а вночі, коли черговий
|
||||
-- заводить хост після аварійної заміни.
|
||||
--
|
||||
-- Тому план self_hosted: усі стелі NULL, усі фічі. Він не «безкоштовний
|
||||
-- enterprise» — він СТАН «ліміти ще ніхто не задавав». Стелі з'являються
|
||||
-- рівно тоді, коли їх задає свідома дія: застосований ліцензійний ключ
|
||||
-- або обраний тариф. Це та сама логіка, що й у 0064 зі строками
|
||||
-- зберігання: оновлення вмикає механізм, але не вмикає його наслідків.
|
||||
--
|
||||
-- is_public = false: у переліку тарифів на сторінці його немає, бо
|
||||
-- купити його не можна. Він показується лише як поточний стан.
|
||||
INSERT INTO bill.plans
|
||||
(key, name, description, base_price_cents, per_device_cents,
|
||||
max_devices, max_maps, max_map_nodes, max_agents, max_users,
|
||||
metric_retention_days, features, sort_order, is_public)
|
||||
SELECT 'self_hosted', 'Self-hosted без ліцензії',
|
||||
'Стелі не задані. Так виглядає інсталяція, якій ще не застосували ключ і не обрали тариф',
|
||||
0, 0, NULL, NULL, NULL, NULL, NULL, 400,
|
||||
-- Набір фіч береться з enterprise, а не переписується списком:
|
||||
-- список розійшовся б із 0010 на першій же новій фічі, і
|
||||
-- розбіжність побачив би лише той, хто відкриє обидва файли.
|
||||
(SELECT features FROM bill.plans WHERE key = 'enterprise'),
|
||||
0, false
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
features = EXCLUDED.features,
|
||||
is_public = false;
|
||||
|
||||
COMMENT ON COLUMN bill.plans.is_public IS
|
||||
'Чи показувати в переліку тарифів. false — стан, а не пропозиція (self_hosted)';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2. Entitlements кожному кабінету
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Матеріалізована таблиця, а не VIEW поверх plans і subscriptions, — і
|
||||
-- це рішення 0009, яке варто підтвердити вголос, бо на нього спирається
|
||||
-- усе решта. Причина в тому, ЗВІДКИ стелі можуть узятись: із тарифу, з
|
||||
-- персональних домовленостей (subscriptions.overrides), з ліцензійного
|
||||
-- ключа, з пільгового періоду після прострочення. Вигляд, який зводить
|
||||
-- чотири джерела, довелось би обчислювати в тригері на кожній вставці
|
||||
-- хоста — тобто платити JOIN-ом по чотирьох таблицях за кожен рядок
|
||||
-- автовиявлення.
|
||||
--
|
||||
-- Ціна матеріалізації — розсинхрон: рядок може відстати від того, що
|
||||
-- насправді дає ліцензія. Тому перерахунок робить рівно одне місце
|
||||
-- (store/billing_license.go, ApplyLicense і годинний такт), а не кожен,
|
||||
-- кому знадобилось.
|
||||
-- Вставка йде по одному кабінету з виставленим app.tenant_id, а не
|
||||
-- одним INSERT ... SELECT, і це не стилістика. 0011 повісила на
|
||||
-- bill.entitlements політику tenant_isolation разом із FORCE ROW LEVEL
|
||||
-- SECURITY — тобто WITH CHECK (tenant_id = core.current_tenant())
|
||||
-- перевіряється й для власника таблиці. Без контексту
|
||||
-- core.current_tenant() дає NULL, порівняння дає NULL, і жоден рядок не
|
||||
-- проходить. Один INSERT спрацював би лише під суперкористувачем; те,
|
||||
-- що міграції сьогодні котять саме ним, — властивість розгортання, а не
|
||||
-- гарантія (0063 якраз забирає BYPASSRLS у робочих ролей). Цикл працює
|
||||
-- під будь-якою роллю, що має право писати в таблицю.
|
||||
DO $$
|
||||
DECLARE
|
||||
t record;
|
||||
BEGIN
|
||||
FOR t IN SELECT id FROM core.tenants WHERE deleted_at IS NULL LOOP
|
||||
PERFORM set_config('app.tenant_id', t.id::text, true);
|
||||
INSERT INTO bill.entitlements
|
||||
(tenant_id, plan_key, max_devices, max_maps, max_map_nodes, max_agents,
|
||||
max_users, metric_retention_days, features, source)
|
||||
SELECT t.id, p.key, p.max_devices, p.max_maps, p.max_map_nodes, p.max_agents,
|
||||
p.max_users, p.metric_retention_days, p.features, 'license_key'
|
||||
FROM bill.plans p
|
||||
WHERE p.key = 'self_hosted'
|
||||
ON CONFLICT (tenant_id) DO NOTHING;
|
||||
END LOOP;
|
||||
PERFORM set_config('app.tenant_id', '', true);
|
||||
END $$;
|
||||
|
||||
-- Причина останнього перерахунку — колонка про людей, а не про машину.
|
||||
--
|
||||
-- «Чому в мене раптом стеля 15 хостів» — питання, на яке без цього
|
||||
-- рядка немає відповіді взагалі: entitlements переписується цілком, і
|
||||
-- попереднього стану ніде не лишається. Значення тут коротке й
|
||||
-- перелічуване, бо його читає інтерфейс: license (застосували ключ),
|
||||
-- license_expired (ключ протермінувався), plan (обрали тариф),
|
||||
-- migration (заведено оновленням), manual (руками в базі).
|
||||
ALTER TABLE bill.entitlements
|
||||
ADD COLUMN IF NOT EXISTS reason text NOT NULL DEFAULT 'migration',
|
||||
ADD COLUMN IF NOT EXISTS license_id uuid;
|
||||
|
||||
COMMENT ON COLUMN bill.entitlements.reason IS
|
||||
'Чому стелі саме такі: license | license_expired | plan | migration | manual';
|
||||
COMMENT ON COLUMN bill.entitlements.grace_until IS
|
||||
'Кінець пільгового періоду. Після нього стеля замерзає на досягнутому, а не падає';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 3. Перевірка лімітів у БД
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- ЧОМУ ПЕРЕВІРКА ЛИШАЄТЬСЯ В БАЗІ, А НЕ ПЕРЕЇЖДЖАЄ В GO
|
||||
--
|
||||
-- Спокуса саме така: «ліміт має відмовляти зрозуміло, отже хай його
|
||||
-- рахує застосунок і сам пише фразу». Це помилка, і вона коштує рівно
|
||||
-- того, заради чого ліміт існує.
|
||||
--
|
||||
-- Порахувати в Go означає SELECT count(*), потім INSERT — тобто вікно
|
||||
-- між ними. Два браузери, два запити автовиявлення, масова вставка з
|
||||
-- імпорту — і обидва бачать «14 з 15», обидва вставляють. Стеля з
|
||||
-- гонкою — це не стеля.
|
||||
--
|
||||
-- Тому перевірка лишається там, де вона й має бути: у тій самій
|
||||
-- транзакції, що й вставка, під тим самим рядковим замком. А зрозумілу
|
||||
-- фразу дає не місце перевірки, а те, ЩО саме вона підіймає нагору.
|
||||
-- Досі вона підіймала англійський рядок без жодних чисел — звідси й
|
||||
-- 500 замість 402.
|
||||
|
||||
-- bill.usage_now — скільки чого зайнято ЗАРАЗ.
|
||||
--
|
||||
-- Одна функція, а не count(*) по місцях виклику, з тієї ж причини, з
|
||||
-- якої строки зберігання зведені в одну таблицю: «що вважається
|
||||
-- зайнятим хостом» — це рішення, і воно має бути записане один раз.
|
||||
-- Тут воно таке: хост займає слот, якщо він не в архіві І ввімкнений.
|
||||
-- Вимкнений хост не опитується, не породжує метрик і не коштує нам
|
||||
-- нічого — брати за нього гроші означало б брати за рядок у таблиці.
|
||||
--
|
||||
-- STABLE, а не VOLATILE: у межах одного запиту відповідь не міняється,
|
||||
-- і планувальник має право не викликати її двічі.
|
||||
-- Імена вихідних колонок із суфіксом, а не devices/maps/agents/users.
|
||||
-- У функції на SQL імена вихідних параметрів підставляються в тіло як
|
||||
-- ідентифікатори, і колонка з іменем `devices` поруч із таблицею
|
||||
-- inv.devices — це рівно та неоднозначність, яку неприємно ловити на
|
||||
-- накочуванні. Суфікс коштує нічого й прибирає питання цілком.
|
||||
CREATE OR REPLACE FUNCTION bill.usage_now(p_tenant uuid)
|
||||
RETURNS TABLE (devices_used int, maps_used int, agents_used int, users_used int)
|
||||
LANGUAGE sql STABLE AS $$
|
||||
SELECT
|
||||
(SELECT count(*)::int FROM inv.devices
|
||||
WHERE tenant_id = p_tenant AND deleted_at IS NULL AND enabled),
|
||||
(SELECT count(*)::int FROM topo.maps
|
||||
WHERE tenant_id = p_tenant AND deleted_at IS NULL),
|
||||
-- Зонди без deleted_at: у core.agents архіву немає, видалення там
|
||||
-- одразу справжнє (store/agents.go). Тому й умови «не в архіві» тут
|
||||
-- немає — не забули, а нема чого писати.
|
||||
(SELECT count(*)::int FROM core.agents
|
||||
WHERE tenant_id = p_tenant),
|
||||
(SELECT count(*)::int FROM core.memberships
|
||||
WHERE tenant_id = p_tenant)
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION bill.usage_now(uuid) IS
|
||||
'Скільки слотів тарифу зайнято зараз. Одне визначення «зайнятого» на весь продукт';
|
||||
|
||||
-- bill.deny_limit — єдине місце, де перевірка перетворюється на відмову.
|
||||
--
|
||||
-- Дві частини повідомлення роблять різну роботу, і плутати їх не можна.
|
||||
--
|
||||
-- MESSAGE — фраза для людини, українською, з числами: «у тарифі
|
||||
-- Free дозволено 15 хостів, зараз 15». Вона потрапляє в лог
|
||||
-- Postgres, у psql, у будь-яку утиліту — тобто в усі місця, куди
|
||||
-- застосунок не дотягнеться. Англійський рядок 0009 у цих місцях
|
||||
-- читав лише розробник.
|
||||
--
|
||||
-- DETAIL — той самий факт у JSON, для застосунку. Розбирати MESSAGE
|
||||
-- регулярками не можна: фразу колись перепишуть, і перевірка тихо
|
||||
-- перестане впізнавати власну помилку — рівно те, що вже сталося з
|
||||
-- isPlanLimit і словом «ліміт».
|
||||
--
|
||||
-- HINT лишається 'upgrade_plan' незмінним: за ним уже впізнає ліміт
|
||||
-- store/maps_write.go::mapPgError, і ламати робочу ланку заради
|
||||
-- однаковості нема причин.
|
||||
CREATE OR REPLACE FUNCTION bill.deny_limit(
|
||||
p_kind text, p_plan text, p_allowed int, p_used int)
|
||||
RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
plan_name text;
|
||||
noun text;
|
||||
BEGIN
|
||||
SELECT name INTO plan_name FROM bill.plans WHERE key = p_plan;
|
||||
plan_name := COALESCE(plan_name, p_plan);
|
||||
|
||||
noun := CASE p_kind
|
||||
WHEN 'devices' THEN 'хостів'
|
||||
WHEN 'maps' THEN 'мап'
|
||||
WHEN 'map_nodes' THEN 'вузлів на мапі'
|
||||
WHEN 'agents' THEN 'зондів'
|
||||
WHEN 'users' THEN 'користувачів'
|
||||
ELSE p_kind
|
||||
END;
|
||||
|
||||
RAISE EXCEPTION 'у тарифі % дозволено % %, зараз %', plan_name, p_allowed, noun, p_used
|
||||
USING ERRCODE = 'check_violation',
|
||||
HINT = 'upgrade_plan',
|
||||
DETAIL = json_build_object(
|
||||
'limit', p_kind,
|
||||
'plan', p_plan,
|
||||
'allowed', p_allowed,
|
||||
'used', p_used)::text;
|
||||
END $$;
|
||||
|
||||
-- Хости. Тепер і на UPDATE — саме там була дірка.
|
||||
--
|
||||
-- Умова спрацювання на UPDATE вужча за «будь-яка правка»: слот
|
||||
-- займається лише переходом у стан «під наглядом». Перейменування
|
||||
-- хоста, зміна адреси, прив'язка до зонда стелі не торкаються, і
|
||||
-- перевіряти їх означало б рахувати count(*) на кожному такті збору,
|
||||
-- який пише status.
|
||||
--
|
||||
-- Порахований count(*) не включає сам рядок, що правиться: BEFORE
|
||||
-- UPDATE бачить таблицю зі СТАРИМИ значеннями, а старі — це «вимкнений»
|
||||
-- або «в архіві», тобто під умову підрахунку рядок не підпадає. Тому
|
||||
-- порівняння cnt >= lim правильне для обох операцій без окремої гілки.
|
||||
CREATE OR REPLACE FUNCTION bill.assert_device_limit() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
lim int;
|
||||
pkey text;
|
||||
cnt int;
|
||||
BEGIN
|
||||
-- Вимкнений або одразу заархівований хост слота не займає. Вихід тут,
|
||||
-- а не в кінці: інакше вимкнений хост не можна було б завести на
|
||||
-- інсталяції під стелею — а саме так заводять хост «про запас» перед
|
||||
-- переїздом, і саме це має лишатись можливим.
|
||||
IF NOT NEW.enabled OR NEW.deleted_at IS NOT NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Вкладений IF, а не один вираз через AND, і це не стиль. plpgsql
|
||||
-- обчислює умову цілим виразом; `TG_OP = 'UPDATE' AND OLD.enabled`
|
||||
-- на INSERT упало б на другій половині — «record old is not assigned
|
||||
-- yet», — тобто перша ж вставка хоста поламала б продукт.
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
IF OLD.enabled AND OLD.deleted_at IS NULL THEN
|
||||
RETURN NEW; -- слот уже був зайнятий цим самим рядком
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
SELECT max_devices, plan_key INTO lim, pkey
|
||||
FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
|
||||
IF lim IS NULL THEN
|
||||
RETURN NEW; -- стелі немає або entitlements ще не заведено
|
||||
END IF;
|
||||
|
||||
SELECT devices_used INTO cnt FROM bill.usage_now(NEW.tenant_id);
|
||||
IF cnt >= lim THEN
|
||||
PERFORM bill.deny_limit('devices', pkey, lim, cnt);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_devices_limit ON inv.devices;
|
||||
CREATE TRIGGER trg_devices_limit BEFORE INSERT OR UPDATE OF enabled, deleted_at
|
||||
ON inv.devices
|
||||
FOR EACH ROW EXECUTE FUNCTION bill.assert_device_limit();
|
||||
|
||||
-- Вузли мапи. Функція з 0009 лишається за змістом (стеля на ОДНУ мапу,
|
||||
-- як і описано в 0010: «Free — 1 мапа, до 15 вузлів»), міняється лише
|
||||
-- те, що вона підіймає нагору.
|
||||
CREATE OR REPLACE FUNCTION bill.assert_map_node_limit() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
lim int;
|
||||
pkey text;
|
||||
cnt int;
|
||||
BEGIN
|
||||
SELECT max_map_nodes, plan_key INTO lim, pkey
|
||||
FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
|
||||
IF lim IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
SELECT count(*)::int INTO cnt FROM topo.map_nodes WHERE map_id = NEW.map_id;
|
||||
IF cnt >= lim THEN
|
||||
PERFORM bill.deny_limit('map_nodes', pkey, lim, cnt);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- Мапи, зонди й користувачі: колонки в bill.plans були з 0009, стелі не
|
||||
-- було ніде. Тарифи, що відрізняються лише невиконуваними числами, —
|
||||
-- це не тарифи, а таблиця.
|
||||
--
|
||||
-- Одна функція на три таблиці, а не три однакові: різниця між ними
|
||||
-- вміщається в аргумент тригера, а три копії розійшлись би на першій же
|
||||
-- правці підрахунку.
|
||||
CREATE OR REPLACE FUNCTION bill.assert_tenant_limit() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
kind text := TG_ARGV[0];
|
||||
lim int;
|
||||
pkey text;
|
||||
u record;
|
||||
cnt int;
|
||||
BEGIN
|
||||
SELECT plan_key,
|
||||
CASE kind
|
||||
WHEN 'maps' THEN max_maps
|
||||
WHEN 'agents' THEN max_agents
|
||||
WHEN 'users' THEN max_users
|
||||
END
|
||||
INTO pkey, lim
|
||||
FROM bill.entitlements WHERE tenant_id = NEW.tenant_id;
|
||||
IF lim IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT * INTO u FROM bill.usage_now(NEW.tenant_id);
|
||||
cnt := CASE kind
|
||||
WHEN 'maps' THEN u.maps_used
|
||||
WHEN 'agents' THEN u.agents_used
|
||||
WHEN 'users' THEN u.users_used
|
||||
END;
|
||||
IF cnt >= lim THEN
|
||||
PERFORM bill.deny_limit(kind, pkey, lim, cnt);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_maps_limit ON topo.maps;
|
||||
CREATE TRIGGER trg_maps_limit BEFORE INSERT ON topo.maps
|
||||
FOR EACH ROW EXECUTE FUNCTION bill.assert_tenant_limit('maps');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_agents_limit ON core.agents;
|
||||
CREATE TRIGGER trg_agents_limit BEFORE INSERT ON core.agents
|
||||
FOR EACH ROW EXECUTE FUNCTION bill.assert_tenant_limit('agents');
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_memberships_limit ON core.memberships;
|
||||
CREATE TRIGGER trg_memberships_limit BEFORE INSERT ON core.memberships
|
||||
FOR EACH ROW EXECUTE FUNCTION bill.assert_tenant_limit('users');
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 4. Стан ліцензії інсталяції
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- ЧОМУ РІВЕНЬ ІНСТАЛЯЦІЇ, А НЕ КАБІНЕТУ
|
||||
--
|
||||
-- Бо ключ ставлять у продукт, який клієнт розгорнув У СЕБЕ. Кабінет там
|
||||
-- один, і питання «яка ліцензія в кабінету Б» не виникає. Той самий
|
||||
-- висновок, що й у 0064 про строки зберігання, і те саме обмеження, яке
|
||||
-- треба знати заздалегідь: на спільному хостингу кількох клієнтів
|
||||
-- ліцензія інсталяції накриє їх усіх.
|
||||
--
|
||||
-- Тому застосування ключа переписує entitlements лише тим кабінетам, у
|
||||
-- яких source = 'license_key'. Кабінет, стелі якого прийшли з підписки
|
||||
-- (source = 'stripe'), ключ інсталяції не чіпає — це і є та межа, за
|
||||
-- якою платіжка не протікає в ліцензії, а ліцензії в платіжку.
|
||||
CREATE TABLE IF NOT EXISTS bill.instance (
|
||||
id boolean PRIMARY KEY DEFAULT true CHECK (id),
|
||||
|
||||
-- Ідентифікатор ЦІЄЇ інсталяції. Заводиться один раз і не міняється:
|
||||
-- ключ, виданий на install_id, більше нікуди не підійде, і саме це
|
||||
-- відрізняє ліцензію від пароля, який перешлють колезі.
|
||||
--
|
||||
-- Живе в базі, а не у файлі поруч із бінарником: контейнер
|
||||
-- перезбирають, том із базою — ні.
|
||||
install_id uuid NOT NULL DEFAULT core.new_id(),
|
||||
|
||||
-- Ключ як його ввела людина — цілком, разом із підписом.
|
||||
--
|
||||
-- Зберігається саме текстом, а не розібраним: перевірити підпис можна
|
||||
-- лише над тими самими байтами, які підписували. Реконструкція
|
||||
-- payload з колонок дала б інший канонічний вигляд і, отже, іншу
|
||||
-- контрольну суму — тобто власна ліцензія перестала б проходити
|
||||
-- перевірку після першої ж зміни схеми.
|
||||
license_key text,
|
||||
|
||||
-- Розібраний і ПЕРЕВІРЕНИЙ payload. Дублює license_key навмисно:
|
||||
-- запити на кшталт «чиї стелі зараз діють» не мають розбирати base64.
|
||||
payload jsonb,
|
||||
|
||||
license_id uuid,
|
||||
issued_to text,
|
||||
|
||||
-- unlicensed — ключа немає зовсім. Це робочий стан, а не поломка:
|
||||
-- так виглядає щойно розгорнута інсталяція до покупки.
|
||||
-- active — ключ дійсний.
|
||||
-- grace — строк минув, пільговий період триває.
|
||||
-- expired — минув і пільговий.
|
||||
-- invalid — ключ є, але підпис/прив'язка не сходяться.
|
||||
state text NOT NULL DEFAULT 'unlicensed'
|
||||
CHECK (state IN ('unlicensed','active','grace','expired','invalid')),
|
||||
-- Чому саме invalid — ФРАЗОЮ, а не кодом причини.
|
||||
--
|
||||
-- Сюди лягає текст помилки перевірки як є («невідомий ключ підпису
|
||||
-- k2», «підпис ліцензії не сходиться»), і показується він людині
|
||||
-- дослівно. Код причини довелося б перекладати назад у фразу ще в
|
||||
-- одному місці, а перелік причин тут не є чимось, за чим фільтрують.
|
||||
-- «Ключ недійсний» без причини перетворює звернення в підтримку на
|
||||
-- вгадування — це і є те, чого колонка не допускає.
|
||||
invalid_reason text,
|
||||
|
||||
expires_at timestamptz,
|
||||
grace_until timestamptz,
|
||||
|
||||
-- МОНОТОННИЙ ГОДИННИК
|
||||
--
|
||||
-- Ліцензія без інтернету перевіряється за системним часом машини, а
|
||||
-- машина належить тому, кого ліцензія обмежує. Перевести годинник на
|
||||
-- рік назад — дія на одну команду.
|
||||
--
|
||||
-- Ловиться це найдешевшим способом, який взагалі є: пам'ятати
|
||||
-- найпізніший час, який ця інсталяція БАЧИЛА. Час назад не йде; якщо
|
||||
-- now() виявився суттєво меншим за побачене, годинник рухали.
|
||||
--
|
||||
-- Наслідок навмисно м'який: строк рахується за clock_max_seen, а не
|
||||
-- за now(), і факт зсуву показується на сторінці. Вимикати щось за
|
||||
-- це не можна — годинник з'їжджає й сам (сів CMOS, зник NTP після
|
||||
-- переїзду в ізольований сегмент), і покарати за це означало б
|
||||
-- покарати за несправність, а не за обхід.
|
||||
clock_max_seen timestamptz NOT NULL DEFAULT now(),
|
||||
clock_warped_at timestamptz,
|
||||
|
||||
-- Коли востаннє перераховували стан. Порожнє поле при непорожньому
|
||||
-- ключі означає, що такт перевірки не працює, — і це видно на
|
||||
-- сторінці, а не лише в логах.
|
||||
checked_at timestamptz,
|
||||
applied_at timestamptz,
|
||||
applied_by uuid REFERENCES core.users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
INSERT INTO bill.instance (id) VALUES (true) ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMENT ON TABLE bill.instance IS
|
||||
'Ліцензія цієї інсталяції: install_id, ключ, стан і монотонний годинник';
|
||||
COMMENT ON COLUMN bill.instance.clock_max_seen IS
|
||||
'Найпізніший побачений час. Строк рахується за ним, а не за now(): годинник належить клієнту';
|
||||
|
||||
-- RLS тут немає, і це не пропуск: політика 0011 накладається на таблиці
|
||||
-- з колонкою tenant_id, а в цієї її немає за побудовою — рівно як у
|
||||
-- core.storage_config з 0064. Читання відкрите: ховати «ліцензія діє до
|
||||
-- 1 березня» немає від кого, а НЕ бачити цього означає дізнатись про
|
||||
-- прострочення від колеги. Право на зміну перевіряє застосунок
|
||||
-- (billing:manage).
|
||||
GRANT SELECT, INSERT, UPDATE ON bill.instance TO netpulse_app, netpulse_worker;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5. Ключі, не прив'язані до кабінету
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- bill.license_keys.tenant_id оголошена в 0009 як NULLABLE — і це
|
||||
-- правильно: ключ для self-hosted випускають ДО того, як у клієнта
|
||||
-- з'явиться кабінет, а часто й на іншій інсталяції (у нас, а не в
|
||||
-- нього). Але 0011 автоматом повісила на таблицю tenant_isolation з
|
||||
-- USING (tenant_id = core.current_tenant()), а NULL = будь-що дає NULL,
|
||||
-- тобто не TRUE. Наслідок: рядок із tenant_id IS NULL не видно НІКОМУ й
|
||||
-- ніколи, включно з тим, хто його щойно вставив.
|
||||
--
|
||||
-- Тобто головний сценарій продукту («клієнт ставить систему в себе»)
|
||||
-- був закритий політикою, написаною для іншого випадку. Помітити це
|
||||
-- читанням 0009 неможливо — політики там немає, вона з'являється через
|
||||
-- дві міграції й циклом по всіх таблицях одразу.
|
||||
DROP POLICY IF EXISTS tenant_isolation ON bill.license_keys;
|
||||
CREATE POLICY license_keys_visible ON bill.license_keys
|
||||
USING (tenant_id IS NULL OR tenant_id = core.current_tenant())
|
||||
WITH CHECK (tenant_id IS NULL OR tenant_id = core.current_tenant());
|
||||
|
||||
-- Ключ підписують Ed25519, а не RSA-4096 PSS, як планувала 0009.
|
||||
--
|
||||
-- Жодного ключа ще не видано (таблиця порожня на всіх інсталяціях),
|
||||
-- тому ламати сумісність нема з чим, а різниця істотна саме для
|
||||
-- ліцензії, яку ЛЮДИНА ВВОДИТЬ РУКАМИ: підпис RSA-4096 — це 512 байтів,
|
||||
-- тобто близько 700 символів base64 на самий лише підпис. Ключ, який не
|
||||
-- вміщається в поле й не переживає копіювання з листа, повертається до
|
||||
-- нас зверненням у підтримку. Ed25519 дає 64 байти.
|
||||
--
|
||||
-- Друга причина важливіша за довжину. У RSA-PSS є що налаштувати
|
||||
-- неправильно — хеш, MGF, довжина солі; у Ed25519 налаштувань немає
|
||||
-- взагалі, і перевірка або сходиться, або ні. Для механізму, який
|
||||
-- працює без інтернету й без можливості відкликати ключ на льоту, це
|
||||
-- вирішальна властивість.
|
||||
--
|
||||
-- Колонки 0009 (signature bytea, signing_key_id text, payload jsonb)
|
||||
-- підходять без змін — вони не називають алгоритму.
|
||||
COMMENT ON COLUMN bill.license_keys.signature IS
|
||||
'Ed25519 над канонічним payload (не RSA-PSS, як планувала 0009 — див. коментар 0069)';
|
||||
COMMENT ON COLUMN bill.license_keys.signing_key_id IS
|
||||
'Ідентифікатор ключа підпису для ротації; входить у сам ключ, щоб перевірка знала, чим перевіряти';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 6. Права
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Обидва права заведено ще 0010 і обидва досі значились у
|
||||
-- store/roles.go серед dormantPerms: ключ у базі є, коду, який його
|
||||
-- питає, немає. Тепер він є, і рядки звідти прибираються тією ж
|
||||
-- правкою — інакше екран ролей і далі попереджав би про право, яке вже
|
||||
-- працює.
|
||||
--
|
||||
-- Розподіл між ними такий самий, як у сховища й дзеркала: ДИВИТИСЬ
|
||||
-- вільно широко, МІНЯТИ — вузько. Але межа проходить не там, де
|
||||
-- зазвичай.
|
||||
--
|
||||
-- billing:read — стан підписки, стелі й скільки зайнято. Це право
|
||||
-- інженера, а не бухгалтера: «чому не заводиться шістнадцятий хост»
|
||||
-- — питання того, хто заводить хости, і відповідь на нього має
|
||||
-- бути в нього перед очима ДО того, як він упреться. Саме тому
|
||||
-- сторінка показує стелю разом із використаним, а не лише рахунок.
|
||||
--
|
||||
-- billing:manage — застосувати ліцензійний ключ, змінити тариф.
|
||||
-- Лише власник: 0010 навмисно не дала цього права навіть адміну.
|
||||
UPDATE core.permissions
|
||||
SET description = 'Перегляд тарифу, стель, використаного та стану ліцензії'
|
||||
WHERE key = 'billing:read';
|
||||
|
||||
UPDATE core.permissions
|
||||
SET description = 'Зміна тарифу, застосування ліцензійного ключа, платіжні дані'
|
||||
WHERE key = 'billing:manage';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 7. Що лишається поза цією міграцією й чому
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- ЖОДНОГО «ВИМКНУТИ ВСЕ» ПІСЛЯ ПРОСТРОЧЕННЯ.
|
||||
--
|
||||
-- У схемі немає ні прапорця «заблоковано», ні тригера, який зупиняв би
|
||||
-- збір. Це не забуто — це головне рішення задачі, і схема мусить його
|
||||
-- витримувати, бо схема переживе будь-який застосунок.
|
||||
--
|
||||
-- Моніторинг, який перестав моніторити через несплачений рахунок, — це
|
||||
-- аварія, яку спричинили ми, у мережі, за яку відповідає клієнт. Він не
|
||||
-- побачить падіння магістралі й дізнається про нього від абонентів; ми
|
||||
-- при цьому не отримаємо грошей, а отримаємо звернення й репутацію
|
||||
-- продукту, який тихо перестав працювати. Жодна ліцензійна угода такої
|
||||
-- відповідальності не покриває.
|
||||
--
|
||||
-- Тому після прострочення замерзає лише РІСТ: не з'являється новий
|
||||
-- хост, зонд, користувач, мапа. Усе, що вже під наглядом, лишається під
|
||||
-- наглядом безстроково — метрики збираються, алерти підіймаються,
|
||||
-- сповіщення йдуть. Стеля при цьому ніколи не опускається нижче
|
||||
-- фактично зайнятого: 500 хостів на протермінованій ліцензії лишаються
|
||||
-- п'ятьмастами, а не падають до 15. Прострочена ліцензія перетворює
|
||||
-- продукт із того, що росте, на те, що працює, — і це найсильніший
|
||||
-- аргумент заплатити з усіх, які в нас є, бо клієнт продовжує бачити
|
||||
-- цінність, а не її відсутність.
|
||||
--
|
||||
-- Реалізує це застосунок (store/billing_license.go): стелі
|
||||
-- перераховуються в bill.entitlements як max(стеля_плану,
|
||||
-- фактично_зайнято). Тригери нижче про ліцензію не знають нічого — вони
|
||||
-- бачать лише число в entitlements, і це навмисно: правило «не
|
||||
-- опускати нижче зайнятого» має жити в одному місці, а не в кожному
|
||||
-- тригері окремо.
|
||||
|
||||
-- Stripe: див. шапку. Таблиці 0009 лишаються порожніми.
|
||||
|
||||
-- bill.usage_daily не заповнюється й цією міграцією. Щоденний зріз
|
||||
-- потрібен для per-device тарифікації (скільки виставити за місяць), а
|
||||
-- виставляти рахунки поки нема чим. Заводити такт, який щоночі пише
|
||||
-- рядок, який ніхто не читає, означало б зробити ще одну таблицю, що
|
||||
-- росте без причини, — рівно те, проти чого написана 0064.
|
||||
354
server/migrations/0070_sla.sql
Normal file
354
server/migrations/0070_sla.sql
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0070_sla.sql
|
||||
-- Звіти SLA: доступність за період, порахована РАЗ і збережена як факт.
|
||||
--
|
||||
-- ЩО ВЖЕ БУЛО
|
||||
--
|
||||
-- 0008 завела core.sla_targets і core.sla_periods. За півтора року в них
|
||||
-- не з'явилось жодного рядка, бо коду, який їх пише, немає. Тобто це не
|
||||
-- «доробити наявне», а «вирішити, як воно взагалі рахується», — і
|
||||
-- рішення тут дорожчі за код.
|
||||
--
|
||||
-- ГОЛОВНА ПАСТКА, І ЧОМУ ВОНА НЕ ВИДНА
|
||||
--
|
||||
-- Наївний звіт про доступність рахують по сирих вимірах: узяли
|
||||
-- ts.icmp_samples за квартал, поділили відповіді на спроби, показали
|
||||
-- 99.94%. Число виглядає правильним і сьогодні воно правильне.
|
||||
--
|
||||
-- Через два місяці той самий запит на той самий квартал дасть інше
|
||||
-- число. Причина — 0005: ts.icmp_samples живе 35 діб, і 0064 зробила цей
|
||||
-- строк ще й РЕДАГОВАНИМ з веб-форми. Тобто дані під звітом зникають
|
||||
-- хвостом уперед, а запит цього не помічає: він не бачить різниці між
|
||||
-- «за 1 квітня втрат не було» й «за 1 квітня рядків уже немає». Звіт
|
||||
-- мовчки їде вгору до 100%.
|
||||
--
|
||||
-- Наслідок називається просто: документ, який показали клієнту або
|
||||
-- аудитору, через два місяці не відтворюється. Це найгірша можлива
|
||||
-- властивість звіту — гірша за відверто неправильне число, бо
|
||||
-- неправильне помітно одразу.
|
||||
--
|
||||
-- ЗВІДСИ ДВА РІШЕННЯ, І ВОНИ Й Є ЦЯ МІГРАЦІЯ
|
||||
--
|
||||
-- Перше: рахувати по годинних згортках ts.icmp_1h, а не по сирих даних.
|
||||
-- З усіх рівнів деталізації рівно в цього немає політики видалення
|
||||
-- (0005: «1h-роллапи не видаляємо: це база для SLA-звітів»), і рівно
|
||||
-- йому retention_policy.go ставить нижню межу 30 діб із поясненням
|
||||
-- «місячні звіти читають саме звідси». Реальні горизонти такі:
|
||||
--
|
||||
-- ts.icmp_samples 35 діб квартал не покриває взагалі
|
||||
-- ts.icmp_5m 400 діб квартал покриває, але це політика,
|
||||
-- яку 0064 дала міняти з форми
|
||||
-- ts.icmp_1h без строку єдине, що продукт уже пообіцяв тримати
|
||||
--
|
||||
-- Друге, важливіше: ЗАКРИТИЙ період не перераховується ніколи. Щойно
|
||||
-- період скінчився й згортки під ним устоялись, рядок у core.sla_periods
|
||||
-- пишеться один раз і стає фактом. Далі його читають, а не рахують.
|
||||
-- Тому навіть якщо завтра прибрати ts.icmp_1h цілком, звіт за минулий
|
||||
-- квартал лишиться тим самим числом — у ньому вже не дані, а висновок.
|
||||
--
|
||||
-- ЧОМУ НЕ ts.device_status_history, ХОЧА ВОНА Й ЗВЕТЬСЯ «ІСТОРІЄЮ СТАНІВ»
|
||||
--
|
||||
-- Спокуса очевидна: там лежать переходи «вгору/вниз» із точністю до
|
||||
-- секунди, а не відра по годині. Три причини проти, і третя вирішальна.
|
||||
--
|
||||
-- 1. Це журнал ЗМІН, а не станів. Щоб знати стан хоста о 00:00 1 квітня,
|
||||
-- треба знайти останній рядок ПЕРЕД періодом — а він може бути
|
||||
-- як завгодно старим. 0064 завела цій таблиці редагований строк
|
||||
-- зберігання, тобто саме той рядок і зникне першим.
|
||||
-- 2. Її пише applyDeviceStatus, тобто вона показує стан, який вирішив
|
||||
-- конвеєр алертів. У ньому вже враховані придушення й антифлап —
|
||||
-- дві політики, змішані в одному числі, яке потім показують
|
||||
-- аудитору як вимір.
|
||||
-- 3. І головне: вона не вміє сказати «ми не знали». Перехід пишеться
|
||||
-- ЛИШЕ при зміні стану. Коли зонд відвалився, вимірів не надходить,
|
||||
-- переходу немає — і таблиця стверджує, що хост був «up» усю добу
|
||||
-- мовчання. Тобто джерело, яке за побудовою рахує «даних немає» як
|
||||
-- «працювало», — саме та помилка, від якої цей файл захищає.
|
||||
--
|
||||
-- ts.icmp_1h цього не робить: у ній є samples і down_samples. Година без
|
||||
-- рядка — це година без даних, і сплутати її з робочою нічим.
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1. Цілі SLA
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Часовий пояс цілі, і це не косметика.
|
||||
--
|
||||
-- «Доступність за квартал» — це календарний квартал у поясі організації,
|
||||
-- а не 92 доби від UTC-опівночі. Різниця для Києва — від двох до трьох
|
||||
-- годин на кожній межі періоду, і саме в них найчастіше й ставлять
|
||||
-- планові роботи. Пояс лежить на цілі, а не береться з core.tenants при
|
||||
-- кожному розрахунку: тенант може переїхати між поясами, і тоді старий
|
||||
-- звіт мовчки почав би описувати інші 92 доби.
|
||||
ALTER TABLE core.sla_targets
|
||||
ADD COLUMN IF NOT EXISTS tz text NOT NULL DEFAULT 'UTC';
|
||||
|
||||
-- Ціль можна вимкнути, не видаляючи. Видалення забирає за собою всі
|
||||
-- закриті періоди (ON DELETE CASCADE на sla_periods.sla_target_id), тобто
|
||||
-- «більше не рахуємо цю ціль» і «зітерти торішні звіти» — це різні
|
||||
-- наміри, і в них мають бути різні кнопки.
|
||||
ALTER TABLE core.sla_targets
|
||||
ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true;
|
||||
|
||||
-- Скільки періоду треба ЗНАТИ, щоб узагалі виносити вердикт.
|
||||
--
|
||||
-- Це найважливіше поле в таблиці. Без нього період, у якому зонд
|
||||
-- пролежав три тижні, дав би «100% доступності» — бо серед тих вимірів,
|
||||
-- що дійшли, справді не було жодної втрати. Формально правда, по суті
|
||||
-- брехня.
|
||||
--
|
||||
-- Тому вердикт («виконано» / «порушено») виноситься лише коли покриття
|
||||
-- не нижче за цей поріг. Нижче — період позначається як «недостатньо
|
||||
-- даних» і не зараховується НІ в який бік. Мовчазне «зелено» тут
|
||||
-- заборонено за побудовою.
|
||||
ALTER TABLE core.sla_targets
|
||||
ADD COLUMN IF NOT EXISTS min_coverage_pct numeric(5,2) NOT NULL DEFAULT 95.00
|
||||
CHECK (min_coverage_pct >= 0 AND min_coverage_pct <= 100);
|
||||
|
||||
-- Тип періоду обмежується явно. Досі це був вільний text із коментарем
|
||||
-- «daily | weekly | monthly | quarterly» — тобто домовленість, яку не
|
||||
-- перевіряє ніхто, а розрахунок за незнайомим значенням мовчки дав би
|
||||
-- порожній звіт.
|
||||
ALTER TABLE core.sla_targets
|
||||
DROP CONSTRAINT IF EXISTS sla_targets_period_kind_chk;
|
||||
ALTER TABLE core.sla_targets
|
||||
ADD CONSTRAINT sla_targets_period_kind_chk
|
||||
CHECK (period_kind IN ('daily','weekly','monthly','quarterly'));
|
||||
|
||||
COMMENT ON COLUMN core.sla_targets.tz IS
|
||||
'Пояс, у якому ріжуться календарні межі періоду';
|
||||
COMMENT ON COLUMN core.sla_targets.min_coverage_pct IS
|
||||
'Нижче цього покриття вердикт не виноситься: період позначається як «недостатньо даних»';
|
||||
COMMENT ON COLUMN core.sla_targets.business_hours IS
|
||||
'НЕ РЕАЛІЗОВАНО (0070). Заповнене поле не звужує розрахунок, а лише додає попередження в період';
|
||||
|
||||
-- Пояс за замовчуванням береться з тенанта — один раз, при накаті.
|
||||
-- Далі вони живуть окремо; див. міркування про переїзд вище.
|
||||
UPDATE core.sla_targets t
|
||||
SET tz = COALESCE(NULLIF(n.timezone, ''), 'UTC')
|
||||
FROM core.tenants n
|
||||
WHERE n.id = t.tenant_id AND t.tz = 'UTC';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2. Закриті періоди
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Найважливіша зміна файлу: рядок періоду перестає залежати від того,
|
||||
-- чи живий ще хост.
|
||||
--
|
||||
-- Було: sla_periods.device_id REFERENCES inv.devices(id) ON DELETE CASCADE.
|
||||
-- Тобто повне видалення хоста (0057) заднім числом стирало його звіти —
|
||||
-- і докладний коментар у devices_purge.go чесно перелічує «періоди SLA»
|
||||
-- серед того, що зникає каскадом.
|
||||
--
|
||||
-- Для будь-якої іншої таблиці це правильно. Для цієї — ні, і причина та
|
||||
-- сама, з якої переживає видалення журнал аудиту: звіт, показаний
|
||||
-- клієнту, не може перестати існувати тому, що хтось прибрав хост із
|
||||
-- переліку. Квартал не «розраховується заново без цього хоста» — він уже
|
||||
-- відбувся.
|
||||
--
|
||||
-- Тому ключ знімається, а id лишається звичайною колонкою. Наслідок,
|
||||
-- який треба знати: після повного видалення хоста в періодах лишається
|
||||
-- uuid, за яким уже нікого немає. Саме заради цього поруч з'являється
|
||||
-- знімок імені — інакше звіт показував би стовпчик із голими uuid.
|
||||
ALTER TABLE core.sla_periods
|
||||
DROP CONSTRAINT IF EXISTS sla_periods_device_id_fkey;
|
||||
|
||||
ALTER TABLE core.sla_periods
|
||||
ADD COLUMN IF NOT EXISTS device_name text NOT NULL DEFAULT '';
|
||||
|
||||
-- Час, розкладений на чотири взаємно виключні частини. Разом вони дають
|
||||
-- clock_sec, і саме тому їх чотири, а не два:
|
||||
--
|
||||
-- maintenance_sec вікно обслуговування — годинник зупинено
|
||||
-- up_sec виміряно, хост відповідав
|
||||
-- downtime_sec виміряно, хост не відповідав
|
||||
-- unknown_sec не виміряно нічим: зонд мовчав, хост був вимкнений,
|
||||
-- даних просто немає
|
||||
--
|
||||
-- Остання й є вся суть. Тримати її окремою колонкою означає, що
|
||||
-- «система не знала» фізично неможливо сплутати ані з «працювало», ані
|
||||
-- з «лежало»: щоб збрехати, довелось би свідомо додати unknown_sec до
|
||||
-- up_sec, а це видно в коді, а не ховається в SQL.
|
||||
ALTER TABLE core.sla_periods
|
||||
ADD COLUMN IF NOT EXISTS up_sec bigint NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS unknown_sec bigint NOT NULL DEFAULT 0,
|
||||
-- Повна тривалість періоду В МЕЖАХ ЖИТТЯ ХОСТА. Хост, заведений
|
||||
-- 20 травня, не має «недоступності» за 1–19 травня: його не було.
|
||||
-- Без цієї колонки різницю між «нема даних» і «нема хоста» довелось би
|
||||
-- відновлювати з inv.devices, якого після видалення теж уже немає.
|
||||
ADD COLUMN IF NOT EXISTS clock_sec bigint NOT NULL DEFAULT 0,
|
||||
-- Частка періоду, про яку взагалі є вимір. Число, за яким читач
|
||||
-- вирішує, чи вірити uptime_pct.
|
||||
ADD COLUMN IF NOT EXISTS coverage_pct numeric(6,3) NOT NULL DEFAULT 0;
|
||||
|
||||
-- Знімки того, ПРОТИ ЧОГО міряли. Ціль живе далі й може змінитись —
|
||||
-- 99.5% підняли до 99.9%, поріг покриття зсунули. Закритий звіт мусить
|
||||
-- пам'ятати умови, що діяли тоді, інакше торішній «виконано» одного дня
|
||||
-- стане «порушено» без жодної події в мережі.
|
||||
ALTER TABLE core.sla_periods
|
||||
ADD COLUMN IF NOT EXISTS target_pct numeric(5,3),
|
||||
ADD COLUMN IF NOT EXISTS min_coverage_pct numeric(5,2),
|
||||
ADD COLUMN IF NOT EXISTS tz text NOT NULL DEFAULT 'UTC';
|
||||
|
||||
-- З якого відношення взято числа. Сьогодні завжди 'icmp_1h'; колонка
|
||||
-- потрібна на той день, коли з'явиться інше джерело: без неї старі рядки
|
||||
-- й нові виглядали б однаково, а порівнювати їх було б не можна.
|
||||
ALTER TABLE core.sla_periods
|
||||
ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'icmp_1h';
|
||||
|
||||
-- closed — межа між «прикидкою» й «фактом».
|
||||
--
|
||||
-- false: період ще триває або згортки під ним не встоялись; число
|
||||
-- показують із позначкою «попередній розрахунок» і перераховують
|
||||
-- скільки завгодно разів.
|
||||
-- true: період закрито. Далі його читають. Перерахунок можливий лише
|
||||
-- через явну дію людини, і кожен такий перерахунок видно —
|
||||
-- див. revision.
|
||||
ALTER TABLE core.sla_periods
|
||||
ADD COLUMN IF NOT EXISTS closed boolean NOT NULL DEFAULT false;
|
||||
|
||||
-- Скільки разів це число вже переписували.
|
||||
--
|
||||
-- Не лічильник заради лічильника. Перерахунок закритого періоду —
|
||||
-- законна дія (виправили пояс, дозаповнили вікно обслуговування), але
|
||||
-- вона МАЄ лишати слід у самому звіті. Інакше два роздруки того самого
|
||||
-- кварталу з різними числами неможливо розрізнити, і правий завжди той,
|
||||
-- у кого папірець свіжіший.
|
||||
ALTER TABLE core.sla_periods
|
||||
ADD COLUMN IF NOT EXISTS revision int NOT NULL DEFAULT 1;
|
||||
|
||||
-- Чого цей розрахунок НЕ врахував.
|
||||
--
|
||||
-- Перелік коротких ключів: 'rrule_ignored', 'business_hours_ignored',
|
||||
-- 'device_purged'. Живе в самому рядку, а не в логах, бо читати його
|
||||
-- має той, хто дивиться на звіт через рік, — а логів за той день уже
|
||||
-- немає. Мовчазна відмова врахувати щось є брехнею; названа вголос —
|
||||
-- ні.
|
||||
ALTER TABLE core.sla_periods
|
||||
ADD COLUMN IF NOT EXISTS warnings jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN core.sla_periods.unknown_sec IS
|
||||
'Час, про який немає жодного виміру. Ніколи не додається ні до up_sec, ні до downtime_sec';
|
||||
COMMENT ON COLUMN core.sla_periods.clock_sec IS
|
||||
'Тривалість періоду в межах життя хоста: створений чи видалений посеред періоду рахується частково';
|
||||
COMMENT ON COLUMN core.sla_periods.closed IS
|
||||
'true — факт, не перераховується; false — попередній розрахунок';
|
||||
COMMENT ON COLUMN core.sla_periods.revision IS
|
||||
'Скільки разів закритий період перераховували руками';
|
||||
|
||||
-- Перелік періодів однієї цілі — головний запит сторінки.
|
||||
CREATE INDEX IF NOT EXISTS sla_periods_target_idx
|
||||
ON core.sla_periods (sla_target_id, period);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 3. Незмінність закритого періоду — на рівні бази
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Правило «закрите не переписують» тримається тригером, а не домовленістю
|
||||
-- в Go.
|
||||
--
|
||||
-- ПРИЧИНА: обіцянка «звіт за минулий квартал не змінюється» коштує рівно
|
||||
-- стільки, скільки коштує найслабший шлях запису. Шляхів уже два (REST
|
||||
-- і фоновий такт), третій з'явиться разом із наступною задачею, і саме
|
||||
-- він забуде перевірку. Перевірка ж, що стоїть на таблиці, не має
|
||||
-- обхідного шляху взагалі.
|
||||
--
|
||||
-- НАСЛІДОК: перерахувати закритий період можна лише свідомо — виставивши
|
||||
-- app.sla_reopen у 'on' у СВОЇЙ транзакції. Це не захист від адміністратора
|
||||
-- бази (він зніме тригер), це захист від власного коду, написаного через
|
||||
-- півроку іншою людиною.
|
||||
CREATE OR REPLACE FUNCTION core.sla_period_guard() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF OLD.closed
|
||||
AND COALESCE(current_setting('app.sla_reopen', true), '') <> 'on' THEN
|
||||
RAISE EXCEPTION 'закритий період SLA не переписують'
|
||||
USING ERRCODE = 'restrict_violation',
|
||||
-- Підказка тут не з ввічливості: без неї перше зіткнення з
|
||||
-- цим тригером виглядає як поломка бази, а не як
|
||||
-- спрацювання правила.
|
||||
HINT = 'для свідомого перерахунку: SET LOCAL app.sla_reopen = ''on''';
|
||||
END IF;
|
||||
|
||||
-- NEW у тригері DELETE не існує, тож повертати його не можна: інакше
|
||||
-- перше ж видалення НЕЗАКРИТОГО періоду впало б на порожньому записі.
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
COMMENT ON FUNCTION core.sla_period_guard() IS
|
||||
'Забороняє правку й видалення закритих періодів SLA без явного app.sla_reopen';
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_sla_period_guard ON core.sla_periods;
|
||||
CREATE TRIGGER trg_sla_period_guard
|
||||
BEFORE UPDATE OR DELETE ON core.sla_periods
|
||||
FOR EACH ROW EXECUTE FUNCTION core.sla_period_guard();
|
||||
|
||||
-- ПОБІЧНИЙ НАСЛІДОК, ЯКИЙ ТРЕБА ЗНАТИ НАПЕРЕД
|
||||
--
|
||||
-- Тригер стоїть і на DELETE, тобто його бачать КАСКАДИ. Два місця, де це
|
||||
-- проявиться:
|
||||
--
|
||||
-- * видалення цілі SLA — оброблено в DeleteSLATarget: воно виставляє
|
||||
-- app.sla_reopen у своїй транзакції, бо «видалити ціль» справді
|
||||
-- означає «разом із її звітами»;
|
||||
-- * жорстке видалення кабінету (DELETE FROM core.tenants) — упаде.
|
||||
-- У продукті такого шляху немає, кабінет прибирається м'яко
|
||||
-- (deleted_at), але тести й ручне прибирання бази роблять саме це.
|
||||
-- Ліки — той самий SET app.sla_reopen = 'on' перед видаленням.
|
||||
--
|
||||
-- Це навмисно не пом'якшено «дозволити каскади»: каскад, який мовчки
|
||||
-- зносить закриті звіти, — рівно те, від чого написаний цей тригер.
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 4. Вікна обслуговування
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Функції в базі під це НЕ заводиться, і про причину варто сказати тут,
|
||||
-- бо перше бажання — саме її й написати.
|
||||
--
|
||||
-- Вікна треба (а) відібрати за селектором і (б) ОБ'ЄДНАТИ, бо вони
|
||||
-- перетинаються: 02:00–04:00 плюс 03:00–05:00 — це три години зупиненого
|
||||
-- годинника, а не чотири. Проста сума дала б завищення, до того ж
|
||||
-- непомітне: доступність просто виходила б трохи кращою, ніж є.
|
||||
--
|
||||
-- Обидві половини вже мають місце в коді. Селектор розбирає
|
||||
-- selectorSQL — той самий, яким придушуються алерти; розійтись їм не
|
||||
-- можна, бо інакше алерт придушено, а SLA зіпсовано. А об'єднання
|
||||
-- відрізків — це та сама арифметика інтервалів, якою й так ріжеться
|
||||
-- життя хоста в періоді, і в Go вона перевіряється тестом без бази,
|
||||
-- тоді як у SQL — лише на живому Postgres.
|
||||
--
|
||||
-- Тому тут лишається одне: індекс під вибірку вікон уже є
|
||||
-- (mw_active_idx, gist(tenant_id, period) з 0007), і додавати нема чого.
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 5. Права
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Нових ключів прав не заводимо, і це рішення, а не лінощі.
|
||||
--
|
||||
-- Дивитись звіт — devices:read: доступність своєї мережі бачить кожен,
|
||||
-- хто взагалі бачить моніторинг. Ховати її немає від кого, а от не
|
||||
-- побачити наближення до порога вчасно коштує грошей за договором.
|
||||
--
|
||||
-- Заводити цілі й закривати періоди — settings:write. Ціль SLA — це
|
||||
-- зобов'язання організації перед клієнтом, того самого класу, що й
|
||||
-- строки зберігання: вона не про один хост, а про те, під чим
|
||||
-- підписались. А закриття періоду ще й незворотне за наслідками.
|
||||
--
|
||||
-- Окремі ключі sla:read/sla:write виглядали б охайніше, але кожен новий
|
||||
-- ключ треба роздати ролям, показати на екрані прав і пояснити — тобто
|
||||
-- заплатити людині за розрізнення, якого вона не просила. Завести їх
|
||||
-- пізніше можна; забрати роздане право назад — уже ні.
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON core.sla_targets, core.sla_periods
|
||||
TO netpulse_app, netpulse_worker;
|
||||
|
||||
-- RLS тут окремо не вмикається: обидві таблиці мають tenant_id і були
|
||||
-- захоплені циклом 0011 ще при створенні. Перевірено переліком політик,
|
||||
-- а не припущенням, — саме на такому припущенні 0063 і знайшла шість
|
||||
-- зв'язкових таблиць без жодної політики.
|
||||
995
server/migrations/0071_builtin_compliance_prototypes.sql
Normal file
995
server/migrations/0071_builtin_compliance_prototypes.sql
Normal file
|
|
@ -0,0 +1,995 @@
|
|||
-- =====================================================================
|
||||
-- NetPulse :: 0071_builtin_compliance_prototypes.sql
|
||||
-- Вбудовані правила відповідності й ще два прототипи — щоб розділи
|
||||
-- перестали бути порожніми.
|
||||
--
|
||||
-- ЩО ЗМІРЯНО НА СТЕНДІ
|
||||
--
|
||||
-- правил відповідності 0
|
||||
-- прототипів увімкнено 0
|
||||
-- подієвих тригерів 0
|
||||
--
|
||||
-- Механіка вся є й перевірена: 0006 завела правила, 0056 навчила їх
|
||||
-- знати, ЯКИЙ конфіг вони читають, 0058 зробила порушення подією,
|
||||
-- 0059 дала прототипи. Немає рівно одного — вмісту. Клієнт, який
|
||||
-- поставив систему, відкриває «Відповідність» і бачить порожню
|
||||
-- таблицю з кнопкою «Додати правило» — тобто пропозицію самому
|
||||
-- придумати, що таке безпечний конфіг. Порожній продукт виглядає
|
||||
-- недоробленим навіть тоді, коли він повний, і найдорожче тут не
|
||||
-- враження: людина, яка не знає, з чого почати, не починає взагалі.
|
||||
--
|
||||
-- ЩО РОБИТЬ ЦЯ МІГРАЦІЯ
|
||||
--
|
||||
-- 1. Заводить довідник вбудованих правил (20 штук) і розкладає його
|
||||
-- по кабінетах — наявних і майбутніх.
|
||||
-- 2. Дає вигляд, який показує, ЯКІ хости жодне вбудоване правило не
|
||||
-- покриває: зелена таблиця в кабінеті, де половина заліза не
|
||||
-- підпадає під жодне правило, — найдорожчий різновид брехні.
|
||||
-- 3. Додає два прототипи сенсорів (ENTITY-SENSOR-MIB), обидва
|
||||
-- вимкнені, в окремому шаблоні.
|
||||
-- 4. Дає два вигляди, які відповідають на питання «скільки чеків
|
||||
-- з'явиться і на яких хостах», ДО того, як прапорець увімкнули.
|
||||
--
|
||||
-- ЧОГО ВОНА НЕ РОБИТЬ
|
||||
--
|
||||
-- Не вмикає жодного прототипу. Не заводить жодного чека. Не змінює
|
||||
-- поведінки збору. Після накату на наявний стенд у мережу не піде
|
||||
-- жодного зайвого пакета — з'явиться лише вміст у двох таблицях і
|
||||
-- чотири вигляди.
|
||||
-- =====================================================================
|
||||
|
||||
|
||||
-- =====================================================================
|
||||
-- ЧАСТИНА 1. ВБУДОВАНІ ПРАВИЛА ВІДПОВІДНОСТІ
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1.1. Чому правило не може бути «спільним для всіх кабінетів»
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Профілі збору (ncm.profiles), шаблони (tpl.templates) і ролі
|
||||
-- (core.roles) мають вбудовані рядки з tenant_id IS NULL: один рядок на
|
||||
-- інсталяцію, видимий усім. Для правил відповідності цей шлях закритий,
|
||||
-- і не через смак:
|
||||
--
|
||||
-- * ncm.compliance_rules.tenant_id оголошено NOT NULL ще в 0006;
|
||||
-- * увесь репозиторний шар читає їх як `WHERE r.tenant_id = $1`
|
||||
-- (store/ncm_compliance.go, ListComplianceRules), а RunCompliance
|
||||
-- ходить рівно через цей перелік.
|
||||
--
|
||||
-- Зробити колонку необов'язковою означало б, що вбудовані правила
|
||||
-- перестануть потрапляти у вибірку — тобто будуть у базі й не будуть
|
||||
-- працювати. Це та сама поломка, від якої застерігає 0058: сутність,
|
||||
-- яка виглядає ввімкненою й не робить нічого. Виправляти її довелося б
|
||||
-- у Go, а Go тут не змінюється жодним рядком — навмисно: міграція,
|
||||
-- яка вимагає одночасного релізу сервера, не має кроку, на якому можна
|
||||
-- зупинитись (та сама причина, що в 0063).
|
||||
--
|
||||
-- Тому кожен кабінет отримує ВЛАСНУ копію правила. Це не обхідний шлях,
|
||||
-- а правильна модель: правило відповідності — не довідник, а політика.
|
||||
-- Один клієнт вимагає SSH-only скрізь, інший тримає telnet у технологічній
|
||||
-- мережі й свідомо цього правила не хоче. Спільний рядок не дав би йому
|
||||
-- ані вимкнути правило, ані звузити його селектор, не зачепивши сусідів.
|
||||
--
|
||||
-- Ціна копії — потрібен ключ, за яким копію можна впізнати.
|
||||
|
||||
ALTER TABLE ncm.compliance_rules
|
||||
ADD COLUMN builtin_key text;
|
||||
|
||||
COMMENT ON COLUMN ncm.compliance_rules.builtin_key IS
|
||||
'Ключ вбудованого правила, з якого зроблено цю копію; NULL — правило написала людина';
|
||||
|
||||
-- Один вбудований ключ на кабінет. Частковий індекс, бо в написаних
|
||||
-- руками правил ключа немає, і NULL-ів тут буде більшість.
|
||||
CREATE UNIQUE INDEX ncm_compliance_builtin_uniq
|
||||
ON ncm.compliance_rules (tenant_id, builtin_key)
|
||||
WHERE builtin_key IS NOT NULL;
|
||||
|
||||
-- Правку копії ключ переживає навмисно: SaveComplianceRule перелічує
|
||||
-- колонки в UPDATE поіменно й builtin_key не чіпає. Тобто людина може
|
||||
-- звузити селектор, підправити зразок чи знизити серйозність — і
|
||||
-- правило лишиться впізнаваним як вбудоване. Скинути ключ означало б
|
||||
-- перетворити «наше правило, підправлене під нас» на «якесь правило».
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1.2. Довідник
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Окрема таблиця, а не INSERT просто в ncm.compliance_rules по колу
|
||||
-- кабінетів. Причина одна й практична: кабінети створюються й після
|
||||
-- накату міграції. Без довідника новий кабінет отримав би порожній
|
||||
-- розділ — тобто рівно те, заради чого ця міграція існує, повернулось
|
||||
-- би через тиждень для наступного клієнта.
|
||||
--
|
||||
-- Без tenant_id, як core.check_types: це код, а не дані клієнта. Той
|
||||
-- самий довід, що в db/profiles/README.md про каталог профілів.
|
||||
CREATE TABLE ncm.builtin_compliance (
|
||||
key core.slug PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL,
|
||||
kind ncm.rule_kind NOT NULL,
|
||||
pattern text NOT NULL,
|
||||
severity ncm.rule_severity NOT NULL,
|
||||
|
||||
-- Тип конфігу задається ЯВНО в кожному рядку, хоч у колонки
|
||||
-- ncm.compliance_rules.config_type і є DEFAULT 'running' із 0056.
|
||||
--
|
||||
-- Значення за замовчуванням означало б, що предмет перевірки правила
|
||||
-- вирішує клауза DEFAULT, а не той, хто правило написав. Для правила
|
||||
-- про `transport input` це нешкідливо рівно доти, доки колись хтось
|
||||
-- не вирішить змінити умовчання, — і тоді двадцять правил мовчки
|
||||
-- почнуть читати інший документ. 0056 з'явилась саме через мовчазну
|
||||
-- підміну документа; повторювати її на рівні DEFAULT смішно.
|
||||
config_type text NOT NULL,
|
||||
|
||||
-- Вендори, до яких правило застосовне. NULL — до всіх.
|
||||
--
|
||||
-- Перетворюється на selector = {"vendors":[...]} — рівно ту форму,
|
||||
-- яку читає store.Selector і компілює selectorSQL у предикат
|
||||
-- `d.vendor = ANY(...)`. Інших полів селектора у вбудованих правил
|
||||
-- немає навмисно (див. 1.5).
|
||||
vendors text[],
|
||||
|
||||
remediation text NOT NULL,
|
||||
enabled boolean NOT NULL,
|
||||
sort_order int NOT NULL DEFAULT 100
|
||||
);
|
||||
|
||||
COMMENT ON TABLE ncm.builtin_compliance IS
|
||||
'Довідник вбудованих правил відповідності. Кожен кабінет отримує власні копії — див. ncm.builtin_compliance_apply().';
|
||||
|
||||
ALTER TABLE ncm.builtin_compliance ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Довідник без tenant_id читають усі — так само, як core.check_types і
|
||||
-- core.permissions у 0011. Політика лише на SELECT: писати в довідник
|
||||
-- має право міграція, а не кабінет.
|
||||
CREATE POLICY read_all ON ncm.builtin_compliance FOR SELECT USING (true);
|
||||
|
||||
GRANT SELECT ON ncm.builtin_compliance TO netpulse_app, netpulse_worker;
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1.3. Головне рішення: вендорна специфіка
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- «Немає telnet» — це не одне правило. На Cisco це `transport input`,
|
||||
-- на Huawei `telnet server enable`, на D-Link `enable telnet`, на
|
||||
-- MikroTik `set telnet disabled=no`. Питання, на яке треба відповісти
|
||||
-- один раз і не переглядати: один зразок на всі родини — чи по правилу
|
||||
-- на родину?
|
||||
--
|
||||
-- ВІДПОВІДЬ: по правилу на родину, прив'язаному через selector.vendors.
|
||||
-- Один виняток — про нього нижче.
|
||||
--
|
||||
-- Три доводи, кожного з яких вистачило б.
|
||||
--
|
||||
-- 1. ЗЕЛЕНЕ ВІД ОДНОЇ РОДИНИ ХОВАЄ СЛІПУ ПЛЯМУ В ІНШІЙ. Правило з
|
||||
-- об'єднаним зразком, яке покриває Cisco й НЕ покриває MikroTik,
|
||||
-- показує «порушень немає» — і воно каже правду про сорок Cisco та
|
||||
-- мовчить про десять MikroTik. Один рядок у таблиці, одне число «0
|
||||
-- порушень», і жодного способу побачити, що десята частина парку
|
||||
-- ніколи не перевірялась. Розбите на родини, воно дає окремий
|
||||
-- рядок на кожну, і родина без правила видима як родина без
|
||||
-- правила. Це та сама думка, що в шапці цього файлу про порожній
|
||||
-- розділ: відсутність, яку видно, дешевша за відсутність, якої не
|
||||
-- видно.
|
||||
--
|
||||
-- 2. ПІДКАЗКА «ЯК ВИПРАВИТИ» В ПРАВИЛА ОДНА. Колонка remediation —
|
||||
-- текст, який людина читає, стоячи перед знахідкою. Об'єднане
|
||||
-- правило змусило б написати туди або чотири команди підряд («якщо
|
||||
-- у вас Cisco, то...»), або одну — правильну для однієї родини й
|
||||
-- хибну для трьох. Порада, правильна на чверть, гірша за її
|
||||
-- відсутність: за відсутньою підуть у документацію, а за хибною
|
||||
-- підуть на залізо.
|
||||
--
|
||||
-- 3. ЗРАЗОК, ЯКИЙ НЕ ЗБІГАЄТЬСЯ НІКОЛИ, НЕ ВІДРІЗНИТИ ВІД СПРАВНОГО.
|
||||
-- Це урок усього проєкту, і тут він має ціну: правило «не має
|
||||
-- бути», чий зразок не підходить до синтаксису, дає «порушень
|
||||
-- немає» — тобто виглядає ідеально. Прив'язка до вендора не
|
||||
-- рятує сама собою, але робить перевірку можливою: набір вендорів
|
||||
-- правила скінченний, і на кожен із них можна показати справжній
|
||||
-- конфіг, де правило спрацювало, і справжній, де не спрацювало.
|
||||
-- Саме це й зроблено — див. server/internal/store/ncm_compliance_builtin_test.go.
|
||||
--
|
||||
-- ЗВІДСИ ЖОРСТКЕ ПРАВИЛО ЦЬОГО ФАЙЛУ: вендор не потрапляє в перелік
|
||||
-- правила, поки для нього немає перевіреного зразка конфігу в тесті.
|
||||
-- Тому Arista, Brocade, Juniper, Extreme, HP і Alcatel, чиї профілі в
|
||||
-- каталозі є, у вбудовані правила НЕ ввійшли: їхній синтаксис я не
|
||||
-- перевіряв, а вписати вендора «бо схоже на Cisco» означає завести
|
||||
-- рівно ту мовчазну зелень, від якої все це й будується. Побачити
|
||||
-- таких у своєму парку можна виглядом
|
||||
-- ncm.builtin_compliance_coverage (1.6).
|
||||
--
|
||||
-- ВИНЯТОК: правило про типові community. Воно єдине не прив'язане до
|
||||
-- вендора, і не з ліні. Його зразок не спирається на синтаксис узагалі:
|
||||
-- `public` і `private` — це не ключові слова вендора, а два рядки, які
|
||||
-- однакові скрізь, бо це типові значення самого SNMP. Слово `community`
|
||||
-- поруч із ними теж є в усіх діалектах (`snmp-server community`,
|
||||
-- `snmp-agent community`, `create snmp community`), а MikroTik, який
|
||||
-- пише `name=public` окремим рядком секції, покритий другою гілкою
|
||||
-- зразка. Прив'язати таке правило до переліку вендорів означало б
|
||||
-- зробити його сліпим до вендора, якого в переліку немає, — тобто
|
||||
-- втратити єдине правило, яке працює на незнайомому залізі з коробки.
|
||||
--
|
||||
-- І ще одне спостереження, яке визначило склад набору. Дві напрямки
|
||||
-- правил помиляються по-різному:
|
||||
--
|
||||
-- «НЕ МАЄ БУТИ» (regex_absent) при неповному зразку МОВЧИТЬ. Помилка
|
||||
-- безкоштовна на вигляд і дорога по суті. Тому такі зразки написані
|
||||
-- широко: кілька гілок на родину, включно з формами, яких у моїх
|
||||
-- зразках немає (`set [ find name=telnet ]`). Зайва гілка тут не
|
||||
-- коштує нічого: вона може лише знайти більше.
|
||||
--
|
||||
-- «МАЄ БУТИ» (regex_match) при неповному зразку КРИЧИТЬ: кожен хост
|
||||
-- родини стає порушником. Помилка гучна, але дорога інакше — з неї
|
||||
-- починається звичка не дивитись на червоне. Тому такі зразки
|
||||
-- написані вузько й лише для родин, де я бачив, як виглядає ПРАВИЛЬНО
|
||||
-- налаштований конфіг.
|
||||
--
|
||||
-- Саме тому переліки вендорів у правил «немає telnet» ширші, ніж у
|
||||
-- правил «є сервер журналювання», хоч ідея в них однакова.
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1.4. Сам набір
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Зразки в доларових лапках ($rx$...$rx$), а не в звичайних. Причина
|
||||
-- механічна: у регулярних виразах живуть зворотні скісні риски й
|
||||
-- апострофи, і подвоєння лапок перетворило б їх на нечитний рядок, у
|
||||
-- якому помилку не видно очима. Друга причина важливіша: у такому
|
||||
-- вигляді зразок однозначно виймається з файлу — тест
|
||||
-- ncm_compliance_builtin_test.go читає САМЕ ЦЕЙ файл через
|
||||
-- schema.Files і ганяє звідси зразки по конфігах. Другого примірника
|
||||
-- цих виразів у репозиторії немає, і розійтись їм нема з чим.
|
||||
|
||||
INSERT INTO ncm.builtin_compliance
|
||||
(key, severity, kind, config_type, vendors, pattern, name, description, remediation, enabled, sort_order)
|
||||
VALUES
|
||||
|
||||
-- === SNMP =============================================================
|
||||
|
||||
-- Типові community — єдине правило набору без прив'язки до вендора
|
||||
-- (обґрунтування в 1.3).
|
||||
--
|
||||
-- Ловить: `snmp-server community public RO` (Cisco/Eltex/ZTE),
|
||||
-- `snmp-agent community read public` (Huawei/H3C),
|
||||
-- `create snmp community private view CommunityView read_write` (D-Link),
|
||||
-- `set [ find default=yes ] name=public` (MikroTik — окрема гілка, бо
|
||||
-- слова `community` в цьому рядку немає, воно в заголовку секції).
|
||||
--
|
||||
-- Не ловить (і не має): `community NP-ro-2024`, `community read cipher`,
|
||||
-- `name=NP-ro-2024`. Межа слова навколо public/private навмисна: без
|
||||
-- неї правило спрацьовувало б на community з назвою `MyPublicKey`.
|
||||
('snmp-default-community', 'critical', 'regex_absent', 'running', NULL,
|
||||
$rx$(?i)(communit(y|ies).*\b(public|private)\b|\bname=(public|private)\b)$rx$,
|
||||
'SNMP: типові community public/private',
|
||||
'Community public і private знає напам''ять кожен сканер. Через RO-community читається таблиця маршрутизації, перелік портів і сусіди; через RW-community конфіг ще й переписується. Це не теоретичний ризик, а перше, що пробує будь-хто, хто дістався до мережі.',
|
||||
'Замініть community на власне значення й обмежте його списком доступу. Cisco: no snmp-server community public / snmp-server community <нове> RO <acl>. Huawei: undo snmp-agent community public. D-Link: delete snmp community public. MikroTik: /snmp community set [ find default=yes ] name=<нове> addresses=<мережа керування>. Найкраще — перейти на SNMPv3.',
|
||||
true, 10),
|
||||
|
||||
-- === Telnet ===========================================================
|
||||
|
||||
-- Cisco-подібні. Три форми, бо родина одна лише за консоллю:
|
||||
-- * `transport input telnet` і `transport input all` на лініях vty;
|
||||
-- * `ip telnet server` — Eltex MES;
|
||||
-- * `telnet server enable` — трапляється на ZTE.
|
||||
-- Форми вимкнення (`no ip telnet server`, `transport input ssh`) під
|
||||
-- зразок не підпадають, бо кожна гілка прив'язана до початку рядка.
|
||||
--
|
||||
-- ЧОГО ЦЕ ПРАВИЛО НЕ ЛОВИТЬ, і це треба знати. Лінія vty БЕЗ жодного
|
||||
-- рядка `transport input` на старих IOS означає «дозволено все», тобто
|
||||
-- telnet увімкнено — і в конфізі про це немає ані слова. Правило
|
||||
-- порядкове (checkCompliance читає рядки, а не блоки), тому відсутність
|
||||
-- рядка всередині блока воно побачити не може в принципі. Це чесна межа
|
||||
-- рушія, а не недогляд зразка.
|
||||
('no-telnet-ios', 'high', 'regex_absent', 'running',
|
||||
string_to_array('Cisco,Eltex,ZTE', ','),
|
||||
$rx$(?i)^\s*(transport input\b.*\b(telnet|all)\b|ip telnet server\b|telnet server enable\b)$rx$,
|
||||
'Telnet на керуванні (Cisco, Eltex, ZTE)',
|
||||
'Telnet передає пароль відкритим текстом. У мережі оператора це означає, що пароль до комутатора видно кожному, хто слухає транзитний сегмент, — включно з абонентом, який зняв дзеркало порту.',
|
||||
'Cisco/ZTE: line vty 0 15 / transport input ssh. Eltex: no ip telnet server. Перед вимкненням переконайтесь, що SSH піднято й ключ згенеровано (crypto key generate rsa), інакше доступ до вузла втрачається разом із telnet.',
|
||||
true, 20),
|
||||
|
||||
-- Huawei VRP і H3C Comware. Три форми: глобальний сервер, `protocol
|
||||
-- inbound` на vty й тип сервісу користувача. Форми з `undo` не
|
||||
-- підпадають — гілки прив'язані до початку рядка.
|
||||
('no-telnet-vrp', 'high', 'regex_absent', 'running',
|
||||
string_to_array('Huawei,H3C', ','),
|
||||
$rx$(?i)^\s*telnet\s+(ipv6\s+)?server\s+enable\b|^\s*protocol\s+inbound\s+(all|telnet)\b|\bservice-type\b[^\n]*\btelnet\b$rx$,
|
||||
'Telnet на керуванні (Huawei, H3C)',
|
||||
'Те саме, що для Cisco, плюс особливість VRP: telnet тут вмикається у трьох незалежних місцях, і вимкнення глобального сервера не забирає telnet у користувача, якому він виданий через service-type.',
|
||||
'undo telnet server enable; на лінії — protocol inbound ssh; у користувача — local-user <ім''я> service-type ssh. Перевірити всі три: залишений service-type telnet лишає вхід відкритим.',
|
||||
true, 21),
|
||||
|
||||
-- D-Link. `enable telnet [порт]` на Smart/xStack і форма
|
||||
-- `config telnet ... state enable` на частині прошивок.
|
||||
('no-telnet-dlink', 'high', 'regex_absent', 'running',
|
||||
string_to_array('D-Link', ','),
|
||||
$rx$(?i)^\s*(enable\s+telnet\b|config\s+telnet\b[^\n]*\bstate\s+enable\b)$rx$,
|
||||
'Telnet на керуванні (D-Link)',
|
||||
'На D-Link telnet увімкнений із заводу, і найчастіше він так і лишається: SSH на цих серіях треба вмикати окремою командою й генерувати ключ.',
|
||||
'disable telnet, попередньо: enable ssh та config ssh authmode password enable. Без піднятого SSH вимкнення telnet лишає лише консольний кабель.',
|
||||
true, 22),
|
||||
|
||||
-- MikroTik RouterOS. Одна гілка покриває обидві форми запису — і
|
||||
-- `set telnet disabled=no`, і `set [ find name=telnet ] disabled=no`,
|
||||
-- бо між `set` і `disabled=no` в обох випадках стоїть слово telnet.
|
||||
('no-telnet-routeros', 'high', 'regex_absent', 'running',
|
||||
string_to_array('MikroTik', ','),
|
||||
$rx$(?i)^\s*set\s+[^\n]*\btelnet\b[^\n]*\bdisabled=no\b$rx$,
|
||||
'Telnet на керуванні (MikroTik)',
|
||||
'У RouterOS telnet — один із сервісів /ip service, увімкнений типово. Поруч із ним типово увімкнені ftp і www, які так само передають пароль відкритим текстом.',
|
||||
'/ip service set telnet disabled=yes; заразом варто вимкнути ftp і www та обмежити ssh і winbox параметром address=<мережа керування>.',
|
||||
true, 23),
|
||||
|
||||
-- === Паролі ===========================================================
|
||||
|
||||
-- Cisco-подібні. Чотири ознаки зворотного (тобто відновлюваного)
|
||||
-- зберігання:
|
||||
-- * `no service password-encryption` — паролі лежать відкрито;
|
||||
-- * `enable password` у будь-якій формі — на відміну від
|
||||
-- `enable secret`, це або відкритий текст, або тип 7;
|
||||
-- * `password 0 ...` (відкрито) і `password 7 ...` (тип 7,
|
||||
-- розшифровується онлайн-декодером за секунду);
|
||||
-- * одинокий `password <слово>` у блоці лінії — форма без типу.
|
||||
--
|
||||
-- Навмисно НЕ ловить `password encrypted <хеш>` (Eltex) і `secret 5|8|9`
|
||||
-- (Cisco): це незворотні схеми, і позначати їх порушенням означало б
|
||||
-- зробити правило червоним на правильно налаштованому вузлі.
|
||||
('plaintext-password-ios', 'high', 'regex_absent', 'running',
|
||||
string_to_array('Cisco,Eltex,ZTE', ','),
|
||||
$rx$(?i)^\s*no service password-encryption\s*$|^\s*enable password\b|\bpassword\s+[07]\s+\S|^\s*password\s+\S+\s*$$rx$,
|
||||
'Пароль у зворотному вигляді (Cisco, Eltex, ZTE)',
|
||||
'Конфіг комутатора бачить кожен, хто має доступ на читання: підрядник, стажер, система резервного копіювання, репозиторій Git. Пароль типу 7 і пароль відкритим текстом — це той самий пароль, різниця лише в секунді на розшифрування.',
|
||||
'enable secret замість enable password; username <ім''я> secret <пароль> замість password; service password-encryption як мінімум. Зміна не розшифровує вже збережені паролі — їх треба задати наново.',
|
||||
true, 30),
|
||||
|
||||
-- Huawei VRP і H3C. Ключове слово `simple` в обох діалектах означає
|
||||
-- рівно «зберігати відкритим текстом».
|
||||
('plaintext-password-vrp', 'high', 'regex_absent', 'running',
|
||||
string_to_array('Huawei,H3C', ','),
|
||||
$rx$(?i)\bpassword\s+simple\b$rx$,
|
||||
'Пароль відкритим текстом (Huawei, H3C)',
|
||||
'На VRP і Comware форма `password simple` кладе пароль у конфіг як є. Поруч у тому ж конфізі є форми cipher та irreversible-cipher, тобто вибір зроблено свідомо або скопійовано з чужого прикладу.',
|
||||
'Huawei: local-user <ім''я> password irreversible-cipher <пароль>. H3C: local-user <ім''я> / password hash <...> або password cipher. Стару форму треба перезадати: перемикання режиму не перешифровує наявний рядок.',
|
||||
true, 31),
|
||||
|
||||
-- MikroTik. Порожній `password=""` під зразок не підпадає навмисно:
|
||||
-- це не пароль у конфізі, а його відсутність у вивантаженні.
|
||||
('plaintext-password-routeros', 'high', 'regex_absent', 'running',
|
||||
string_to_array('MikroTik', ','),
|
||||
$rx$(?i)\bpassword=("[^"]+"|[^\s"]+)$rx$,
|
||||
'Пароль відкритим текстом (MikroTik)',
|
||||
'У вивантаженні RouterOS пароль з''являється там, де його задали параметром: /ppp secret, /interface pppoe-client, /tool e-mail. Такий конфіг не можна ані показати підряднику, ані покласти в Git.',
|
||||
'Виносьте секрети з конфігу: для PPP — на RADIUS, для решти — окремі облікові записи з мінімальними правами. Пам''ятайте, що резервна копія /export уже поїхала в сховище — змініть паролі, а не лише конфіг.',
|
||||
true, 32),
|
||||
|
||||
-- === Журналювання =====================================================
|
||||
|
||||
-- Cisco-подібні. Зразок вимагає саме АДРЕСУ: `logging trap
|
||||
-- informational` описує рівень, а не одержувача, і зарахувати його як
|
||||
-- «сервер журналювання є» означало б дати зелене вузлу, який пише
|
||||
-- журнал у власну пам'ять і губить його при перезавантаженні.
|
||||
('syslog-target-ios', 'medium', 'regex_match', 'running',
|
||||
string_to_array('Cisco,Eltex,ZTE', ','),
|
||||
$rx$(?i)^\s*logging\s+(host\s+|server\s+)?(\d{1,3}\.){3}\d{1,3}\b$rx$,
|
||||
'Журнал іде на сервер (Cisco, Eltex, ZTE)',
|
||||
'Локальний буфер журналу гине разом із вузлом: перезавантаження, збій живлення й підміна пристрою забирають із собою рівно ті записи, заради яких журнал і читають. Розбір аварії починається з питання «що було за хвилину до», і відповідь має лежати не на аварійному вузлі.',
|
||||
'logging host <адреса> (Cisco/Eltex) або logging server <адреса> (ZTE), плюс logging trap informational. Перевірте, що адреса досяжна з vrf керування.',
|
||||
true, 40),
|
||||
|
||||
('syslog-target-vrp', 'medium', 'regex_match', 'running',
|
||||
string_to_array('Huawei,H3C', ','),
|
||||
$rx$(?i)^\s*info-center\s+loghost\b$rx$,
|
||||
'Журнал іде на сервер (Huawei, H3C)',
|
||||
'Те саме, що для Cisco. На VRP і Comware за відправлення журналу відповідає info-center, і він може бути ввімкнений без жодного одержувача — тобто працювати «в нікуди».',
|
||||
'info-center enable, потім info-center loghost <адреса> [channel <n>]. Без другої команди перша нічого не надсилає.',
|
||||
true, 41),
|
||||
|
||||
('syslog-target-dlink', 'medium', 'regex_match', 'running',
|
||||
string_to_array('D-Link', ','),
|
||||
$rx$(?i)^\s*create\s+syslog\s+host\b$rx$,
|
||||
'Журнал іде на сервер (D-Link)',
|
||||
'На D-Link одержувач журналу — окремий об''єкт, який треба створити. Команда enable syslog без нього вмикає підсистему, у якої немає куди писати.',
|
||||
'create syslog host 1 ipaddress <адреса> severity informational facility local0 udp_port 514 state enable, потім enable syslog.',
|
||||
true, 42),
|
||||
|
||||
-- MikroTik. Дві гілки: заголовок секції дій журналювання й параметр
|
||||
-- `remote=<адреса>` у самій дії. Секція /system logging без дій —
|
||||
-- це локальний журнал у пам'яті, тобто саме те, що правило шукає й
|
||||
-- не має зараховувати.
|
||||
('syslog-target-routeros', 'medium', 'regex_match', 'running',
|
||||
string_to_array('MikroTik', ','),
|
||||
$rx$(?i)^\s*/system\s+logging\s+action\b|\bremote=(\d{1,3}\.){3}\d{1,3}\b$rx$,
|
||||
'Журнал іде на сервер (MikroTik)',
|
||||
'Типовий RouterOS пише журнал у пам''ять, і перезавантаження стирає його повністю. На платах із малим обсягом пам''яті буфер до того ж короткий — кілька хвилин під навантаженням.',
|
||||
'/system logging action add name=remote target=remote remote=<адреса> та /system logging add action=remote topics=info,error,warning,critical.',
|
||||
true, 43),
|
||||
|
||||
-- === Час ==============================================================
|
||||
|
||||
-- Cisco-подібні. Одна гілка на `ntp server` і `sntp server`: Eltex
|
||||
-- використовує другу форму, решта — першу.
|
||||
('ntp-source-ios', 'medium', 'regex_match', 'running',
|
||||
string_to_array('Cisco,Eltex,ZTE', ','),
|
||||
$rx$(?i)^\s*s?ntp\s+server\b$rx$,
|
||||
'Джерело часу задано (Cisco, Eltex, ZTE)',
|
||||
'Час без синхронізації — це журнал, який неможливо звести з журналом сусіднього вузла. Розбір аварії, у якому мітки двох пристроїв розходяться на години, перетворюється на здогади; сертифікати при цьому починають «протухати» або, навпаки, ставати чинними завчасно.',
|
||||
'ntp server <адреса> (Cisco/ZTE) або sntp server <адреса> + sntp unicast client enable (Eltex). Задавайте два джерела: одне джерело — це не резервування, а точка відмови з мітками часу.',
|
||||
true, 50),
|
||||
|
||||
('ntp-source-vrp', 'medium', 'regex_match', 'running',
|
||||
string_to_array('Huawei,H3C', ','),
|
||||
$rx$(?i)^\s*ntp(-service)?\s+unicast-server\b$rx$,
|
||||
'Джерело часу задано (Huawei, H3C)',
|
||||
'Те саме, що для Cisco. VRP називає команду ntp-service, Comware 7 — ntp-service або ntp; обидві форми покриті.',
|
||||
'ntp-service unicast-server <адреса>. Другий сервер — окремою командою.',
|
||||
true, 51),
|
||||
|
||||
('ntp-source-dlink', 'medium', 'regex_match', 'running',
|
||||
string_to_array('D-Link', ','),
|
||||
$rx$(?i)^\s*(config\s+sntp\b|enable\s+sntp\b)$rx$,
|
||||
'Джерело часу задано (D-Link)',
|
||||
'На D-Link SNTP типово вимкнений, і рядок disable sntp у конфізі — звичайна річ. Годинник при цьому йде від внутрішнього кварцу й розходиться на хвилини за тиждень.',
|
||||
'config sntp primary <адреса> secondary <адреса> poll-interval 720, потім enable sntp.',
|
||||
true, 52),
|
||||
|
||||
-- MikroTik. Заголовок секції `/system ntp client` з'являється у
|
||||
-- вивантаженні лише тоді, коли клієнт налаштований, тому його
|
||||
-- достатньо; друга гілка ловить форму зі старих версій, де адреса
|
||||
-- задається параметром primary-ntp.
|
||||
('ntp-source-routeros', 'medium', 'regex_match', 'running',
|
||||
string_to_array('MikroTik', ','),
|
||||
$rx$(?i)^\s*/system\s+ntp\s+client\b|\b(primary-ntp|ntp-server)=\S$rx$,
|
||||
'Джерело часу задано (MikroTik)',
|
||||
'RouterOS без NTP-клієнта тримає час від запуску: після перезавантаження без батарейки годинник починається з дати прошивки. Мітки в журналі й правила з розкладом стають безглуздими.',
|
||||
'/system ntp client set enabled=yes servers=<адреса1>,<адреса2>. Заразом задайте /system clock set time-zone-name=Europe/Kyiv.',
|
||||
true, 53),
|
||||
|
||||
-- === Обмеження доступу до керування ===================================
|
||||
|
||||
-- Cisco-подібні. Дві форми: access-class на лінії vty (Cisco, ZTE) і
|
||||
-- management access-class (Eltex).
|
||||
('mgmt-acl-ios', 'medium', 'regex_match', 'running',
|
||||
string_to_array('Cisco,Eltex,ZTE', ','),
|
||||
$rx$(?i)^\s*(access-class\s+\S+\s+in\b|management\s+access-class\b)$rx$,
|
||||
'Доступ до керування обмежено списком (Cisco, Eltex, ZTE)',
|
||||
'Вузол, у якого SSH відкритий з усіх адрес, доступний для підбору пароля з будь-якого абонентського порту. Список доступу на лініях керування — єдиний рубіж, який працює до перевірки пароля, тобто до того, як підбір узагалі щось коштує.',
|
||||
'Cisco/ZTE: ip access-list standard MGMT / permit <мережа керування>, потім line vty 0 15 / access-class MGMT in. Eltex: management access-list MGMT / permit ip-source <мережа> та management access-class MGMT.',
|
||||
true, 60),
|
||||
|
||||
('mgmt-acl-vrp', 'medium', 'regex_match', 'running',
|
||||
string_to_array('Huawei,H3C', ','),
|
||||
$rx$(?i)^\s*acl\s+\d+\s+inbound\b$rx$,
|
||||
'Доступ до керування обмежено списком (Huawei, H3C)',
|
||||
'Те саме, що для Cisco. На VRP і Comware список чіпляється до user-interface vty командою acl <номер> inbound.',
|
||||
'acl number 2000 / rule permit source <мережа> <вілдкард>, потім user-interface vty 0 4 / acl 2000 inbound.',
|
||||
true, 61),
|
||||
|
||||
('mgmt-acl-dlink', 'medium', 'regex_match', 'running',
|
||||
string_to_array('D-Link', ','),
|
||||
$rx$(?i)^\s*create\s+trusted_host\b$rx$,
|
||||
'Доступ до керування обмежено списком (D-Link)',
|
||||
'На D-Link роль списку доступу для керування виконує trusted_host. Поки не створено жодного, керування відкрите з усієї мережі.',
|
||||
'create trusted_host network <мережа>/<маска> — і одразу перевірте, що ваша власна адреса до неї входить.',
|
||||
true, 62),
|
||||
|
||||
-- MikroTik. Параметр address= на сервісі — рівно той самий рубіж, що
|
||||
-- access-class на vty.
|
||||
('mgmt-acl-routeros', 'medium', 'regex_match', 'running',
|
||||
string_to_array('MikroTik', ','),
|
||||
$rx$(?i)^\s*set\s+(ssh|winbox|www-ssl|api|api-ssl)\b[^\n]*\baddress=\S$rx$,
|
||||
'Доступ до керування обмежено списком (MikroTik)',
|
||||
'Winbox і API RouterOS — постійні цілі масових сканів, і кілька гучних кампаній масового зламу починались саме з відкритих у світ портів керування.',
|
||||
'/ip service set ssh address=<мережа керування>; те саме для winbox і api. Параметр address приймає кілька мереж через кому.',
|
||||
true, 63);
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1.5. Чого у вбудованих правил немає в селекторі
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Лише vendors. Ані груп, ані сайтів, ані міток — і це не спрощення.
|
||||
--
|
||||
-- Групи, сайти й мітки — це те, як КЛІЄНТ поділив свою мережу. Ми про
|
||||
-- цей поділ не знаємо нічого: у одного «core» означає ядро мережі, у
|
||||
-- другого — назву майданчика. Вбудоване правило, яке посилається на
|
||||
-- групу, або не знайде її (і мовчки не застосується ні до кого), або
|
||||
-- знайде однойменну чужу (і застосується не туди). Вендор натомість —
|
||||
-- це властивість самого заліза, яку система визначила сама
|
||||
-- (inv.ident_rules, 0035), а не назва, яку хтось придумав.
|
||||
--
|
||||
-- Цей же факт робить можливим вигляд нижче: селектор вбудованого
|
||||
-- правила має рівно одну форму, і її можна чесно порахувати в SQL.
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1.6. Хто лишився непокритим
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Правило, прив'язане до вендора, мовчазне двічі. Перший раз — коли
|
||||
-- зразок не підходить (від цього рятує тест). Другий — коли хост просто
|
||||
-- не підпадає під селектор: він не рахується ані як пройдений, ані як
|
||||
-- проваленений, ані як пропущений. У ComplianceStat його немає взагалі.
|
||||
--
|
||||
-- Для одного правила це правильно (правило про D-Link не має що сказати
|
||||
-- про Cisco). Для НАБОРУ — небезпечно: хост вендора, якого немає в
|
||||
-- жодному правилі, дає порожній результат, який зовні не відрізнити від
|
||||
-- бездоганного. Саме це й треба зробити видимим.
|
||||
--
|
||||
-- Вигляд рахує лише ВБУДОВАНІ правила навмисно. Селектор правил,
|
||||
-- написаних руками, тлумачить Go (store.selectorSQL: групи, сайти,
|
||||
-- мітки, шаблони), і повторити цю логіку в SQL означало б завести
|
||||
-- другий примірник, який розійдеться з першим на першій же правці —
|
||||
-- рівно те, від чого застерігає db/profiles/README.md. Вбудовані ж
|
||||
-- правила мають селектор однієї відомої форми (1.5), тож число тут
|
||||
-- точне, а не приблизне.
|
||||
--
|
||||
-- security_invoker обов'язковий: без нього вигляд читав би таблиці
|
||||
-- правами ВЛАСНИКА й показував би хости всіх кабінетів. Це не здогад —
|
||||
-- 0063 знайшла рівно таку діру в topo.link_live і назвала її єдиним
|
||||
-- місцем у схемі, де перехід на роль без BYPASSRLS сам собою нічого не
|
||||
-- змінює. Другого такого місця не буде.
|
||||
CREATE VIEW ncm.builtin_compliance_coverage
|
||||
WITH (security_invoker = true) AS
|
||||
SELECT
|
||||
d.tenant_id,
|
||||
d.id AS device_id,
|
||||
d.name AS device_name,
|
||||
COALESCE(d.vendor, '') AS vendor,
|
||||
count(r.id) AS builtin_rules,
|
||||
count(r.id) FILTER (WHERE r.enabled) AS builtin_rules_enabled
|
||||
FROM inv.devices d
|
||||
LEFT JOIN ncm.compliance_rules r
|
||||
ON r.tenant_id = d.tenant_id
|
||||
AND r.builtin_key IS NOT NULL
|
||||
-- Три випадки, і всі три треба назвати явно:
|
||||
-- * ключа vendors немає — правило застосовне до всіх;
|
||||
-- * перелік порожній — те саме, але записане інакше;
|
||||
-- * вендор хоста є в переліку.
|
||||
-- COALESCE навколо jsonb_typeof не косметика: для селектора {}
|
||||
-- вираз `jsonb_typeof(NULL) <> 'array'` дає NULL, а не TRUE, і
|
||||
-- універсальне правило випало б із підрахунку — тобто вигляд
|
||||
-- недорахував би покриття саме там, де воно повне.
|
||||
AND ( COALESCE(jsonb_typeof(r.selector -> 'vendors'), 'null') <> 'array'
|
||||
OR jsonb_array_length(r.selector -> 'vendors') = 0
|
||||
OR jsonb_exists(r.selector -> 'vendors', COALESCE(d.vendor, '')) )
|
||||
WHERE d.deleted_at IS NULL
|
||||
GROUP BY d.tenant_id, d.id, d.name, d.vendor;
|
||||
|
||||
COMMENT ON VIEW ncm.builtin_compliance_coverage IS
|
||||
'Скільки вбудованих правил накриває кожен хост. Нуль означає, що перевірка відповідності про цей хост не знає нічого — і це не те саме, що «порушень немає».';
|
||||
|
||||
GRANT SELECT ON ncm.builtin_compliance_coverage TO netpulse_app, netpulse_worker;
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1.7. Розкладання по кабінетах
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- SECURITY DEFINER, і це не зручність. Політика tenant_isolation на
|
||||
-- ncm.compliance_rules має WITH CHECK (tenant_id = core.current_tenant()),
|
||||
-- тобто вставити рядок для кабінету, який ЩОЙНО створюють і який
|
||||
-- поточним ще не став, під роллю netpulse_app неможливо за побудовою.
|
||||
-- Тригер без прав власника мовчки нічого б не вставив — і новий кабінет
|
||||
-- отримав би той самий порожній розділ.
|
||||
--
|
||||
-- search_path прибитий цвяхами з тієї ж причини, що в core.apply_retention_policies
|
||||
-- (0064): функція правами власника не має читати таблицю, підмінену
|
||||
-- схемою в search_path того, хто викликає.
|
||||
--
|
||||
-- EXECUTE відкликано в PUBLIC. Функція правами власника, яка приймає
|
||||
-- чужий tenant_id, — це готовий обхід ізоляції: будь-хто з доступом до
|
||||
-- бази міг би писати рядки в чужий кабінет. Тригеру дозвіл не потрібен:
|
||||
-- права на тригерну функцію перевіряються при CREATE TRIGGER, а не при
|
||||
-- спрацюванні.
|
||||
CREATE FUNCTION ncm.builtin_compliance_apply(p_tenant uuid) RETURNS int
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = pg_catalog, ncm, core, public AS $fn$
|
||||
DECLARE
|
||||
added int;
|
||||
BEGIN
|
||||
INSERT INTO ncm.compliance_rules
|
||||
(tenant_id, name, description, kind, pattern, severity, selector,
|
||||
remediation, enabled, config_type, builtin_key)
|
||||
SELECT p_tenant, b.name, b.description, b.kind, b.pattern, b.severity,
|
||||
CASE WHEN b.vendors IS NULL OR cardinality(b.vendors) = 0
|
||||
THEN '{}'::jsonb
|
||||
ELSE jsonb_build_object('vendors', to_jsonb(b.vendors))
|
||||
END,
|
||||
b.remediation, b.enabled, b.config_type, b.key
|
||||
FROM ncm.builtin_compliance b
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM ncm.compliance_rules r
|
||||
WHERE r.tenant_id = p_tenant AND r.builtin_key = b.key
|
||||
)
|
||||
ORDER BY b.sort_order, b.key;
|
||||
|
||||
GET DIAGNOSTICS added = ROW_COUNT;
|
||||
RETURN added;
|
||||
END
|
||||
$fn$;
|
||||
|
||||
COMMENT ON FUNCTION ncm.builtin_compliance_apply(uuid) IS
|
||||
'Довкладає в кабінет ті вбудовані правила, яких у ньому ще немає. Наявних не чіпає — правку копії людина робить назавжди.';
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION ncm.builtin_compliance_apply(uuid) FROM PUBLIC;
|
||||
|
||||
-- Наявні кабінети.
|
||||
--
|
||||
-- Видалені пропускаємо: рядки в них нікому не видно, а FK на
|
||||
-- core.tenants однаково тримає їх до фізичного видалення.
|
||||
DO $$
|
||||
DECLARE
|
||||
t record;
|
||||
BEGIN
|
||||
FOR t IN SELECT id FROM core.tenants WHERE deleted_at IS NULL LOOP
|
||||
PERFORM ncm.builtin_compliance_apply(t.id);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Майбутні кабінети.
|
||||
--
|
||||
-- Тригером, а не кроком у коді створення кабінету, бо такого коду
|
||||
-- немає: кабінети заводяться SQL-ом при встановленні (див.
|
||||
-- deploy/ і тести httpapi). Обов'язок, покладений на процедуру, яку
|
||||
-- виконують руками, виконується рівно доти, доки про нього пам'ятає
|
||||
-- той, хто її виконує.
|
||||
--
|
||||
-- AFTER INSERT і RETURN NULL: рядок кабінету вже вставлений, і
|
||||
-- повертати з нього нічого не треба.
|
||||
CREATE FUNCTION ncm.builtin_compliance_on_tenant() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path = pg_catalog, ncm, core, public AS $fn$
|
||||
BEGIN
|
||||
PERFORM ncm.builtin_compliance_apply(NEW.id);
|
||||
RETURN NULL;
|
||||
END
|
||||
$fn$;
|
||||
|
||||
REVOKE EXECUTE ON FUNCTION ncm.builtin_compliance_on_tenant() FROM PUBLIC;
|
||||
|
||||
CREATE TRIGGER trg_tenants_builtin_compliance
|
||||
AFTER INSERT ON core.tenants
|
||||
FOR EACH ROW EXECUTE FUNCTION ncm.builtin_compliance_on_tenant();
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1.8. Чому правила УВІМКНЕНІ типово — при жорсткому правилі «нічого не
|
||||
-- вмикати само»
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Правило проєкту звучить так: оновлення не має ввімкнути нічого, що
|
||||
-- заводить чеки або підіймає алерти. Його вже двічі порушували ціною
|
||||
-- поламаних хостів, і 0059 свідомо лишила hrStorage вимкненим саме
|
||||
-- через нього.
|
||||
--
|
||||
-- Перевіримо вбудовані правила відповідності проти обох половин.
|
||||
--
|
||||
-- ЧЕКИ. Правило не заводить жодного. RunCompliance
|
||||
-- (store/ncm_compliance.go) читає ncm.configs — те, що ВЖЕ зібрано, — і
|
||||
-- не створює ані сесії до заліза, ані рядка в core.checks. Приріст
|
||||
-- трафіку в мережу після цієї міграції рівно нульовий, і це можна
|
||||
-- перевірити не міркуванням, а grep-ом: у RunCompliance немає жодного
|
||||
-- запису в core.checks.
|
||||
--
|
||||
-- АЛЕРТИ. Складніше й цікавіше. Джерело `compliance` в alr.rules
|
||||
-- працює з 0058, і порушення справді може стати алертом. Але подивимось,
|
||||
-- ХТО запускає прогін: RunCompliance викликається рівно з одного місця —
|
||||
-- handleRunCompliance, тобто POST /api/v1/ncm/compliance/run. Ані такту
|
||||
-- воркера, ані розкладу, ані виклику після збору конфігу немає (перевірено
|
||||
-- пошуком по всьому дереву: три згадки, усі в цьому ланцюжку).
|
||||
--
|
||||
-- Тобто ввімкнене правило не робить НІЧОГО, доки людина не натисне
|
||||
-- «Перевірити». Оновлення не вмикає нічого само — принцип не порушено
|
||||
-- ані буквою, ані духом. Вимкнути правила «про всяк випадок» означало б
|
||||
-- дати клієнтові двадцять сірих рядків і кнопку «увімкнути» біля
|
||||
-- кожного, тобто повернути ту саму порожнечу, лише багатослівнішу.
|
||||
--
|
||||
-- ЧЕСНО ПРО РИЗИК. Перше натискання «Перевірити» на стенді, де вже є
|
||||
-- правило алертів із джерелом `compliance`, підніме стільки алертів,
|
||||
-- скільки знайдеться порушень. Це не «мовчазний наслідок оновлення» —
|
||||
-- це наслідок свідомого натискання, і саме тому він прийнятний. Пом'якшує
|
||||
-- його наявна механіка, а не нова: один алерт на пару «правило + хост»
|
||||
-- (0058), min_interval_seconds, min_severity у самому правилі алертів і
|
||||
-- auto_close_seconds = 86400, який 0058 виставила подієвим правилам
|
||||
-- джерела compliance.
|
||||
--
|
||||
-- І окремо про серйозність. Жодне вбудоване правило не має рівня
|
||||
-- `critical`, крім типових community, — а це рівно той випадок, коли
|
||||
-- розбудити вночі правильно. Решта — high і medium: вони мають
|
||||
-- потрапити у звіт, а не в телефон.
|
||||
|
||||
|
||||
-- =====================================================================
|
||||
-- ЧАСТИНА 2. ПРОТОТИПИ
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2.1. Що лишилось після 0059
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- 0059 лишила рівно один вбудований прототип — hrStorage — і вимкнула
|
||||
-- його. Рішення було правильним і лишається правильним: шаблон
|
||||
-- «snmp-host-resources» вже причеплений до хостів на робочих стендах, і
|
||||
-- ввімкнений прототип завів би їм по два чеки мовчки, під час міграції.
|
||||
--
|
||||
-- Але «правильно вимкнено» — це половина відповіді. Друга половина:
|
||||
-- людина, яка хоче його ввімкнути, зараз не може дізнатись, що станеться.
|
||||
-- Прапорець стоїть у редакторі шаблону поруч зі словом «Увімкнено», і
|
||||
-- натиснути його — це рішення, ухвалене наосліп: скільки чеків
|
||||
-- з'явиться? на яких хостах? скільки рядків знайдеться? Відповідь є в
|
||||
-- базі, але немає жодного запиту, який її ставить.
|
||||
--
|
||||
-- Вимкнений прапорець без цифри поруч перетворює обережність на
|
||||
-- параліч: не ввімкне ніхто й ніколи, бо ніхто не знає ціни. Тому цифри
|
||||
-- нижче — не звіт, а умова, за якої вимкнене типово взагалі має сенс.
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2.2. Скільки це коштуватиме
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- Що саме заводить увімкнений прототип (reconcilePrototypes,
|
||||
-- store/prototypes.go), по кроках:
|
||||
--
|
||||
-- 1. Один чек snmp.walk на пару (хост, шаблон) для кожного РІЗНОГО
|
||||
-- discovery_sec. Прототипи одного шаблону з однаковою частотою
|
||||
-- обходу злипаються в один чек — тому «ще один прототип» не
|
||||
-- обов'язково означає «ще один обхід».
|
||||
-- 2. Після першого обходу — по одному snmp.get на прототип на хост, у
|
||||
-- якому стільки OID, скільки знайдено рядків × метрик у прототипі.
|
||||
--
|
||||
-- Другий крок відкладений, і це важливо для чесності цифр: скільки
|
||||
-- буде рядків, до обходу не знає ніхто. Тому вигляд каже те, що знає:
|
||||
-- скільки хостів, скільки обходів з'явиться НЕГАЙНО, скільки чеків
|
||||
-- значень буде щонайбільше й скільки рядків уже відомо.
|
||||
--
|
||||
-- Спершу — вигляд «хост за хостом», а зведення будується поверх нього.
|
||||
--
|
||||
-- Порядок не косметичний. Зведення мусить порахувати, у скількох
|
||||
-- хостів обходу ЩЕ НЕМА, тобто відфільтрувати рядки за умовою «чека не
|
||||
-- існує». Написати це як count(*) FILTER (WHERE NOT EXISTS (...))
|
||||
-- означало б покласти підзапит усередину фільтра агрегата — місце, де
|
||||
-- планувальник хоч і не бореться, але перевірити це на живій базі під
|
||||
-- час написання не було де. Обчислити ознаку один раз у нижньому
|
||||
-- вигляді й фільтрувати за звичайною булевою колонкою — те саме число
|
||||
-- без жодної тонкості. Заразом «на яких саме хостах» стає окремим
|
||||
-- питанням із окремою відповіддю, а не масивом усередині зведення.
|
||||
--
|
||||
-- security_invoker — з тієї ж причини, що в 1.6.
|
||||
CREATE VIEW tpl.prototype_impact_devices
|
||||
WITH (security_invoker = true) AS
|
||||
SELECT
|
||||
d.tenant_id,
|
||||
p.id AS prototype_id,
|
||||
t.key AS template_key,
|
||||
p.key AS prototype_key,
|
||||
p.enabled,
|
||||
d.id AS device_id,
|
||||
d.name AS device_name,
|
||||
COALESCE(d.vendor, '') AS vendor,
|
||||
EXISTS (
|
||||
SELECT 1 FROM core.checks c
|
||||
WHERE c.device_id = d.id
|
||||
AND c.template_id = p.template_id
|
||||
AND c.check_type = 'snmp.walk'
|
||||
AND c.interval_sec = p.discovery_sec
|
||||
) AS walk_check_exists,
|
||||
(
|
||||
SELECT count(*) FROM tpl.discovered_rows dr
|
||||
WHERE dr.device_id = d.id
|
||||
AND dr.template_id = p.template_id
|
||||
AND dr.prototype_key = p.key
|
||||
AND dr.gone_at IS NULL
|
||||
) AS rows_known
|
||||
FROM tpl.item_prototypes p
|
||||
JOIN tpl.templates t ON t.id = p.template_id
|
||||
JOIN tpl.device_templates dt ON dt.template_id = p.template_id
|
||||
JOIN inv.devices d ON d.id = dt.device_id
|
||||
WHERE d.deleted_at IS NULL AND d.enabled;
|
||||
|
||||
COMMENT ON VIEW tpl.prototype_impact_devices IS
|
||||
'Хост за хостом: кому саме поїде новий чек, якщо прототип увімкнути, і чи є в нього обхід уже зараз.';
|
||||
|
||||
-- Зведення. Те саме, згорнуте до чисел, якими ухвалюють рішення.
|
||||
--
|
||||
-- Що саме заводить увімкнений прототип (reconcilePrototypes,
|
||||
-- store/prototypes.go), по кроках:
|
||||
--
|
||||
-- 1. Один чек snmp.walk на пару (хост, шаблон) для кожного РІЗНОГО
|
||||
-- discovery_sec. Прототипи одного шаблону з однаковою частотою
|
||||
-- обходу злипаються в один чек — тому «ще один прототип» не
|
||||
-- обов'язково означає «ще один обхід», і саме це показує
|
||||
-- new_walk_checks.
|
||||
-- 2. Після першого обходу — по одному snmp.get на прототип на хост, у
|
||||
-- якому стільки OID, скільки знайдено рядків × метрик у прототипі.
|
||||
--
|
||||
-- Другий крок відкладений, і це важливо для чесності цифр: скільки буде
|
||||
-- рядків, до обходу не знає ніхто. Тому max_value_checks зветься
|
||||
-- стелею, а не прогнозом: на хості, де таблиці немає, не з'явиться
|
||||
-- жодного (reconcilePrototypes не пише порожній snmp.get).
|
||||
CREATE VIEW tpl.prototype_impact
|
||||
WITH (security_invoker = true) AS
|
||||
SELECT
|
||||
x.tenant_id,
|
||||
p.id AS prototype_id,
|
||||
t.key AS template_key,
|
||||
t.name AS template_name,
|
||||
p.key AS prototype_key,
|
||||
p.name AS prototype_name,
|
||||
p.enabled,
|
||||
p.discovery_sec,
|
||||
p.interval_sec,
|
||||
p.max_rows,
|
||||
jsonb_array_length(p.metrics) AS metrics_per_row,
|
||||
|
||||
-- Хости, до яких шаблон уже причеплений. Вимкнені й видалені сюди не
|
||||
-- потрапили ще в нижньому вигляді: чек їм не поїде, і показувати їх у
|
||||
-- ціні означало б лякати числом, якого не буде.
|
||||
count(*) AS devices,
|
||||
count(*) FILTER (WHERE NOT x.walk_check_exists) AS new_walk_checks,
|
||||
count(*) AS max_value_checks,
|
||||
COALESCE(sum(x.rows_known), 0) AS rows_known
|
||||
FROM tpl.prototype_impact_devices x
|
||||
JOIN tpl.item_prototypes p ON p.id = x.prototype_id
|
||||
JOIN tpl.templates t ON t.id = p.template_id
|
||||
GROUP BY x.tenant_id, p.id, t.key, t.name;
|
||||
|
||||
COMMENT ON VIEW tpl.prototype_impact IS
|
||||
'Ціна прототипу до натискання прапорця: скільки хостів, скільки обходів з''явиться негайно, скільки чеків значень буде щонайбільше.';
|
||||
|
||||
GRANT SELECT ON tpl.prototype_impact TO netpulse_app, netpulse_worker;
|
||||
GRANT SELECT ON tpl.prototype_impact_devices TO netpulse_app, netpulse_worker;
|
||||
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2.3. Ще два прототипи: сенсори шасі
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
-- ENTITY-SENSOR-MIB (RFC 3433) — те саме, чим hrStorage є для дисків:
|
||||
-- стандартна таблиця, яку віддає залізо різних вендорів без вендорних
|
||||
-- MIB. Температура й живлення — рівно ті два виміри, заради яких на
|
||||
-- комутатор дивляться до того, як він помер, а не після.
|
||||
--
|
||||
-- ОКРЕМИЙ ШАБЛОН, а не прототипи в наявному snmp-generic. Це головне
|
||||
-- рішення блоку, і воно про ту саму свідомість вибору.
|
||||
--
|
||||
-- snmp-generic причеплений до кожного SNMP-хоста. Прототип у ньому
|
||||
-- означав би, що натискання одного прапорця вмикає обхід на ВСЬОМУ
|
||||
-- парку одночасно — рішення масштабу «вся мережа», замасковане під
|
||||
-- рішення масштабу «один перемикач». Окремий шаблон розкладає його на
|
||||
-- два свідомі кроки: спершу людина обирає, яким хостам це потрібно
|
||||
-- (причепити шаблон), потім бачить у tpl.prototype_impact реальне
|
||||
-- число й вмикає. Причому число до першого кроку дорівнює нулю — тобто
|
||||
-- помилитись нічим.
|
||||
--
|
||||
-- Ціна рішення чесна: шаблон, поки його нікому не причепили, виглядає
|
||||
-- у переліку порожнім. Це прийнятно — порожній шаблон нічого не
|
||||
-- ламає, на відміну від обходу, що поїхав на тисячу хостів.
|
||||
INSERT INTO tpl.templates (id, tenant_id, key, name, description, vendor, is_builtin) VALUES
|
||||
('00000000-0000-0000-0000-0000000000c6'::uuid, NULL, 'snmp-entity-sensors',
|
||||
'SNMP: сенсори шасі (ENTITY-SENSOR-MIB)',
|
||||
'Температура й живлення з RFC 3433 — стандартної таблиці, яку віддає більшість керованого заліза без вендорних MIB. Обидва прототипи типово вимкнені: перед увімкненням подивіться на ціну у tpl.prototype_impact.',
|
||||
NULL, true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Перелік рядків беремо з entPhysicalName (ENTITY-MIB), а не з самої
|
||||
-- таблиці сенсорів, і це вибір із двох поганих варіантів.
|
||||
--
|
||||
-- entPhySensorType (.1.3.6.1.2.1.99.1.1.1.1) перелічує РІВНО сенсори —
|
||||
-- жодного зайвого рядка. Але назви в нього немає: підписом стане
|
||||
-- entPhysicalIndex, тобто число. Графік «сенсор 1007» не читає ніхто, а
|
||||
-- відрізнити на ньому вхідну температуру від температури живлення
|
||||
-- неможливо.
|
||||
--
|
||||
-- entPhysicalName (.1.3.6.1.2.1.47.1.1.1.1.7) перелічує ВСІ фізичні
|
||||
-- сутності: шасі, слоти, порти, трансивери — на стеку це сотні рядків.
|
||||
-- Зате в кожного є людська назва, а отже, за нею можна відфільтрувати
|
||||
-- потрібні. Саме для цього row_filter із 0059 і існує.
|
||||
--
|
||||
-- Обрано друге. Рядки, що не є сенсорами, відсіює фільтр; ті кілька,
|
||||
-- що просочаться, повернуть noSuchInstance й не дадуть метрики —
|
||||
-- неприємно, але тихо й дешево. Зворотний вибір дав би графіки, яких
|
||||
-- не можна прочитати, а це не дешевше, а безглуздіше.
|
||||
--
|
||||
-- Обидва прототипи ходять по ОДНОМУ піддереву з ОДНАКОВОЮ частотою —
|
||||
-- і це не збіг, а розрахунок: reconcilePrototypes злипає такі обходи в
|
||||
-- один чек snmp.walk. Тобто другий прототип додає чек значень, але не
|
||||
-- додає обходу. Число у new_walk_checks це показує.
|
||||
--
|
||||
-- 900 секунд на обхід замість типової години: перелік сутності
|
||||
-- змінюється тоді, коли в шасі вставили модуль або витягли блок
|
||||
-- живлення, і чверть години — прийнятний час, щоб побачити це на
|
||||
-- графіку. 300 секунд на значення: температура шасі не стрибає за
|
||||
-- хвилину, а кожен такт коштує стільки OID, скільки сенсорів × метрик.
|
||||
--
|
||||
-- max_rows = 32: сенсорів у шасі одиниці, десятки на стеку. Стеля тут
|
||||
-- не про пам'ять, а про PDU (див. 0059): 32 рядки × 4 метрики — це вже
|
||||
-- 6 запитів на такт.
|
||||
--
|
||||
-- ТИПОВО ВИМКНЕНІ. З тієї ж причини, що hrStorage у 0059, і без жодного
|
||||
-- послаблення: увімкнений прототип у вбудованому шаблоні завів би чеки
|
||||
-- мовчки, під час накату. Те, що шаблон новий і поки нікому не
|
||||
-- причеплений, робить ризик нульовим СЬОГОДНІ — але шаблон причеплять
|
||||
-- завтра, і тоді ввімкнений прапорець спрацював би заднім числом, без
|
||||
-- жодного рішення людини. Вимкнене типово має бути властивістю
|
||||
-- прототипу, а не наслідком того, що його ще нікуди не поставили.
|
||||
INSERT INTO tpl.item_prototypes
|
||||
(template_id, key, name, discover_oid, name_from, row_filter, metrics,
|
||||
discovery_sec, interval_sec, max_rows, enabled)
|
||||
VALUES
|
||||
-- Температура.
|
||||
--
|
||||
-- Фільтр за назвою, бо тип сенсора лежить в іншій колонці, а
|
||||
-- row_filter із 0059 знає про рядок рівно два поля — індекс і назву
|
||||
-- (і це не тимчасове звуження, див. rowFields у store/prototypes.go).
|
||||
-- Перелік слів узятий із того, як вендори називають ці сутності:
|
||||
-- Temp, Temperature, Inlet, Outlet, Hotspot.
|
||||
('00000000-0000-0000-0000-0000000000c6'::uuid, 'sensor-temp', 'Температура',
|
||||
'.1.3.6.1.2.1.47.1.1.1.1.7', 'value',
|
||||
'{"match":"all","conditions":[
|
||||
{"field":"name","op":"matches","value":"(?i)(temp|inlet|outlet|hotspot)"}
|
||||
]}'::jsonb,
|
||||
-- Три числа замість одного, рівно як у hrStorage 0059: RFC 3433
|
||||
-- віддає показ, ступінь десятки й кількість знаків після коми
|
||||
-- ОКРЕМИМИ об'єктами, і перемножити їх ані на зонді, ані на сервері
|
||||
-- нема де — ts.samples зберігає те, що виміряли. Четверте число —
|
||||
-- стан сенсора: значення справного й значення збоєного сенсора
|
||||
-- виглядають однаково, і без цієї метрики «раптом 0 °C» не
|
||||
-- відрізнити від «сенсор відвалився».
|
||||
'[{"oid":".1.3.6.1.2.1.99.1.1.1.4","metric_key":"sensor.temp_raw","unit":"","scale":1},
|
||||
{"oid":".1.3.6.1.2.1.99.1.1.1.2","metric_key":"sensor.temp_scale_code","unit":"","scale":1},
|
||||
{"oid":".1.3.6.1.2.1.99.1.1.1.3","metric_key":"sensor.temp_precision","unit":"","scale":1},
|
||||
{"oid":".1.3.6.1.2.1.99.1.1.1.5","metric_key":"sensor.temp_status","unit":"","scale":1}]'::jsonb,
|
||||
900, 300, 32, false),
|
||||
|
||||
-- Живлення.
|
||||
--
|
||||
-- Окремий прототип, а не ще кілька метрик у першому: ключі метрик
|
||||
-- мають бути різні. Вольти й градуси під одним metric_key дали б один
|
||||
-- ряд, у якому половина точок — температура, і жоден поріг на такому
|
||||
-- ряді не має сенсу.
|
||||
('00000000-0000-0000-0000-0000000000c6'::uuid, 'sensor-power', 'Живлення',
|
||||
'.1.3.6.1.2.1.47.1.1.1.1.7', 'value',
|
||||
'{"match":"all","conditions":[
|
||||
{"field":"name","op":"matches","value":"(?i)(power|psu|supply|volt|amp)"}
|
||||
]}'::jsonb,
|
||||
'[{"oid":".1.3.6.1.2.1.99.1.1.1.4","metric_key":"sensor.power_raw","unit":"","scale":1},
|
||||
{"oid":".1.3.6.1.2.1.99.1.1.1.2","metric_key":"sensor.power_scale_code","unit":"","scale":1},
|
||||
{"oid":".1.3.6.1.2.1.99.1.1.1.3","metric_key":"sensor.power_precision","unit":"","scale":1},
|
||||
{"oid":".1.3.6.1.2.1.99.1.1.1.5","metric_key":"sensor.power_status","unit":"","scale":1}]'::jsonb,
|
||||
900, 300, 32, false)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
|
||||
-- =====================================================================
|
||||
-- ЧОГО ТУТ НЕМАЄ
|
||||
-- =====================================================================
|
||||
|
||||
-- Немає жодного тригера шаблону (tpl.template_triggers, 0024). Третій
|
||||
-- нуль зі зміряного стенду — «подієвих тригерів 0» — лишається нулем
|
||||
-- навмисно. Тригер шаблону породжує ПРАВИЛО АЛЕРТІВ на кожному хості,
|
||||
-- якому шаблон причеплений (0024/0025), тобто вмикається саме те, що
|
||||
-- вмикати не можна: оновлення підняло б алерти на наявних стендах без
|
||||
-- жодного рішення людини. Це рівно та поломка, яку проєкт уже двічі
|
||||
-- оплатив. Вбудовані тригери мають з'явитись у міграції, яка
|
||||
-- одночасно дасть спосіб побачити їхню ціну — так само, як ця дає його
|
||||
-- прототипам.
|
||||
--
|
||||
-- Немає правил під config_type = 'files'. Конфіг-файли сервера (0055)
|
||||
-- — це /etc, і осмислені правила для нього («PermitRootLogin no»,
|
||||
-- «PasswordAuthentication no») вимагають знати, ЯКИЙ файл читається;
|
||||
-- зараз під типом `files` їде набір, а правило порядкове й читає його
|
||||
-- суцільно. Правило «немає рядка PermitRootLogin yes» дало б зелене на
|
||||
-- сервері, де sshd_config у набір узагалі не входить, — тобто мовчазну
|
||||
-- зелень, від якої весь цей файл і будується.
|
||||
--
|
||||
-- Немає правил для Juniper, Arista, HP, Extreme, Brocade, Alcatel.
|
||||
-- Причина одна: перевіреного зразка конфігу цих платформ у мене не
|
||||
-- було, а вписати вендора «бо синтаксис схожий» — це і є те саме
|
||||
-- правило, яке завжди зелене. Скільки таких хостів у парку, показує
|
||||
-- ncm.builtin_compliance_coverage; додавати їх треба разом зі зразком
|
||||
-- у ncm_compliance_builtin_test.go, а не окремо.
|
||||
127
web/TESTING.md
127
web/TESTING.md
|
|
@ -69,18 +69,67 @@ sh ../scripts/check.sh web # те саме, але тим самим шлях
|
|||
|
||||
### Мережа й живі дані
|
||||
|
||||
- **`api/client.ts`, `api/ws.ts`, `hooks/*`** — не покриті взагалі. Ні
|
||||
реконект WebSocket, ні повторна підписка, ні обробка `ApiError`
|
||||
(`isConflict`, `isPlanLimit`), ні токен із `localStorage`.
|
||||
- **Жодного тесту зі справжнім сервером.** Усі перевірки — на чистих функціях
|
||||
і на компонентах із підставленими даними. Розбіжність між `types.ts` і
|
||||
реальною відповіддю API тут не ловиться в принципі.
|
||||
Мережевий шар тепер покритий (див. «Що покрито»), але покритий він **проти
|
||||
підставного `fetch`**, а не проти сервера. Тому лишається непокритим:
|
||||
|
||||
- **Жодного тесту зі справжнім сервером.** Підставні відповіді написані за
|
||||
`server/API.md` і за обробниками `server/internal/httpapi/`, і звірка ця
|
||||
зроблена **очима, один раз**. Ніщо не тримає її актуальною: якщо сервер
|
||||
перейменує поле, тести лишаться зеленими на старій формі. Розбіжність між
|
||||
`types.ts` і реальною відповіддю API тут не ловиться в принципі — так само,
|
||||
як і раніше.
|
||||
- **Справжній `WebSocket` не бере участі.** Перевірено логіку
|
||||
`LiveConnection` проти підставного сокета: рукостискання, підпротоколи,
|
||||
поведінка проксі, `bufferedAmount`, порядок подій у реальному браузері — ні.
|
||||
- **Черга подій під навантаженням.** Що буде, коли сервер надішле тисячі
|
||||
повідомлень за секунду, тест не знає: усі перевірки — на одиницях подій.
|
||||
- **Ротація refresh-токена в часі.** Перевірено, що обмін ОДИН на сплеск
|
||||
запитів; що буде, коли access-токен протухне рівно між `fetch` і читанням
|
||||
тіла, — не перевірено.
|
||||
- **Режим кіоска (`VITE_API_TOKEN`).** `staticTokenPresent` читається з
|
||||
`import.meta.env` під час імпорту модуля, тому в тестах він завжди
|
||||
порожній: жодна перевірка цієї гілки не виконується (див. «знайдене» у
|
||||
звіті — там про неї є що сказати).
|
||||
|
||||
### Сторінки
|
||||
|
||||
Жодна сторінка з `src/pages/` не покрита. Не перевірено: маршрутизація,
|
||||
фільтри в адресному рядку (`AuditPage.parseFilter/writeFilter`), права доступу,
|
||||
форми хостів, шаблонів, правил, ролей. Це найбільша діра за обсягом коду.
|
||||
Покрито п’ять сторінок із двадцяти семи (`AuditPage`, `DevicesPage`,
|
||||
`TeamPage`, `RolesPage`, `AlertsPage`) і два вікна незворотних дій.
|
||||
**Решта двадцять дві не покриті нічим:** `MapPage`, `DevicePage`,
|
||||
`ConfigsPage`, `CommandsPage`, `RulesPage`, `TemplatesPage`, `TrapsPage`,
|
||||
`StoragePage`, `QueuesPage`, `MetricsPage`, `DashboardPage`, `GroupsPage`,
|
||||
`CredentialsPage`, `ProfilesPage`, `CompliancePage`, `MirrorPage`,
|
||||
`ChannelsPage`, `EscalationsPage`, `AgentsPage`, `ServerFilesPage`,
|
||||
`ProfilePage`, `TvPage`. Для них не перевірено ні права, ні форми, ні фільтри.
|
||||
|
||||
Окремо про те, чого немає навіть на покритих сторінках:
|
||||
|
||||
- **Фільтри в адресі перевірені лише в `AuditPage`.** `DevicesPage` тримає
|
||||
свої фільтри в стані компонента, а не в адресі, — тобто відфільтрований
|
||||
перелік хостів колезі не перешлеш. Це не поломка тесту, це властивість
|
||||
продукту, і тест її не покриває, бо покривати нічого.
|
||||
- **`DeviceFilterPanel` і фільтри метрик** — перевірено лише чисті функції
|
||||
(`filters.test.ts`), не зв’язку «панель → запит».
|
||||
- **Створення хоста, шаблона, правила, каналу** — форми не покриті.
|
||||
Перевірено лише те, що кнопка створення з’являється за правом.
|
||||
- **Порядок і кількість запитів при монтуванні сторінки** перевірено лише
|
||||
там, де це саме предмет тесту (зонди на `DevicesPage`). Зайвий запит на
|
||||
решті сторінок пройде зеленим.
|
||||
|
||||
### Права доступу
|
||||
|
||||
Перевірено пари «є право / немає права» для хостів, користувачів, ролей і
|
||||
алертів. **Не перевірено:**
|
||||
|
||||
- **звуження групами доступу** (`writable`, `access: read|write` на мапі):
|
||||
тести працюють із хостами, у яких `writable: true`;
|
||||
- **розбіжність між тим, що ховає клієнт, і тим, що відхиляє сервер.**
|
||||
Клієнтський `session.can()` і серверний `requirePerm()` — два різні
|
||||
переліки, і ніщо не звіряє їх між собою. Право, яке сервер уже вимагає, а
|
||||
клієнт ще ні (або навпаки), пройде зеленим по обидва боки;
|
||||
- **право, що змінилось під час роботи вкладки.** Сервер перечитує членство
|
||||
при кожній ротації токена, клієнт — при `session.set`. Що бачить людина в
|
||||
проміжку, не перевірено.
|
||||
|
||||
### Доступність і клавіатура
|
||||
|
||||
|
|
@ -101,6 +150,38 @@ sh ../scripts/check.sh web # те саме, але тим самим шлях
|
|||
|
||||
---
|
||||
|
||||
## Знайдене, але не закріплене тестом
|
||||
|
||||
Правило 3 нижче забороняє закріплювати ваду зеленим `expect`. Це знайдено під
|
||||
час написання тестів мережевого шару й сторінок; тестів на це навмисно немає —
|
||||
або вони були б червоні, або зафіксували б неправильну поведінку.
|
||||
|
||||
1. **«Будь-який машинний токен» у журналі аудиту їде не тим параметром.**
|
||||
`AuditPage` кладе значення `-` у `filter.tokens`, звідки `api.audit()`
|
||||
надсилає `token=-`. Сервер шукає `-` серед `actor`
|
||||
(`httpapi/audit.go`, гілка `f.AnyToken`), а `token` кладе в
|
||||
`ActorTokenIDs` — і той іде в SQL як `actor_token_id = ANY(...)` по
|
||||
стовпцю `uuid` (`migrations/0001_core.sql:215`). Тобто вибір цієї
|
||||
позначки дає не «рядки без людини», а помилку розбору UUID.
|
||||
Показово: коментар у `types.ts` над `AuditFilter.actors` описує
|
||||
ПРАВИЛЬНУ поведінку — `-` мав лежати серед `actors`.
|
||||
|
||||
2. **Режим кіоска нікому нічого не показує.** При заданому
|
||||
`VITE_API_TOKEN` `App` пропускає відновлення сесії, тож
|
||||
`session.me()` лишається `null` назавжди — а `session.can()` без `me`
|
||||
повертає `false` на будь-яке право. Наслідок: бічне меню порожнє, а
|
||||
кожен маршрут показує «Розділ недоступний». `api.me()` у клієнті є, але
|
||||
не викликається звідки-небудь жодного разу.
|
||||
|
||||
3. **`api.logout()` кидає помилку в порожнечу.** `AppShell` кличе його як
|
||||
`void api.logout()`; сесія чиститься в `finally`, але відмова сервера
|
||||
стає необробленим відхиленням промісу.
|
||||
|
||||
Тестів на п.1 і п.2 немає навмисно: вони були б червоні, а червоний тест у
|
||||
`check.sh` зупиняє роботу всім.
|
||||
|
||||
---
|
||||
|
||||
## Що покрито
|
||||
|
||||
### Чиста логіка
|
||||
|
|
@ -130,6 +211,34 @@ sh ../scripts/check.sh web # те саме, але тим самим шлях
|
|||
| `confirm.test.tsx` | подвійний клік не шле другий запит, помилка сервера НЕ закриває вікно, Esc = скасувати, підпис кнопки |
|
||||
| `datatable.test.tsx` | порожній стан, подвійна відмальовка рядків, `hideOnMobile`, `Toggle` |
|
||||
|
||||
### Мережевий шар
|
||||
|
||||
| Файл тесту | Що саме |
|
||||
|---|---|
|
||||
| `apiclient.test.ts` | заголовок `Bearer`, `credentials: same-origin`, 204 без тіла, розбір `{"error":{code,message}}`, `isConflict`/`isPlanLimit`/`isForbidden` **і навпаки**, тихий обмін на `401 token_expired`, відсутність обміну на `no_session` і `403`, один обмін на десять паралельних запитів, `restore()` один на завантаження, `logout` чистить сесію навіть при відмові сервера, `session.can`, `meFromLogin` |
|
||||
| `ws.test.ts` | адреса й токен у підпротоколі, читання токена **заново** після реконекту, `connecting/online/offline`, затримка 1→2→4→8→15 с зі стелею й скиданням, `stop()` зупиняє реконект і забуває підписку, повторна підписка на мапу після кожного розриву, пошкоджене повідомлення, слухач, що впав |
|
||||
| `hooks.test.tsx` | злиття сплеску подій в один перечит, перечит після паузи, чужі повідомлення ігноруються, розмонтування скасовує заплановане, `useAlerts` без `alerts:read` не питає нічого |
|
||||
|
||||
Найважливіше тут — **негативні половини**. «Обмін відбувся» нічого не варте
|
||||
без «на `no_session` не відбувся»; «реконект стався» — без «після `stop()` не
|
||||
стався». Саме асиметрична перевірка й пропустила зламаний вхід у тесті
|
||||
ізоляції RLS.
|
||||
|
||||
### Сторінки
|
||||
|
||||
| Файл тесту | Що саме |
|
||||
|---|---|
|
||||
| `routing.test.tsx` | неавторизований на закритій адресі бачить вхід і сторінка **даних не питає**; після входу відкривається та сама адреса; `Guard` без права не малює сторінку й не робить запиту; домівка залежить від ролі; пункт меню без права відсутній; вихід повертає до входу й **розриває сокет** |
|
||||
| `pagepermissions.test.tsx` | пари «є право / немає права» для хостів, користувачів, ролей, алертів; себе й власника прибрати не можна; право, якого немає в тебе, не можна віддати ролі; зміна ролі шле **лише те, що змінилось**; порожня правка запиту не робить; подвійний клік не шле двох PATCH; `409 shared_user` лишає вікно з набраним |
|
||||
| `auditfilter.test.tsx` | фільтр із посилання й той самий фільтр, обраний руками, дають **однаковий** запит і однакову адресу; типовий період в адресу не пишеться; скидання чистить і адресу, і запит; гортання йде у вікні, яке зібрав сервер |
|
||||
| `destructive.test.tsx` | видалення хоста: набір звіряється з сервером, повне видалення заблоковане до підтвердження втрати, без зібраного галочки немає, типово — повне, підпис кнопки називає режим, подвійний клік не дублює запит, помилка не закриває вікно; відкат конфігу: план, непідтримуваний профіль, ручні рядки, `plan_hash`, збережена причина після відмови |
|
||||
|
||||
Підставні відповіді написані не «схоже», а за формою сервера: помилка — з
|
||||
`writeError()` (`httpapi/server.go`), вхід і `refresh` — з `API.md`, масові
|
||||
дії над хостами — з `devices_bulk.go` і `store.BulkDeviceTarget`, журнал — з
|
||||
`handleListAudit`. Спільні підпори лежать у `src/test/support.ts` — щоб
|
||||
розбіжність із сервером правилась в одному місці й ламала всі тести одразу.
|
||||
|
||||
Обрано саме ці два вікна не випадково: `Modal` — єдине місце, де самовільне
|
||||
закриття зʼїдає набране в довгій формі, а `ConfirmDialog` — останній екран перед
|
||||
незворотною дією, і його дві тихі поломки (другий запит на видалення; закриття
|
||||
|
|
|
|||
|
|
@ -28,8 +28,10 @@ import { TeamPage } from './pages/TeamPage'
|
|||
import { RolesPage } from './pages/RolesPage'
|
||||
import { AuditPage } from './pages/AuditPage'
|
||||
import { TrapsPage } from './pages/TrapsPage'
|
||||
import { SLAPage } from './pages/SLAPage'
|
||||
import { StoragePage } from './pages/StoragePage'
|
||||
import { ProfilePage } from './pages/ProfilePage'
|
||||
import { BillingPage } from './pages/BillingPage'
|
||||
import { TvPage } from './pages/TvPage'
|
||||
import type { Permission } from './types'
|
||||
|
||||
|
|
@ -136,11 +138,22 @@ function Workspace() {
|
|||
систему. Право потрібне лише на ЗМІНУ строків, і його
|
||||
перевіряє сервер. Те саме рішення, що й для дзеркала. */}
|
||||
<Route path="/storage" element={<Guard perm="devices:read" element={<StoragePage />} />} />
|
||||
{/* Звіти SLA під devices:read з тих самих міркувань, що й
|
||||
сховище: доступність своєї мережі бачить кожен, хто взагалі
|
||||
бачить моніторинг. Заводити цілі й закривати періоди —
|
||||
settings:write, і його перевіряє сервер. */}
|
||||
<Route path="/sla" element={<Guard perm="devices:read" element={<SLAPage />} />} />
|
||||
{/* Трапи під devices:read, а не під alerts:read: це подія з
|
||||
мережі, і бачити її має той, хто щодня дивиться на мережу.
|
||||
Правити словник назв — окреме право (settings:write), і
|
||||
його перевіряє сервер. */}
|
||||
<Route path="/traps" element={<Guard perm="devices:read" element={<TrapsPage />} />} />
|
||||
{/* Тариф під billing:read, а не під billing:manage: побачити
|
||||
стелю має той, хто в неї впреться, тобто інженер, який
|
||||
заводить хости. Змінити тариф чи застосувати ключ — окреме
|
||||
право, і його перевіряє сервер. Те саме рішення, що для
|
||||
сховища й дзеркала. */}
|
||||
<Route path="/billing" element={<Guard perm="billing:read" element={<BillingPage />} />} />
|
||||
<Route path="/audit" element={<Guard perm="audit:read" element={<AuditPage />} />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="*" element={<Home />} />
|
||||
|
|
|
|||
|
|
@ -90,6 +90,11 @@ import type {
|
|||
TrapPage,
|
||||
TrapsMeta,
|
||||
TrapFilter,
|
||||
SLATarget,
|
||||
SLATargetInput,
|
||||
SLAReport,
|
||||
BillingOverview,
|
||||
LicenseState,
|
||||
} from '../types'
|
||||
|
||||
/** Те, що приймає POST/PATCH /api/v1/devices. */
|
||||
|
|
@ -1556,4 +1561,116 @@ export const api = {
|
|||
method: 'PUT',
|
||||
body: JSON.stringify({ keep }),
|
||||
}),
|
||||
|
||||
// --- звіти SLA ---
|
||||
|
||||
slaTargets: () =>
|
||||
request<{ targets: SLATarget[] | null; can_edit: boolean; settle_lag_sec: number }>(
|
||||
'/api/v1/sla/targets',
|
||||
).then((r) => ({ ...r, targets: r.targets ?? [] })),
|
||||
|
||||
saveSLATarget: (in_: SLATargetInput) =>
|
||||
request<SLATarget>(
|
||||
in_.id ? `/api/v1/sla/targets/${in_.id}` : '/api/v1/sla/targets',
|
||||
{ method: in_.id ? 'PUT' : 'POST', body: JSON.stringify(in_) },
|
||||
),
|
||||
|
||||
deleteSLATarget: (id: string) =>
|
||||
request<{ periods_deleted: number }>(`/api/v1/sla/targets/${id}`, { method: 'DELETE' }),
|
||||
|
||||
/**
|
||||
* Звіт за період, у який потрапляє date.
|
||||
*
|
||||
* Дата, а не межі періоду: межі кварталу — властивість цілі (її типу
|
||||
* й поясу), і рахувати їх у браузері означало б завести другу
|
||||
* реалізацію календаря, яка розійдеться з серверною на переході часу
|
||||
* або на межі року. Порожня дата означає «попередній період» — саме
|
||||
* той, який питають на аудиті.
|
||||
*/
|
||||
slaReport: (id: string, date?: string) =>
|
||||
request<SLAReport>(`/api/v1/sla/targets/${id}/report${date ? `?date=${date}` : ''}`),
|
||||
|
||||
closeSLAPeriod: (id: string, date?: string, force = false) => {
|
||||
const p = new URLSearchParams()
|
||||
if (date) p.set('date', date)
|
||||
if (force) p.set('force', '1')
|
||||
const q = p.toString()
|
||||
return request<SLAReport>(`/api/v1/sla/targets/${id}/close${q ? `?${q}` : ''}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Вивантаження звіту.
|
||||
*
|
||||
* CSV збирає сервер, а не браузер: саме цей файл кладуть у папку до
|
||||
* договору й потім звіряють із тим, що показує сторінка. Дві
|
||||
* реалізації одного округлення розійдуться на третьому знаку, і
|
||||
* побачить це не той, хто помилився, а клієнт.
|
||||
*
|
||||
* Тягнемо через fetch, а не звичайним посиланням: посилання не вміє
|
||||
* додати заголовок Authorization. Квиток завантаження (0038) тут
|
||||
* зайвий — звіт це десятки кілобайтів, а не десятки мегабайтів.
|
||||
*/
|
||||
downloadSLAReport: async (id: string, date?: string) => {
|
||||
const path = `/api/v1/sla/targets/${id}/report.csv${date ? `?date=${date}` : ''}`
|
||||
const headers: Record<string, string> = {}
|
||||
const t = authToken()
|
||||
if (t) headers.Authorization = `Bearer ${t}`
|
||||
|
||||
const res = await fetch(path, { headers, credentials: 'same-origin' })
|
||||
if (!res.ok) {
|
||||
// Помилка тут приходить у JSON, а успіх — у CSV. Розбирати тіло
|
||||
// однаково не можна: на помилці воно вже не той тип.
|
||||
let message = res.statusText
|
||||
try {
|
||||
const body = await res.json()
|
||||
message = body?.error?.message ?? message
|
||||
} catch {
|
||||
// Тіло не JSON — лишається код відповіді.
|
||||
}
|
||||
throw new ApiError(res.status, 'download_failed', message)
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const name =
|
||||
/filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') ?? '')?.[1] ?? 'sla.csv'
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = name
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Тариф, стелі й ліцензія
|
||||
//
|
||||
// Стан їде однією ручкою навмисно: стелі, використане й ліцензія — це
|
||||
// одне питання («на чому я зараз і скільки лишилось»), і зібрана з
|
||||
// трьох запитів у різні секунди відповідь суперечила б сама собі —
|
||||
// «14 з 15» поруч із «15 з 15» на сусідній картці.
|
||||
//
|
||||
// Ліцензія має ще й власну ручку: install_id потрібен у мить
|
||||
// замовлення ключа, а туди приходять із порожньої інсталяції, де
|
||||
// перелік тарифів іще нічого не означає.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
billing: () => request<BillingOverview>('/api/v1/billing'),
|
||||
|
||||
setPlan: (plan: string) =>
|
||||
request<BillingOverview>('/api/v1/billing/plan', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ plan }),
|
||||
}),
|
||||
|
||||
license: () => request<LicenseState>('/api/v1/billing/license'),
|
||||
|
||||
applyLicense: (key: string) =>
|
||||
request<LicenseState>('/api/v1/billing/license', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
|
||||
clearLicense: () =>
|
||||
request<LicenseState>('/api/v1/billing/license', { method: 'DELETE' }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ const navGroups: NavGroup[] = [
|
|||
// немає взагалі.
|
||||
{ to: '/traps', label: 'Трапи', icon: '📥', perm: 'devices:read' },
|
||||
{ to: '/metrics', label: 'Метрики', icon: '📈', perm: 'devices:read' },
|
||||
// SLA в «Моніторингу», а не в «Адмініструванні»: це підсумок того,
|
||||
// що показують решта пунктів цієї групи, і питають його ті самі
|
||||
// люди. Поруч із метриками навмисно — там дивляться, як мережа
|
||||
// почувається зараз, тут — як вона почувалась за квартал, і другий
|
||||
// погляд без першого не має сенсу.
|
||||
{ to: '/sla', label: 'Звіти SLA', icon: '📋', perm: 'devices:read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -153,6 +159,11 @@ const navGroups: NavGroup[] = [
|
|||
// пункт не з'явиться — а разом із ним і група, якщо в ній більше
|
||||
// нічого не лишилось.
|
||||
{ to: '/audit', label: 'Журнал аудиту', icon: '🧾', perm: 'audit:read' },
|
||||
// Тариф останнім в «Адмініструванні», а не окремою групою: питання
|
||||
// «скільки в мене лишилось слотів» ставлять поруч із питанням
|
||||
// «кого ще завести», і обидва — тут. Окрема група з одного пункту
|
||||
// додала б заголовок, який нічого не групує.
|
||||
{ to: '/billing', label: 'Тариф і ліцензія', icon: '💳', perm: 'billing:read' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
649
web/src/pages/BillingPage.tsx
Normal file
649
web/src/pages/BillingPage.tsx
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ErrorNote,
|
||||
Modal,
|
||||
PageBody,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
inputClass,
|
||||
plural,
|
||||
} from '../components/ui'
|
||||
import type {
|
||||
BillingOverview,
|
||||
BillingUsage,
|
||||
LicenseState,
|
||||
Plan,
|
||||
PlanLimits,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* Тариф: що включено, скільки зайнято, коли платити.
|
||||
*
|
||||
* ГОЛОВНЕ ПРО ЦЮ СТОРІНКУ
|
||||
*
|
||||
* Вона існує, щоб людина побачила стелю ДО того, як у неї впреться.
|
||||
*
|
||||
* Це не риторика. Стеля тарифу — єдине обмеження в продукті, яке
|
||||
* проявляється відмовою в мить дії: диск закінчується поступово й про
|
||||
* це попереджає окрема сторінка, черга росте й це видно на графіку, а
|
||||
* стеля мовчить рівно до кроку, на якому вже пізно. Мережевий інженер,
|
||||
* який о другій ночі міняє згорілий комутатор і не може завести новий
|
||||
* хост, дізнається про тариф найгіршим із можливих способів.
|
||||
*
|
||||
* Тому перше, що на сторінці, — не ціни й не рахунок, а числа «зайнято
|
||||
* зі стелі». Ціни нижче й дрібніше: питання «скільки це коштує» ставлять
|
||||
* раз на рік, а «скільки в мене лишилось» — щотижня.
|
||||
*
|
||||
* ЧОМУ ЦЕ БАЧИТЬ ІНЖЕНЕР, А НЕ ЛИШЕ ВЛАСНИК
|
||||
*
|
||||
* Право billing:read має кожна роль, якій його дали, і сторінка
|
||||
* відкрита під ним, а не під billing:manage. Ховати від того, хто
|
||||
* заводить хости, число «15 з 15» означає лишити його з питанням «чому
|
||||
* не працює» й без способу на нього відповісти. Змінити тариф чи
|
||||
* застосувати ключ він при цьому не зможе — це окреме право, і його
|
||||
* перевіряє сервер.
|
||||
*/
|
||||
export function BillingPage() {
|
||||
const [data, setData] = useState<BillingOverview | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [licenseOpen, setLicenseOpen] = useState(false)
|
||||
const [planOpen, setPlanOpen] = useState(false)
|
||||
|
||||
const reload = () => {
|
||||
setLoading(true)
|
||||
api
|
||||
.billing()
|
||||
.then((d) => {
|
||||
setData(d)
|
||||
setErr(null)
|
||||
})
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(reload, [])
|
||||
|
||||
const canManage = data?.can_manage ?? false
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Тариф і ліцензія"
|
||||
subtitle="Що включено, скільки зайнято й до якої дати"
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={reload}>Оновити</Button>
|
||||
{canManage && <Button onClick={() => setPlanOpen(true)}>Змінити тариф</Button>}
|
||||
{canManage && (
|
||||
<Button kind="primary" onClick={() => setLicenseOpen(true)}>
|
||||
Ліцензійний ключ
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<PageBody className="space-y-3">
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
{loading && !data ? (
|
||||
<Spinner />
|
||||
) : data ? (
|
||||
<>
|
||||
<LicenseBanner
|
||||
license={data.license}
|
||||
onManage={canManage ? () => setLicenseOpen(true) : undefined}
|
||||
/>
|
||||
<UsageGrid limits={data.entitlement} usage={data.usage} />
|
||||
<CurrentPlan data={data} />
|
||||
<PlanTable data={data} />
|
||||
</>
|
||||
) : null}
|
||||
</PageBody>
|
||||
|
||||
{licenseOpen && data && (
|
||||
<LicenseDialog
|
||||
license={data.license}
|
||||
onClose={() => setLicenseOpen(false)}
|
||||
onDone={() => {
|
||||
setLicenseOpen(false)
|
||||
reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{planOpen && data && (
|
||||
<PlanDialog
|
||||
data={data}
|
||||
onClose={() => setPlanOpen(false)}
|
||||
onDone={() => {
|
||||
setPlanOpen(false)
|
||||
reload()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Стан ліцензії
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Смужка стану ліцензії.
|
||||
*
|
||||
* Формулювання тут важливіші за оформлення, і найважливіше з них — те,
|
||||
* що стоїть на стані `expired`. Людина, яка бачить слово «протерміновано»
|
||||
* у системі моніторингу, першою думкою має «отже, воно вже нічого не
|
||||
* стежить» — і саме цю думку рядок мусить зняти в тому ж реченні, у
|
||||
* якому повідомляє погану новину. Інакше о цій же хвилині почнеться
|
||||
* позапланова перевірка «а чи бачимо ми взагалі мережу».
|
||||
*/
|
||||
function LicenseBanner({
|
||||
license,
|
||||
onManage,
|
||||
}: {
|
||||
license: LicenseState
|
||||
onManage?: () => void
|
||||
}) {
|
||||
const tone: Record<string, string> = {
|
||||
active: 'border-slate-800 bg-slate-900',
|
||||
grace: 'border-amber-900/60 bg-amber-950/20',
|
||||
expired: 'border-amber-900/60 bg-amber-950/20',
|
||||
invalid: 'border-rose-900/60 bg-rose-950/20',
|
||||
unlicensed: 'border-slate-800 bg-slate-900',
|
||||
}
|
||||
|
||||
const text: Record<string, JSX.Element> = {
|
||||
unlicensed: (
|
||||
<>
|
||||
Ліцензійного ключа немає — стелі не задані, працює все. Так виглядає інсталяція, якій
|
||||
ще не застосували ключ і не обрали тариф.
|
||||
</>
|
||||
),
|
||||
active: (
|
||||
<>
|
||||
Ліцензія діє {license.issued_to ? <>для «{license.issued_to}» </> : null}до{' '}
|
||||
<span className="text-slate-300">{fmtDate(license.expires_at)}</span>
|
||||
{typeof license.days_left === 'number' && license.days_left <= 45 ? (
|
||||
<> — лишилось {license.days_left} {plural(license.days_left, ['доба', 'доби', 'діб'])}</>
|
||||
) : null}
|
||||
.
|
||||
</>
|
||||
),
|
||||
grace: (
|
||||
<>
|
||||
Строк ліцензії минув {fmtDate(license.expires_at)}, триває пільговий період до{' '}
|
||||
<span className="text-slate-300">{fmtDate(license.grace_until)}</span>. Зараз не
|
||||
змінюється нічого. Після цієї дати збір, алерти й сповіщення так само працюватимуть, але
|
||||
нові хости, зонди й користувачі перестануть заводитись.
|
||||
</>
|
||||
),
|
||||
expired: (
|
||||
<>
|
||||
Строк ліцензії та пільговий період минули.{' '}
|
||||
<span className="text-slate-300">
|
||||
Збір даних, алерти й сповіщення працюють далі й не зупиняться.
|
||||
</span>{' '}
|
||||
Замерз лише ріст: усе, що вже під наглядом, лишається під наглядом, а нові хости, зонди,
|
||||
користувачі й мапи не заводяться до продовження ліцензії.
|
||||
</>
|
||||
),
|
||||
invalid: (
|
||||
<>
|
||||
Ключ є, але перевірити його не вдалось: {license.reason || 'причина невідома'}. Стелі
|
||||
лишились такими, якими були — ми не знаємо напевно, чи справа в ключі, чи в тому, що
|
||||
збірка втратила відкритий ключ підпису, і відбирати доступ через власну невизначеність
|
||||
не станемо.
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`p-3 ${tone[license.state] ?? tone.active}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<p className="max-w-3xl text-xs leading-relaxed text-slate-400">
|
||||
{text[license.state] ?? text.unlicensed}
|
||||
</p>
|
||||
{onManage && (
|
||||
<Button onClick={onManage}>
|
||||
{license.state === 'unlicensed' ? 'Ввести ключ' : 'Керувати'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-slate-500">
|
||||
<div>
|
||||
<dt className="inline">Інсталяція: </dt>
|
||||
{/* Саме це число людина надсилає, замовляючи ключ. Воно має
|
||||
бути на видноті, а не в налаштуваннях: по нього приходять
|
||||
рівно раз і завжди поспіхом. */}
|
||||
<dd className="inline select-all font-mono text-slate-400">{license.install_id}</dd>
|
||||
</div>
|
||||
{license.checked_at && (
|
||||
<div>
|
||||
<dt className="inline">Перевірено: </dt>
|
||||
<dd className="inline text-slate-400">{fmtDate(license.checked_at)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{license.verifier_empty && (
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-slate-500">
|
||||
У цій збірці немає жодного відкритого ключа перевірки ліцензій
|
||||
(NETPULSE_LICENSE_PUBKEY порожній). Ключ ввести не вийде — і це сказано прямо, щоб не
|
||||
виглядало, ніби ключ неправильний.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{license.clock_warped && (
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-amber-200">
|
||||
Системний час на цій машині йшов назад проти раніше побаченого. Строк ліцензії
|
||||
рахується за пізнішою з двох міток. Нічого не вимкнено: годинник з'їжджає й сам —
|
||||
сів CMOS, зник NTP після переїзду в ізольований сегмент.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Скільки зайнято
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Головний блок сторінки: зайняте проти стелі.
|
||||
*
|
||||
* Смужка заповнення, а не лише два числа. «12 з 15» і «120 з 150» —
|
||||
* різні за терміновістю, хоч частка та сама, і саме довжина смужки
|
||||
* повідомляє це швидше за арифметику в голові.
|
||||
*/
|
||||
function UsageGrid({ limits, usage }: { limits: PlanLimits; usage: BillingUsage }) {
|
||||
const cells: { label: string; used: number; limit: number | null; hint: string }[] = [
|
||||
{
|
||||
label: 'Хости',
|
||||
used: usage.devices,
|
||||
limit: limits.max_devices,
|
||||
// Правило підрахунку названо вголос саме тут: воно неочевидне й
|
||||
// саме через нього виникає розбіжність «у списку 40, а тариф каже 28».
|
||||
hint: 'рахуються лише ввімкнені; архівні й вимкнені слота не займають',
|
||||
},
|
||||
{ label: 'Мапи', used: usage.maps, limit: limits.max_maps, hint: 'без архівних' },
|
||||
{ label: 'Зонди', used: usage.agents, limit: limits.max_agents, hint: 'усі зареєстровані' },
|
||||
{ label: 'Користувачі', used: usage.users, limit: limits.max_users, hint: 'учасники кабінету' },
|
||||
]
|
||||
|
||||
// Підпис розділу — не окрема назва, а те, чим цей блок відрізняється
|
||||
// від таблиці тарифів нижче: там ті самі слова («Хости», «Мапи»)
|
||||
// означають пропозицію, тут — поточний стан. Без підпису два блоки
|
||||
// читаються як один, і це помітно навіть у тесті, який не може
|
||||
// відрізнити «Хости» від «Хости».
|
||||
return (
|
||||
<section aria-label="Використано зі стелі" className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{cells.map((c) => (
|
||||
<UsageCell key={c.label} {...c} />
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function UsageCell({
|
||||
label,
|
||||
used,
|
||||
limit,
|
||||
hint,
|
||||
}: {
|
||||
label: string
|
||||
used: number
|
||||
limit: number | null
|
||||
hint: string
|
||||
}) {
|
||||
// Стеля, нижча за зайняте, — не помилка й не рідкість: так виглядає
|
||||
// кабінет, який свідомо перевели на менший тариф, і кабінет із
|
||||
// протермінованою ліцензією. Показуємо як є; ховати це означало б
|
||||
// приховати саме те, через що нічого не заводиться.
|
||||
const over = limit !== null && used >= limit
|
||||
const pct = limit && limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0
|
||||
|
||||
return (
|
||||
<Card className="p-3">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-xs text-slate-500">{label}</span>
|
||||
<span className={`text-sm ${over ? 'text-amber-300' : 'text-slate-200'}`}>
|
||||
{used}
|
||||
<span className="text-slate-600"> / {limit === null ? '∞' : limit}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded bg-slate-800">
|
||||
<div
|
||||
className={`h-full ${over ? 'bg-amber-500' : 'bg-sky-600'}`}
|
||||
style={{ width: limit === null ? '0%' : `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-slate-600">
|
||||
{limit === null ? 'без обмеження' : over ? 'стелю досягнуто — нове не заведеться' : hint}
|
||||
</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Поточний тариф
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const reasonText: Record<string, string> = {
|
||||
license: 'стелі задає ліцензійний ключ',
|
||||
license_expired: 'ліцензія протермінована — стелі заморожені на досягнутому',
|
||||
plan: 'тариф обрав власник кабінету',
|
||||
migration: 'стелі не задавались',
|
||||
manual: 'стелі виставлені руками',
|
||||
}
|
||||
|
||||
function CurrentPlan({ data }: { data: BillingOverview }) {
|
||||
const e = data.entitlement
|
||||
const catalog = useMemo(
|
||||
() => new Map(data.features.map((f) => [f.key, f])),
|
||||
[data.features],
|
||||
)
|
||||
const included = new Set(e.features)
|
||||
|
||||
return (
|
||||
<Card className="p-3">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="text-sm font-medium text-slate-200">{e.plan_name}</h2>
|
||||
<span className="text-[11px] text-slate-600">
|
||||
{reasonText[e.reason] ?? e.reason} · метрики живуть {e.metric_retention_days}{' '}
|
||||
{plural(e.metric_retention_days, ['добу', 'доби', 'діб'])}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Перелік показує ВСІ можливості каталогу, а не лише включені.
|
||||
Список без «чого немає» відповідає на питання «що я маю» й
|
||||
мовчить про «що я отримаю, якщо заплачу» — тобто про єдине
|
||||
питання, заради якого на цю сторінку приходять двічі. */}
|
||||
<ul className="mt-3 grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{data.features.map((f) => {
|
||||
const on = included.has(f.key)
|
||||
return (
|
||||
<li
|
||||
key={f.key}
|
||||
className={`flex items-baseline gap-2 text-xs ${on ? 'text-slate-300' : 'text-slate-600'}`}
|
||||
title={catalog.get(f.key)?.description}
|
||||
>
|
||||
<span className={on ? 'text-emerald-500' : 'text-slate-700'}>{on ? '✓' : '·'}</span>
|
||||
{f.name}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Тарифи
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function money(cents: number, currency: string): string {
|
||||
if (cents === 0) return '0'
|
||||
return `${(cents / 100).toFixed(2)} ${currency}`
|
||||
}
|
||||
|
||||
function limitCell(v: number | null): string {
|
||||
return v === null ? '∞' : String(v)
|
||||
}
|
||||
|
||||
function PlanTable({ data }: { data: BillingOverview }) {
|
||||
const plans = data.plans.filter((p) => p.is_public || p.key === data.entitlement.plan_key)
|
||||
if (plans.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card className="p-3">
|
||||
<h2 className="mb-2 text-sm font-medium text-slate-200">Тарифи</h2>
|
||||
{/* Таблиця в контейнері з власним прокрученням: на телефоні
|
||||
сторінка не має їхати вбік цілком через один широкий блок. */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[38rem] text-xs">
|
||||
<thead className="text-slate-500">
|
||||
<tr className="border-b border-slate-800">
|
||||
<th className="py-1.5 text-left font-normal">Тариф</th>
|
||||
<th className="py-1.5 text-right font-normal">Абонплата</th>
|
||||
<th className="py-1.5 text-right font-normal">За хост</th>
|
||||
<th className="py-1.5 text-right font-normal">Хости</th>
|
||||
<th className="py-1.5 text-right font-normal">Мапи</th>
|
||||
<th className="py-1.5 text-right font-normal">Зонди</th>
|
||||
<th className="py-1.5 text-right font-normal">Люди</th>
|
||||
<th className="py-1.5 text-right font-normal">Метрики</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans.map((p) => {
|
||||
const current = p.key === data.entitlement.plan_key
|
||||
return (
|
||||
<tr
|
||||
key={p.key}
|
||||
className={`border-b border-slate-900 ${current ? 'text-slate-200' : 'text-slate-500'}`}
|
||||
>
|
||||
<td className="py-1.5">
|
||||
{p.name}
|
||||
{current && <span className="ml-2 text-[11px] text-sky-500">поточний</span>}
|
||||
</td>
|
||||
<td className="py-1.5 text-right">{money(p.base_price_cents, p.currency)}</td>
|
||||
<td className="py-1.5 text-right">{money(p.per_device_cents, p.currency)}</td>
|
||||
<td className="py-1.5 text-right">{limitCell(p.max_devices)}</td>
|
||||
<td className="py-1.5 text-right">{limitCell(p.max_maps)}</td>
|
||||
<td className="py-1.5 text-right">{limitCell(p.max_agents)}</td>
|
||||
<td className="py-1.5 text-right">{limitCell(p.max_users)}</td>
|
||||
<td className="py-1.5 text-right">{p.metric_retention_days} діб</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Діалоги
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Зміна тарифу.
|
||||
*
|
||||
* Попередження про звуження стелі показується ДО збереження й з
|
||||
* числами. Це той самий принцип, що в масовому видаленні хостів і в
|
||||
* попередньому перегляді очистки: підтвердження без точного переліку
|
||||
* наслідків підтвердженням не вважається.
|
||||
*
|
||||
* Але перехід НЕ забороняється. Власник має право перевести кабінет на
|
||||
* менший тариф, маючи 40 хостів при стелі 15: наявні 40 лишаються під
|
||||
* наглядом, нові не заводяться. Вимагати спершу видалити 25 хостів,
|
||||
* щоб дізнатись, чи влаштує дешевший тариф, — це не обережність, а
|
||||
* пастка.
|
||||
*/
|
||||
function PlanDialog({
|
||||
data,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
data: BillingOverview
|
||||
onClose: () => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const [plan, setPlan] = useState(data.entitlement.plan_key)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
const chosen = data.plans.find((p) => p.key === plan)
|
||||
const shrink = chosen ? shrinkWarnings(chosen, data.usage) : []
|
||||
|
||||
const save = () => {
|
||||
setBusy(true)
|
||||
api
|
||||
.setPlan(plan)
|
||||
.then(onDone)
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Змінити тариф" onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<select className={inputClass} value={plan} onChange={(e) => setPlan(e.target.value)}>
|
||||
{data.plans
|
||||
.filter((p) => p.is_public || p.key === data.entitlement.plan_key)
|
||||
.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{shrink.length > 0 && (
|
||||
<div className="rounded border border-amber-900/60 bg-amber-950/20 p-2">
|
||||
<p className="text-xs leading-relaxed text-amber-200">
|
||||
Після переходу стеля стане нижчою за вже зайняте:
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5 text-xs text-amber-200">
|
||||
{shrink.map((s) => (
|
||||
<li key={s}>· {s}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1.5 text-[11px] leading-relaxed text-slate-400">
|
||||
Нічого не зникне й не вимкнеться: усе, що вже під наглядом, лишається під наглядом.
|
||||
Перестане заводитись НОВЕ — доти, доки зайняте не опуститься нижче стелі.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button kind="primary" onClick={save} disabled={busy}>
|
||||
{busy ? 'Збереження…' : 'Змінити'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function shrinkWarnings(p: Plan, u: BillingUsage): string[] {
|
||||
const rows: [string, number | null, number][] = [
|
||||
['хостів', p.max_devices, u.devices],
|
||||
['мап', p.max_maps, u.maps],
|
||||
['зондів', p.max_agents, u.agents],
|
||||
['користувачів', p.max_users, u.users],
|
||||
]
|
||||
return rows
|
||||
.filter(([, limit, used]) => limit !== null && used > limit)
|
||||
.map(([name, limit, used]) => `${name}: зайнято ${used}, у тарифі ${limit}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ліцензійний ключ.
|
||||
*
|
||||
* Поле — textarea, а не input, і це не дрібниця: ключ довгий, приходить
|
||||
* листом і повертається з переносами рядків. Однорядкове поле показало б
|
||||
* людині його хвіст і сховало початок — тобто рівно те, що вона хоче
|
||||
* перевірити очима перед натисканням.
|
||||
*/
|
||||
function LicenseDialog({
|
||||
license,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
license: LicenseState
|
||||
onClose: () => void
|
||||
onDone: () => void
|
||||
}) {
|
||||
const [key, setKey] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [confirmClear, setConfirmClear] = useState(false)
|
||||
|
||||
const run = (p: Promise<unknown>) => {
|
||||
setBusy(true)
|
||||
p.then(onDone)
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Ліцензійний ключ" onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<ErrorNote>{err}</ErrorNote>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">Ідентифікатор цієї інсталяції</p>
|
||||
<p className="mt-0.5 select-all break-all font-mono text-xs text-slate-300">
|
||||
{license.install_id}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] leading-relaxed text-slate-600">
|
||||
Надішліть його, замовляючи ключ. Ключ, виданий на цей ідентифікатор, більше нікуди не
|
||||
підійде — саме це відрізняє ліцензію від пароля, який перешлють колезі.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs text-slate-500">Ключ</span>
|
||||
<textarea
|
||||
className={`${inputClass} mt-1 h-28 font-mono text-[11px]`}
|
||||
placeholder="NP1.…"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
<span className="mt-1 block text-[11px] text-slate-600">
|
||||
Переноси рядків і пробіли з листа прибираються самі — вставляйте як є.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{license.state !== 'unlicensed' &&
|
||||
(confirmClear ? (
|
||||
<Button kind="danger" onClick={() => run(api.clearLicense())} disabled={busy}>
|
||||
Точно зняти ключ
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setConfirmClear(true)} disabled={busy}>
|
||||
Зняти ключ
|
||||
</Button>
|
||||
))}
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button
|
||||
kind="primary"
|
||||
onClick={() => run(api.applyLicense(key))}
|
||||
disabled={busy || key.trim() === ''}
|
||||
>
|
||||
{busy ? 'Перевірка…' : 'Застосувати'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{confirmClear && (
|
||||
<p className="text-[11px] leading-relaxed text-slate-500">
|
||||
Після зняття стелі повернуться до «не задані», а не до найменшого тарифу: зняття
|
||||
ключа не має бути дією, після якої система перестає приймати хости.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function fmtDate(iso?: string): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
return d.toLocaleDateString('uk-UA', { day: '2-digit', month: 'long', year: 'numeric' })
|
||||
}
|
||||
654
web/src/pages/SLAPage.tsx
Normal file
654
web/src/pages/SLAPage.tsx
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import { session } from '../api/session'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Column,
|
||||
ConfirmRequest,
|
||||
Confirm,
|
||||
DataTable,
|
||||
ErrorNote,
|
||||
Field,
|
||||
Modal,
|
||||
PageBody,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
Toggle,
|
||||
inputClass,
|
||||
} from '../components/ui'
|
||||
import type { SLAOutcome, SLAPeriodKind, SLAReport, SLARow, SLATarget } from '../types'
|
||||
|
||||
/**
|
||||
* Звіти SLA: доступність за період і вивантаження.
|
||||
*
|
||||
* ЩО ЦЯ СТОРІНКА МАЄ СКАЗАТИ, КРІМ ВІДСОТКА
|
||||
*
|
||||
* Відсоток тут — найпростіша частина. Дорожче коштує те, що поруч із
|
||||
* ним, бо саме цього немає в жодному звіті, який доводилось бачити:
|
||||
*
|
||||
* 1. Це остаточне число чи прикидка. Закритий період не
|
||||
* перераховується — його роздрук завтра дасть те саме. Незакритий
|
||||
* змінюється щогодини, і роздруковувати його не можна.
|
||||
* 2. Яку частку періоду ми взагалі бачили. «99.9% за квартал» при
|
||||
* покритті 40% — це не 99.9%, це «не знаємо».
|
||||
* 3. Скільки історії лишилось під звітом. Строк зберігання годинних
|
||||
* згорток — та єдина причина, через яку звіт може змінитись заднім
|
||||
* числом, і людина має бачити її поруч із числами, а не в довідці.
|
||||
*
|
||||
* Тому головна колонка тут не «доступність», а «вердикт», і в нього три
|
||||
* значення, а не два.
|
||||
*/
|
||||
|
||||
const PERIOD_LABELS: Record<SLAPeriodKind, string> = {
|
||||
daily: 'Доба',
|
||||
weekly: 'Тиждень',
|
||||
monthly: 'Місяць',
|
||||
quarterly: 'Квартал',
|
||||
}
|
||||
|
||||
/** Секунди в те, що читає людина. Точність — хвилина: секунди простою за
|
||||
* квартал ніхто не звіряє, а от «2 год 12 хв» одразу порівнюється з
|
||||
* бюджетом 99.9%. */
|
||||
export function humanSec(sec: number): string {
|
||||
if (sec <= 0) return '—'
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (d) parts.push(`${d} д`)
|
||||
if (h) parts.push(`${h} год`)
|
||||
if (m || (!d && !h)) parts.push(`${m} хв`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function pct(v: number): string {
|
||||
return `${v.toLocaleString('uk-UA', { maximumFractionDigits: 3 })}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* Вердикт трьома станами, а не двома.
|
||||
*
|
||||
* «Недостатньо даних» мусить бути окремим і мусить бути видно здалеку.
|
||||
* Звести його до зеленого означає збрехати, до червоного — звинуватити
|
||||
* мережу в тому, що зламався моніторинг.
|
||||
*
|
||||
* Винесено з розмітки окремою функцією рівно тому, що це рішення, а не
|
||||
* оформлення: його перевіряє тест, а не око.
|
||||
*/
|
||||
export function verdictLabel(o: SLAOutcome): 'недостатньо даних' | 'порушено' | 'виконано' {
|
||||
if (o.insufficient) return 'недостатньо даних'
|
||||
if (o.breached) return 'порушено'
|
||||
return 'виконано'
|
||||
}
|
||||
|
||||
function Verdict({ o }: { o: SLAOutcome }) {
|
||||
if (o.insufficient) {
|
||||
return (
|
||||
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] text-amber-300">
|
||||
недостатньо даних
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (o.breached) {
|
||||
return (
|
||||
<span className="rounded bg-rose-500/15 px-1.5 py-0.5 text-[11px] text-rose-300">
|
||||
порушено
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[11px] text-emerald-300">
|
||||
виконано
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Tile({ title, value, note }: { title: string; value: string; note?: string }) {
|
||||
return (
|
||||
<Card className="p-3">
|
||||
<div className="text-[11px] text-slate-500">{title}</div>
|
||||
<div className="mt-1 text-xl tabular-nums text-slate-100">{value}</div>
|
||||
{note && <div className="mt-1 text-[11px] text-slate-500">{note}</div>}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function SLAPage() {
|
||||
const [sp, setSp] = useSearchParams()
|
||||
const targetID = sp.get('target') ?? ''
|
||||
const date = sp.get('date') ?? ''
|
||||
|
||||
const [targets, setTargets] = useState<SLATarget[]>([])
|
||||
const [canEdit, setCanEdit] = useState(false)
|
||||
const [settleLag, setSettleLag] = useState(0)
|
||||
const [report, setReport] = useState<SLAReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<SLATarget | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [confirm, setConfirm] = useState<ConfirmRequest | null>(null)
|
||||
|
||||
const canWrite = session.can('settings:write')
|
||||
|
||||
const loadTargets = useCallback(() => {
|
||||
api
|
||||
.slaTargets()
|
||||
.then((r) => {
|
||||
setTargets(r.targets)
|
||||
setCanEdit(r.can_edit)
|
||||
setSettleLag(r.settle_lag_sec)
|
||||
setErr(null)
|
||||
// Перша ціль обирається сама: сторінка без обраної цілі порожня,
|
||||
// а порожня сторінка читається як «звітів немає».
|
||||
if (!targetID && r.targets.length > 0) {
|
||||
setSp({ target: r.targets[0].id }, { replace: true })
|
||||
}
|
||||
})
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [targetID, setSp])
|
||||
|
||||
useEffect(loadTargets, [loadTargets])
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetID) {
|
||||
setReport(null)
|
||||
return
|
||||
}
|
||||
let alive = true
|
||||
setLoading(true)
|
||||
api
|
||||
.slaReport(targetID, date || undefined)
|
||||
.then((r) => {
|
||||
if (!alive) return
|
||||
setReport(r)
|
||||
setErr(null)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive) return
|
||||
setReport(null)
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
})
|
||||
.finally(() => alive && setLoading(false))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [targetID, date])
|
||||
|
||||
const target = useMemo(() => targets.find((t) => t.id === targetID), [targets, targetID])
|
||||
|
||||
/**
|
||||
* Крок по періодах рахує СЕРВЕР, а не браузер.
|
||||
*
|
||||
* Межі періоду повертаються у звіті (period_from / period_to), тож
|
||||
* попередній період — це доба перед його початком, а наступний —
|
||||
* перший день після кінця. Календаря в браузері немає взагалі, і
|
||||
* розійтися з серверним йому нема з чим.
|
||||
*/
|
||||
const shift = (dir: -1 | 1) => {
|
||||
if (!report) return
|
||||
if (dir < 0) {
|
||||
const d = new Date(`${report.period_from}T00:00:00Z`)
|
||||
d.setUTCDate(d.getUTCDate() - 1)
|
||||
setSp({ target: targetID, date: d.toISOString().slice(0, 10) })
|
||||
} else {
|
||||
setSp({ target: targetID, date: report.period_to })
|
||||
}
|
||||
}
|
||||
|
||||
const close = (force: boolean) => {
|
||||
if (!report) return
|
||||
api
|
||||
.closeSLAPeriod(targetID, date || undefined, force)
|
||||
.then((r) => {
|
||||
setReport(r)
|
||||
setErr(null)
|
||||
})
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
|
||||
const removeTarget = (t: SLATarget) => {
|
||||
setConfirm({
|
||||
title: 'Видалити ціль SLA?',
|
||||
message: (
|
||||
<>
|
||||
Разом із ціллю «{t.name}» зникнуть <b>усі закриті звіти за нею</b>. Це саме ті
|
||||
числа, які вже могли поїхати клієнту, і відновити їх не можна: дані під ними
|
||||
живуть за строком зберігання.
|
||||
</>
|
||||
),
|
||||
detail: 'Щоб просто припинити розрахунок, вимкніть ціль замість видалення.',
|
||||
confirmLabel: 'Видалити назавжди',
|
||||
onConfirm: async () => {
|
||||
await api.deleteSLATarget(t.id)
|
||||
setSp({}, { replace: true })
|
||||
setReport(null)
|
||||
loadTargets()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: Column<SLARow>[] = [
|
||||
{ key: 'name', header: 'Хост', cell: (r) => r.device_name || r.device_id },
|
||||
{
|
||||
key: 'uptime',
|
||||
header: 'Доступність',
|
||||
cell: (r) => (
|
||||
// Порожньо, а не «0%», коли не виміряли. Нуль тут читається як
|
||||
// «лежало весь період» — рівно навпаки до того, що сталось.
|
||||
<span className="tabular-nums">{r.insufficient ? '—' : pct(r.uptime_pct)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'coverage',
|
||||
header: 'Покриття',
|
||||
cell: (r) => (
|
||||
<span
|
||||
className={`tabular-nums ${r.coverage_pct < (target?.min_coverage_pct ?? 0) ? 'text-amber-300' : 'text-slate-400'}`}
|
||||
>
|
||||
{pct(r.coverage_pct)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'verdict', header: 'Вердикт', cell: (r) => <Verdict o={r} /> },
|
||||
{
|
||||
key: 'down',
|
||||
header: 'Простій',
|
||||
hideOnMobile: true,
|
||||
cell: (r) => <span className="tabular-nums">{humanSec(r.downtime_sec)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'unknown',
|
||||
header: 'Немає даних',
|
||||
hideOnMobile: true,
|
||||
cell: (r) => (
|
||||
<span className="tabular-nums text-amber-300/80">{humanSec(r.unknown_sec)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'maint',
|
||||
header: 'Обслуговування',
|
||||
hideOnMobile: true,
|
||||
cell: (r) => <span className="tabular-nums text-slate-500">{humanSec(r.maintenance_sec)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'incidents',
|
||||
header: 'Смуг',
|
||||
hideOnMobile: true,
|
||||
cell: (r) => <span className="tabular-nums">{r.incidents || '—'}</span>,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Звіти SLA"
|
||||
subtitle="Доступність за період, порахована раз і збережена як факт"
|
||||
actions={
|
||||
canEdit && (
|
||||
<Button kind="primary" onClick={() => setCreating(true)}>
|
||||
Нова ціль
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PageBody className="space-y-3">
|
||||
{err && <ErrorNote>{err}</ErrorNote>}
|
||||
|
||||
{targets.length === 0 && !loading && (
|
||||
<Card className="space-y-2 p-4 text-sm text-slate-400">
|
||||
<div className="text-slate-200">Цілей SLA ще немає.</div>
|
||||
<div>
|
||||
Ціль — це обіцянка: скільки відсотків часу перелічені хости мають
|
||||
відповідати й за який період це рахувати. Поки цілі немає, немає й того,
|
||||
з чим порівнювати доступність.
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{targets.length > 0 && (
|
||||
<Card className="flex flex-wrap items-end gap-2 p-3">
|
||||
<label className="text-xs text-slate-400">
|
||||
Ціль
|
||||
<select
|
||||
className={`${inputClass} mt-1`}
|
||||
value={targetID}
|
||||
onChange={(e) => setSp({ target: e.target.value })}
|
||||
>
|
||||
{targets.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
{t.enabled ? '' : ' (вимкнено)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button onClick={() => shift(-1)} disabled={!report}>
|
||||
←
|
||||
</Button>
|
||||
<div className="min-w-40 px-2 text-center text-sm text-slate-200 tabular-nums">
|
||||
{report ? `${report.period_from} — ${report.period_to}` : '…'}
|
||||
</div>
|
||||
<Button onClick={() => shift(1)} disabled={!report}>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
||||
<Button onClick={() => api.downloadSLAReport(targetID, date || undefined)} disabled={!report}>
|
||||
Вивантажити CSV
|
||||
</Button>
|
||||
{canEdit && target && (
|
||||
<Button onClick={() => setEditing(target)}>Налаштувати</Button>
|
||||
)}
|
||||
{canEdit && target && (
|
||||
<Button kind="danger" onClick={() => removeTarget(target)}>
|
||||
Видалити
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading && <Spinner />}
|
||||
|
||||
{report && !loading && (
|
||||
<>
|
||||
<StateNote report={report} settleLag={settleLag} canWrite={canWrite && canEdit} onClose={close} />
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Tile
|
||||
title="Доступність за період"
|
||||
value={report.totals.insufficient ? '—' : pct(report.totals.uptime_pct)}
|
||||
note={`ціль ${pct(report.target.target_pct)}`}
|
||||
/>
|
||||
<Tile
|
||||
title="Покриття"
|
||||
value={pct(report.totals.coverage_pct)}
|
||||
note={`поріг ${pct(report.target.min_coverage_pct)}`}
|
||||
/>
|
||||
<Tile title="Простій" value={humanSec(report.totals.downtime_sec)} />
|
||||
<Tile
|
||||
title="Немає даних"
|
||||
value={humanSec(report.totals.unknown_sec)}
|
||||
note="не зараховано ні як робота, ні як аварія"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HorizonNote report={report} />
|
||||
|
||||
{report.warnings.length > 0 && (
|
||||
<Card className="space-y-1 p-3 text-xs text-amber-300/90">
|
||||
<div className="text-slate-300">Чого цей розрахунок не врахував:</div>
|
||||
{report.warnings.map((w) => (
|
||||
<div key={w}>• {warningLabel(w)}</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
rows={report.rows}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.device_id || r.device_name}
|
||||
empty="Під цю ціль не потрапляє жоден хост"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageBody>
|
||||
|
||||
{(editing || creating) && (
|
||||
<TargetForm
|
||||
target={editing}
|
||||
onClose={() => {
|
||||
setEditing(null)
|
||||
setCreating(false)
|
||||
}}
|
||||
onSaved={(t) => {
|
||||
setEditing(null)
|
||||
setCreating(false)
|
||||
loadTargets()
|
||||
setSp({ target: t.id })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Confirm request={confirm} onClose={() => setConfirm(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Смужка стану — найважливіший елемент сторінки.
|
||||
*
|
||||
* Питання «це остаточне число чи ще ні» має бути видно ДО того, як
|
||||
* людина натисне «друк». Закритий період не змінюється; попередній
|
||||
* змінюється щогодини, і роздрукований учора він завтра вже не
|
||||
* зійдеться сам із собою.
|
||||
*/
|
||||
function StateNote({
|
||||
report,
|
||||
settleLag,
|
||||
canWrite,
|
||||
onClose,
|
||||
}: {
|
||||
report: SLAReport
|
||||
settleLag: number
|
||||
canWrite: boolean
|
||||
onClose: (force: boolean) => void
|
||||
}) {
|
||||
if (report.closed) {
|
||||
return (
|
||||
<Card className="flex flex-wrap items-center gap-2 border-emerald-800/50 p-3 text-sm">
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[11px] text-emerald-300">
|
||||
закритий період
|
||||
</span>
|
||||
<span className="text-slate-400">
|
||||
Пораховано{' '}
|
||||
{report.computed_at ? new Date(report.computed_at).toLocaleString('uk-UA') : '—'} і
|
||||
більше не перераховується. Дані під звітом можуть уже зникнути за строком
|
||||
зберігання — на числа це не впливає.
|
||||
</span>
|
||||
{(report.revision ?? 1) > 1 && (
|
||||
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] text-amber-300">
|
||||
перераховано {report.revision} рази
|
||||
</span>
|
||||
)}
|
||||
{canWrite && (
|
||||
<Button
|
||||
className="ml-auto"
|
||||
onClick={() => onClose(true)}
|
||||
title="Порахувати заново по сьогоднішніх даних. Підніме номер перерахунку — старий роздрук перестане збігатися з новим."
|
||||
>
|
||||
Перерахувати
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="flex flex-wrap items-center gap-2 border-amber-800/50 p-3 text-sm">
|
||||
<span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] text-amber-300">
|
||||
попередній розрахунок
|
||||
</span>
|
||||
<span className="text-slate-400">
|
||||
{report.closable
|
||||
? 'Період скінчився. Поки його не закрито, числа перераховуються щоразу — роздруковувати такий звіт не можна.'
|
||||
: `Період ще триває. Закрити його можна за ${Math.round(settleLag / 3600)} год після кінця: стільки TimescaleDB рахує годинні згортки.`}
|
||||
</span>
|
||||
{canWrite && report.closable && (
|
||||
<Button kind="primary" className="ml-auto" onClick={() => onClose(false)}>
|
||||
Закрити період
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Горизонт даних.
|
||||
*
|
||||
* Причина, через яку звіт узагалі може змінитись заднім числом, стоїть
|
||||
* на самій сторінці, а не в довідці: питання «чому за березень порожньо»
|
||||
* має мати відповідь у тому самому вікні, де видно порожнечу.
|
||||
*/
|
||||
function HorizonNote({ report }: { report: SLAReport }) {
|
||||
const keep = report.horizon.keep_days
|
||||
const oldest = report.horizon.oldest_bucket
|
||||
return (
|
||||
<Card className="p-3 text-xs text-slate-500">
|
||||
Рахується з <span className="text-slate-300">{report.horizon.relation}</span> — годинних
|
||||
згорток доступності.{' '}
|
||||
{keep === null ? (
|
||||
<>Строку зберігання на них немає, тобто історія не обрізається.</>
|
||||
) : (
|
||||
<span className="text-amber-300/90">
|
||||
Строк зберігання — {keep} діб. Періоди, старші за це, буде видно лише як закриті
|
||||
звіти.
|
||||
</span>
|
||||
)}{' '}
|
||||
{oldest && (
|
||||
<>
|
||||
Найстаріші дані в базі — від{' '}
|
||||
{new Date(oldest).toLocaleDateString('uk-UA', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Формулювання попереджень живуть у браузері, а коди — на сервері.
|
||||
*
|
||||
* Код лежить у закритому періоді й читається через рік; формулювання
|
||||
* можна переписати будь-коли, не чіпаючи вже збережених рядків.
|
||||
*/
|
||||
export const SLA_WARNINGS: Record<string, string> = {
|
||||
rrule_ignored:
|
||||
'повторювані вікна обслуговування враховано лише першим входженням — розгортання RRULE у продукті немає',
|
||||
business_hours_ignored: 'робочі години задано, але розрахунок їх не звужує',
|
||||
beyond_horizon:
|
||||
'початок періоду старший за збережену історію годинних згорток — частина періоду без даних не тому, що їх не збирали',
|
||||
unknown_tz: 'часовий пояс цілі не впізнано, рахували в UTC',
|
||||
device_purged: 'хост видалено назавжди; показано ім’я на момент розрахунку',
|
||||
}
|
||||
|
||||
export function warningLabel(code: string): string {
|
||||
return SLA_WARNINGS[code] ?? code
|
||||
}
|
||||
|
||||
/** Форма цілі. */
|
||||
function TargetForm({
|
||||
target,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
target: SLATarget | null
|
||||
onClose: () => void
|
||||
onSaved: (t: SLATarget) => void
|
||||
}) {
|
||||
const [name, setName] = useState(target?.name ?? '')
|
||||
const [pctValue, setPctValue] = useState(String(target?.target_pct ?? 99.9))
|
||||
const [kind, setKind] = useState<SLAPeriodKind>(target?.period_kind ?? 'monthly')
|
||||
const [tz, setTz] = useState(target?.tz ?? 'Europe/Kyiv')
|
||||
const [minCov, setMinCov] = useState(String(target?.min_coverage_pct ?? 95))
|
||||
const [enabled, setEnabled] = useState(target?.enabled ?? true)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const save = () => {
|
||||
setSaving(true)
|
||||
api
|
||||
.saveSLATarget({
|
||||
id: target?.id,
|
||||
name,
|
||||
// Селектор поки не редагується у формі: порожній означає «весь
|
||||
// кабінет». Звузити його можна через API — і це чесніше, ніж
|
||||
// напівробочий конструктор, за яким не видно, кого саме він
|
||||
// накрив.
|
||||
selector: target?.selector ?? {},
|
||||
target_pct: Number(pctValue),
|
||||
period_kind: kind,
|
||||
tz,
|
||||
min_coverage_pct: Number(minCov),
|
||||
enabled,
|
||||
})
|
||||
.then(onSaved)
|
||||
.catch((e) => setErr(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={target ? 'Ціль SLA' : 'Нова ціль SLA'}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Скасувати</Button>
|
||||
<Button kind="primary" onClick={save} disabled={saving || !name.trim()}>
|
||||
Зберегти
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{err && <ErrorNote>{err}</ErrorNote>}
|
||||
|
||||
<Field label="Назва">
|
||||
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Ціль доступності, %"
|
||||
hint="99.9 за квартал — це 2 год 12 хв допустимого простою. 99.99 — 13 хвилин, і на годинних згортках така точність уже не вимірюється."
|
||||
>
|
||||
<input
|
||||
className={inputClass}
|
||||
value={pctValue}
|
||||
onChange={(e) => setPctValue(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Період">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as SLAPeriodKind)}
|
||||
>
|
||||
{(Object.keys(PERIOD_LABELS) as SLAPeriodKind[]).map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{PERIOD_LABELS[k]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Часовий пояс"
|
||||
hint="У ньому ріжуться межі періоду. Квартал для клієнта в Києві — це три календарні місяці за київським календарем, а не 92 доби від UTC."
|
||||
>
|
||||
<input className={inputClass} value={tz} onChange={(e) => setTz(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Мінімальне покриття, %"
|
||||
hint="Нижче цього вердикт не виноситься взагалі: період позначається як «недостатньо даних». Без порога місяць із мертвим зондом показав би 100%."
|
||||
>
|
||||
<input className={inputClass} value={minCov} onChange={(e) => setMinCov(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field label="Рахувати" hint="Вимкнена ціль перестає рахуватись, але закриті звіти лишаються.">
|
||||
<Toggle checked={enabled} onChange={setEnabled} />
|
||||
</Field>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
395
web/src/test/apiclient.test.ts
Normal file
395
web/src/test/apiclient.test.ts
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errRes, fetchQueue, loginBody, res } from './support'
|
||||
|
||||
/**
|
||||
* Мережевий шар: `api/client.ts` і `api/session.ts`.
|
||||
*
|
||||
* ПРИЧИНА, чому це найперший тест: через `request()` проходить КОЖЕН
|
||||
* виклик продукту. Тиха поломка тут не ламає жодну сторінку окремо —
|
||||
* вона ламає всі одразу й однаково непомітно: людину викидає на вхід
|
||||
* посеред роботи, або, навпаки, не викидає ніколи й вона дивиться на
|
||||
* дані, яких їй уже не видно.
|
||||
*
|
||||
* НАСЛІДОК для форми тестів: підставні відповіді тут не вигадані. Форма
|
||||
* помилки — з `writeError()` (`server/internal/httpapi/server.go:582`),
|
||||
* форма входу — з `server/API.md`, код `token_expired` — з
|
||||
* `server/internal/httpapi/principal.go:89`. Якщо сервер змінить форму,
|
||||
* а тест лишиться зеленим — тест закріпив вигадку, і саме цього ми
|
||||
* уникаємо звіркою, а не здогадом.
|
||||
*
|
||||
* Стан модуля (`refreshing`, `restoring`, токен сесії) переживає
|
||||
* окремий тест, тому кожен тест бере СВІЙ екземпляр модуля через
|
||||
* `vi.resetModules()`. Інакше засувка «один refresh на завантаження»
|
||||
* робила б результат тесту залежним від порядку прогону.
|
||||
*/
|
||||
|
||||
async function load() {
|
||||
vi.resetModules()
|
||||
const client = await import('../api/client')
|
||||
const sessionMod = await import('../api/session')
|
||||
return { ...client, session: sessionMod.session, meFromLogin: sessionMod.meFromLogin }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Заголовки й тіло
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('запит', () => {
|
||||
it('без сесії йде БЕЗ заголовка Authorization', async () => {
|
||||
// Негативна половина перевірки нижче. Порожній рядок токена в
|
||||
// заголовку («Bearer ») сервер прочитав би як спробу автентифікації
|
||||
// й відповів 401 замість того, щоб застосувати машинний токен.
|
||||
const { api, session } = await load()
|
||||
session.clear()
|
||||
const { calls } = fetchQueue(res(200, { devices: [] }))
|
||||
await api.listDevices()
|
||||
expect(calls[0].headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('токен сесії їде заголовком Bearer, тіло — JSON, кукі — same-origin', async () => {
|
||||
const { api, session } = await load()
|
||||
session.set('tok-1', { tenantID: 't-1', permissions: ['*'] })
|
||||
const { calls } = fetchQueue(res(200, { devices: [] }))
|
||||
await api.listDevices()
|
||||
|
||||
expect(calls[0].url).toBe('/api/v1/devices')
|
||||
expect(calls[0].headers.Authorization).toBe('Bearer tok-1')
|
||||
expect(calls[0].headers['Content-Type']).toBe('application/json')
|
||||
// Без цього httpOnly-кукі np_refresh не поїде, і тихе продовження
|
||||
// сесії не спрацює жодного разу — сторінка виглядатиме справною
|
||||
// рівно 15 хвилин.
|
||||
expect(calls[0].init.credentials).toBe('same-origin')
|
||||
})
|
||||
|
||||
it('204 без тіла не падає на розборі порожнього рядка', async () => {
|
||||
// DELETE віддає саме 204: `JSON.parse('')` тут кинув би SyntaxError,
|
||||
// і видалення виглядало б невдалим, хоча відбулось.
|
||||
const { api, session } = await load()
|
||||
session.set('tok-1', { tenantID: 't-1', permissions: ['*'] })
|
||||
fetchQueue(res(204))
|
||||
await expect(api.deleteDevice('d-1')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('?view=archived — окрема адреса, а не той самий перелік', async () => {
|
||||
const { api, session } = await load()
|
||||
session.set('tok-1', { tenantID: 't-1', permissions: ['*'] })
|
||||
const { calls } = fetchQueue(res(200, { devices: [] }), res(200, { devices: [] }))
|
||||
await api.listDevices()
|
||||
await api.listDevices('archived')
|
||||
expect(calls.map((c) => c.url)).toEqual(['/api/v1/devices', '/api/v1/devices?view=archived'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Помилки
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('ApiError', () => {
|
||||
it('код і текст беруться з тіла помилки, а не зі статусу', async () => {
|
||||
const { api, ApiError, session } = await load()
|
||||
session.set('tok-1', { tenantID: 't-1', permissions: ['*'] })
|
||||
fetchQueue(errRes(409, 'duplicate', 'група з такою назвою вже є'))
|
||||
|
||||
const e = await api.createDeviceGroup('Ядро').catch((x) => x)
|
||||
expect(e).toBeInstanceOf(ApiError)
|
||||
expect(e.status).toBe(409)
|
||||
expect(e.code).toBe('duplicate')
|
||||
// Саме це людина побачить у червоній смузі. Підміна на «409» або на
|
||||
// «Conflict» перетворює зрозумілу відмову на загадку.
|
||||
expect(e.message).toBe('група з такою назвою вже є')
|
||||
})
|
||||
|
||||
it('відповідь без блоку error дає код unknown і текст статусу', async () => {
|
||||
// Так виглядає відмова, що прийшла НЕ від нашого обробника:
|
||||
// зворотний проксі, балансувальник, сторінка помилки шлюзу.
|
||||
const { api, session } = await load()
|
||||
session.set('tok-1', { tenantID: 't-1', permissions: ['*'] })
|
||||
fetchQueue(res(502, {}, 'Bad Gateway'))
|
||||
|
||||
const e = await api.listDevices().catch((x) => x)
|
||||
expect(e.code).toBe('unknown')
|
||||
expect(e.message).toBe('Bad Gateway')
|
||||
})
|
||||
|
||||
it('isConflict / isPlanLimit / isForbidden читають РІЗНІ статуси', async () => {
|
||||
const { ApiError } = await load()
|
||||
const conflict = new ApiError(409, 'revision_conflict', 'мапу змінив хтось інший')
|
||||
const plan = new ApiError(402, 'plan_limit', 'ліміт тарифу')
|
||||
const forbidden = new ApiError(403, 'forbidden', 'немає права')
|
||||
|
||||
expect(conflict.isConflict).toBe(true)
|
||||
expect(plan.isPlanLimit).toBe(true)
|
||||
expect(forbidden.isForbidden).toBe(true)
|
||||
|
||||
// Друга половина перевірки, без якої перша нічого не доводить:
|
||||
// якби всі три ознаки повертали true на будь-якій помилці, тест
|
||||
// вище був би так само зелений. Плутанина 402↔409 тут коштує
|
||||
// дорого: конфлікт правки мапи UI лікує перечитуванням і повтором,
|
||||
// а на ліміт тарифу повторювати нема сенсу — вийшов би нескінченний
|
||||
// цикл запитів.
|
||||
expect(conflict.isPlanLimit).toBe(false)
|
||||
expect(conflict.isForbidden).toBe(false)
|
||||
expect(plan.isConflict).toBe(false)
|
||||
expect(plan.isForbidden).toBe(false)
|
||||
expect(forbidden.isConflict).toBe(false)
|
||||
expect(forbidden.isPlanLimit).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Тихе продовження сесії
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('протермінований токен', () => {
|
||||
it('401 token_expired: один обмін і повтор запиту вже з НОВИМ токеном', async () => {
|
||||
const { api, session } = await load()
|
||||
session.set('old', { tenantID: 't-1', permissions: ['*'] })
|
||||
const { calls } = fetchQueue(
|
||||
errRes(401, 'token_expired', 'строк дії токена минув'),
|
||||
res(200, loginBody('new')),
|
||||
res(200, { devices: [{ id: 'd-1' }] }),
|
||||
)
|
||||
|
||||
const devices = await api.listDevices()
|
||||
expect(devices).toEqual([{ id: 'd-1' }])
|
||||
|
||||
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
|
||||
'GET /api/v1/devices',
|
||||
'POST /api/v1/auth/refresh',
|
||||
'GET /api/v1/devices',
|
||||
])
|
||||
// Головне: повтор іде з новим токеном. Повтор зі старим дав би
|
||||
// другий 401, і людину викинуло б на вхід — саме те, чого весь цей
|
||||
// механізм уникає.
|
||||
expect(calls[0].headers.Authorization).toBe('Bearer old')
|
||||
expect(calls[2].headers.Authorization).toBe('Bearer new')
|
||||
expect(session.token()).toBe('new')
|
||||
})
|
||||
|
||||
it('401 з іншим кодом обміну НЕ запускає', async () => {
|
||||
// Негативна половина. `no_session` означає, що refresh-кукі вже
|
||||
// мертва: обмін по ній дав би ще один 401, а поки він іде — людина
|
||||
// дивиться на порожній екран. Сервер віддає цей код у
|
||||
// `handleRefresh` (`auth.go:172`), і плутати його з `token_expired`
|
||||
// не можна.
|
||||
const { api, session } = await load()
|
||||
session.set('old', { tenantID: 't-1', permissions: ['*'] })
|
||||
const { calls } = fetchQueue(errRes(401, 'no_session', 'сесії немає'))
|
||||
|
||||
await expect(api.listDevices()).rejects.toMatchObject({ code: 'no_session' })
|
||||
expect(calls).toHaveLength(1)
|
||||
// Сесію при цьому НЕ чистимо: гілка `session.clear()` живе лише
|
||||
// всередині невдалого обміну.
|
||||
expect(session.token()).toBe('old')
|
||||
})
|
||||
|
||||
it('403 обміну НЕ запускає', async () => {
|
||||
const { api, session } = await load()
|
||||
session.set('old', { tenantID: 't-1', permissions: ['*'] })
|
||||
const { calls } = fetchQueue(errRes(403, 'forbidden', 'немає права'))
|
||||
await expect(api.listDevices()).rejects.toMatchObject({ status: 403 })
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('невдалий обмін чистить сесію й повертає ПОЧАТКОВУ помилку', async () => {
|
||||
const { api, session } = await load()
|
||||
session.set('old', { tenantID: 't-1', permissions: ['*'] })
|
||||
fetchQueue(
|
||||
errRes(401, 'token_expired', 'строк дії токена минув'),
|
||||
errRes(401, 'session_invalid', 'сесія недійсна, увійдіть знову'),
|
||||
)
|
||||
|
||||
const e = await api.listDevices().catch((x) => x)
|
||||
// Саме token_expired, а не session_invalid: помилка обміну
|
||||
// ковтається (`.catch(() => false)`), назовні йде та, що стосується
|
||||
// запиту, який робила людина.
|
||||
expect(e.code).toBe('token_expired')
|
||||
expect(session.token()).toBe('')
|
||||
expect(session.me()).toBeNull()
|
||||
})
|
||||
|
||||
it('обмін без access_token у відповіді вважається невдалим', async () => {
|
||||
// Так виглядає відповідь login/refresh, коли людина працює в
|
||||
// кількох організаціях: приходить перелік `tenants`, а токена
|
||||
// немає. Прийняти її за успіх означало б записати в сесію
|
||||
// `undefined` і ходити далі без автентифікації.
|
||||
const { api, session } = await load()
|
||||
session.set('old', { tenantID: 't-1', permissions: ['*'] })
|
||||
fetchQueue(
|
||||
errRes(401, 'token_expired', 'строк дії токена минув'),
|
||||
res(200, { tenants: [{ tenant_id: 't-1', tenant_name: 'Acme' }] }),
|
||||
)
|
||||
await expect(api.listDevices()).rejects.toMatchObject({ code: 'token_expired' })
|
||||
expect(session.token()).toBe('')
|
||||
})
|
||||
|
||||
it('десять паралельних запитів дають РІВНО ОДИН обмін', async () => {
|
||||
// Ротація робить кожен наступний обмін недійсним: десять обмінів
|
||||
// підряд = дев'ять відкликаних сесій і вихід на сторінку входу
|
||||
// рівно тоді, коли все насправді гаразд.
|
||||
const { api, session } = await load()
|
||||
session.set('old', { tenantID: 't-1', permissions: ['*'] })
|
||||
|
||||
const expired = () => errRes(401, 'token_expired', 'строк дії токена минув')
|
||||
const ok = () => res(200, { devices: [] })
|
||||
const { calls } = fetchQueue(
|
||||
...Array.from({ length: 10 }, () => expired),
|
||||
res(200, loginBody('new')),
|
||||
...Array.from({ length: 10 }, () => ok),
|
||||
)
|
||||
|
||||
await Promise.all(Array.from({ length: 10 }, () => api.listDevices()))
|
||||
const refreshes = calls.filter((c) => c.url === '/api/v1/auth/refresh')
|
||||
expect(refreshes).toHaveLength(1)
|
||||
expect(calls.filter((c) => c.url === '/api/v1/devices')).toHaveLength(20)
|
||||
})
|
||||
|
||||
it('вхід не ходить через обмін: 401 bad_credentials лишається відмовою входу', async () => {
|
||||
// `login` навмисно кличе `raw`, а не `request`. Інакше невірний
|
||||
// пароль тягнув би за собою спробу обміну — зайвий запит на кожну
|
||||
// помилку вводу.
|
||||
const { api } = await load()
|
||||
const { calls } = fetchQueue(errRes(401, 'bad_credentials', 'невірний логін або пароль'))
|
||||
await expect(api.login('admin', 'nope')).rejects.toMatchObject({
|
||||
code: 'bad_credentials',
|
||||
})
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0].body).toEqual({ login: 'admin', password: 'nope' })
|
||||
})
|
||||
|
||||
it('вхід із вибором організації надсилає tenant_id', async () => {
|
||||
const { api } = await load()
|
||||
const { calls } = fetchQueue(res(200, loginBody('tok')))
|
||||
await api.login('admin', 'pw', 't-2')
|
||||
expect(calls[0].body).toEqual({ login: 'admin', password: 'pw', tenant_id: 't-2' })
|
||||
})
|
||||
|
||||
it('logout чистить сесію навіть тоді, коли сервер відповів помилкою', async () => {
|
||||
// Інакше після невдалого виходу вкладка лишається з живим токеном,
|
||||
// а людина впевнена, що вийшла. На спільному комп'ютері в
|
||||
// диспетчерській це і є проблема.
|
||||
const { api, session } = await load()
|
||||
session.set('tok', { tenantID: 't-1', permissions: ['*'] })
|
||||
fetchQueue(errRes(500, 'internal', 'внутрішня помилка'))
|
||||
await expect(api.logout()).rejects.toBeTruthy()
|
||||
expect(session.token()).toBe('')
|
||||
expect(session.me()).toBeNull()
|
||||
})
|
||||
|
||||
it('restore() — один обмін на завантаження сторінки, скільки б разів його не покликали', async () => {
|
||||
// React у режимі розробки виконує ефекти двічі, а обмін
|
||||
// refresh-токена одноразовий: другий приніс би вже відкликаний
|
||||
// токен, і сесія відновилася б у стан «щойно розлогінено».
|
||||
const { api, session } = await load()
|
||||
const { calls } = fetchQueue(res(200, loginBody('restored', ['maps:read'])))
|
||||
|
||||
const [a, b] = await Promise.all([api.restore(), api.restore()])
|
||||
expect(a).toBe(true)
|
||||
expect(b).toBe(true)
|
||||
expect(await api.restore()).toBe(true)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(session.token()).toBe('restored')
|
||||
expect(session.me()?.permissions).toEqual(['maps:read'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Сесія
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('session', () => {
|
||||
it('can() дає true лише на надане право', async () => {
|
||||
const { session } = await load()
|
||||
session.set('t', { tenantID: 't-1', permissions: ['devices:read', 'alerts:read'] })
|
||||
expect(session.can('devices:read')).toBe(true)
|
||||
// Половина, без якої перша нічого не доводить.
|
||||
expect(session.can('devices:write')).toBe(false)
|
||||
expect(session.can('audit:read')).toBe(false)
|
||||
})
|
||||
|
||||
it('«*» відкриває все, але лише тому, хто його має', async () => {
|
||||
const { session } = await load()
|
||||
session.set('t', { tenantID: 't-1', permissions: ['*'] })
|
||||
expect(session.can('billing:manage')).toBe(true)
|
||||
session.set('t', { tenantID: 't-1', permissions: ['devices:read'] })
|
||||
expect(session.can('billing:manage')).toBe(false)
|
||||
})
|
||||
|
||||
it('без сесії не можна нічого', async () => {
|
||||
const { session } = await load()
|
||||
session.clear()
|
||||
expect(session.can('devices:read')).toBe(false)
|
||||
})
|
||||
|
||||
it('підписники дізнаються і про вхід, і про вихід', async () => {
|
||||
const { session } = await load()
|
||||
const seen: (string | null)[] = []
|
||||
const off = session.subscribe(() => seen.push(session.me()?.tenantID ?? null))
|
||||
session.set('t', { tenantID: 't-1', permissions: [] })
|
||||
session.clear()
|
||||
off()
|
||||
session.set('t', { tenantID: 't-2', permissions: [] })
|
||||
// Третьої події немає: відписка справді знімає слухача, інакше
|
||||
// розмонтований компонент оновлював би стан і React лаявся б.
|
||||
expect(seen).toEqual(['t-1', null])
|
||||
})
|
||||
|
||||
it('meFromLogin бере tenant_id з кореня, коли блоку tenant немає', async () => {
|
||||
// Так відповідає `/api/v1/me` для машинного токена: `tenant_id` у
|
||||
// корені, `token_name` замість користувача (див. API.md, «GET
|
||||
// /api/v1/me»).
|
||||
const { meFromLogin } = await load()
|
||||
const me = meFromLogin({ tenant_id: 't-9', permissions: ['maps:read'] })
|
||||
expect(me.tenantID).toBe('t-9')
|
||||
expect(me.permissions).toEqual(['maps:read'])
|
||||
expect(me.userID).toBeUndefined()
|
||||
})
|
||||
|
||||
it('meFromLogin розкладає повну відповідь входу', async () => {
|
||||
const { meFromLogin } = await load()
|
||||
const me = meFromLogin(loginBody('tok', ['maps:read', 'users:write']))
|
||||
expect(me).toEqual({
|
||||
userID: 'u-1',
|
||||
username: 'admin',
|
||||
email: 'admin@acme.io',
|
||||
fullName: 'Admin',
|
||||
tenantID: 't-1',
|
||||
tenantName: 'Acme',
|
||||
roleName: 'Власник',
|
||||
permissions: ['maps:read', 'users:write'],
|
||||
})
|
||||
})
|
||||
|
||||
it('відповідь без permissions дає порожній набір, а не падіння', async () => {
|
||||
const { meFromLogin, session } = await load()
|
||||
session.set('t', meFromLogin({ tenant_id: 't-1' }))
|
||||
expect(session.can('devices:read')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Токен для WebSocket
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('authToken', () => {
|
||||
it('без сесії й без машинного токена — порожньо', async () => {
|
||||
const { authToken, session, staticTokenPresent } = await load()
|
||||
session.clear()
|
||||
// У тестовій збірці VITE_API_TOKEN не заданий; якщо він колись
|
||||
// з'явиться в оточенні CI, цей тест скаже про це прямо, а не
|
||||
// проведе решту перевірок у режимі кіоска.
|
||||
expect(staticTokenPresent).toBe(false)
|
||||
expect(authToken()).toBe('')
|
||||
})
|
||||
|
||||
it('токен сесії стає токеном для сокета одразу після входу', async () => {
|
||||
const { authToken, session } = await load()
|
||||
session.set('live-token', { tenantID: 't-1', permissions: [] })
|
||||
expect(authToken()).toBe('live-token')
|
||||
})
|
||||
})
|
||||
281
web/src/test/auditfilter.test.tsx
Normal file
281
web/src/test/auditfilter.test.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import { AuditPage } from '../pages/AuditPage'
|
||||
import { fetchRouter, type Call } from './support'
|
||||
|
||||
/**
|
||||
* Фільтри журналу аудиту в адресному рядку.
|
||||
*
|
||||
* ПРИЧИНА, чому саме тут: відфільтрований журнал — це те, що
|
||||
* пересилають колезі («глянь, хто стер ці конфіги»). Стан фільтра живе
|
||||
* в адресі саме для цього. НАСЛІДОК поломки: посилання відкривається,
|
||||
* сторінка малюється, фільтр на екрані виглядає обраним — а перелік
|
||||
* унизу зібрано за іншим питанням. Це не падіння й не порожній екран;
|
||||
* це неправильна відповідь із виглядом правильної, і помітити її можна
|
||||
* лише звіркою з тим, хто дивиться той самий журнал руками.
|
||||
*
|
||||
* Тому головна перевірка файла — рівність: фільтр, прочитаний з
|
||||
* адреси, і той самий фільтр, обраний руками, мають дати ОДНАКОВИЙ
|
||||
* запит до сервера. Перевіряти кожен з них окремо проти очікуваного
|
||||
* рядка означало б двічі записати те саме припущення.
|
||||
*
|
||||
* Набір параметрів звірений із `handleListAudit`
|
||||
* (`server/internal/httpapi/audit.go`): action, object_type, actor,
|
||||
* token, ip, q, from, to, limit, cursor. «-» серед `token` означає
|
||||
* «будь-який машинний токен» — теж із обробника.
|
||||
*/
|
||||
|
||||
const meta = {
|
||||
actions: [
|
||||
{ key: 'device.delete', label: 'Видалення хоста', group: 'devices' },
|
||||
{ key: 'ncm.config.delete', label: 'Видалення версій конфігів', group: 'ncm' },
|
||||
],
|
||||
object_types: [
|
||||
{ key: 'device', label: 'Хост', group: 'devices' },
|
||||
{ key: 'role', label: 'Роль', group: 'users' },
|
||||
],
|
||||
actors: [
|
||||
{ id: 'u-1', label: 'admin', kind: 'user' },
|
||||
{ id: 'tok-9', label: 'grafana', kind: 'token' },
|
||||
],
|
||||
bounds: { first: '2026-01-01T00:00:00Z', last: '2026-08-27T00:00:00Z' },
|
||||
blind_spots: ['перегляд конфігів не записується'],
|
||||
max_range_days: 365,
|
||||
}
|
||||
|
||||
function page(events: unknown[] = [], next?: string) {
|
||||
return {
|
||||
from: '2026-07-28T00:00:00Z',
|
||||
to: '2026-08-27T00:00:00Z',
|
||||
events,
|
||||
next_cursor: next,
|
||||
}
|
||||
}
|
||||
|
||||
function event(id: string, action = 'device.delete') {
|
||||
return {
|
||||
id,
|
||||
ts: '2026-08-20T10:00:00Z',
|
||||
action,
|
||||
action_label: 'Видалення хоста',
|
||||
object_type: 'device',
|
||||
object_id: 'd-1',
|
||||
actor: { kind: 'user', user_id: 'u-1', username: 'admin', ip: '10.20.0.5' },
|
||||
summary: `подія ${id}`,
|
||||
bytes: 120,
|
||||
has_before: false,
|
||||
has_after: false,
|
||||
has_details: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Остання адреса, на якій опинилася сторінка. */
|
||||
let lastSearch = ''
|
||||
function Spy() {
|
||||
lastSearch = useLocation().search
|
||||
return null
|
||||
}
|
||||
|
||||
function open(url: string, routes: Record<string, unknown>) {
|
||||
const server = fetchRouter({
|
||||
'GET /api/v1/audit/meta': meta,
|
||||
'GET /api/v1/audit': page(),
|
||||
...routes,
|
||||
})
|
||||
render(
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/audit"
|
||||
element={
|
||||
<>
|
||||
<Spy />
|
||||
<AuditPage />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return server
|
||||
}
|
||||
|
||||
/**
|
||||
* Питання до сервера без часових меж.
|
||||
*
|
||||
* `from`/`to` рахуються від `Date.now()` і в двох прогонах збігтись не
|
||||
* можуть з точністю до мілісекунди. Ширина вікна перевіряється окремо —
|
||||
* саме вона й несе зміст періоду.
|
||||
*/
|
||||
function question(call: Call): Record<string, string[]> {
|
||||
const sp = new URLSearchParams(call.url.split('?')[1] ?? '')
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const key of [...new Set(sp.keys())].sort()) {
|
||||
if (key === 'from' || key === 'to') continue
|
||||
out[key] = sp.getAll(key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function windowDays(call: Call): number {
|
||||
const sp = new URLSearchParams(call.url.split('?')[1] ?? '')
|
||||
const from = Date.parse(sp.get('from')!)
|
||||
const to = Date.parse(sp.get('to')!)
|
||||
return Math.round((to - from) / 86_400_000)
|
||||
}
|
||||
|
||||
const audits = (s: { calls: Call[] }) => s.calls.filter((c) => c.url.startsWith('/api/v1/audit?'))
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
const { session } = await import('../api/session')
|
||||
session.set('tok', { tenantID: 't-1', permissions: ['*'] })
|
||||
lastSearch = ''
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('фільтр в адресі', () => {
|
||||
it('прочитаний з посилання доїжджає до сервера повністю', async () => {
|
||||
const server = open(
|
||||
'/audit?period=7d&action=device.delete&action=ncm.config.delete' +
|
||||
'&object_type=device&actor=u-1&token=-&ip=10.20.0.0%2F24&q=core-sw',
|
||||
{},
|
||||
)
|
||||
|
||||
await waitFor(() => expect(audits(server)).toHaveLength(1))
|
||||
expect(question(audits(server)[0])).toEqual({
|
||||
action: ['device.delete', 'ncm.config.delete'],
|
||||
object_type: ['device'],
|
||||
actor: ['u-1'],
|
||||
token: ['-'],
|
||||
ip: ['10.20.0.0/24'],
|
||||
q: ['core-sw'],
|
||||
limit: ['50'],
|
||||
})
|
||||
expect(windowDays(audits(server)[0])).toBe(7)
|
||||
})
|
||||
|
||||
it('той самий фільтр, обраний руками, дає ТОЙ САМИЙ запит', async () => {
|
||||
// Це і є головна перевірка файла. Порівнюються два шляхи до одного
|
||||
// питання, а не кожен окремо проти очікуваного рядка: розбіжність
|
||||
// між «прийшов за посиланням» і «набрав руками» — це рівно та
|
||||
// помилка, через яку двоє дивляться на різні переліки й думають, що
|
||||
// на один.
|
||||
const byLink = open(
|
||||
'/audit?period=7d&action=device.delete&object_type=device&actor=u-1&q=core-sw',
|
||||
{},
|
||||
)
|
||||
await waitFor(() => expect(audits(byLink)).toHaveLength(1))
|
||||
const expected = question(audits(byLink)[0])
|
||||
const expectedSearch = lastSearch
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
|
||||
const byHand = open('/audit?period=7d', {})
|
||||
await waitFor(() => expect(audits(byHand)).toHaveLength(1))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Видалення хоста' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Хост' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'admin' }))
|
||||
fireEvent.change(screen.getByPlaceholderText("ім'я хоста, команда, майданчик…"), {
|
||||
target: { value: 'core-sw' },
|
||||
})
|
||||
|
||||
// Пошук відкладено на 400 мс: у сервер летить слово, а не кожна
|
||||
// літера.
|
||||
await waitFor(
|
||||
() => expect(question(audits(byHand)[audits(byHand).length - 1])).toEqual(expected),
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
// Адреса теж має збігтись: саме її пересилають.
|
||||
expect(new URLSearchParams(lastSearch).toString()).toBe(
|
||||
new URLSearchParams(expectedSearch).toString(),
|
||||
)
|
||||
})
|
||||
|
||||
it('типовий період в адресу не пишеться', async () => {
|
||||
// 30 днів — типове значення, і писати його в адресу означало б
|
||||
// робити «чисте» посилання довшим без жодної потреби. Але прочитане
|
||||
// з порожньої адреси питання має бути тим самим.
|
||||
const server = open('/audit?period=7d', {})
|
||||
await waitFor(() => expect(audits(server)).toHaveLength(1))
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('останні 7 днів'), { target: { value: '30d' } })
|
||||
await waitFor(() => expect(audits(server)).toHaveLength(2))
|
||||
expect(lastSearch).toBe('')
|
||||
expect(windowDays(audits(server)[1])).toBe(30)
|
||||
})
|
||||
|
||||
it('скидання прибирає фільтри й з адреси, і з запиту', async () => {
|
||||
const server = open('/audit?action=device.delete&q=core&ip=10.0.0.1', {})
|
||||
await waitFor(() => expect(audits(server)).toHaveLength(1))
|
||||
|
||||
// Число на кнопці — це те, що людина скидає. Три умови: дія, пошук,
|
||||
// адреса.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Скинути фільтри (3)' }))
|
||||
await waitFor(() => expect(lastSearch).toBe(''))
|
||||
await waitFor(() => expect(audits(server).length).toBeGreaterThan(1))
|
||||
const last = audits(server)[audits(server).length - 1]
|
||||
expect(question(last)).toEqual({ limit: ['50'] })
|
||||
})
|
||||
|
||||
it('свій період їде як межі, а не як назва', async () => {
|
||||
const server = open('/audit?period=custom&from=2026-03-01T00:00&to=2026-03-08T00:00', {})
|
||||
await waitFor(() => expect(audits(server)).toHaveLength(1))
|
||||
expect(windowDays(audits(server)[0])).toBe(7)
|
||||
// Назва періоду серверу не потрібна й не має до нього доїхати:
|
||||
// обробник читає лише from/to.
|
||||
expect(question(audits(server)[0])).toEqual({ limit: ['50'] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('гортання', () => {
|
||||
it('наступна сторінка береться в тому вікні, яке зібрав СЕРВЕР', async () => {
|
||||
// Вікно фіксується відповіддю (`page.from`/`page.to`), а не
|
||||
// перераховується від Date.now(). Інакше друга сторінка збиралась
|
||||
// би в трохи іншому періоді, і подія на межі або зникла б, або
|
||||
// приїхала двічі.
|
||||
const server = fetchRouter({
|
||||
'GET /api/v1/audit/meta': meta,
|
||||
'GET /api/v1/audit': (c: Call) =>
|
||||
c.url.includes('cursor=')
|
||||
? ({
|
||||
status: 200,
|
||||
ok: true,
|
||||
statusText: '',
|
||||
text: async () => JSON.stringify(page([event('e2')])),
|
||||
} as unknown as Response)
|
||||
: ({
|
||||
status: 200,
|
||||
ok: true,
|
||||
statusText: '',
|
||||
text: async () => JSON.stringify(page([event('e1')], 'cur-1')),
|
||||
} as unknown as Response),
|
||||
})
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/audit?period=7d&action=device.delete']}>
|
||||
<Routes>
|
||||
<Route path="/audit" element={<AuditPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
await screen.findByRole('button', { name: 'Показати ще' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Показати ще' }))
|
||||
await waitFor(() => expect(audits(server)).toHaveLength(2))
|
||||
|
||||
const second = new URLSearchParams(audits(server)[1].url.split('?')[1])
|
||||
expect(second.get('from')).toBe('2026-07-28T00:00:00Z')
|
||||
expect(second.get('to')).toBe('2026-08-27T00:00:00Z')
|
||||
expect(second.get('cursor')).toBe('cur-1')
|
||||
// Фільтр не губиться при гортанні: інакше друга сторінка приносила
|
||||
// б чужі рядки в той самий перелік.
|
||||
expect(second.getAll('action')).toEqual(['device.delete'])
|
||||
})
|
||||
})
|
||||
208
web/src/test/billing.test.tsx
Normal file
208
web/src/test/billing.test.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { BillingPage } from '../pages/BillingPage'
|
||||
import { session } from '../api/session'
|
||||
import { fetchRouter } from './support'
|
||||
import type { BillingOverview, LicenseStateKind } from '../types'
|
||||
|
||||
/**
|
||||
* Сторінка тарифу.
|
||||
*
|
||||
* ЩО САМЕ ТУТ ВАРТО ПЕРЕВІРЯТИ
|
||||
*
|
||||
* Сторінка існує заради однієї речі: людина має побачити стелю ДО того,
|
||||
* як у неї впреться. Перевірка «сторінка відкривається» цього не
|
||||
* доводить — вона лишається зеленою й на сторінці, яка показує саме́ лише
|
||||
* «Тариф: Free».
|
||||
*
|
||||
* Тому нижче — два питання, і обидва про зміст:
|
||||
*
|
||||
* 1. Чи видно ЧИСЛА стелі й зайнятого, і чи позначено вичерпане. Це
|
||||
* те, заради чого на сторінку заходять щотижня.
|
||||
*
|
||||
* 2. Чи каже сторінка на протермінованій ліцензії, що збір ПРАЦЮЄ.
|
||||
* Це не про формулювання — це запобіжник проти зміни поведінки.
|
||||
* Людина, яка бачить «протерміновано» в системі моніторингу,
|
||||
* першою думкою має «отже, воно вже нічого не стежить», і саме цю
|
||||
* думку рядок мусить зняти. Якщо колись хтось вирішить, що
|
||||
* прострочення має вимикати збір, цей тест упаде — і саме там, де
|
||||
* треба зупинитись і подумати ще раз.
|
||||
*/
|
||||
|
||||
const featureCatalog = [
|
||||
{ key: 'icmp', name: 'ICMP-моніторинг' },
|
||||
{ key: 'ncm_git', name: 'NCM + Git diff' },
|
||||
]
|
||||
|
||||
function overview(over: Partial<BillingOverview> = {}): BillingOverview {
|
||||
return {
|
||||
entitlement: {
|
||||
plan_key: 'free',
|
||||
plan_name: 'Free / Community',
|
||||
max_devices: 15,
|
||||
max_maps: 1,
|
||||
max_map_nodes: 15,
|
||||
max_agents: 1,
|
||||
max_users: 3,
|
||||
metric_retention_days: 7,
|
||||
features: ['icmp'],
|
||||
source: 'license_key',
|
||||
reason: 'license',
|
||||
updated_at: '2026-08-01T00:00:00Z',
|
||||
},
|
||||
usage: { devices: 15, maps: 1, agents: 1, users: 2 },
|
||||
plans: [
|
||||
{
|
||||
key: 'free',
|
||||
name: 'Free / Community',
|
||||
base_price_cents: 0,
|
||||
per_device_cents: 0,
|
||||
currency: 'USD',
|
||||
billing_period: 'monthly',
|
||||
max_devices: 15,
|
||||
max_maps: 1,
|
||||
max_map_nodes: 15,
|
||||
max_agents: 1,
|
||||
max_users: 3,
|
||||
metric_retention_days: 7,
|
||||
features: ['icmp'],
|
||||
is_public: true,
|
||||
},
|
||||
],
|
||||
features: featureCatalog,
|
||||
license: {
|
||||
state: 'active',
|
||||
install_id: '11111111-2222-3333-4444-555555555555',
|
||||
expires_at: '2027-01-01T00:00:00Z',
|
||||
},
|
||||
can_manage: true,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function withLicense(state: LicenseStateKind, extra: Record<string, unknown> = {}) {
|
||||
return overview({
|
||||
license: {
|
||||
state,
|
||||
install_id: '11111111-2222-3333-4444-555555555555',
|
||||
expires_at: '2026-01-01T00:00:00Z',
|
||||
grace_until: '2026-01-31T00:00:00Z',
|
||||
...extra,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
session.set('tok', {
|
||||
tenantID: 't-1',
|
||||
permissions: ['billing:read', 'billing:manage'],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
session.clear()
|
||||
})
|
||||
|
||||
describe('сторінка тарифу', () => {
|
||||
it('показує зайняте зі стелею й позначає вичерпане', async () => {
|
||||
fetchRouter({ 'GET /api/v1/billing': overview() })
|
||||
render(<BillingPage />)
|
||||
|
||||
// Шукаємо всередині блоку «Використано зі стелі», а не по всій
|
||||
// сторінці: слово «Хости» стоїть іще й у шапці таблиці тарифів, де
|
||||
// воно означає пропозицію, а не поточний стан.
|
||||
const grid = await screen.findByLabelText('Використано зі стелі')
|
||||
|
||||
// Хости: 15 із 15 — саме та мить, у яку людина має дізнатись про
|
||||
// стелю не з відмови на створенні.
|
||||
const devices = within(grid).getByText('Хости').closest('div')?.parentElement
|
||||
expect(devices?.textContent).toContain('15')
|
||||
expect(devices?.textContent).toContain('/ 15')
|
||||
// Вичерпану стелю має бути видно словами, а не лише кольором: колір
|
||||
// не читається ні скрінрідером, ні людиною, яка дивиться скоса.
|
||||
expect(devices?.textContent).toContain('стелю досягнуто')
|
||||
|
||||
// Користувачі: 2 з 3 — стеля не досягнута, попередження бути не має.
|
||||
const users = within(grid).getByText('Користувачі').closest('div')?.parentElement
|
||||
expect(users?.textContent).toContain('/ 3')
|
||||
expect(users?.textContent).not.toContain('стелю досягнуто')
|
||||
})
|
||||
|
||||
it('показує «без обмеження» замість нуля, коли стелі немає', async () => {
|
||||
const data = overview()
|
||||
data.entitlement.max_devices = null
|
||||
fetchRouter({ 'GET /api/v1/billing': data })
|
||||
render(<BillingPage />)
|
||||
|
||||
const grid = await screen.findByLabelText('Використано зі стелі')
|
||||
const devices = within(grid).getByText('Хости').closest('div')?.parentElement
|
||||
expect(devices?.textContent).toContain('∞')
|
||||
expect(devices?.textContent).toContain('без обмеження')
|
||||
// Найгірше прочитання «немає стелі» — як «стеля нуль». Якщо колись
|
||||
// null перетвориться на 0 дорогою через форму, тут буде «/ 0».
|
||||
expect(devices?.textContent).not.toContain('/ 0')
|
||||
})
|
||||
|
||||
it('на протермінованій ліцензії каже, що збір і сповіщення працюють', async () => {
|
||||
fetchRouter({ 'GET /api/v1/billing': withLicense('expired') })
|
||||
render(<BillingPage />)
|
||||
|
||||
const banner = await screen.findByText(/Строк ліцензії та пільговий період минули/)
|
||||
const text = banner.textContent ?? ''
|
||||
|
||||
// Обіцянка продукту, записана перевіркою: моніторинг не зупиняється.
|
||||
expect(text).toContain('працюють далі й не зупиняться')
|
||||
// І симетрично — сказано, чого саме не буде. Без цієї половини
|
||||
// попередження перетворюється на «все добре», і платити нема причин.
|
||||
expect(text).toContain('нові хости')
|
||||
})
|
||||
|
||||
it('на пільговому періоді називає дату, після якої щось зміниться', async () => {
|
||||
fetchRouter({ 'GET /api/v1/billing': withLicense('grace') })
|
||||
render(<BillingPage />)
|
||||
|
||||
const banner = await screen.findByText(/триває пільговий період/)
|
||||
expect(banner.textContent).toContain('31 січня 2026')
|
||||
})
|
||||
|
||||
it('показує ідентифікатор інсталяції — його надсилають, замовляючи ключ', async () => {
|
||||
fetchRouter({ 'GET /api/v1/billing': overview() })
|
||||
render(<BillingPage />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText('11111111-2222-3333-4444-555555555555').length).toBeGreaterThan(0),
|
||||
)
|
||||
})
|
||||
|
||||
it('без права керувати не показує кнопок зміни', async () => {
|
||||
const data = overview({ can_manage: false })
|
||||
fetchRouter({ 'GET /api/v1/billing': data })
|
||||
render(<BillingPage />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Оновити')).toBeTruthy())
|
||||
expect(screen.queryByText('Змінити тариф')).toBeNull()
|
||||
expect(screen.queryByText('Ліцензійний ключ')).toBeNull()
|
||||
})
|
||||
|
||||
it('з правом керувати кнопки є', async () => {
|
||||
fetchRouter({ 'GET /api/v1/billing': overview() })
|
||||
render(<BillingPage />)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Змінити тариф')).toBeTruthy())
|
||||
expect(screen.getByText('Ліцензійний ключ')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('каже прямо, коли перевіряти ключ нічим', async () => {
|
||||
fetchRouter({
|
||||
'GET /api/v1/billing': withLicense('unlicensed', { verifier_empty: true }),
|
||||
})
|
||||
render(<BillingPage />)
|
||||
|
||||
// Мовчання тут виглядало б як «ключ неправильний», і людина шукала б
|
||||
// не там.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/немає жодного відкритого ключа/)).toBeTruthy(),
|
||||
)
|
||||
})
|
||||
})
|
||||
470
web/src/test/destructive.test.tsx
Normal file
470
web/src/test/destructive.test.tsx
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { BulkDeviceForm } from '../components/BulkDeviceForm'
|
||||
import { RollbackDialog } from '../components/RollbackDialog'
|
||||
import { errRes, fetchRouter, res, type Call } from './support'
|
||||
import type { ConfigVersion, DeviceSummary } from '../types'
|
||||
|
||||
/**
|
||||
* Незворотні дії: видалення хоста й відкат конфігу.
|
||||
*
|
||||
* ПРИЧИНА окремого файла: у решті продукту помилку видно й можна
|
||||
* виправити. Тут — ні. Три поломки, кожна з яких проходить повз
|
||||
* поверхневий погляд:
|
||||
*
|
||||
* 1. **Підтвердження вдалося обійти.** Кнопка активна раніше, ніж
|
||||
* людина побачила наслідки.
|
||||
* 2. **Подвійний клік послав два запити.** Другий іде вже по тому, чого
|
||||
* немає, і в журналі аудиту лишається дві дії замість однієї.
|
||||
* 3. **Помилка сервера закрила вікно.** Читається як «виконано», хоча
|
||||
* не виконано нічого, — і разом із вікном зникає набране.
|
||||
*
|
||||
* Форма підставних відповідей звірена з
|
||||
* `server/internal/httpapi/devices_bulk.go` (`bulk-targets`,
|
||||
* `bulk-delete`) і з `store.BulkDeviceTarget` / `store.PurgeResult`.
|
||||
*/
|
||||
|
||||
function device(id: string, name: string): DeviceSummary {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
address: '10.20.0.1',
|
||||
kind: 'switch',
|
||||
status: 'up',
|
||||
enabled: true,
|
||||
interface_count: 24,
|
||||
group_ids: [],
|
||||
writable: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** Ціль масової дії — усі поля, які читає `DeleteChoice`. */
|
||||
function target(id: string, name: string, history = true) {
|
||||
return {
|
||||
device_id: id,
|
||||
name,
|
||||
address: '10.20.0.1',
|
||||
kind: 'switch',
|
||||
status: 'up',
|
||||
enabled: true,
|
||||
checks: 4,
|
||||
active_alerts: 1,
|
||||
map_nodes: 2,
|
||||
has_backup_policy: true,
|
||||
configs: history ? 12 : 0,
|
||||
config_bytes: history ? 480_000 : 0,
|
||||
series: history ? 340 : 0,
|
||||
samples: history ? 1_000_000 : 0,
|
||||
samples_capped: history,
|
||||
alerts: history ? 57 : 0,
|
||||
interfaces: 24,
|
||||
branch: history ? 'devices/core-sw' : '',
|
||||
}
|
||||
}
|
||||
|
||||
const purgeResult = {
|
||||
purged: [{ device_id: 'd-1', name: 'core-sw' }],
|
||||
configs: 12,
|
||||
freed_bytes: 480_000,
|
||||
series: 340,
|
||||
samples: 1_000_000,
|
||||
branches: 1,
|
||||
}
|
||||
|
||||
const emptyFilter = { query: '', group_ids: [], statuses: [], only_enabled: false }
|
||||
|
||||
function renderDelete(
|
||||
targets: unknown[],
|
||||
routes: Record<string, unknown>,
|
||||
props: Partial<Parameters<typeof BulkDeviceForm>[0]> = {},
|
||||
) {
|
||||
const server = fetchRouter({
|
||||
'POST /api/v1/devices/bulk-targets': { devices: targets, dropped: [], max: 500 },
|
||||
...routes,
|
||||
})
|
||||
const onClose = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
render(
|
||||
<BulkDeviceForm
|
||||
mode="delete"
|
||||
devices={[device('d-1', 'core-sw')]}
|
||||
filter={emptyFilter}
|
||||
groups={[]}
|
||||
agents={[]}
|
||||
credentials={[]}
|
||||
checkTypes={[]}
|
||||
onClose={onClose}
|
||||
onDone={onDone}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
return { server, onClose, onDone }
|
||||
}
|
||||
|
||||
const bulkDeletes = (s: { calls: Call[] }) =>
|
||||
s.calls.filter((c) => c.url === '/api/v1/devices/bulk-delete')
|
||||
|
||||
/**
|
||||
* Кнопка підтвердження внизу вікна.
|
||||
*
|
||||
* Саме за точним підписом: картка-перемикач «Видалити назавжди» теж має
|
||||
* role=button, і збіг за підрядком узяв би її. Тест, який натискає
|
||||
* перемикач замість підтвердження, лишався б зеленим, нічого не
|
||||
* перевіряючи.
|
||||
*/
|
||||
const purgeBtn = () => screen.getByRole('button', { name: /^Видалити назавжди \d+ хост(и|ів)?$/ })
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
const { session } = await import('../api/session')
|
||||
session.set('tok', { tenantID: 't-1', permissions: ['*'] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Видалення хоста: архів проти повного
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('видалення хоста', () => {
|
||||
it('поки перелік не звірено з сервером, натиснути не можна', async () => {
|
||||
// Набір береться не зі сторінки, а з сервера: сторінка могла
|
||||
// постаріти, а тут із неї роблять підтвердження.
|
||||
renderDelete([target('d-1', 'core-sw')], {})
|
||||
expect(screen.getAllByText('Звіряємо перелік…').length).toBeGreaterThan(0)
|
||||
expect(screen.getByRole('button', { name: /^Видалити назавжди 0 хостів$/ })).toBeDisabled()
|
||||
// Коли набір приїхав, число на кнопці стає справжнім.
|
||||
expect(await screen.findByRole('button', { name: /^Видалити назавжди 1 хост$/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('повне видалення заблоковане, доки не підтверджено втрату зібраного', async () => {
|
||||
const { server } = renderDelete([target('d-1', 'core-sw')], {})
|
||||
|
||||
const confirm = await screen.findByRole('checkbox')
|
||||
const btn = purgeBtn()
|
||||
// Галочки не поставлено — кнопка мертва, і причина написана
|
||||
// словами, а не мовчазним disabled.
|
||||
expect(btn).toBeDisabled()
|
||||
expect(screen.getByText('Підтвердьте, що зібране можна стерти назавжди')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(confirm)
|
||||
expect(btn).toBeEnabled()
|
||||
// До натискання жодного запиту на видалення не пішло.
|
||||
expect(bulkDeletes(server)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('без зібраного галочки не питають — питати нема про що', async () => {
|
||||
// Негативна половина попереднього. Зайва галочка на хості, у якого
|
||||
// нічого немає, привчає ставити її не читаючи — і тоді вона
|
||||
// перестає працювати там, де потрібна.
|
||||
renderDelete([target('d-1', 'new-sw', false)], {})
|
||||
await screen.findByRole('button', { name: /^Видалити назавжди 1 хост$/ })
|
||||
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
|
||||
expect(purgeBtn()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('типово обрано ПОВНЕ видалення, і саме воно їде на сервер', async () => {
|
||||
// «Видалити» в інтерфейсі означає видалити. Якби типовим був архів,
|
||||
// людина, яка не помітила вибору, отримувала б не те, чого просила,
|
||||
// — і дізнавалась би про це лише тоді, коли місце на диску не
|
||||
// звільнилось.
|
||||
const { server, onDone } = renderDelete([target('d-1', 'core-sw')], {
|
||||
'POST /api/v1/devices/bulk-delete': {
|
||||
mode: 'purge',
|
||||
deleted: 1,
|
||||
names: ['core-sw'],
|
||||
dropped: [],
|
||||
purged: purgeResult,
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox'))
|
||||
fireEvent.click(purgeBtn())
|
||||
|
||||
await waitFor(() => expect(bulkDeletes(server)).toHaveLength(1))
|
||||
expect(bulkDeletes(server)[0].body).toEqual({
|
||||
filter: emptyFilter,
|
||||
device_ids: ['d-1'],
|
||||
mode: 'purge',
|
||||
})
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('вибір «прибрати з переліку» міняє і підпис кнопки, і режим у запиті', async () => {
|
||||
const { server, onClose } = renderDelete([target('d-1', 'core-sw')], {
|
||||
'POST /api/v1/devices/bulk-delete': {
|
||||
mode: 'archive',
|
||||
deleted: 1,
|
||||
names: ['core-sw'],
|
||||
dropped: [],
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Прибрати з переліку/ }))
|
||||
// Підпис кнопки має називати те, що станеться. «Видалити» над
|
||||
// архівуванням — найдешевший спосіб зробити дію не тією, якої
|
||||
// очікували.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Прибрати з переліку 1 хост$/ }))
|
||||
|
||||
await waitFor(() => expect(bulkDeletes(server)).toHaveLength(1))
|
||||
expect(bulkDeletes(server)[0].body).toMatchObject({ mode: 'archive' })
|
||||
// Архівне видалення нічого не додає понад прочитане — вікно
|
||||
// закривається.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('над архівом вибору немає: архівувати вдруге нема куди', async () => {
|
||||
renderDelete([target('d-1', 'core-sw')], {}, { archived: true })
|
||||
await screen.findByRole('button', { name: /^Видалити назавжди 1 хост$/ })
|
||||
expect(screen.queryByRole('button', { name: /Прибрати з переліку/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('подвійний клік НЕ шле другого запиту на видалення', async () => {
|
||||
// Другий запит іде вже по тому, чого немає: у журналі аудиту
|
||||
// лишається дві дії, а в підтримці — питання «чому воно видалилось
|
||||
// двічі».
|
||||
let release!: () => void
|
||||
const held = new Promise<void>((r) => (release = r))
|
||||
const { server } = renderDelete([target('d-1', 'core-sw')], {
|
||||
'POST /api/v1/devices/bulk-delete': async () => {
|
||||
await held
|
||||
return res(200, {
|
||||
mode: 'purge',
|
||||
deleted: 1,
|
||||
names: ['core-sw'],
|
||||
dropped: [],
|
||||
purged: purgeResult,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox'))
|
||||
const btn = purgeBtn()
|
||||
fireEvent.click(btn)
|
||||
fireEvent.click(btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Виконуємо…' })).toBeDisabled())
|
||||
expect(bulkDeletes(server)).toHaveLength(1)
|
||||
release()
|
||||
// І після завершення другого запиту так і не з'явилось.
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Виконуємо…' })).toBeNull())
|
||||
expect(bulkDeletes(server)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('помилка сервера НЕ закриває вікно й показує причину', async () => {
|
||||
// Закрите вікно читається як «виконано». Найдорожча з тихих
|
||||
// поломок: людина йде далі, впевнена, що хоста немає.
|
||||
const { server, onClose } = renderDelete([target('d-1', 'core-sw')], {
|
||||
'POST /api/v1/devices/bulk-delete': () =>
|
||||
errRes(403, 'forbidden', 'потрібне право ncm:delete: у хостів є збережені конфіги'),
|
||||
})
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox'))
|
||||
fireEvent.click(purgeBtn())
|
||||
|
||||
expect(
|
||||
await screen.findByText('потрібне право ncm:delete: у хостів є збережені конфіги'),
|
||||
).toBeInTheDocument()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(bulkDeletes(server)).toHaveLength(1)
|
||||
// Кнопка знову жива: після виправлення причини дію можна повторити,
|
||||
// не збираючи набір заново.
|
||||
expect(purgeBtn()).toBeEnabled()
|
||||
})
|
||||
|
||||
it('порожній набір не можна видалити навіть із поставленою галочкою', async () => {
|
||||
// Сервер відсіює недоступні на запис хости. Якщо не лишилось
|
||||
// жодного, дія має бути неможливою, а не «успішною» з нулем.
|
||||
renderDelete([], {})
|
||||
await screen.findByText('Немає жодного хоста, доступного на запис')
|
||||
expect(screen.getByRole('button', { name: /^Видалити назавжди 0 хостів$/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('перелік показує ІМЕНА, а не лише кількість', async () => {
|
||||
// Кількість підтверджує намір, перелік підтверджує вибір.
|
||||
renderDelete([target('d-1', 'core-sw'), target('d-2', 'dist-sw')], {}, {
|
||||
devices: [device('d-1', 'core-sw'), device('d-2', 'dist-sw')],
|
||||
})
|
||||
expect(await screen.findByText('core-sw')).toBeInTheDocument()
|
||||
expect(screen.getByText('dist-sw')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Відкат конфігу
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
function version(id: string, at: string): ConfigVersion {
|
||||
return {
|
||||
id,
|
||||
device_id: 'd-1',
|
||||
config_type: 'running',
|
||||
size_bytes: 4096,
|
||||
line_count: 120,
|
||||
content_hash: 'h-' + id,
|
||||
lines_added: 3,
|
||||
lines_removed: 1,
|
||||
is_change: true,
|
||||
collected_at: at,
|
||||
}
|
||||
}
|
||||
|
||||
function plan(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
device_id: 'd-1',
|
||||
device_name: 'core-sw',
|
||||
target_config_id: 'c-old',
|
||||
target_collected_at: '2026-08-01T10:00:00Z',
|
||||
base_config_id: 'c-new',
|
||||
base_collected_at: '2026-08-20T10:00:00Z',
|
||||
profile_name: 'Cisco IOS',
|
||||
supported: true,
|
||||
commands: ['configure terminal', 'no vlan 200', 'end'],
|
||||
manual: [],
|
||||
commit_command: 'write memory',
|
||||
added: 1,
|
||||
removed: 2,
|
||||
requires_approval: false,
|
||||
agent_online: true,
|
||||
plan_hash: 'hash-1',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function renderRollback(routes: Record<string, unknown>) {
|
||||
const server = fetchRouter({
|
||||
'POST /api/v1/ncm/rollbacks/plan': plan(),
|
||||
'GET /api/v1/configs/c-new': { body: 'vlan 100\nvlan 200\n' },
|
||||
'GET /api/v1/configs/c-old': { body: 'vlan 100\n' },
|
||||
...routes,
|
||||
})
|
||||
const onClose = vi.fn()
|
||||
const onCreated = vi.fn()
|
||||
render(
|
||||
<RollbackDialog
|
||||
device={device('d-1', 'core-sw')}
|
||||
target={version('c-old', '2026-08-01T10:00:00Z')}
|
||||
current={version('c-new', '2026-08-20T10:00:00Z')}
|
||||
onClose={onClose}
|
||||
onCreated={onCreated}
|
||||
/>,
|
||||
)
|
||||
return { server, onClose, onCreated }
|
||||
}
|
||||
|
||||
const creates = (s: { calls: Call[] }) =>
|
||||
s.calls.filter((c) => c.method === 'POST' && c.url === '/api/v1/ncm/rollbacks')
|
||||
|
||||
describe('відкат конфігу', () => {
|
||||
it('поки план не приїхав, підтвердити не можна', async () => {
|
||||
renderRollback({})
|
||||
expect(screen.getByText('план ще будується')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Відкотити' })).toBeDisabled()
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('план ще будується')).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('профіль без підтримки відкату не дає підтвердити, але різницю показує', async () => {
|
||||
// Мовчазна кнопка, яка «щось не спрацювала», гірша за чесну
|
||||
// відмову: після неї шукають поломку в системі замість того, щоб
|
||||
// узятись за конфіг руками.
|
||||
renderRollback({
|
||||
'POST /api/v1/ncm/rollbacks/plan': plan({ supported: false, note: 'профіль без шаблону' }),
|
||||
})
|
||||
expect(
|
||||
await screen.findByText('для цього профілю відкат не налаштований'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Відкотити' })).toBeDisabled()
|
||||
expect(screen.getByText('Для цього профілю відкат не налаштований')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('рядки, які лишаться на пристрої, треба підтвердити окремо', async () => {
|
||||
// Це найтихіша частина відкату: система прибрати їх не може, і
|
||||
// після «успішного» відкату конфіг усе одно не той, що очікували.
|
||||
const { server } = renderRollback({
|
||||
'POST /api/v1/ncm/rollbacks/plan': plan({ manual: ['aaa new-model'] }),
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('підтвердіть рядки, які лишаться на пристрої'),
|
||||
).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Відкотити' })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox'))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Відкотити' })).toBeEnabled())
|
||||
expect(creates(server)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('подвійний клік не створює двох намірів', async () => {
|
||||
let release!: () => void
|
||||
const held = new Promise<void>((r) => (release = r))
|
||||
const { server, onCreated } = renderRollback({
|
||||
'POST /api/v1/ncm/rollbacks': async () => {
|
||||
await held
|
||||
return res(200, { id: 'rb-1', device_id: 'd-1', status: 'approved' })
|
||||
},
|
||||
})
|
||||
|
||||
const btn = await screen.findByRole('button', { name: 'Відкотити' })
|
||||
await waitFor(() => expect(btn).toBeEnabled())
|
||||
fireEvent.click(btn)
|
||||
fireEvent.click(btn)
|
||||
fireEvent.click(btn)
|
||||
|
||||
expect(creates(server)).toHaveLength(1)
|
||||
expect(creates(server)[0].body).toMatchObject({
|
||||
device_id: 'd-1',
|
||||
target_config_id: 'c-old',
|
||||
// Підпис плану їде назад: сервер будує план заново й звіряє його.
|
||||
// Без нього між переглядом і натисканням міг би пройти новий збір.
|
||||
plan_hash: 'hash-1',
|
||||
})
|
||||
release()
|
||||
await waitFor(() => expect(onCreated).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('помилка сервера лишає вікно відкритим і зберігає набрану причину', async () => {
|
||||
const { onClose, onCreated } = renderRollback({
|
||||
'POST /api/v1/ncm/rollbacks': () =>
|
||||
errRes(409, 'plan_stale', 'конфіг змінився — перегляньте різницю заново'),
|
||||
})
|
||||
|
||||
const btn = await screen.findByRole('button', { name: 'Відкотити' })
|
||||
await waitFor(() => expect(btn).toBeEnabled())
|
||||
const reason = screen.getByPlaceholderText(
|
||||
'напр. після нічної правки впав OSPF з ядром',
|
||||
) as HTMLInputElement
|
||||
fireEvent.change(reason, { target: { value: 'аварія на кільці' } })
|
||||
fireEvent.click(btn)
|
||||
|
||||
// `plan_stale` перекладається на людську мову: сирий код сервера
|
||||
// тут нічого не пояснює, а дія, яку треба зробити, — інша (закрити
|
||||
// й відкрити вікно заново), і сказати про неї має саме вікно.
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/Конфіг на пристрої змінився, поки ви дивились на різницю/,
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(onCreated).not.toHaveBeenCalled()
|
||||
// Набране лишилось на місці: людина може повторити дію, не
|
||||
// згадуючи, що вона писала.
|
||||
expect(reason.value).toBe('аварія на кільці')
|
||||
})
|
||||
|
||||
it('коли потрібне погодження, кнопка називає саме це', async () => {
|
||||
// Підпис «Відкотити» на дії, яка нічого не відкотить до чужого
|
||||
// натискання, — обіцянка, якої вікно не виконує.
|
||||
renderRollback({
|
||||
'POST /api/v1/ncm/rollbacks/plan': plan({ requires_approval: true }),
|
||||
})
|
||||
expect(await screen.findByRole('button', { name: 'Подати на погодження' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Відкотити' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
182
web/src/test/hooks.test.tsx
Normal file
182
web/src/test/hooks.test.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import { liveEvents } from '../api/ws'
|
||||
import { session } from '../api/session'
|
||||
import { useAlerts } from '../hooks/useAlerts'
|
||||
import { isAlertEvent, useLiveRefresh } from '../hooks/useLiveRefresh'
|
||||
import { fetchRouter, type Call } from './support'
|
||||
import type { WsMessage } from '../types'
|
||||
|
||||
/**
|
||||
* Хуки живих даних: `useLiveRefresh` і `useAlerts`.
|
||||
*
|
||||
* ПРИЧИНА, чому це важливо саме тут: падіння комутатора піднімає
|
||||
* десятки алертів за один тік движка, і кожен приходить окремим
|
||||
* повідомленням. Хук зливає сплеск в один перечит. Поломка злиття не
|
||||
* видно в інтерфейсі взагалі — видно її на сервері, і рівно тоді, коли
|
||||
* він найзавантаженіший: клієнт додає десятки однакових запитів у
|
||||
* момент аварії.
|
||||
*
|
||||
* Зворотна поломка так само тиха: перечиту немає взагалі, лічильник у
|
||||
* шапці замерз, і NOC-екран показує спокій під час аварії.
|
||||
*/
|
||||
|
||||
function Probe({ onEvent, delay }: { onEvent: () => void; delay?: number }) {
|
||||
useLiveRefresh(isAlertEvent, onEvent, delay)
|
||||
return null
|
||||
}
|
||||
|
||||
function AlertsProbe({ onState }: { onState: (s: ReturnType<typeof useAlerts>) => void }) {
|
||||
onState(useAlerts())
|
||||
return null
|
||||
}
|
||||
|
||||
const emit = (msg: WsMessage) => act(() => liveEvents.emit(msg))
|
||||
|
||||
const fired = (id: string): WsMessage => ({
|
||||
type: 'alert.fired',
|
||||
payload: { alert_id: id, device_id: 'd-1', severity: 'high', title: 'впав', state: 'firing' },
|
||||
})
|
||||
|
||||
const alertsBody = {
|
||||
alerts: [],
|
||||
counts: { firing: 3, acknowledged: 0, suppressed: 0, disaster: 0, high: 3 },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('useLiveRefresh', () => {
|
||||
it('сплеск подій дає ОДИН перечит, а не по одному на подію', async () => {
|
||||
const reload = vi.fn()
|
||||
render(<Probe onEvent={reload} />)
|
||||
|
||||
for (let i = 0; i < 40; i++) emit(fired(`a-${i}`))
|
||||
// Поки вікно злиття не вийшло — жодного запиту.
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('другий сплеск після паузи перечитує ще раз', async () => {
|
||||
// Негативна половина: злиття не має перетворитись на «перечитати
|
||||
// один раз і більше ніколи». Замерзлий екран виглядає справним.
|
||||
const reload = vi.fn()
|
||||
render(<Probe onEvent={reload} />)
|
||||
|
||||
emit(fired('a-1'))
|
||||
await act(async () => vi.advanceTimersByTime(400))
|
||||
emit(fired('a-2'))
|
||||
await act(async () => vi.advanceTimersByTime(400))
|
||||
expect(reload).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('чужі повідомлення перечиту не викликають', async () => {
|
||||
// `map.updated` цікавить полотно, а не лічильник алертів.
|
||||
const reload = vi.fn()
|
||||
render(<Probe onEvent={reload} />)
|
||||
emit({ type: 'map.updated', map_id: 'm-1', revision: 3 })
|
||||
emit({ type: 'hello', tenant_id: 't-1' })
|
||||
await act(async () => vi.advanceTimersByTime(1000))
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('розмонтування знімає підписку й скасовує вже заплановане', async () => {
|
||||
// Інакше після переходу на іншу сторінку спрацював би перечит
|
||||
// компонента, якого вже немає, — і React лаявся б на оновлення
|
||||
// стану розмонтованого.
|
||||
const reload = vi.fn()
|
||||
const view = render(<Probe onEvent={reload} />)
|
||||
emit(fired('a-1'))
|
||||
view.unmount()
|
||||
await act(async () => vi.advanceTimersByTime(1000))
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
|
||||
emit(fired('a-2'))
|
||||
await act(async () => vi.advanceTimersByTime(1000))
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isAlertEvent бере всі alert.*, і лише їх', () => {
|
||||
expect(isAlertEvent({ type: 'alert.fired' } as WsMessage)).toBe(true)
|
||||
expect(isAlertEvent({ type: 'alert.resolved' } as WsMessage)).toBe(true)
|
||||
expect(isAlertEvent({ type: 'alert.updated' } as WsMessage)).toBe(true)
|
||||
expect(isAlertEvent({ type: 'map.updated' } as WsMessage)).toBe(false)
|
||||
expect(isAlertEvent({ type: 'device.status' } as WsMessage)).toBe(false)
|
||||
// Повідомлення без типу приходить від пошкодженого потоку; воно не
|
||||
// має ні падати, ні вважатись алертом.
|
||||
expect(isAlertEvent({} as WsMessage)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAlerts', () => {
|
||||
it('без alerts:read запиту немає взагалі, і завантаження не висне', async () => {
|
||||
// Спінер, що крутиться вічно, читається як «сервер не відповідає».
|
||||
session.set('tok', { tenantID: 't-1', permissions: ['devices:read'] })
|
||||
const server = fetchRouter({ 'GET /api/v1/alerts': alertsBody })
|
||||
|
||||
let state!: ReturnType<typeof useAlerts>
|
||||
render(<AlertsProbe onState={(s) => (state = s)} />)
|
||||
await waitFor(() => expect(state.loading).toBe(false))
|
||||
|
||||
expect(server.calls.filter((c: Call) => c.url === '/api/v1/alerts')).toHaveLength(0)
|
||||
expect(state.counts.firing).toBe(0)
|
||||
})
|
||||
|
||||
it('із alerts:read лічильники приїжджають', async () => {
|
||||
session.set('tok', { tenantID: 't-1', permissions: ['alerts:read'] })
|
||||
fetchRouter({ 'GET /api/v1/alerts': alertsBody })
|
||||
|
||||
let state!: ReturnType<typeof useAlerts>
|
||||
render(<AlertsProbe onState={(s) => (state = s)} />)
|
||||
await waitFor(() => expect(state.counts.firing).toBe(3))
|
||||
})
|
||||
|
||||
it('подія по сокету перечитує перелік — але один раз на сплеск', async () => {
|
||||
session.set('tok', { tenantID: 't-1', permissions: ['alerts:read'] })
|
||||
const server = fetchRouter({ 'GET /api/v1/alerts': alertsBody })
|
||||
const of = () => server.calls.filter((c: Call) => c.url === '/api/v1/alerts')
|
||||
|
||||
let state!: ReturnType<typeof useAlerts>
|
||||
render(<AlertsProbe onState={(s) => (state = s)} />)
|
||||
await waitFor(() => expect(of()).toHaveLength(1))
|
||||
|
||||
for (let i = 0; i < 15; i++) emit(fired(`a-${i}`))
|
||||
await act(async () => vi.advanceTimersByTime(400))
|
||||
await waitFor(() => expect(of()).toHaveLength(2))
|
||||
expect(state.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('без права подія по сокету перечиту не викликає', async () => {
|
||||
// Негативна половина: інакше глядач без alerts:read сипав би 403 на
|
||||
// кожен алерт у мережі.
|
||||
session.set('tok', { tenantID: 't-1', permissions: ['devices:read'] })
|
||||
const server = fetchRouter({ 'GET /api/v1/alerts': alertsBody })
|
||||
|
||||
render(<AlertsProbe onState={() => {}} />)
|
||||
emit(fired('a-1'))
|
||||
await act(async () => vi.advanceTimersByTime(400))
|
||||
expect(server.calls.filter((c: Call) => c.url === '/api/v1/alerts')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('помилка сервера показується, а не ковтається', async () => {
|
||||
session.set('tok', { tenantID: 't-1', permissions: ['alerts:read'] })
|
||||
fetchRouter({})
|
||||
|
||||
let state!: ReturnType<typeof useAlerts>
|
||||
render(<AlertsProbe onState={(s) => (state = s)} />)
|
||||
await waitFor(() => expect(state.error).toBeTruthy())
|
||||
expect(state.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
527
web/src/test/pagepermissions.test.tsx
Normal file
527
web/src/test/pagepermissions.test.tsx
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Outlet, Route, Routes } from 'react-router-dom'
|
||||
import type { ReactNode } from 'react'
|
||||
import { DevicesPage } from '../pages/DevicesPage'
|
||||
import { TeamPage } from '../pages/TeamPage'
|
||||
import { RolesPage } from '../pages/RolesPage'
|
||||
import { AlertsPage } from '../pages/AlertsPage'
|
||||
import { session } from '../api/session'
|
||||
import { errRes, fetchRouter, res, type Call } from './support'
|
||||
import type { Permission } from '../types'
|
||||
|
||||
/**
|
||||
* Права доступу на сторінках.
|
||||
*
|
||||
* З ROADMAP, дослівно: «кнопка, яка завжди дає 403, гірша за її
|
||||
* відсутність». Продукт ховає дії, на які немає права, — і саме тому
|
||||
* помилка тут двостороння й тиха:
|
||||
*
|
||||
* - **показали зайве** — людина натискає, дістає 403 і йде питати, що
|
||||
* вона зробила не так; насправді нічого;
|
||||
* - **сховали потрібне** — людина впевнена, що продукт цього не вміє, і
|
||||
* робить це руками повз систему. Про таку поломку не повідомляють
|
||||
* ніколи: вона не схожа на поломку.
|
||||
*
|
||||
* Тому кожна перевірка тут — пара: із урізаним набором прав дії немає,
|
||||
* з повним — є. Один бік окремо не доводить нічого. Саме на цьому й
|
||||
* погорів тест ізоляції RLS: перевіряв «чи не видно чужого», коли
|
||||
* зламалось «чи видно своє».
|
||||
*
|
||||
* Сервер однаково перевіряє права ще раз — тут ідеться лише про те, що
|
||||
* бачить людина.
|
||||
*/
|
||||
|
||||
const emptyLive = {
|
||||
alerts: [],
|
||||
counts: { firing: 0, acknowledged: 0, suppressed: 0, disaster: 0, high: 0 },
|
||||
loading: false,
|
||||
error: null,
|
||||
reload: async () => {},
|
||||
ack: async () => {},
|
||||
close: async () => {},
|
||||
mute: async () => {},
|
||||
}
|
||||
|
||||
function alert(id: string, state = 'firing') {
|
||||
return {
|
||||
id,
|
||||
rule_name: 'Хост недоступний',
|
||||
device_id: 'd-1',
|
||||
device_name: 'core-sw',
|
||||
severity: 'high',
|
||||
state,
|
||||
title: 'core-sw недоступний',
|
||||
dedup_key: 'k-' + id,
|
||||
started_at: '2026-08-27T10:00:00Z',
|
||||
last_seen_at: '2026-08-27T10:05:00Z',
|
||||
notify_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/** Сторінка під оболонкою: DevicesPage і AlertsPage читають її контекст. */
|
||||
function withShell(page: ReactNode, live: unknown = emptyLive) {
|
||||
return (
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes>
|
||||
<Route element={<Outlet context={live} />}>
|
||||
<Route path="/" element={page} />
|
||||
<Route path="/devices/:id" element={<div>картка хоста</div>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Хто зараз дивиться на сторінку.
|
||||
*
|
||||
* Без `vi.resetModules()` навмисно: сторінки імпортовані статично й
|
||||
* тримають посилання на ТОЙ САМИЙ модуль сесії. Скидання модулів дало б
|
||||
* їм один екземпляр, а тесту — інший, і будь-який набір прав виглядав
|
||||
* би як порожній. Перевірка «з правом кнопка є» при цьому лишалась би
|
||||
* червоною, а от «без права кнопки немає» — зеленою завжди, нічого не
|
||||
* перевіряючи.
|
||||
*/
|
||||
function as(permissions: Permission[]) {
|
||||
session.set('tok', {
|
||||
userID: 'u-me',
|
||||
username: 'me',
|
||||
tenantID: 't-1',
|
||||
permissions,
|
||||
})
|
||||
}
|
||||
|
||||
const devicesRoutes = {
|
||||
'GET /api/v1/devices': { devices: [] },
|
||||
'GET /api/v1/device-groups': { groups: [] },
|
||||
'GET /api/v1/agents': { agents: [] },
|
||||
'GET /api/v1/check-types': { check_types: [] },
|
||||
'GET /api/v1/credentials': { credentials: [] },
|
||||
}
|
||||
|
||||
const teamRoutes = {
|
||||
'GET /api/v1/team': {
|
||||
members: [
|
||||
{
|
||||
user_id: 'u-me',
|
||||
username: 'me',
|
||||
role_id: 'r-admin',
|
||||
role_key: 'admin',
|
||||
role_name: 'Адміністратор',
|
||||
},
|
||||
{
|
||||
user_id: 'u-own',
|
||||
username: 'boss',
|
||||
role_id: 'r-own',
|
||||
role_key: 'owner',
|
||||
role_name: 'Власник',
|
||||
},
|
||||
{
|
||||
user_id: 'u-noc',
|
||||
username: 'noc',
|
||||
role_id: 'r-noc',
|
||||
role_key: 'viewer',
|
||||
role_name: 'Глядач',
|
||||
},
|
||||
],
|
||||
},
|
||||
'GET /api/v1/roles': {
|
||||
roles: [
|
||||
{ id: 'r-noc', key: 'viewer', name: 'Глядач', is_system: true, permissions: ['devices:read'] },
|
||||
{
|
||||
id: 'r-admin',
|
||||
key: 'admin',
|
||||
name: 'Адміністратор',
|
||||
is_system: true,
|
||||
permissions: ['devices:read', 'users:write'],
|
||||
},
|
||||
],
|
||||
},
|
||||
'GET /api/v1/permissions': {
|
||||
groups: [
|
||||
{
|
||||
key: 'ncm',
|
||||
title: 'Конфіги',
|
||||
permissions: [
|
||||
{ key: 'ncm:read', description: 'Дивитись архів конфігів', group: 'ncm' },
|
||||
{ key: 'ncm:exec', description: 'Виконувати команди на залізі', group: 'ncm', sensitive: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const nothingSent = (s: { calls: Call[] }) =>
|
||||
s.calls.filter((c) => c.method !== 'GET').length === 0
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('WebSocket', class {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Хости
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('хости', () => {
|
||||
it('без devices:write немає ні створення, ні масової дії', async () => {
|
||||
as(['devices:read'])
|
||||
fetchRouter(devicesRoutes)
|
||||
render(withShell(<DevicesPage />))
|
||||
|
||||
await screen.findByText('Хости')
|
||||
expect(screen.queryByRole('button', { name: '+ Хост' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Обрати' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('із devices:write обидві дії на місці', async () => {
|
||||
// Друга половина. Без неї перша була б зелена й тоді, коли кнопки
|
||||
// зникли для всіх — наприклад, після помилки в читанні прав.
|
||||
as(['devices:read', 'devices:write'])
|
||||
fetchRouter(devicesRoutes)
|
||||
render(withShell(<DevicesPage />))
|
||||
|
||||
expect(await screen.findByRole('button', { name: '+ Хост' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Обрати' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('без agents:read перелік зондів не питається взагалі', async () => {
|
||||
// Не «запит зробили й проковтнули 403», а не зробили: зайвий 403 у
|
||||
// журналі сервера виглядає як спроба обійти права, і розбирати його
|
||||
// доведеться людині.
|
||||
as(['devices:read'])
|
||||
const server = fetchRouter(devicesRoutes)
|
||||
render(withShell(<DevicesPage />))
|
||||
await screen.findByText('Хости')
|
||||
expect(server.calls.filter((c) => c.url === '/api/v1/agents')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('із agents:read перелік зондів питається', async () => {
|
||||
as(['devices:read', 'agents:read'])
|
||||
const server = fetchRouter(devicesRoutes)
|
||||
render(withShell(<DevicesPage />))
|
||||
await screen.findByText('Хости')
|
||||
await waitFor(() =>
|
||||
expect(server.calls.filter((c) => c.url === '/api/v1/agents')).toHaveLength(1),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Користувачі
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('користувачі', () => {
|
||||
it('без users:write сторінка лише показує — жодної дії над учасником', async () => {
|
||||
as(['users:read'])
|
||||
const server = fetchRouter(teamRoutes)
|
||||
render(withShell(<TeamPage />))
|
||||
|
||||
await screen.findByText('boss')
|
||||
expect(screen.queryByRole('button', { name: '+ Користувач' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Змінити' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Прибрати' })).not.toBeInTheDocument()
|
||||
expect(nothingSent(server)).toBe(true)
|
||||
})
|
||||
|
||||
it('із users:write дії з’являються', async () => {
|
||||
as(['users:read', 'users:write'])
|
||||
fetchRouter(teamRoutes)
|
||||
render(withShell(<TeamPage />))
|
||||
|
||||
expect(await screen.findByRole('button', { name: '+ Користувач' })).toBeInTheDocument()
|
||||
// DataTable малює кожен рядок двічі (таблиця + картка), тому
|
||||
// рахуємо не кількість кнопок, а сам факт їх наявності.
|
||||
expect(screen.getAllByRole('button', { name: 'Змінити' }).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('себе й власника прибрати не можна навіть із users:write', async () => {
|
||||
// Обидва запобіжники рятують від одного й того самого — організації
|
||||
// без жодного власника. Сервер їх теж тримає (API.md, «GET|POST|
|
||||
// PATCH|DELETE /api/v1/team»), але кнопка, яка гарантовано дасть
|
||||
// відмову, тут така сама шкідлива, як і будь-де.
|
||||
as(['users:read', 'users:write'])
|
||||
fetchRouter(teamRoutes)
|
||||
render(withShell(<TeamPage />))
|
||||
|
||||
await screen.findByText('boss')
|
||||
const rows = screen.getAllByRole('row')
|
||||
const rowOf = (name: string) => rows.find((r) => r.textContent?.includes(name))!
|
||||
expect(rowOf('boss').querySelector('button[title="Прибрати з організації"]')).toBeNull()
|
||||
expect(rowOf('me').querySelector('button[title="Прибрати з організації"]')).toBeNull()
|
||||
// А над звичайним учасником — є. Без цієї половини попередні дві
|
||||
// перевірки були б зелені й тоді, коли кнопка зникла в усіх рядках.
|
||||
expect(rowOf('noc').querySelector('button[title="Прибрати з організації"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('прибирання учасника йде через підтвердження, а не одразу', async () => {
|
||||
as(['users:read', 'users:write'])
|
||||
const server = fetchRouter({
|
||||
...teamRoutes,
|
||||
'DELETE /api/v1/team/u-noc': {},
|
||||
})
|
||||
render(withShell(<TeamPage />))
|
||||
|
||||
await screen.findByText('noc')
|
||||
const row = screen.getAllByRole('row').find((r) => r.textContent?.includes('noc'))!
|
||||
fireEvent.click(row.querySelector('button[title="Прибрати з організації"]')!)
|
||||
|
||||
// Вікно з наслідками, і поки воно відкрите — жодного запиту.
|
||||
expect(await screen.findByRole('dialog')).toHaveAccessibleName('Прибрати з організації')
|
||||
expect(
|
||||
screen.getByText(/Усі його сесії тут буде відкликано негайно/),
|
||||
).toBeInTheDocument()
|
||||
expect(server.calls.filter((c) => c.method === 'DELETE')).toHaveLength(0)
|
||||
|
||||
// Кнопку беремо саме з вікна: у переліку є однойменна, і тест, що
|
||||
// натиснув би її, перевіряв би відкриття вікна вдруге.
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Прибрати' }))
|
||||
await waitFor(() =>
|
||||
expect(server.calls.filter((c) => c.url === '/api/v1/team/u-noc')).toHaveLength(1),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Ролі
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('ролі', () => {
|
||||
it('без users:write ролі можна лише читати', async () => {
|
||||
as(['users:read'])
|
||||
fetchRouter(teamRoutes)
|
||||
render(withShell(<RolesPage />))
|
||||
|
||||
await screen.findByText('Ролі та права')
|
||||
expect(screen.queryByRole('button', { name: '+ Роль' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('право, якого немає в тебе самого, віддати не можна', async () => {
|
||||
// Найтихіша з поломок цього екрана. Якби галочка була доступною,
|
||||
// людина склала б роль, натиснула «Зберегти» й дістала відмову
|
||||
// сервера над уже набраною формою — замість того, щоб побачити межу
|
||||
// одразу.
|
||||
as(['users:read', 'users:write', 'ncm:read'])
|
||||
fetchRouter(teamRoutes)
|
||||
render(withShell(<RolesPage />))
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '+ Роль' }))
|
||||
await screen.findByRole('dialog')
|
||||
|
||||
const boxOf = (key: string) =>
|
||||
screen
|
||||
.getByText(key)
|
||||
.closest('label')!
|
||||
.querySelector('input[type=checkbox]') as HTMLInputElement
|
||||
|
||||
// ncm:read у мене є — віддати можу.
|
||||
expect(boxOf('ncm:read')).toBeEnabled()
|
||||
// ncm:exec не маю — і галочка мертва, з поясненням у підказці.
|
||||
expect(boxOf('ncm:exec')).toBeDisabled()
|
||||
expect(boxOf('ncm:exec').closest('label')).toHaveAttribute(
|
||||
'title',
|
||||
'Ви не можете надати право, якого немає у вас самих',
|
||||
)
|
||||
})
|
||||
|
||||
it('із «*» доступні всі права', async () => {
|
||||
// Друга половина: перевірка вище має ловити «зайве заблоковано», а
|
||||
// не «заблоковано все».
|
||||
as(['*'])
|
||||
fetchRouter(teamRoutes)
|
||||
render(withShell(<RolesPage />))
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '+ Роль' }))
|
||||
await screen.findByRole('dialog')
|
||||
const boxOf = (key: string) =>
|
||||
screen.getByText(key).closest('label')!.querySelector('input[type=checkbox]')!
|
||||
expect(boxOf('ncm:exec')).toBeEnabled()
|
||||
expect(boxOf('ncm:read')).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Алерти
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('алерти', () => {
|
||||
it('без alerts:ack підтвердити алерт нема чим', async () => {
|
||||
as(['alerts:read'])
|
||||
fetchRouter({})
|
||||
render(withShell(<AlertsPage />, { ...emptyLive, alerts: [alert('a-1')] }))
|
||||
|
||||
await screen.findByText('core-sw недоступний')
|
||||
expect(screen.queryByRole('button', { name: 'Прийняти' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('із alerts:ack кнопка є', async () => {
|
||||
as(['alerts:read', 'alerts:ack'])
|
||||
fetchRouter({})
|
||||
render(withShell(<AlertsPage />, { ...emptyLive, alerts: [alert('a-1')] }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Прийняти' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('уже прийнятий алерт не пропонують прийняти вдруге', async () => {
|
||||
// Другий «Прийняти» нічого не змінює на сервері, але в переліку
|
||||
// читається як «ще не розібрано»: дві людини беруться за одну
|
||||
// проблему, а третя лишається без уваги.
|
||||
as(['alerts:read', 'alerts:ack'])
|
||||
fetchRouter({})
|
||||
render(
|
||||
withShell(<AlertsPage />, {
|
||||
...emptyLive,
|
||||
alerts: [alert('a-1', 'acknowledged')],
|
||||
counts: { firing: 0, acknowledged: 1, suppressed: 0, disaster: 0, high: 1 },
|
||||
}),
|
||||
)
|
||||
|
||||
// Прийняті лежать під власним фільтром: серед активних їх немає, і
|
||||
// це теж перевірка — «Активні» не має показувати розібране.
|
||||
expect(screen.queryByText('core-sw недоступний')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Прийняті/ }))
|
||||
|
||||
await screen.findByText('core-sw недоступний')
|
||||
expect(screen.queryByRole('button', { name: 'Прийняти' })).not.toBeInTheDocument()
|
||||
// Але закрити вручну можна: алерт прийнятий, а не розв'язаний.
|
||||
expect(screen.getAllByRole('button', { name: /^Закрити/ }).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Зміна ролі
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Відкрити картку учасника з переліку. */
|
||||
async function openMember(name: string) {
|
||||
await screen.findByText(name)
|
||||
const row = screen.getAllByRole('row').find((r) => r.textContent?.includes(name))!
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'Змінити' }))
|
||||
return await screen.findByRole('dialog')
|
||||
}
|
||||
|
||||
const roleSelect = (dialog: HTMLElement) =>
|
||||
within(dialog)
|
||||
.getByText('Роль')
|
||||
.closest('label')!
|
||||
.querySelector('select') as HTMLSelectElement
|
||||
|
||||
describe('зміна ролі', () => {
|
||||
it('свою роль змінити не можна', async () => {
|
||||
// Організація без жодного власника через інтерфейс не
|
||||
// відновлюється. Сервер це теж не дозволить (API.md, «три обмеження
|
||||
// вшиті навмисно»), але поле, яке дає обрати й дістати відмову,
|
||||
// читається як поломка продукту, а не як правило.
|
||||
as(['users:read', 'users:write'])
|
||||
fetchRouter(teamRoutes)
|
||||
render(withShell(<TeamPage />))
|
||||
|
||||
const dialog = await openMember('me')
|
||||
expect(roleSelect(dialog)).toBeDisabled()
|
||||
expect(within(dialog).getByText('Свою роль змінити не можна')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('роль власника не змінюється через інтерфейс', async () => {
|
||||
as(['users:read', 'users:write'])
|
||||
fetchRouter(teamRoutes)
|
||||
render(withShell(<TeamPage />))
|
||||
|
||||
const dialog = await openMember('boss')
|
||||
expect(roleSelect(dialog)).toBeDisabled()
|
||||
})
|
||||
|
||||
it('чужу роль змінити можна, і в запит іде ЛИШЕ вона', async () => {
|
||||
// Друга половина двох попередніх: якби поле було мертвим завжди,
|
||||
// вони лишались би зеленими, а роль не змінювалась би нікому.
|
||||
//
|
||||
// «Лише вона» — окрема вимога сервера: порожнє поле означає «не
|
||||
// чіпати», тож форма не має надсилати того, чого не показувала.
|
||||
as(['users:read', 'users:write'])
|
||||
const server = fetchRouter({ ...teamRoutes, 'PATCH /api/v1/team/u-noc': {} })
|
||||
render(withShell(<TeamPage />))
|
||||
|
||||
const dialog = await openMember('noc')
|
||||
const select = roleSelect(dialog)
|
||||
expect(select).toBeEnabled()
|
||||
fireEvent.change(select, { target: { value: 'r-admin' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Зберегти' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(server.calls.filter((c) => c.method === 'PATCH')).toHaveLength(1),
|
||||
)
|
||||
expect(server.calls.find((c) => c.method === 'PATCH')!.body).toEqual({ role_id: 'r-admin' })
|
||||
})
|
||||
|
||||
it('нічого не змінили — запиту немає, вікно просто закривається', async () => {
|
||||
// Порожній PATCH відкликав би сесії людини (зміна пароля робить це
|
||||
// безумовно) або лишив би слід у журналі аудиту про правку, якої не
|
||||
// було.
|
||||
as(['users:read', 'users:write'])
|
||||
const server = fetchRouter({ ...teamRoutes, 'PATCH /api/v1/team/u-noc': {} })
|
||||
render(withShell(<TeamPage />))
|
||||
|
||||
const dialog = await openMember('noc')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Зберегти' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(server.calls.filter((c) => c.method === 'PATCH')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('подвійний клік не шле другої правки', async () => {
|
||||
as(['users:read', 'users:write'])
|
||||
let release!: () => void
|
||||
const held = new Promise<void>((r) => (release = r))
|
||||
const server = fetchRouter({
|
||||
...teamRoutes,
|
||||
'PATCH /api/v1/team/u-noc': async () => {
|
||||
await held
|
||||
return res(200, {})
|
||||
},
|
||||
})
|
||||
render(withShell(<TeamPage />))
|
||||
|
||||
const dialog = await openMember('noc')
|
||||
fireEvent.change(roleSelect(dialog), { target: { value: 'r-admin' } })
|
||||
const save = within(dialog).getByRole('button', { name: 'Зберегти' })
|
||||
fireEvent.click(save)
|
||||
fireEvent.click(save)
|
||||
fireEvent.click(save)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByRole('button', { name: 'Збереження…' })).toBeDisabled(),
|
||||
)
|
||||
expect(server.calls.filter((c) => c.method === 'PATCH')).toHaveLength(1)
|
||||
release()
|
||||
})
|
||||
|
||||
it('відмова сервера лишає вікно відкритим із набраним', async () => {
|
||||
// `409 shared_user` — реальна відмова цього екрана: людина працює
|
||||
// ще в одній організації, і її логін/пошта не належать цій. Вікно,
|
||||
// що закрилось би тут, читалось би як «збережено».
|
||||
as(['users:read', 'users:write'])
|
||||
fetchRouter({
|
||||
...teamRoutes,
|
||||
'PATCH /api/v1/team/u-noc': () =>
|
||||
errRes(409, 'shared_user', 'цей обліковий запис використовується ще в одній організації'),
|
||||
})
|
||||
render(withShell(<TeamPage />))
|
||||
|
||||
const dialog = await openMember('noc')
|
||||
const nameField = within(dialog).getByDisplayValue('noc')
|
||||
fireEvent.change(nameField, { target: { value: 'noc-a' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Зберегти' }))
|
||||
|
||||
expect(
|
||||
await within(dialog).findByText(
|
||||
'цей обліковий запис використовується ще в одній організації',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument()
|
||||
// Набране на місці: інакше правку довелося б набирати заново.
|
||||
expect((nameField as HTMLInputElement).value).toBe('noc-a')
|
||||
})
|
||||
})
|
||||
355
web/src/test/routing.test.tsx
Normal file
355
web/src/test/routing.test.tsx
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { FakeWebSocket, errRes, fetchRouter, loginBody, res } from './support'
|
||||
|
||||
/**
|
||||
* Маршрутизація й захист маршрутів: `App.tsx`.
|
||||
*
|
||||
* Три речі, які тут ламаються тихо.
|
||||
*
|
||||
* 1. **Неавторизований на закритій сторінці.** Якщо форма входу не
|
||||
* з'явиться, людина побачить порожній каркас і вирішить, що зламався
|
||||
* продукт.
|
||||
* 2. **Повернення після входу.** Посилання на картку хоста надсилають
|
||||
* колезі. Якщо після входу відкривається домівка, а не та адреса, за
|
||||
* якою прийшли, — посилання перестає працювати, і ніхто не скаже про
|
||||
* це «зламано», бо сторінка ж відкрилась.
|
||||
* 3. **Право на розділ.** Помилка тут двостороння: або видно те, чого
|
||||
* не можна, або не видно того, що можна. Обидві тихі, тому кожна
|
||||
* перевірка нижче має обидві половини.
|
||||
*
|
||||
* Підмінено лише `fetch` і `WebSocket` — увесь `api/client.ts` працює
|
||||
* справжній. Форма відповідей звірена з `server/API.md` і з обробниками
|
||||
* в `server/internal/httpapi/`.
|
||||
*/
|
||||
|
||||
const emptyAlerts = {
|
||||
alerts: [],
|
||||
counts: { firing: 0, acknowledged: 0, suppressed: 0, disaster: 0, high: 0 },
|
||||
}
|
||||
|
||||
const auditMeta = {
|
||||
actions: [{ key: 'device.delete', label: 'Видалення хоста', group: 'devices' }],
|
||||
object_types: [{ key: 'device', label: 'Хост', group: 'devices' }],
|
||||
actors: [{ id: 'u-1', label: 'admin', kind: 'user' }],
|
||||
bounds: { first: '2026-01-01T00:00:00Z', last: '2026-08-27T00:00:00Z' },
|
||||
blind_spots: ['перегляд конфігів'],
|
||||
max_range_days: 365,
|
||||
}
|
||||
|
||||
const auditPage = {
|
||||
from: '2026-07-28T00:00:00Z',
|
||||
to: '2026-08-27T00:00:00Z',
|
||||
events: [],
|
||||
}
|
||||
|
||||
/** Типовий набір ручок, потрібних оболонці на будь-якій сторінці. */
|
||||
function shell(extra: Record<string, unknown> = {}) {
|
||||
return { 'GET /api/v1/alerts': emptyAlerts, ...extra }
|
||||
}
|
||||
|
||||
async function boot(path: string, routes: Record<string, unknown>) {
|
||||
vi.resetModules()
|
||||
window.history.pushState({}, '', path)
|
||||
const server = fetchRouter(routes)
|
||||
const { default: App } = await import('../App')
|
||||
render(<App />)
|
||||
return server
|
||||
}
|
||||
|
||||
/** Відповідь refresh для людини з рівно цим набором прав. */
|
||||
function refreshAs(permissions: string[]) {
|
||||
return { 'POST /api/v1/auth/refresh': loginBody('tok-1', permissions) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.reset()
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
window.history.pushState({}, '', '/')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Неавторизований
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('неавторизований', () => {
|
||||
it('на закритій адресі бачить форму входу, а не сторінку', async () => {
|
||||
// 401 no_session — саме те, що віддає `handleRefresh`, коли кукі
|
||||
// немає або вона протермінована (`auth.go:172`).
|
||||
const server = await boot('/audit', {
|
||||
'POST /api/v1/auth/refresh': () => errRes(401, 'no_session', 'сесії немає'),
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Увійти' })).toBeInTheDocument()
|
||||
// Половина, без якої перша нічого не доводить: сторінка не просто
|
||||
// «не показана» — вона й даних не питала. Запит журналу з-під
|
||||
// форми входу означав би, що каркас усе-таки змонтувався.
|
||||
expect(server.of('GET /api/v1/audit')).toHaveLength(0)
|
||||
expect(screen.queryByText('Журнал аудиту')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('поки триває відновлення сесії, не показує ні входу, ні сторінки', async () => {
|
||||
// Проміжний екран існує рівно для того, щоб форма входу не
|
||||
// мигнула перед тим, хто насправді залогінений. Мигання читається
|
||||
// як «мене викинуло».
|
||||
let release!: () => void
|
||||
const held = new Promise<void>((r) => (release = r))
|
||||
await boot('/devices', {
|
||||
'POST /api/v1/auth/refresh': () => {
|
||||
void held
|
||||
return res(200, loginBody('tok-1', ['devices:read']))
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('Відновлення сесії…')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Увійти' })).not.toBeInTheDocument()
|
||||
release()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Повернення після входу
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('вхід', () => {
|
||||
it('після входу відкривається ТА САМА адреса, за якою прийшли', async () => {
|
||||
const server = await boot('/team', {
|
||||
'POST /api/v1/auth/refresh': () => errRes(401, 'no_session', 'сесії немає'),
|
||||
'POST /api/v1/auth/login': () => res(200, loginBody('tok-1', ['users:read'])),
|
||||
...shell({
|
||||
'GET /api/v1/team': { members: [] },
|
||||
'GET /api/v1/roles': { roles: [] },
|
||||
'GET /api/v1/permissions': { groups: [] },
|
||||
}),
|
||||
})
|
||||
|
||||
const submit = await screen.findByRole('button', { name: 'Увійти' })
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'admin' } })
|
||||
fireEvent.change(document.querySelector('input[type=password]')!, {
|
||||
target: { value: 'secret' },
|
||||
})
|
||||
fireEvent.click(submit)
|
||||
|
||||
// Саме «Користувачі», а не домівка. Домівка для users:read — теж
|
||||
// /team, тому перевіряємо ще й запит: сторінка справді змонтувалась
|
||||
// і пішла по свої дані.
|
||||
expect(
|
||||
await screen.findByRole('heading', { level: 1, name: 'Користувачі' }),
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() => expect(server.of('GET /api/v1/team')).toHaveLength(1))
|
||||
expect(server.of('POST /api/v1/auth/login')[0].body).toEqual({
|
||||
login: 'admin',
|
||||
password: 'secret',
|
||||
tenant_id: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('невірний пароль не пускає далі й не розкриває, що саме невірне', async () => {
|
||||
// Сервер навмисно не розрізняє невідомий логін і невірний пароль
|
||||
// (`auth.go:78`). Клієнт не має розрізняти теж — інакше захист від
|
||||
// перебору акаунтів зводиться нанівець саме тут.
|
||||
await boot('/devices', {
|
||||
'POST /api/v1/auth/refresh': () => errRes(401, 'no_session', 'сесії немає'),
|
||||
'POST /api/v1/auth/login': () =>
|
||||
errRes(401, 'bad_credentials', 'невірний логін або пароль'),
|
||||
})
|
||||
|
||||
const submit = await screen.findByRole('button', { name: 'Увійти' })
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'admin' } })
|
||||
fireEvent.change(document.querySelector('input[type=password]')!, {
|
||||
target: { value: 'nope' },
|
||||
})
|
||||
fireEvent.click(submit)
|
||||
|
||||
expect(await screen.findByText('Невірний логін або пароль')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Увійти' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Захист маршрутів
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('право на розділ', () => {
|
||||
it('без права сторінка НЕ малюється і даних НЕ питає', async () => {
|
||||
const server = await boot('/audit', {
|
||||
...refreshAs(['devices:read', 'maps:read']),
|
||||
...shell(),
|
||||
})
|
||||
|
||||
expect(await screen.findByText('Розділ недоступний')).toBeInTheDocument()
|
||||
// Назва права — на екрані: людина має знати, що просити в
|
||||
// власника, а не гадати.
|
||||
expect(screen.getByText('audit:read')).toBeInTheDocument()
|
||||
// Головна половина: жодного запиту до журналу. Інакше сторінка
|
||||
// все-таки виконалась, і «недоступно» — лише картинка поверх неї.
|
||||
expect(server.of('GET /api/v1/audit')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('із правом та сама адреса відкривається й тягне дані', async () => {
|
||||
const server = await boot('/audit', {
|
||||
...refreshAs(['audit:read']),
|
||||
...shell({
|
||||
'GET /api/v1/audit/meta': auditMeta,
|
||||
'GET /api/v1/audit': auditPage,
|
||||
}),
|
||||
})
|
||||
|
||||
// Саме заголовок сторінки, а не пункт меню з тією ж назвою: пункт
|
||||
// з'являється й тоді, коли сторінка показала відмову.
|
||||
expect(
|
||||
await screen.findByRole('heading', { level: 1, name: 'Журнал аудиту' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('Розділ недоступний')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(server.of('GET /api/v1/audit?')).toHaveLength(1))
|
||||
})
|
||||
|
||||
it('право на читання не відкриває сусідній розділ', async () => {
|
||||
// `ncm:read` дає /configs, але не /commands: там `ncm:exec`, і
|
||||
// жива команда на залізо — не те саме, що перегляд архіву.
|
||||
await boot('/commands', {
|
||||
...refreshAs(['ncm:read']),
|
||||
...shell(),
|
||||
})
|
||||
expect(await screen.findByText('Розділ недоступний')).toBeInTheDocument()
|
||||
expect(screen.getByText('ncm:exec')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('«*» відкриває розділ, який іменованим правом не відкривався', async () => {
|
||||
const server = await boot('/audit', {
|
||||
...refreshAs(['*']),
|
||||
...shell({
|
||||
'GET /api/v1/audit/meta': auditMeta,
|
||||
'GET /api/v1/audit': auditPage,
|
||||
}),
|
||||
})
|
||||
expect(
|
||||
await screen.findByRole('heading', { level: 1, name: 'Журнал аудиту' }),
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() => expect(server.of('GET /api/v1/audit?')).toHaveLength(1))
|
||||
})
|
||||
|
||||
it('профіль відкритий будь-кому — навіть без жодного права', async () => {
|
||||
// Людина без прав має бачити хоча б, ким вона зайшла, і мати змогу
|
||||
// змінити пароль. Інакше єдиний доступний їй екран — сторінка
|
||||
// відмови.
|
||||
await boot('/profile', {
|
||||
...refreshAs([]),
|
||||
...shell(),
|
||||
})
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Профіль' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Домівка
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('домівка', () => {
|
||||
it('веде на перший доступний розділ, а не на мапу завжди', async () => {
|
||||
// У глядача без maps:read «/» має відкрити те, що йому видно.
|
||||
// Інакше вхід у продукт починається з екрана відмови.
|
||||
const server = await boot('/', {
|
||||
...refreshAs(['alerts:read']),
|
||||
...shell(),
|
||||
})
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/alerts'))
|
||||
expect(server.of('GET /api/v1/maps')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('із правом на мапи веде саме на мапу', async () => {
|
||||
await boot('/', {
|
||||
...refreshAs(['maps:read', 'alerts:read']),
|
||||
...shell({ 'GET /api/v1/maps': { maps: [] } }),
|
||||
})
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/map'))
|
||||
})
|
||||
|
||||
it('без жодного права з переліку веде на профіль, а не в нікуди', async () => {
|
||||
await boot('/', { ...refreshAs(['billing:read']), ...shell() })
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/profile'))
|
||||
})
|
||||
|
||||
it('невідома адреса веде туди ж, куди й домівка', async () => {
|
||||
await boot('/такої-сторінки-немає', {
|
||||
...refreshAs(['alerts:read']),
|
||||
...shell(),
|
||||
})
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/alerts'))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Навігація
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('бічна навігація', () => {
|
||||
it('показує лише те, на що є право', async () => {
|
||||
await boot('/devices', {
|
||||
...refreshAs(['devices:read']),
|
||||
...shell({
|
||||
'GET /api/v1/devices': { devices: [] },
|
||||
'GET /api/v1/device-groups': { groups: [] },
|
||||
'GET /api/v1/check-types': { check_types: [] },
|
||||
'GET /api/v1/credentials': { credentials: [] },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('link', { name: /Хости/ })).toBeInTheDocument()
|
||||
// Пункт, який завжди дає 403, гірший за його відсутність: він
|
||||
// обіцяє можливість, якої немає.
|
||||
expect(screen.queryByRole('link', { name: /Журнал аудиту/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Користувачі/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Мапа/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('із правом пункт з’являється', async () => {
|
||||
await boot('/devices', {
|
||||
...refreshAs(['devices:read', 'audit:read', 'users:read']),
|
||||
...shell({
|
||||
'GET /api/v1/devices': { devices: [] },
|
||||
'GET /api/v1/device-groups': { groups: [] },
|
||||
'GET /api/v1/check-types': { check_types: [] },
|
||||
'GET /api/v1/credentials': { credentials: [] },
|
||||
}),
|
||||
})
|
||||
expect(await screen.findByRole('link', { name: /Журнал аудиту/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /Користувачі/ })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Вихід
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('вихід', () => {
|
||||
it('повертає до форми входу й РОЗРИВАЄ живе з’єднання', async () => {
|
||||
// Сокет, що пережив вихід, живе з мертвим токеном і стукає в сервер
|
||||
// з кожної вкладки спільного комп’ютера в диспетчерській. Помітити
|
||||
// це в інтерфейсі неможливо: сторінка входу виглядає однаково.
|
||||
await boot('/devices', {
|
||||
...refreshAs(['devices:read']),
|
||||
'POST /api/v1/auth/logout': {},
|
||||
...shell({
|
||||
'GET /api/v1/devices': { devices: [] },
|
||||
'GET /api/v1/device-groups': { groups: [] },
|
||||
'GET /api/v1/check-types': { check_types: [] },
|
||||
'GET /api/v1/credentials': { credentials: [] },
|
||||
}),
|
||||
})
|
||||
|
||||
await screen.findByRole('heading', { level: 1, name: 'Хости' })
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
expect(FakeWebSocket.last.closed).toBe(false)
|
||||
|
||||
// Кнопка підписана лише піктограмою, тож шукаємо за title — тим
|
||||
// самим, що бачить людина, підвівши мишу.
|
||||
fireEvent.click(screen.getByTitle('Вийти'))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Увійти' })).toBeInTheDocument()
|
||||
expect(FakeWebSocket.last.closed).toBe(true)
|
||||
})
|
||||
})
|
||||
96
web/src/test/sla.test.ts
Normal file
96
web/src/test/sla.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { humanSec, verdictLabel, warningLabel } from '../pages/SLAPage'
|
||||
import type { SLAOutcome } from '../types'
|
||||
|
||||
/**
|
||||
* Звіт SLA: те, що вирішує сторінка, а не сервер.
|
||||
*
|
||||
* Сервер віддає числа; сторінка вирішує, як їх НАЗВАТИ. Саме на назві й
|
||||
* ламається довіра до звіту: «0%» замість «немає даних» звинувачує
|
||||
* мережу в аварії, якої не було, а зелене «виконано» на порожньому
|
||||
* періоді — рівно та брехня, заради якої весь розрахунок і переписували.
|
||||
*
|
||||
* Тому тут перевіряються не компоненти, а дві чисті функції, у яких це
|
||||
* рішення й живе.
|
||||
*/
|
||||
|
||||
function outcome(over: Partial<SLAOutcome> = {}): SLAOutcome {
|
||||
return {
|
||||
clock_sec: 86400,
|
||||
maintenance_sec: 0,
|
||||
up_sec: 86400,
|
||||
downtime_sec: 0,
|
||||
unknown_sec: 0,
|
||||
uptime_pct: 100,
|
||||
coverage_pct: 100,
|
||||
incidents: 0,
|
||||
insufficient: false,
|
||||
breached: false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('вердикт', () => {
|
||||
it('повний період без втрат — виконано', () => {
|
||||
expect(verdictLabel(outcome())).toBe('виконано')
|
||||
})
|
||||
|
||||
it('недостатність даних НЕ дорівнює виконанню', () => {
|
||||
// Найдорожчий випадок: серед вимірів, що дійшли, втрат не було, і
|
||||
// наївна логіка показала б зелене. Правильна відповідь — «не знаємо».
|
||||
const o = outcome({ insufficient: true, coverage_pct: 12, uptime_pct: 100 })
|
||||
expect(verdictLabel(o)).toBe('недостатньо даних')
|
||||
})
|
||||
|
||||
it('недостатність даних НЕ дорівнює порушенню', () => {
|
||||
// Дзеркальна помилка: зарахувати мовчання зонда як аварію в мережі
|
||||
// клієнта, за яку хтось платить за договором.
|
||||
const o = outcome({ insufficient: true, breached: false, uptime_pct: 0 })
|
||||
expect(verdictLabel(o)).toBe('недостатньо даних')
|
||||
})
|
||||
|
||||
it('недостатність перекриває порушення, якщо сервер помилково поставив обидва', () => {
|
||||
// Сервер такого не робить, але порядок перевірок у сторінці має бути
|
||||
// однозначним: із двох прапорців виграє той, що каже «не знаємо».
|
||||
const o = outcome({ insufficient: true, breached: true })
|
||||
expect(verdictLabel(o)).toBe('недостатньо даних')
|
||||
})
|
||||
|
||||
it('порушення показується порушенням', () => {
|
||||
expect(verdictLabel(outcome({ breached: true, uptime_pct: 99.1 }))).toBe('порушено')
|
||||
})
|
||||
})
|
||||
|
||||
describe('тривалості', () => {
|
||||
it('нуль простою — риска, а не «0 хв»', () => {
|
||||
// «0 хв» читається як виміряний нуль. Риска — як «нічого не було».
|
||||
expect(humanSec(0)).toBe('—')
|
||||
})
|
||||
|
||||
it('бюджет 99.9% за квартал читається як години й хвилини', () => {
|
||||
// 0.1% від 92 діб — це 2 год 12 хв 28 с. Саме з цим числом людина
|
||||
// порівнює простій, тому воно має бути видно як «2 год 12 хв».
|
||||
expect(humanSec(7948)).toBe('2 год 12 хв')
|
||||
})
|
||||
|
||||
it('довгі простої показуються добами', () => {
|
||||
expect(humanSec(3 * 86400 + 3600)).toBe('3 д 1 год')
|
||||
})
|
||||
|
||||
it('менше за хвилину не зникає в нуль', () => {
|
||||
expect(humanSec(30)).toBe('0 хв')
|
||||
})
|
||||
})
|
||||
|
||||
describe('попередження', () => {
|
||||
it('відомий код перекладається', () => {
|
||||
expect(warningLabel('beyond_horizon')).toContain('історію годинних згорток')
|
||||
})
|
||||
|
||||
it('невідомий код показується як є, а не ковтається', () => {
|
||||
// Коди лежать у закритих періодах і читаються через рік. Код, якого
|
||||
// сторінка ще не знає, має бути видно — інакше застереження зникне
|
||||
// мовчки, і саме те, чого звіт не врахував, стане невидимим.
|
||||
expect(warningLabel('щось_нове')).toBe('щось_нове')
|
||||
})
|
||||
})
|
||||
238
web/src/test/support.ts
Normal file
238
web/src/test/support.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { vi } from 'vitest'
|
||||
|
||||
/**
|
||||
* Спільні підпори для тестів мережевого шару й сторінок.
|
||||
*
|
||||
* ПРИЧИНА окремого файла: підставна відповідь сервера має мати ту саму
|
||||
* форму, що й справжня, і форма ця описана в одному місці — у
|
||||
* `server/API.md` та в обробниках `server/internal/httpapi/`. Якби
|
||||
* кожен тест ліпив свій «схожий» об'єкт, розбіжність із сервером
|
||||
* з'явилась би в одному файлі й лишилась непоміченою в решті.
|
||||
* НАСЛІДОК: правити форму треба тут, і тоді впадуть усі тести одразу —
|
||||
* саме те, що потрібно.
|
||||
*
|
||||
* Файл не є тестом (`include` бере лише `*.test.ts{,x}`), тому vitest
|
||||
* його не збирає окремо.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Підставний fetch
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export interface Call {
|
||||
url: string
|
||||
init: RequestInit
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
body: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Відповідь у тій формі, у якій її бачить `raw()` у `api/client.ts`:
|
||||
* читаються лише `status`, `ok`, `statusText` і `text()`.
|
||||
*
|
||||
* Справжній `Response` тут не потрібен і навіть шкідливий: він тягне
|
||||
* різні реалізації в Node різних версій, і тест почав би залежати від
|
||||
* того, на чому його запустили.
|
||||
*/
|
||||
export function res(status: number, body?: unknown, statusText = ''): Response {
|
||||
return {
|
||||
status,
|
||||
ok: status >= 200 && status < 300,
|
||||
statusText,
|
||||
text: async () => (body === undefined ? '' : JSON.stringify(body)),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
/**
|
||||
* Помилка рівно в тій формі, яку віддає `writeError()`
|
||||
* (`server/internal/httpapi/server.go:582`):
|
||||
*
|
||||
* {"error": {"code": "...", "message": "..."}}
|
||||
*
|
||||
* Це єдина форма помилки на всьому API, і саме її розбирає `raw()`.
|
||||
*/
|
||||
export function errRes(status: number, code: string, message: string): Response {
|
||||
return res(status, { error: { code, message } })
|
||||
}
|
||||
|
||||
/** Черга відповідей: кожен виклик fetch знімає наступну. */
|
||||
export function fetchQueue(...queue: (Response | (() => Response | Promise<Response>))[]) {
|
||||
const calls: Call[] = []
|
||||
const fn = vi.fn(async (url: string, init: RequestInit = {}) => {
|
||||
const headers = (init.headers ?? {}) as Record<string, string>
|
||||
calls.push({
|
||||
url,
|
||||
init,
|
||||
method: init.method ?? 'GET',
|
||||
headers,
|
||||
body: typeof init.body === 'string' ? JSON.parse(init.body) : undefined,
|
||||
})
|
||||
const next = queue.shift()
|
||||
if (!next) throw new Error(`несподіваний запит: ${init.method ?? 'GET'} ${url}`)
|
||||
return typeof next === 'function' ? next() : next
|
||||
})
|
||||
vi.stubGlobal('fetch', fn)
|
||||
return { calls, fn }
|
||||
}
|
||||
|
||||
/**
|
||||
* Підставний сервер за адресами: ключ — «МЕТОД /шлях» БЕЗ рядка запиту.
|
||||
*
|
||||
* ПРИЧИНА саме такої підміни: підмінявся б модуль `api/client.ts`
|
||||
* цілком, з-під тесту зник би весь мережевий шар — заголовки, розбір
|
||||
* помилок, тихе продовження сесії. Сторінка тоді перевірялася б проти
|
||||
* вигаданого клієнта, а не проти того, що поїде на сервер.
|
||||
* НАСЛІДОК: тут підмінюється лише `fetch`, і кожен тест сторінки
|
||||
* проходить крізь справжній `request()`.
|
||||
*/
|
||||
export function fetchRouter(routes: Record<string, unknown | ((c: Call) => Response)>) {
|
||||
const calls: Call[] = []
|
||||
const fn = vi.fn(async (url: string, init: RequestInit = {}) => {
|
||||
const headers = (init.headers ?? {}) as Record<string, string>
|
||||
const method = init.method ?? 'GET'
|
||||
const path = url.split('?')[0]
|
||||
const call: Call = {
|
||||
url,
|
||||
init,
|
||||
method,
|
||||
headers,
|
||||
body: typeof init.body === 'string' ? JSON.parse(init.body) : undefined,
|
||||
}
|
||||
calls.push(call)
|
||||
|
||||
const route = routes[`${method} ${path}`]
|
||||
if (route === undefined) {
|
||||
// Мовчазний 404 сховав би те, що сторінка ходить не туди, куди
|
||||
// думає тест. Явна відмова з адресою в тексті — навпаки.
|
||||
return errRes(404, 'not_found', `тест не описав ${method} ${path}`)
|
||||
}
|
||||
if (typeof route === 'function') return (route as (c: Call) => Response)(call)
|
||||
return res(200, route)
|
||||
})
|
||||
vi.stubGlobal('fetch', fn)
|
||||
return {
|
||||
calls,
|
||||
fn,
|
||||
/** Виклики однієї адреси — у порядку надходження. */
|
||||
of(prefix: string) {
|
||||
return calls.filter((c) => `${c.method} ${c.url}`.startsWith(prefix))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Відповідь входу
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Тіло `POST /api/v1/auth/login` і `POST /api/v1/auth/refresh`.
|
||||
* Форма — з `server/API.md`, розділ «POST /api/v1/auth/login»:
|
||||
* access_token, expires_in, user{id,username,email,full_name},
|
||||
* tenant{tenant_id,tenant_name,role_key,role_name}, permissions[].
|
||||
*/
|
||||
export function loginBody(token: string, permissions: string[] = ['*']) {
|
||||
return {
|
||||
access_token: token,
|
||||
expires_in: 900,
|
||||
user: {
|
||||
id: 'u-1',
|
||||
username: 'admin',
|
||||
email: 'admin@acme.io',
|
||||
full_name: 'Admin',
|
||||
},
|
||||
tenant: {
|
||||
tenant_id: 't-1',
|
||||
tenant_name: 'Acme',
|
||||
tenant_slug: 'acme',
|
||||
role_key: 'owner',
|
||||
role_name: 'Власник',
|
||||
},
|
||||
permissions,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Підставний WebSocket
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type WsHandler = ((ev: unknown) => void) | null
|
||||
|
||||
/**
|
||||
* WebSocket, яким керує тест.
|
||||
*
|
||||
* jsdom має власний WebSocket, але він справді ходить у мережу: у
|
||||
* тесті це означало б реальне з'єднання з localhost, таймаути й
|
||||
* недетермінований порядок подій. Тут — рівно ті чотири зачіпки, які
|
||||
* читає `LiveConnection`.
|
||||
*/
|
||||
export class FakeWebSocket {
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSING = 2
|
||||
static readonly CLOSED = 3
|
||||
|
||||
static instances: FakeWebSocket[] = []
|
||||
static reset() {
|
||||
FakeWebSocket.instances = []
|
||||
}
|
||||
static get last(): FakeWebSocket {
|
||||
const w = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]
|
||||
if (!w) throw new Error('жодного з’єднання ще не відкрито')
|
||||
return w
|
||||
}
|
||||
|
||||
readyState = FakeWebSocket.CONNECTING
|
||||
sent: string[] = []
|
||||
closed = false
|
||||
|
||||
onopen: WsHandler = null
|
||||
onmessage: WsHandler = null
|
||||
onclose: WsHandler = null
|
||||
onerror: WsHandler = null
|
||||
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly protocols?: string | string[],
|
||||
) {
|
||||
FakeWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.onclose?.({})
|
||||
}
|
||||
|
||||
// --- керування з тесту ---
|
||||
|
||||
open() {
|
||||
this.readyState = FakeWebSocket.OPEN
|
||||
this.onopen?.({})
|
||||
}
|
||||
|
||||
message(data: unknown) {
|
||||
this.onmessage?.({ data: typeof data === 'string' ? data : JSON.stringify(data) })
|
||||
}
|
||||
|
||||
/** Розрив ззовні — так, як його бачить браузер: onclose без onerror. */
|
||||
drop() {
|
||||
this.closed = true
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.onclose?.({})
|
||||
}
|
||||
|
||||
fail() {
|
||||
this.onerror?.({})
|
||||
}
|
||||
|
||||
/** Перший підпротокол — саме туди `ws.ts` кладе токен. */
|
||||
get protocol(): string {
|
||||
return Array.isArray(this.protocols) ? (this.protocols[0] ?? '') : (this.protocols ?? '')
|
||||
}
|
||||
}
|
||||
414
web/src/test/ws.test.ts
Normal file
414
web/src/test/ws.test.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FakeWebSocket } from './support'
|
||||
import type { WsMessage } from '../types'
|
||||
|
||||
/**
|
||||
* Живе з'єднання: `api/ws.ts`.
|
||||
*
|
||||
* ПРИЧИНА, чому це перевіряють окремо й прискіпливо: NOC-екран висить
|
||||
* тижнями, і будь-який проксі рано чи пізно розірве сокет. Поломка
|
||||
* реконекту НЕ виглядає як поломка — мапа лишається на екрані з
|
||||
* останнім кадром і виглядає цілком живою. Це найгірший стан
|
||||
* моніторингу: система показує «все добре» тому, що перестала питати.
|
||||
*
|
||||
* НАСЛІДОК для тестів: кожна перевірка тут має негативну половину.
|
||||
* «Реконект стався» нічого не варте без «а після stop() — не стався»:
|
||||
* зайве з'єднання після виходу з акаунта живе з мертвим токеном і
|
||||
* стукає в сервер вічно.
|
||||
*
|
||||
* `live` — синглтон модуля, тому кожен тест бере свій екземпляр через
|
||||
* `vi.resetModules()`.
|
||||
*/
|
||||
|
||||
async function load(token = 'tok-1') {
|
||||
vi.resetModules()
|
||||
const sessionMod = await import('../api/session')
|
||||
sessionMod.session.set(token, { tenantID: 't-1', permissions: ['*'] })
|
||||
const ws = await import('../api/ws')
|
||||
return { ...ws, session: sessionMod.session }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.reset()
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Підключення
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('підключення', () => {
|
||||
it('start() відкриває ws:// на /api/v1/ws із токеном у підпротоколі', async () => {
|
||||
// Токен їде саме підпротоколом, а не в URL: адреса потрапляє в логи
|
||||
// зворотного проксі цілком, разом із запитом. Формат рядка звіряний
|
||||
// із `websocketProtocols()` у `server/internal/httpapi/ws.go`.
|
||||
const { live } = await load('tok-1')
|
||||
live.start()
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
expect(FakeWebSocket.last.url).toBe(`ws://${location.host}/api/v1/ws`)
|
||||
expect(FakeWebSocket.last.protocol).toBe('netpulse.token.tok-1')
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('повторний start() другого з’єднання не відкриває', async () => {
|
||||
// Оболонка монтується двічі в режимі розробки. Друге з'єднання
|
||||
// означало б подвійну доставку кожної події й подвійне
|
||||
// перечитування алертів під час аварії.
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
live.start()
|
||||
live.start()
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('після реконекту токен читається ЗАНОВО, а не береться з моменту старту', async () => {
|
||||
// Ключова перевірка. Сокет живе довше за access-токен (15 хвилин),
|
||||
// і після тихого оновлення сесії стара підписка вже недійсна.
|
||||
// З'єднання зі старим токеном сервер відкине — і воно
|
||||
// перепідключатиметься вічно, доки людина не перезавантажить
|
||||
// сторінку.
|
||||
const { live, session } = await load('old-token')
|
||||
live.start()
|
||||
expect(FakeWebSocket.last.protocol).toBe('netpulse.token.old-token')
|
||||
|
||||
session.set('new-token', { tenantID: 't-1', permissions: ['*'] })
|
||||
FakeWebSocket.last.drop()
|
||||
vi.advanceTimersByTime(1000)
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(2)
|
||||
expect(FakeWebSocket.last.protocol).toBe('netpulse.token.new-token')
|
||||
expect(FakeWebSocket.last.protocol).not.toBe('netpulse.token.old-token')
|
||||
live.stop()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Стан з'єднання
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('стан з’єднання', () => {
|
||||
it('connecting → online → offline, і підписники дізнаються про кожен перехід', async () => {
|
||||
// Стан видно в шапці на кожній сторінці. Замерзлий інтерфейс
|
||||
// виглядає точно так само, як справний, — крапка в шапці єдине, що
|
||||
// їх розрізняє.
|
||||
const { live } = await load()
|
||||
const seen: string[] = []
|
||||
const off = live.subscribeState(() => seen.push(live.getState()))
|
||||
|
||||
live.start()
|
||||
// Початковий стан уже «connecting», тому перший start() нікого не
|
||||
// будить — і це правильно: useSyncExternalStore бере перше
|
||||
// значення через getState(), а не з події.
|
||||
expect(live.getState()).toBe('connecting')
|
||||
expect(seen).toEqual([])
|
||||
|
||||
FakeWebSocket.last.open()
|
||||
expect(live.getState()).toBe('online')
|
||||
|
||||
FakeWebSocket.last.drop()
|
||||
expect(live.getState()).toBe('offline')
|
||||
|
||||
// «offline» тримається всю паузу й міняється на «connecting» лише
|
||||
// тоді, коли спроба справді почалась. Інакше шапка показувала б
|
||||
// «підключення…» весь час, поки нічого не відбувається.
|
||||
vi.advanceTimersByTime(999)
|
||||
expect(live.getState()).toBe('offline')
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(live.getState()).toBe('connecting')
|
||||
|
||||
expect(seen).toEqual(['online', 'offline', 'connecting'])
|
||||
off()
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('однаковий стан підряд підписників не будить', async () => {
|
||||
// useSyncExternalStore перемальовує компонент на кожне сповіщення;
|
||||
// зайві сповіщення тут — зайві перемальовки шапки на всіх сторінках.
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
|
||||
const seen: string[] = []
|
||||
live.subscribeState(() => seen.push(live.getState()))
|
||||
FakeWebSocket.last.open()
|
||||
FakeWebSocket.last.open()
|
||||
expect(seen).toEqual([])
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('час останньої події оновлюється лише коректним повідомленням', async () => {
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
expect(live.getLastEventAt()).toBeNull()
|
||||
|
||||
FakeWebSocket.last.message('це не JSON')
|
||||
// Негативна половина: пошкоджене повідомлення НЕ має виглядати як
|
||||
// ознака життя, інакше «дані свіжі» показуватиметься на потоці
|
||||
// сміття.
|
||||
expect(live.getLastEventAt()).toBeNull()
|
||||
|
||||
FakeWebSocket.last.message({ type: 'hello', tenant_id: 't-1' })
|
||||
expect(live.getLastEventAt()).toBeGreaterThan(0)
|
||||
live.stop()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Реконект
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('реконект', () => {
|
||||
it('розрив піднімає нову спробу через секунду', async () => {
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
FakeWebSocket.last.drop()
|
||||
|
||||
// До секунди — нічого. Негайна спроба перетворила б розрив на
|
||||
// цикл запитів без пауз.
|
||||
vi.advanceTimersByTime(999)
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(FakeWebSocket.instances).toHaveLength(2)
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('затримка подвоюється й упирається в 15 секунд', async () => {
|
||||
// Без стелі затримка зростає до годин, і екран, що пережив нічну
|
||||
// аварію проксі, зранку показує вчорашній стан.
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
|
||||
const delays = [1000, 2000, 4000, 8000, 15000, 15000, 15000]
|
||||
let expected = 1
|
||||
for (const delay of delays) {
|
||||
FakeWebSocket.last.drop()
|
||||
vi.advanceTimersByTime(delay - 1)
|
||||
expect(FakeWebSocket.instances).toHaveLength(expected)
|
||||
vi.advanceTimersByTime(1)
|
||||
expected += 1
|
||||
expect(FakeWebSocket.instances).toHaveLength(expected)
|
||||
}
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('вдале підключення скидає затримку назад до секунди', async () => {
|
||||
// Інакше екран, який за добу пережив десяток коротких розривів,
|
||||
// після наступного чекав би 15 секунд, хоча мережа справна.
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.drop()
|
||||
vi.advanceTimersByTime(1000)
|
||||
FakeWebSocket.last.drop()
|
||||
vi.advanceTimersByTime(2000)
|
||||
expect(FakeWebSocket.instances).toHaveLength(3)
|
||||
|
||||
FakeWebSocket.last.open()
|
||||
FakeWebSocket.last.drop()
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(FakeWebSocket.instances).toHaveLength(4)
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('помилка сокета закриває його — і тим запускає реконект', async () => {
|
||||
// Браузер не завжди шле onclose після onerror. Без явного close()
|
||||
// з'єднання лишилось би напівмертвим: подій немає, а спроби
|
||||
// повернутись не буде ніколи.
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
FakeWebSocket.last.fail()
|
||||
expect(live.getState()).toBe('offline')
|
||||
vi.advanceTimersByTime(1000)
|
||||
expect(FakeWebSocket.instances).toHaveLength(2)
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('stop() зупиняє реконект назавжди', async () => {
|
||||
// Негативна половина всього розділу. Сокет, що переживає вихід із
|
||||
// акаунта, стукає в сервер з мертвим токеном на кожній вкладці
|
||||
// спільного комп'ютера в диспетчерській.
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
live.stop()
|
||||
|
||||
expect(FakeWebSocket.last.closed).toBe(true)
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('stop() під час паузи знімає вже заплановану спробу', async () => {
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.drop()
|
||||
live.stop()
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Підписка на мапу
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('підписка на мапу', () => {
|
||||
it('надсилається одразу, коли з’єднання вже відкрите', async () => {
|
||||
// Формат звіряний із `wsIn` у `server/internal/httpapi/ws.go:260`:
|
||||
// {"type":"subscribe","map_id":"…"}.
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
live.subscribe('map-1')
|
||||
expect(FakeWebSocket.last.sent).toEqual([JSON.stringify({ type: 'subscribe', map_id: 'map-1' })])
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('запам’ятовується до відкриття й летить на onopen', async () => {
|
||||
// Сторінка мапи монтується раніше, ніж встигає відкритись сокет.
|
||||
// Без запам'ятовування підписки полотно лишалось би без живих
|
||||
// статусів до першого розриву.
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
live.subscribe('map-1')
|
||||
expect(FakeWebSocket.last.sent).toEqual([])
|
||||
|
||||
FakeWebSocket.last.open()
|
||||
expect(FakeWebSocket.last.sent).toEqual([JSON.stringify({ type: 'subscribe', map_id: 'map-1' })])
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('повторюється після КОЖНОГО реконекту', async () => {
|
||||
// Сервер тримає підписку в пам'яті з'єднання: після розриву він про
|
||||
// мапу не пам'ятає нічого. Без повтору полотно після реконекту
|
||||
// виглядає живим (крапка «наживо» зелена), але завантаження ліній
|
||||
// на нього більше не приходять.
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
live.subscribe('map-1')
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
FakeWebSocket.last.drop()
|
||||
vi.advanceTimersByTime(15_000)
|
||||
FakeWebSocket.last.open()
|
||||
expect(FakeWebSocket.last.sent).toEqual([
|
||||
JSON.stringify({ type: 'subscribe', map_id: 'map-1' }),
|
||||
])
|
||||
}
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('після stop() підписка забувається: нове з’єднання її не тягне', async () => {
|
||||
// Негативна половина. Інакше після виходу й входу іншим
|
||||
// користувачем полотно підписалось би на мапу попереднього.
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
live.subscribe('map-1')
|
||||
live.stop()
|
||||
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
expect(FakeWebSocket.last.sent).toEqual([])
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('зміна мапи не залишає підписки на попередню', async () => {
|
||||
const { live } = await load()
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
live.subscribe('map-1')
|
||||
live.subscribe('map-2')
|
||||
|
||||
FakeWebSocket.last.drop()
|
||||
vi.advanceTimersByTime(1000)
|
||||
FakeWebSocket.last.open()
|
||||
expect(FakeWebSocket.last.sent).toEqual([JSON.stringify({ type: 'subscribe', map_id: 'map-2' })])
|
||||
live.stop()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Шина подій
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
describe('шина подій', () => {
|
||||
it('доставляє повідомлення всім слухачам', async () => {
|
||||
const { live, liveEvents } = await load()
|
||||
const a: WsMessage[] = []
|
||||
const b: WsMessage[] = []
|
||||
liveEvents.subscribe((m) => a.push(m))
|
||||
liveEvents.subscribe((m) => b.push(m))
|
||||
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
const msg = {
|
||||
type: 'device.status',
|
||||
payload: { device_id: 'd-1', status: 'down', previous_status: 'up' },
|
||||
}
|
||||
FakeWebSocket.last.message(msg)
|
||||
|
||||
expect(a).toEqual([msg])
|
||||
expect(b).toEqual([msg])
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('пошкоджене повідомлення не рве з’єднання й нікому не доставляється', async () => {
|
||||
const { live, liveEvents } = await load()
|
||||
const seen: WsMessage[] = []
|
||||
liveEvents.subscribe((m) => seen.push(m))
|
||||
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
FakeWebSocket.last.message('{зламаний JSON')
|
||||
|
||||
expect(seen).toEqual([])
|
||||
expect(live.getState()).toBe('online')
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('слухач, що впав, не глушить решти', async () => {
|
||||
// Одна сторінка з помилкою в обробнику не має зупиняти живі
|
||||
// оновлення на всіх інших: лічильник алертів у шапці замерз би
|
||||
// разом із нею.
|
||||
const { live, liveEvents } = await load()
|
||||
const seen: string[] = []
|
||||
liveEvents.subscribe(() => {
|
||||
throw new Error('поганий слухач')
|
||||
})
|
||||
liveEvents.subscribe((m) => seen.push(m.type))
|
||||
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
FakeWebSocket.last.message({ type: 'map.updated', map_id: 'm-1', revision: 7 })
|
||||
expect(seen).toEqual(['map.updated'])
|
||||
live.stop()
|
||||
})
|
||||
|
||||
it('відписка справді знімає слухача', async () => {
|
||||
const { live, liveEvents } = await load()
|
||||
const seen: string[] = []
|
||||
const off = liveEvents.subscribe((m) => seen.push(m.type))
|
||||
|
||||
live.start()
|
||||
FakeWebSocket.last.open()
|
||||
FakeWebSocket.last.message({ type: 'alert.acked', payload: { alert_id: 'a-1' } })
|
||||
off()
|
||||
FakeWebSocket.last.message({ type: 'alert.acked', payload: { alert_id: 'a-2' } })
|
||||
|
||||
expect(seen).toEqual(['alert.acked'])
|
||||
live.stop()
|
||||
})
|
||||
})
|
||||
195
web/src/types.ts
195
web/src/types.ts
|
|
@ -2181,3 +2181,198 @@ export interface TrapFilter {
|
|||
limit?: number
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Звіти SLA
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Кого накриває ціль. Порожній селектор — увесь кабінет. */
|
||||
export interface SLASelector {
|
||||
device_ids?: string[]
|
||||
group_ids?: string[]
|
||||
site_ids?: string[]
|
||||
kinds?: string[]
|
||||
vendors?: string[]
|
||||
template_ids?: string[]
|
||||
tags?: Record<string, string>
|
||||
}
|
||||
|
||||
export type SLAPeriodKind = 'daily' | 'weekly' | 'monthly' | 'quarterly'
|
||||
|
||||
export interface SLATarget {
|
||||
id: string
|
||||
name: string
|
||||
selector: SLASelector
|
||||
/** Обіцяний відсоток. 99.9 за квартал — це 2 год 12 хв простою. */
|
||||
target_pct: number
|
||||
period_kind: SLAPeriodKind
|
||||
/** Пояс, у якому ріжуться межі періоду. */
|
||||
tz: string
|
||||
/**
|
||||
* Нижче цього покриття вердикт не виноситься взагалі: період
|
||||
* позначається як «недостатньо даних». Без порога місяць із мертвим
|
||||
* зондом показав би 100%.
|
||||
*/
|
||||
min_coverage_pct: number
|
||||
enabled: boolean
|
||||
/** Заповнене поле НЕ звужує розрахунок — сервер додає попередження. */
|
||||
business_hours?: unknown
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Розкладений час періоду. Чотири величини взаємно виключні й у сумі
|
||||
* дають clock_sec.
|
||||
*/
|
||||
export interface SLAOutcome {
|
||||
clock_sec: number
|
||||
maintenance_sec: number
|
||||
up_sec: number
|
||||
downtime_sec: number
|
||||
/** Час, про який немає ЖОДНОГО виміру. Ніколи не «працювало». */
|
||||
unknown_sec: number
|
||||
|
||||
/** З виміряного часу, а не з періоду. */
|
||||
uptime_pct: number
|
||||
/** Яку частку годинника взагалі видно. Число, за яким вирішують, чи вірити uptime. */
|
||||
coverage_pct: number
|
||||
incidents: number
|
||||
/** Даних замало для вердикту. Це НЕ «виконано». */
|
||||
insufficient: boolean
|
||||
breached: boolean
|
||||
}
|
||||
|
||||
export interface SLARow extends SLAOutcome {
|
||||
device_id: string
|
||||
device_name: string
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
/** Скільки історії реально є під звітом. */
|
||||
export interface SLAHorizon {
|
||||
relation: string
|
||||
/** Строк на джерело; null — не видаляється. */
|
||||
keep_days: number | null
|
||||
oldest_bucket?: string
|
||||
}
|
||||
|
||||
export interface SLAReport {
|
||||
target: SLATarget
|
||||
from: string
|
||||
to: string
|
||||
period_from: string
|
||||
period_to: string
|
||||
/** Рядки прочитано з бази, а не пораховано. Саме це робить звіт документом. */
|
||||
closed: boolean
|
||||
computed_at?: string
|
||||
revision?: number
|
||||
/** Період скінчився й згортки під ним устоялись. */
|
||||
closable: boolean
|
||||
rows: SLARow[]
|
||||
horizon: SLAHorizon
|
||||
warnings: string[]
|
||||
totals: SLAOutcome
|
||||
}
|
||||
|
||||
export interface SLATargetInput {
|
||||
id?: string
|
||||
name: string
|
||||
selector: SLASelector
|
||||
target_pct: number
|
||||
period_kind: SLAPeriodKind
|
||||
tz: string
|
||||
min_coverage_pct: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Тариф, стелі й ліцензія
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Стелі тарифу. null — без обмеження, і це НЕ те саме, що нуль.
|
||||
*
|
||||
* Різницю тримає саме тип, а не домовленість: `max_devices: 0` означав
|
||||
* би «жодного хоста», і одне мовчазне перетворення null→0 у формі
|
||||
* заблокувало б інсталяцію цілком.
|
||||
*/
|
||||
export interface PlanLimits {
|
||||
max_devices: number | null
|
||||
max_maps: number | null
|
||||
max_map_nodes: number | null
|
||||
max_agents: number | null
|
||||
max_users: number | null
|
||||
metric_retention_days: number
|
||||
}
|
||||
|
||||
export interface Plan extends PlanLimits {
|
||||
key: string
|
||||
name: string
|
||||
description?: string
|
||||
base_price_cents: number
|
||||
per_device_cents: number
|
||||
currency: string
|
||||
billing_period: string
|
||||
features: string[]
|
||||
/** false — це стан, а не пропозиція (self_hosted). Купити його не можна. */
|
||||
is_public: boolean
|
||||
}
|
||||
|
||||
export interface BillingFeature {
|
||||
key: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface Entitlement extends PlanLimits {
|
||||
plan_key: string
|
||||
plan_name: string
|
||||
features: string[]
|
||||
/** Хто задав стелі: license_key | stripe | paddle | manual. */
|
||||
source: string
|
||||
/** Чому саме такі: license | license_expired | plan | migration | manual. */
|
||||
reason: string
|
||||
valid_until?: string
|
||||
grace_until?: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** Скільки слотів зайнято. Рахує bill.usage_now — та сама функція, що й тригери. */
|
||||
export interface BillingUsage {
|
||||
devices: number
|
||||
maps: number
|
||||
agents: number
|
||||
users: number
|
||||
}
|
||||
|
||||
export type LicenseStateKind = 'unlicensed' | 'active' | 'grace' | 'expired' | 'invalid'
|
||||
|
||||
export interface LicenseState {
|
||||
state: LicenseStateKind
|
||||
reason?: string
|
||||
/** Те, що людина надсилає, замовляючи ключ. */
|
||||
install_id: string
|
||||
license_id?: string
|
||||
issued_to?: string
|
||||
plan?: string
|
||||
expires_at?: string
|
||||
grace_until?: string
|
||||
/** До кінця пільгового періоду. Від'ємне — уже після нього. */
|
||||
days_left?: number
|
||||
/** Системний час пішов назад проти побаченого. Нічого не вимикає — лише видно. */
|
||||
clock_warped?: boolean
|
||||
clock_max_seen?: string
|
||||
checked_at?: string
|
||||
applied_at?: string
|
||||
/** У збірці немає жодного відкритого ключа — перевірити ліцензію нічим. */
|
||||
verifier_empty?: boolean
|
||||
}
|
||||
|
||||
export interface BillingOverview {
|
||||
entitlement: Entitlement
|
||||
usage: BillingUsage
|
||||
plans: Plan[]
|
||||
features: BillingFeature[]
|
||||
license: LicenseState
|
||||
can_manage: boolean
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue