Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s

П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

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:
byrsapty 2026-08-27 21:17:23 +03:00
parent 25da4ffea8
commit ca143a616b
51 changed files with 14829 additions and 29 deletions

View file

@ -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`, і на машині не лишається нічого.
### Скільки це коштує
| Що | Скільки |
| -------------- | ------------------------------------------------------------ |
| Пам'ять у роботі | ~550900 МБ на всі шість служб |
| Стеля пам'яті | ~2.05 ГБ — жорстке обмеження з накладки, вище не підніметься |
| Пік при збірці | ще ~12 ГБ, поки збираються образи 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_…`)

View 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 []

View file

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

905
netpulse

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,14 @@
# ./netpulse install
#
# Синтаксис: КЛЮЧ=значення, без лапок, без пробілів навколо «=».
#
# ПІСОЧНИЦЯ (`./netpulse sandbox`) цей файл НЕ читає, і це навмисно.
# Вона ставить систему на localhost із самопідписаним сертифікатом,
# а справжній DOMAIN звідси відправив би її по сертифікат для адреси,
# яка веде на бойовий стенд: витрачені спроби списались би з тижневої
# квоти домену, і платив би за перевірку той, кого перевіряли. З
# TRAPS_FROM те саме — правило в DOCKER-USER це стан хоста, і прибрати
# його разом із томами пісочниці не вийшло б.
# ---------------------------------------------------------------------

View file

@ -43,6 +43,9 @@
set -u
# CDPATH= — не помилка з пробілом, а гасіння CDPATH: якщо він виставлений
# в оточенні, cd мовчки піде не туди, і скрипт перевірятиме чужий каталог.
# shellcheck disable=SC1007
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT" || exit 1

View file

@ -23,6 +23,9 @@
set -u
# CDPATH= — не помилка з пробілом, а гасіння CDPATH: якщо він виставлений
# в оточенні, cd мовчки піде не туди, і скрипт перевірятиме чужий каталог.
# shellcheck disable=SC1007
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT" || exit 1

View file

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

View file

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

View file

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

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

View file

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

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

View file

@ -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' —
// за полем, яке ставить сама перевірка, а не за текстом, який
// переписують, коли він погано читається.
// ---------------------------------------------------------------------
// Групи користувачів

View file

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

View file

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

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

View file

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

View 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()
}

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

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

View 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
}

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

View 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 "«порушення»"
}

View file

@ -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 — усі права, згруповані, з описом із бази.

View 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 травня, не має
// «недоступності» за 119 травня — його не було.
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:0004:00 і 03:0005: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

View 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
}

View 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
}

View 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
}

View 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 строку немає
//
// Квартал — 9092 доби. Порахований по сирих вимірах, він СЬОГОДНІ
// правильний, а через два місяці той самий запит на той самий квартал
// дасть інше число: рядків під ним просто не буде, а «немає рядка» й
// «втрат не було» запит не розрізняє. Звіт мовчки поїде до 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
}

View 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:0001: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:0005: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("вигаданий часовий пояс приймається")
}
}

View 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.

View 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 травня, не має «недоступності» за 119 травня: його не було.
-- Без цієї колонки різницю між «нема даних» і «нема хоста» довелось би
-- відновлювати з 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:0004:00 плюс 03:0005: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 і знайшла шість
-- зв'язкових таблиць без жодної політики.

View 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, а не окремо.

View file

@ -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` — останній екран перед
незворотною дією, і його дві тихі поломки (другий запит на видалення; закриття

View file

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

View file

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

View file

@ -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' },
],
},
]

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

View 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')
})
})

View 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'])
})
})

View 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(),
)
})
})

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

View 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')
})
})

View 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
View 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
View 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
View 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()
})
})

View file

@ -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` означав
* би «жодного хоста», і одне мовчазне перетворення null0 у формі
* заблокувало б інсталяцію цілком.
*/
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
}