Netpulse_SasS/server/API.md
byrsapty ec4b2cd54b
All checks were successful
CI / hygiene (push) Successful in 10s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m54s
CI / agent (push) Successful in 1m1s
Тиха година й драбина: вада окремо, вибір окремо
Асиметрія: аварія о 21:59 ескалювала всю ніч, о 22:01 не ескалювала
ніколи. Дві хвилини різниці — протилежні наслідки, причому гірший
(повна тиша) виглядав як тиша справна.

ВАДА. targets() повертав порожньо в тиху годину, а взведення читало це
як «немає куди слати». Взводять лише новий алерт, тож драбина не
з'являлась уже ніколи: тиха година вимикала механізм саме тоді, коли
перше сповіщення не спрацювало. Тепер targets() розрізняє «каналів
немає» і «канали є, просто зараз ніч».

ВИБІР. 0072 додає respect_quiet_hours на драбину:
  false (типово, як діяло) — драбина пробивається;
  true  — сходинка відкладається до ранку і НЕ витрачається.
Залежить від того, чи є в кабінету нічна зміна — це вирішує кабінет.
disaster пробивається за будь-якого значення, як і в targets().

Відлік драбини — від першого сповіщення, а не від started_at: інакше
для розглушеного алерту вона протухла б ще у вікні.

І сам прогін проти бази брехав: dbtest.sh котив схему готовим образом
(старі міграції), а тести брав із нового дерева. Тепер міграції з того
ж дерева. 64 міграції, усе зелене.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 18:08:27 +03:00

1325 lines
91 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# NetPulse API — REST і WebSocket для фронтенду
```bash
go build -o netpulse-api ./cmd/netpulse-api
```
```bash
./netpulse-api -listen :8080 -dsn "postgres://..." -cert api.pem -key api.key
```
Окремий процес від `netpulse-server` (AgentService) навмисно: зонди й браузери мають
різні профілі навантаження, різні мережеві периметри й різні цикли релізів. Спільним
лишається лише шар `store`, тому дані обидва бачать однакові.
## Автентифікація
Два рівноправні способи довести, хто ти. Обидва приходять одним заголовком:
```
Authorization: Bearer <токен>
```
Сервер розрізняє їх за формою: рівно дві крапки — це JWT людини, інакше машинний
токен. Це навмисно не залежить від префікса, бо префікс може змінитись, а формат
JWT — ні.
**Люди** входять **за логіном** (`core.users.username`), а не за поштою: у
мережевій інсталяції половина облікових записів технічні (`noc`, `monitoring`,
`oncall`) і скриньки не мають узагалі. Пошта лишається прийнятною — сервер шукає
за обома полями, — але вона більше не обов'язкова.
Люди входять через `/api/v1/auth/login` і отримують короткий access-токен
(HS256, 15 хвилин, у пам'яті вкладки) плюс refresh-сесію в `httpOnly`-кукі
`np_refresh` (30 днів, `SameSite=Strict`, `Path=/api/v1/auth`). Access-токен
свідомо не кладеться в `localStorage`: будь-який XSS звідти його забирає, а з
замикання модуля — ні.
**Машини** (NOC-екрани, скрипти, кіоски) користуються токенами з `core.api_tokens`;
у БД лежить лише `sha256`, сам токен показується один раз при створенні. Порожній
`scopes` означає повний доступ — так поводяться токени, створені власником тенанта
для себе.
Права в обох випадках зводяться до одного набору рядків (`maps:write`,
`users:write`, `billing:manage`, …) і перевіряються однаково. Для людини набір
щоразу читається з БД за членством, а не береться з токена: інакше відкликана роль
жила б до кінця TTL.
`GET /healthz` свідомо відкритий: його опитує балансувальник.
### Перший користувач
Порожня база не має кого впустити, тому власник заводиться CLI-утилітою, а не
через API:
```bash
./netpulse-user -dsn "postgres://..." -tenant acme -login admin -role owner
```
`-list` показує учасників, `-reset-password` міняє пароль і відкликає всі сесії.
## Ендпоїнти
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/healthz` | стан процесу й кількість WebSocket-підписників |
| `POST` | `/api/v1/auth/login` | вхід за логіном і паролем |
| `POST` | `/api/v1/auth/refresh` | обмін refresh-кукі на новий access-токен |
| `POST` | `/api/v1/auth/logout` | відкликати поточну сесію |
| `POST` | `/api/v1/auth/password` | змінити свій пароль |
| `GET` | `/api/v1/me` | хто я і що мені можна |
| `GET` | `/api/v1/team` | учасники організації |
| `POST` | `/api/v1/team` | завести користувача (`users:write`) |
| `PATCH` | `/api/v1/team/{id}` | змінити роль (`users:write`) |
| `DELETE` | `/api/v1/team/{id}` | прибрати з організації (`users:write`) |
| `GET` | `/api/v1/roles` | доступні ролі з правами |
| `GET` | `/api/v1/maps` | перелік мап із лічильниками вузлів і ребер |
| `POST` | `/api/v1/maps` | створити мапу |
| `GET` | `/api/v1/maps/{id}` | **повний стан полотна разом із живими статусами** |
| `PATCH` | `/api/v1/maps/{id}` | **редактор полотна** з оптимістичним блокуванням |
| `DELETE` | `/api/v1/maps/{id}` | м'яко видалити мапу (топологія лишається) |
| `POST` | `/api/v1/maps/{id}/build` | добудувати мапу з виявленої топології |
| `POST` | `/api/v1/maps/{id}/undo` | відкотити останню зміну полотна |
| `GET` | `/api/v1/devices` | інвентар (звужений групами доступу); `?view=archived` — навпаки, ЛИШЕ прибрані хости |
| `POST` | `/api/v1/devices` | додати хост (`devices:write`) |
| `PATCH` | `/api/v1/devices/{id}` | змінити хост і його групи |
| `DELETE` | `/api/v1/devices/{id}` | мʼяко видалити хост |
| `POST` | `/api/v1/devices/bulk-targets` | що саме зачепить масова дія (`mode`: archive/purge/restore) |
| `POST` | `/api/v1/devices/bulk-update` | масова правка хостів |
| `POST` | `/api/v1/devices/bulk-delete` | масове видалення: `mode=archive` (в архів) або `mode=purge` (назавжди, з `ncm:delete` за наявності конфігів) |
| `POST` | `/api/v1/devices/bulk-restore` | повернути хости з архіву (`devices:write`) |
| `GET` | `/api/v1/check-types` | що система вміє опитувати |
| `GET` | `/api/v1/devices/{id}/checks` | перевірки хоста |
| `PUT` | `/api/v1/devices/{id}/checks` | замінити набір перевірок |
| `GET` | `/api/v1/credentials` | доступи до обладнання (без секретів) |
| `POST` | `/api/v1/credentials` | створити доступ (`devices:write`) |
| `GET` | `/api/v1/device-groups` | групи хостів із лічильниками |
| `POST` | `/api/v1/device-groups` | створити групу хостів |
| `DELETE` | `/api/v1/device-groups/{id}` | видалити групу |
| `GET` | `/api/v1/user-groups` | групи доступу з правами |
| `POST` | `/api/v1/user-groups` | створити групу доступу |
| `PATCH` | `/api/v1/user-groups/{id}` | склад і права одним запитом |
| `DELETE` | `/api/v1/user-groups/{id}` | видалити групу доступу |
| `GET` | `/api/v1/agents` | зонди, версії, самометрики |
| `GET` | `/api/v1/alerts` | активні алерти + лічильники |
| `POST` | `/api/v1/alerts/{id}/ack` | підтвердити (`alerts:ack`) |
| `POST` | `/api/v1/alerts/{id}/close` | закрити вручну (`alerts:ack`) |
| `POST` | `/api/v1/mutes` | заглушити пристрій (`alerts:ack`) |
| `GET` | `/api/v1/alert-rules` | правила з лічильником активних |
| `POST` | `/api/v1/alert-rules` | створити правило (`alerts:write`) |
| `PUT` | `/api/v1/alert-rules/{id}` | замінити правило цілком (`alerts:write`) |
| `PATCH` | `/api/v1/alert-rules/{id}` | увімкнути/вимкнути (`alerts:write`) |
| `DELETE` | `/api/v1/alert-rules/{id}` | видалити правило (`alerts:write`) |
| `GET` | `/api/v1/channels` | канали доставки (без секретів) |
| `POST` | `/api/v1/channels` | створити канал (`alerts:write`) |
| `POST` | `/api/v1/channels/{id}/test` | пробне повідомлення (`alerts:write`) |
| `DELETE` | `/api/v1/channels/{id}` | видалити канал (`alerts:write`) |
| `GET` | `/api/v1/escalation-policies` | драбини ескалації |
| `POST` | `/api/v1/escalation-policies` | створити драбину (`alerts:write`) |
| `PUT` | `/api/v1/escalation-policies/{id}` | замінити драбину цілком (`alerts:write`) |
| `DELETE` | `/api/v1/escalation-policies/{id}` | видалити драбину (`alerts:write`) |
| `GET` | `/api/v1/storage` | розміри даних, приріст за добу й запас місця |
| `PUT` | `/api/v1/storage/config` | ємність тому під базу (`settings:write`) |
| `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`.
### Відповідність конфігів
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/api/v1/ncm/compliance/rules` | правила з підсумком останньої перевірки (`ncm:read`) |
| `POST` | `/api/v1/ncm/compliance/rules` | створити СВОЄ правило (`ncm:write`) |
| `PUT` | `/api/v1/ncm/compliance/rules/{id}` | змінити правило (`ncm:write`) |
| `DELETE` | `/api/v1/ncm/compliance/rules/{id}` | видалити СВОЄ правило (`ncm:write`) |
| `GET` | `/api/v1/ncm/compliance/results` | знахідки; `?failed=1` — лише порушення (`ncm:read`) |
| `POST` | `/api/v1/ncm/compliance/run` | прогін по вже зібраних конфігах (`ncm:read`) |
| `GET` | `/api/v1/ncm/compliance/probe-devices` | на чому перевірити зразок + вендори парку; `?config_type=` (`ncm:read`) |
| `POST` | `/api/v1/ncm/compliance/probe` | **прогнати незбережений зразок** по конфігу одного хоста (`ncm:read`) |
| `GET` | `/api/v1/ncm/compliance/report` | звіт: зведення, склад правил, порушення з порадами (`ncm:read`) |
| `GET` | `/api/v1/ncm/compliance/report.csv` | те саме вивантаженням (`ncm:read`) |
**Вбудовані правила.** Кабінет отримує ВЛАСНУ копію кожного правила з
довідника `ncm.builtin_compliance` (0071); копію впізнають за
`builtin_key`. У таких правил замкнено предмет перевірки — `name`,
`kind`, `pattern`, `config_type`: за вбудованим зразком стоїть перевірка
на справжніх конфігах кількох вендорів, а переписаний руками зразок
цього доказу вже не має й лишався б позначеним як вбудований. Спроба
змінити замкнене поле — `400 invalid`. Решта полів (`enabled`,
`severity`, `selector`, `description`, `remediation`) — політика
кабінету, і вона відкрита. Видалення вбудованого правила — теж
`400 invalid`: вимкнене правило лишається видимим у звіті, видалене —
робить кабінет схожим на той, де вимога виконана.
**Чому зразок перевіряють окремою ручкою.** Регулярний вираз, який
компілюється й не збігається НІКОЛИ, дає «порушень немає» — тобто
виглядає бездоганно й не перевіряє нічого; за результатом прогону його
не відрізнити від справного правила. `POST .../probe` віддає РЯДКИ, на
які зразок наліг, із номерами:
```jsonc
{
"device_name": "core-sw-01",
"config_type": "running",
"collected_at": "2026-08-28T06:00:00Z",
"lines": 412,
"no_config": false, // конфігу цього типу немає — це НЕ «нічого не знайшов»
"in_selector": true, // хост підпадає під склад правила
"matches": [ { "line_number": 11, "line": "transport input telnet ssh" } ],
"truncated": false, // збігів більше за стелю (200)
"passed": false // той самий висновок, що зробить прогін
}
```
**Зразок не може містити переносу рядка.** Перевірка йде порядково, тож
зразок із `\n` або `\r` не збігся б НІКОЛИ — а для `must_not_contain` і
`regex_absent` це вічне тихе «пройдено». Відмова — `400 invalid`.
Збереження правила стирає його результати ЛИШЕ тоді, коли змінився
предмет перевірки (вид, зразок, тип конфігу, селектор). Вимкнення,
перейменування, інша серйозність чи порада картину прогону не чіпають.
Селектор порівнюється за ЗНАЧЕННЯМ і зберігається в канонічній формі
(порожні виміри не пишуться): `{}`, `{"vendors":[]}` і селектор із
`null` у всіх ключах — це один і той самий «до всіх», і правка правила
через них результатів не стирає.
**Що вважається чинною знахідкою.** `/results` і звіт віддають лише
результати УВІМКНЕНИХ правил і лише по хостах, які не видалено. Вимкнули
правило — його старі знахідки перестають рахуватись у «Порушень N»
(самé правило лишається у звіті поіменно як вимкнене, і рядки в базі
теж лишаються: увімкніть і прожену́ть — картина повернеться). Прогін
додатково прибирає результати по хостах, які випали із селектора
правила: `/run` — єдина мить, коли повний склад чинних питань відомий.
**Лічильники `failed`/`passed`** у `/rules` рахуються з тих самих рядків,
які людина отримує в `/results` — тобто після відсіву за видимістю. Два
числа не можуть розійтись: це одне число. Перелік вендорів у
`/probe-devices` теж обмежений видимістю — це агрегат про склад парку.
**CSV.** Клітинка, що починається з `=`, `+`, `-`, `@`, таба чи CR,
екранується апострофом: у звіт їде сирий рядок конфігу пристрою, а Excel
прочитав би його як формулу й виконав на машині аудитора. Числа
(`-5`, `+12`) не чіпаються. Останній рядок файла — `КІНЕЦЬ ЗВІТУ` з
кількістю порушень: відповідь іде потоком уже після `200 OK`, тож
обірваний файл інакше не відрізнити від повного.
**Звіт** несе дату, автора, час останньої перевірки, зведення за
серйозністю — і СКЛАД ПРАВИЛ на момент звіту зі зразками й походженням.
Без складу правил два роздруки з різними числами нічим пояснити: «18
порушень» і «4» — це або робота інженерів, або хтось вимкнув
чотирнадцять правил. Окремо позначено правила, які не дали жодної
перевірки (`rules_never_run`) і обмежену видимість автора
(`partial_scope`). PDF немає: базові шрифти PDF не мають кирилиці, і
звіт без вкладеного шрифту вийшов би тихо зіпсованим.
### `POST /api/v1/auth/login` — вхід
```jsonc
{"login": "admin", "password": "…", "tenant_id": "…"} // tenant_id — опційно
```
Поле `login` приймає і логін, і пошту. Старе `email` теж лишається робочим:
інтеграції, написані до перейменування, не мають ламатися через назву ключа.
Успіх:
```jsonc
{
"access_token": "eyJ…",
"expires_in": 900,
"user": {"id": "…", "username": "admin", "email": "admin@acme.io", "full_name": "Admin"},
"tenant": {"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"},
"permissions": ["maps:read", "maps:write", "users:write", ]
}
```
Якщо людина працює в кількох організаціях і `tenant_id` не вказано, токен **не
видається** — приходить перелік на вибір, і клієнт повторює вхід із `tenant_id`:
```jsonc
{"tenants": [{"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"}, ]}
```
Помилки навмисно нерозрізненні: невідомий логін і невірний пароль дають однаковий
`401 bad_credentials`, і на неіснуючому користувачі сервер спалює стільки ж часу на
фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які акаунти
існують.
Після 10 невдач з одного логіна або IP за 15 хвилин — `429 too_many_attempts`.
Лічильник живе в `core.login_attempts` (гіпертаблиця), тому переживає рестарт
процесу й працює на кількох інстансах одразу.
### `POST /api/v1/auth/refresh` — продовження сесії
Тіла немає — сервер читає кукі `np_refresh`. Відповідь така сама, як у `login`.
Токен **ротується**: старий рядок сесії відкликається, видається новий. Це
перетворює викрадену кукі на видиму подію — злодій і власник не можуть
користуватись однією сесією паралельно, другий отримає `401 no_session`.
Членство перевіряється при кожній ротації, тож прибраний з організації користувач
випадає щонайбільше за 15 хвилин, а не за 30 днів.
### `GET /api/v1/me`
```jsonc
{"tenant_id": "…", "user_id": "…", "email": "admin@acme.io", "permissions": [...]}
```
Для машинного токена замість `user_id`/`email` приходить `token_name`.
Формат логіна: 364 символи з латиниці, цифр, крапки, дефіса й підкреслення;
перший і останній символ — літера або цифра. Перевіряється і в БД (CHECK), і в
API — відмова БД виглядає як «violates check constraint», і людині з неї нічого
не зрозуміло.
### `GET|POST|PATCH|DELETE /api/v1/team`
Керування учасниками; усе, крім читання, потребує `users:write`.
`PATCH` міняє роль, профіль і пароль одним запитом:
```jsonc
{"role_id": "…", "username": "noc-a", "email": "noc@acme.io",
"full_name": "Черговий", "password": "…"}
```
Порожнє поле означає «не чіпати» — форма не стирає того, чого не
показувала. Порожній `email` прибирає адресу (сигнал `-`). Зміна пароля
відкликає всі сесії цієї людини: інакше той, хто знав старий, лишається
всередині до місяця, доки не протермінується refresh.
**Профіль редагується лише в того, хто працює тільки в цій організації.**
`core.users` глобальна: одна людина може мати доступ до кількох тенантів
(типово для MSP). Логін, пошта й пароль — її власність, а не власність
організації, тому адмін філії не може змінити їх тому, хто заходить тим
самим акаунтом ще кудись — інакше той навіть не дізнався б. Спроба дає
`409 shared_user`. Роль і членство в групах локальні й редагуються
завжди.
Три обмеження вшиті навмисно й не обходяться параметрами: роль `owner` не
видається через API (лише CLI), не можна змінити роль самому собі й не можна
прибрати себе з організації. Кожне з них рятує від одного й того самого —
організації без жодного власника.
`DELETE` відкликає всі сесії цієї людини **в цьому тенанті**, не чіпаючи інші
організації. Зміна власного пароля відкликає всі сесії скрізь.
### `GET /api/v1/maps/{id}` — головний запит продукту
Одним викликом повертає готове до рендеру полотно: мапу, підкладки, вузли, ребра
**і живий стан**. Це не оптимізація: без підмішаного статусу мапа малювалася б сірою
й лише потім доганяла кольори сотнею дозапитів.
```jsonc
{
"id": "…", "name": "Автомапа", "layout_algo": "manual",
"viewport": {"x":0,"y":0,"zoom":1},
"grid": {"enabled":true,"size":16,"snap":true},
"clustering": {"enabled":true,"zoom_threshold":0.4},
"backgrounds": [
{"kind":"image","storage_key":"s3://…/floorplan.svg","opacity":0.6,
"x":0,"y":0,"width":2400,"height":1600,"locked":true}
],
"nodes": [
{"id":"…","kind":"device","label":"core-sw","x":100,"y":200,
"device_id":"…","style":{"icon":"switch"},
"status":"up","rtt_ms":0.113,"loss_pct":0} // ← фарбує вузол
],
"edges": [
{"id":"…","source_node_id":"…","target_node_id":"…",
"source_port":"Gi0/1","target_port":"ether1","label":"Gi0/1 → ether1",
"style":"smoothstep","waypoints":[],
"animation":{"enabled":true,"speed_source":"utilization"},
"thresholds":{"warn_pct":70,"crit_pct":90},
"link_status":"up","util_pct":7.2,"capacity_bps":10000000000}
]
}
```
**`link_status` виводиться з кінців лінка, а не читається з колонки.** У схемі є
`topo.links.status`, але її ніхто не підтримує: писати туди означало б оновлювати всі
лінки пристрою на кожну зміну його статусу й тримати це узгодженим. Стан лінка —
похідна величина, тож рахується на читанні. Порядок гілок важливий: обрив перекриває
все інше, а «невідомо» стоїть перед «up», щоб мапа не малювала зеленим те, чого ще
жодного разу не опитували.
**`rtt_ms` і `loss_pct` беруться лише за останні 15 хвилин** (`ts.device_last_icmp`).
Старіші дані не характеризують поточний стан, і показувати їх означало б брехати про
живість пристрою.
### `PATCH /api/v1/maps/{id}` — редактор
```jsonc
{
"revision": 7, // ревізія, яку бачив клієнт
"viewport": {"x":0,"y":0,"zoom":1.2},
"nodes": {
"upsert": [
{"id": "…", "x": 1500, "y": 640}, // перетягування
{"client_id": "tmp-1", "kind": "cloud", // новий вузол
"label": "Інтернет", "x": 900, "y": 100}
],
"remove": ["…"]
},
"edges": {
"upsert": [{"client_id": "tmp-e", "source_node_id": "…",
"target_node_id": "tmp-1", "style": "bezier"}]
},
"comment": "додав аплінк"
}
```
Відповідь повертає нову ревізію й мапу `client_id → id`:
```json
{"revision": 8, "node_ids": {"tmp-1": "…"}, "edge_ids": {"tmp-e": "…"}}
```
**Усі скалярні поля — необов'язкові, і відсутнє означає «не чіпати».** Це не
формальність: перетягування шле лише `x`/`y`, і якби відсутні поля трактувались як
порожні, кожен рух миші стирав би стиль, розмір і прив'язку до пристрою.
**Ребро може посилатися на вузол, створений у цьому ж патчі**, за `client_id`
інакше намалювати зв'язок до нового вузла вимагало б двох запитів і проміжного стану.
**Оптимістичне блокування.** Клієнт надсилає ревізію, яку бачив; якщо мапу встиг
змінити хтось інший — `409 revision_conflict` із поточним номером, а не тихе
затирання. У NOC над однією мапою часто працюють кілька людей, і мовчазна втрата
чужих правок гірша за помилку. Пропустити `revision` можна, але це вимикає перевірку —
так робить лише серверний код.
**Кожна правка лишає знімок** у `topo.map_revisions` (зберігаються останні 50) — це
основа undo. Знімок пишеться тією ж транзакцією, що й зміна: інакше після збою в
історії лишався б крок, якого в мапі немає, і відкат ламав би її.
Невідоме поле в тілі — `400`. Мовчки проковтнути друкарську помилку клієнта означає,
що правка «збереглася», але не застосувалась.
| Код | Коли |
|-----|------|
| `409 revision_conflict` | мапу змінив хтось інший |
| `402 plan_limit` | упёрлись у ліміт тарифу (тригер у БД) — UI має показати пропозицію змінити тариф |
| `400 invalid` | чужий вузол, невідоме поле, некоректний uuid |
### `POST /api/v1/maps/{id}/build` — автопобудова
Додає на мапу пристрої, що беруть участь у виявлених лінках, і ребра між ними.
**Ідемпотентна:** наявні вузли не дублюються, а координати не чіпаються — інакше
кожен запуск скидав би розкладку, яку оператор робив руками.
Нові вузли розставляються сіткою. Осмислену розкладку дає лише клієнт (він знає
розміри полотна й алгоритм), а сервер має покласти їх хоч кудись, але не в одну точку.
```json
{"nodes_added": 2, "edges_added": 1, "revision": 2}
```
### `POST /api/v1/maps/{id}/undo` — відкат
Повертає полотно до попереднього знімка з `topo.map_revisions`.
```json
{"revision": 11, "restored_from": 9}
```
**Відкат оформлюється як НОВА ревізія, а не як відмотування лічильника.** Інакше
клієнти, що тримають номер 10, після повернення до 9 отримали б «свою» ревізію знову
актуальною й тихо перезаписали б відкочене. Undo — така сама зміна, як будь-яка інша,
і має рухати історію вперед.
**Ідентифікатори вузлів зберігаються.** Відновлений вузол повертається з тим самим
`id`, тому ребра, що на нього спираються, знову працюють, а виділення в UI й зовнішні
посилання не ламаються.
Відновлення йде в порядку «ребра геть → вузли геть → вузли назад → ребра назад»:
зовнішні ключі не дозволяють інакше.
`409 nothing_to_undo`у щойно створеної мапи історії ще немає.
Sec-WebSocket-Protocol: netpulse.token.<токен>
```
Браузерний WebSocket API не дозволяє довільні заголовки, тому токен їде підпротоколом.
Сам токен при цьому не потрапляє в URL, а отже і в логи проксі.
**Клієнт → сервер**
```json
{"type": "subscribe", "map_id": "…"}
```
Мапа перевіряється на належність тенанту: вгаданий id не відкриє чужу топологію.
**Сервер → клієнт**
| `type` | Коли | Вміст |
|--------|------|-------|
| `hello` | одразу після рукостискання | `tenant_id` |
| `subscribed` | підтвердження підписки | `map_id` |
| `error` | напр. чужа мапа | `code`, `map_id` |
| `device.status` | зміна статусу пристрою | `device_id`, `status`, `previous_status`, `reason` |
| `link.load` | кожні 5 с для підписаної мапи | `map_id`, `links: [{link_id, status, util_pct}]` |
| `map.updated` | мапу змінив інший клієнт | `map_id`, `revision` |
`map.updated` несе лише номер ревізії, а не сам патч: клієнт сам вирішує, чи
перечитувати полотно. Розсилати зміни дельтами означало б тримати на сервері модель
того, що бачить кожен клієнт, — а це вже спільне редагування з CRDT, окрема задача.
Дві частоти навмисно різні. Зміна статусу — **подія**: рідка, але має дійти майже
миттєво, інакше мапа бреше про стан мережі. Завантаження каналу — **величина**: вона
змінюється весь час, і слати її частіше, ніж оновлюються лічильники (60 с), означає
слати ту саму цифру по колу.
### Звідки беруться події
`core.event_outbox` наповнюється **тією ж транзакцією**, що й зміна, яку описує.
Інакше WebSocket міг би розповісти про перехід, якого в базі ще (або вже) немає.
Транспорт — опитування таблиці раз на секунду, а не `LISTEN/NOTIFY`. NOTIFY не
переживає падіння підписника й обмежений 8 КБ на повідомлення, а тут потрібна
гарантія, що жодна зміна статусу не загубиться між перезапусками API. Ціна — один
дешевий запит за індексом.
Нова сесія починає з кінця журналу: клієнт щойно завантажив повний стан мапи, і все
старіше в ньому вже враховано.
Підписник, який не встигає читати, **відключається**, а не сповільнює решту: тримати
його чергу означало б віддавати пам'ять і затримувати всіх інших.
## Стан перевірки
Тести працюють проти справжньої БД, справжнього HTTP і справжнього WebSocket
(`NETPULSE_TEST_DSN`; без змінної пропускаються). `go test -race`усі проходять.
| Тест | Що доводить |
|------|-------------|
| `TestAuthRequired` | без токена й з чужим токеном — 401; `healthz` відкритий |
| `TestMapStateIsRenderReady` | одним викликом приходять підкладка, координати, статус вузла, RTT, порти ребра, `util_pct`, `capacity_bps`, налаштування анімації |
| `TestLinkStatusFollowsEndpoints` | лінк червоніє, коли впав кінець або порт, — без жодних змін у `topo.links` |
| `TestTenantIsolation` | чужа мапа за точним id → 404; у переліку пристроїв немає чужого тенанта |
| `TestBadMapID` | некоректний uuid → 400, а не 500 |
| `TestWebSocketRequiresToken` | без токена з'єднання не відкривається |
| `TestWebSocketDeliversStatusChange` | справжній батч телеметрії → `applyDeviceStatus` → outbox → hub → браузер отримав `device.status` із `previous_status` |
| `TestWebSocketRejectsForeignMap` | підписка на чужу мапу відхилена |
| `TestWebSocketPushesLinkLoads` | періодичний `link.load` із реальним `util_pct` |
| `TestPatchMoveNodeKeepsOtherFields` | драг шле лише x/y — підпис, прив'язка до пристрою й статус не затираються |
| `TestPatchRevisionConflict` | друга вкладка зі старою ревізією отримує 409, перша правка ціла |
| `TestPatchCreatesNodeAndEdgeTogether` | ребро прив'язується до вузла, створеного тим же патчем, за `client_id` |
| `TestPatchDeleteNodeRemovesEdges` | видалення вузла не лишає ребер у нікуди |
| `TestPatchStoresRevisionSnapshot` | знімок із коментарем і повним складом вузлів |
| `TestPatchRejectsForeignNode` | вузол чужої мапи не редагується через свою |
| `TestPatchRejectsUnknownField` | друкарська помилка в клієнті — 400, а не тиха втрата |
| `TestPatchRequiresWriteScope` | токен `maps:read` не пише, але читає |
| `TestBuildFromTopologyIsIdempotent` | повторна побудова нічого не додає й не скидає ручну розкладку |
| `TestPatchBroadcastsToOtherViewers` | правка долітає до інших відкритих полотен як `map.updated` |
| `TestUndoRestoresNodePosition` | відкат повертає координати й створює нову ревізію, а не відмотує лічильник |
| `TestUndoRestoresDeletedNodeWithEdges` | видалений вузол повертається **з тим самим id**, ребра прив'язуються назад, живий стан лінка на місці |
| `TestUndoWithoutHistory` | у мапи без історії — `409 nothing_to_undo` |
| `TestUndoRequiresWriteScope` | токен `maps:read` не відкочує |
### Живий прогін
Агент, `netpulse-server` і `netpulse-api` запущені разом проти справжнього `snmpd`:
```
мапа: Автомапа | вузлів: 2 | ребер: 1
вузол snmp-host статус=up rtt=0.113 мс loss=0
вузол gateway статус=up rtt=0.827 мс loss=0
ребро eth0 → ? порти=eth0->None лінк=up util=7.0e-06% capacity=10000000000
зонд probe-snmp: online, linux/amd64, пристроїв=2,
health={rss_bytes: 11624464, dropped_samples: 0, clock_skew_ms: 0}
```
`target_port` порожній — і це правильно: лінк знайдено через ARP, а ARP не повідомляє
порт віддаленої сторони. Заповнить його LLDP, коли поруч буде обладнання, що його шле.
### Живий прогін редактора
Проти справжніх даних, зібраних агентом:
```
1) створюємо порожню мапу map_id=a4eb2025…
2) будуємо з виявленої топології +2 вузлів, +1 ребер, ревізія 2
3) читаємо полотно вузли snmp-host(380,120) і gateway(120,120), обидва up
4) пересуваємо вузол нова ревізія 3
5) той самий патч зі старою ревізією 409 revision_conflict
6) повторна автопобудова +0 вузлів, +0 ребер
7) координати після побудови x=1500 y=640 — ручна розкладка збережена
знімків в історії: 2
```
## Опитування хоста
Хост сам по собі нічого не робить. Опитує його `core.checks` — рядок
«пристрій X, тип перевірки Y, кожні N секунд, з такими параметрами».
Тому `POST /api/v1/devices` приймає перевірки одразу:
```jsonc
{
"name": "sw-core-1",
"address": "10.0.0.1",
"kind": "switch",
"credential_ids": ["…"],
"checks": [
{"check_type": "icmp.ping", "params": {"count": 3}, "interval_sec": 30},
{"check_type": "snmp.if", "params": {"use_hc_counters": true}, "interval_sec": 300}
]
}
```
Створювати хост без перевірок можна, але це свідомий вибір: такий хост
лежить у списку й не опитується ніколи. Форма в UI попереджає про це
прямим текстом і починає новий хост із `icmp.ping` — єдиної перевірки,
яка працює будь-де без налаштування.
`GET /api/v1/check-types` віддає перелік із `params_schema` (JSON Schema)
для кожного типу. Форма будує поля з неї, а не зі свого списку: інакше
кожен новий тип перевірки, доданий плагіном, вимагав би перезбирання
фронтенду. Поле `available` каже, чи плагін увімкнений цьому тенанту —
базові (`is_core`) доступні завжди, решта потребує запису в
`core.plugin_installs`.
**Правка перевірок іде за `id`, а не перестворенням.** Унікальний індекс
`checks_uniq` включає `md5(params)`, тому «видалити й вставити» на зміні
параметрів створило б ДРУГУ перевірку того самого типу. Плюс
перестворення скидає `next_run_at` і збиває рівномірність опитування по
всьому парку.
`PUT /api/v1/devices/{id}/checks` замінює набір цілком: форма показує
повний список, і зняту перевірку треба вміти зняти. Перевірки на
інтерфейсах (`interface_id IS NOT NULL`) не чіпаються — їх заводить
автовиявлення, і форма хоста про них не знає.
Породжені шаблоном (`template_id IS NOT NULL`) — так само: ними володіє
реконсиляція, і видалення тут означало б, що вони зникають на кожне
збереження форми, щоб за секунду з'явитися знову. З цієї ж причини
`GET /api/v1/devices/{id}/checks` їх не показує: у формі ручних
перевірок їм нема що робити.
### Шаблони опитування
Те, що знімається з Mikrotik, однакове на всіх Mikrotik. Без шаблону цей
факт живе в голові інженера й повторюється стільки разів, скільки в
мережі пристроїв.
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/api/v1/templates` | довідник із лічильниками |
| `POST` | `/api/v1/templates` | створити (`devices:write`) |
| `GET` | `/api/v1/templates/{id}` | шаблон разом з елементами |
| `PUT` | `/api/v1/templates/{id}` | замінити цілком |
| `DELETE` | `/api/v1/templates/{id}` | видалити |
| `GET` | `/api/v1/devices/{id}/templates` | які шаблони на хості |
| `PUT` | `/api/v1/devices/{id}/templates` | замінити набір |
Шаблон також приймається полем `template_ids` у тілі хоста — разом із
`checks` і `credential_ids`, щоб форма зберігалася одним запитом.
```jsonc
{
"key": "mikrotik-crs",
"name": "Mikrotik CRS",
"vendor": "Mikrotik",
"items": [
{"name": "Температура", "oid": "1.3.6.1.4.1.14988.1.1.3.10.0",
"metric_key": "sensor.temp_c", "unit": "°C", "scale": 0.1, "interval_sec": 300}
]
}
```
**Шаблон описує перевірки будь-якого типу, не лише OID.** `snmp.get`
адресується OID-ом і збирається в пачку; `icmp.ping`, `http.status` і
`snmp.if` описуються полем `params` — тим самим, що лягає в
`core.checks.params`. Ділити на «пінг заводиться руками, а SNMP
шаблоном» означало б змусити людину пам'ятати, що саме шаблон покриває.
Для негрупованих типів елемент відповідає окремому чеку, і його сліду в
`core.checks.template_item_key` вистачає, щоб упізнати рядок. Ключ
елемента, а не його id: збереження шаблону перезаписує елементи цілком,
тож id живуть недовго.
**Елементи замінюються цілком, а не додаються.** Форма редагує шаблон як
один документ, і «прибрати метрику» має бути таким самим звичайним
рухом, як «додати». Крапку на початку OID сервер дописує сам: у
документації вендорів її пишуть, і відмовляти через символ, який нічого
не означає, — дурний спосіб витратити людині хвилину.
**Вбудовані шаблони (`tenant_id IS NULL`) не редагуються й не
видаляються** — `403 builtin`. Вони спільні для всіх тенантів, і правка
одного мовчки змінила б опитування в чужих мережах. Хто хоче свій
варіант — робить копію.
#### Реконсиляція в перевірки
Прив'язка шаблону не створює перевірку на кожну метрику. Елементи
групуються за **(шаблон, тип, інтервал)** в один `snmp.get`: агент уміє
питати список OID однією пачкою, і сотня окремих перевірок замість
однієї пачки — це сотня SNMP-сесій там, де досить кількох PDU.
Інтервал у ключі групування, бо пачка ходить цілком: змішавши хвилинну
метрику з п'ятихвилинною, ми або опитували б рідкісну надто часто, або
часту — надто рідко.
`core.checks.template_id` позначає породжені рядки. Без цієї позначки
відв'язування шаблону не знало б, що прибирати, а зміна OID плодила б
другу перевірку замість правки першої. `ON DELETE CASCADE`, а не
`SET NULL`: перевірка без шаблону, який її створив, нікому не належить —
вона б просто тихо опитувала пристрій вічно.
#### Обмін
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/api/v1/templates/export` | документ із шаблонами (`?ids=a,b` — вибрані) |
| `POST` | `/api/v1/templates/import` | залити документ |
Формат свій, не Zabbix-YAML: там елемент описується ключем виду
`snmp.get[...]`, до нього чіпляються препроцесинг, value maps і тригери —
нічого з цього тут поки немає, і вдавати сумісність означало б мовчки
втрачати половину імпортованого.
`update_existing` вирішує долю збігів за ключем: без нього наявний
шаблон іде в `skipped`. Мовчазне перезаписування — найшвидший спосіб
втратити локальні правки. Вбудований шаблон не чіпається ніколи: він
спільний для всіх тенантів.
Відповідь — три списки ключів: `created`, `updated`, `skipped`.
#### Як зміна доїжджає до зонда
Перевірки міняє REST-процес, а живу сесію зонда тримає AgentService —
інший процес. Звірка планів раз на п'ять секунд порівнює хеш плану в
базі з тим, що зараз у зонда, і перезаливає план при розбіжності.
**Повний план, а не дельта.** Дельта вміє додавати й міняти, але не
знає, що зникло; порівняння хешів теж не знає — воно каже лише
«інакше». Перезалив кількох тисяч задач раз на зміну дешевший за
перевірку, яка лишилась опитувати видалений хост.
Разом із планом ідуть модулі (у плані міг з'явитись перший `snmp`-чек
на зонді, де модуль не вмикали) і креденшели. Останнє знайдено живим
прогоном: хост, приписаний зонду вже після його підключення, отримував
задачі й падав на кожній із «немає SNMP-креденшелів» — пачка доступів
видається на `Hello`, а тоді цього хоста в ній ще не було.
### Реєстрація зонда
Досі зонд заводився `INSERT`-ом у БД, а токен вписувався в командний
рядок руками. На стенді це прийнятно; у клієнта — ні: людина, яка
ставить агента, не має доступу до бази й не повинна його мати.
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/api/v1/agent-enrollments` | запрошення тенанта |
| `POST` | `/api/v1/agent-enrollments` | видати одноразовий токен |
| `DELETE` | `/api/v1/agent-enrollments/{id}` | відкликати невикористане |
| `PATCH` | `/api/v1/agents/{id}` | імʼя, модулі, ліміти |
| `DELETE` | `/api/v1/agents/{id}` | видалити зонд |
**Токен повертається рівно один раз**у відповіді на створення. У базі
лежить лише його sha256. Якщо людина закрила вікно, не скопіювавши
команду, простіше створити нове запрошення, ніж тримати чинний доступ у
базі заради такого випадку.
Строк життя — доба, стеля тиждень. Запрошення, що живе місяць, перестає
бути запрошенням і стає постійним ключем у чиємусь чаті.
#### Обмін
`EnrollmentService.Enroll` (gRPC) міняє одноразовий токен на постійний.
Це **єдиний виклик без токена зонда**, і саме тому він виведений з-під
інтерсептора автентифікації за повним префіксом сервісу — не за
підрядком у назві методу, який колись відкриє ще щось.
Уся операція в одній транзакції під `FOR UPDATE`: два агенти, стартовані
з однієї скопійованої команди, інакше створили б два зонди з одного
запрошення. Перевірено — другий отримує `PermissionDenied`.
Відповідь на «немає», «згоріло» і «вже використано» однакова: розрізняти
їх означало б підказувати тому, хто підбирає токени, наскільки він
близько.
Токен зонда їде окремим полем `agent_token`, а не в `certificate`.
Сертифікат відповідає на інше питання — «чи має право говорити з
сервером» — і живе за іншим життєвим циклом; складати два різні секрети
в одне поле означає зафіксувати проміжний етап у протоколі назавжди.
mTLS у продукті є (сервер приймає `-client-ca`), але власний CA з
ротацією — окрема система, і вдавати, що вона вже працює, тут не варто.
#### Що робить агент
```
netpulse-agent -server netpulse.example.com:9443 -enroll np_enr_…
```
Отримане посвідчення лягає у `/etc/netpulse/agent.json` з правами 0600 —
через тимчасовий файл і перейменування, щоб обрив живлення посеред
запису не лишив зонд із половиною токена. Далі агент запускається без
жодних параметрів автентифікації: `-server` і файл посвідчення.
Якщо запис не вдався, повідомлення несе сам токен: інакше довелося б
створювати нове запрошення лише через те, що каталог виявився
недоступним.
### Історія метрик
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/api/v1/devices/{id}/series` | які метрики є в хоста + останнє значення |
| `GET` | `/api/v1/devices/{id}/metrics` | точки для графіка |
Параметри запиту точок: `series=1,2,3` (обов'язково, до 20),
`range=6h` або пара `from`/`to` в RFC3339, `points` (типово 300,
стеля 2000).
**Крок рахується з бажаної кількості точок**, а не приходить ззовні:
інакше вузьке вікно з дрібним кроком повернуло б десятки тисяч точок, з
яких екран покаже сотні.
**Джерело обирається за кроком** і повідомляється в полі `source`:
крок до 5 хвилин — сирі `ts.samples`, до години — роллап
`ts.samples_5m`, далі — `ts.samples_1h`. Читати сирі точки за місяць —
це мільйони рядків заради трьохсот пікселів; брати годинні бакети на
вікні в п'ять хвилин — це графік з однієї точки.
Значення точки може бути `null`. Пропуск і нуль — різні речі: лінія,
проведена через діру в даних, каже «все було добре», хоча насправді
нічого не відомо.
`series_id` приходить від клієнта, тож належність хосту перевіряється
явним запитом до `ts.series` під RLS. Самі `ts.*` під RLS не стоять
(несумісно зі стисненням), і без цієї перевірки чужий ідентифікатор
віддав би чужі дані.
### Доступи до обладнання
SNMP-community, паролі SSH і Telnet живуть в `inv.credentials`,
зашифровані тим самим кільцем, що й секрети каналів. Прив'язка до хоста —
`credential_ids` у тілі хоста. Без доступу працює лише `icmp.ping`.
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/api/v1/credentials` | перелік + `usage`: скільки хостів кожним користується |
| `POST` | `/api/v1/credentials` | створити |
| `PATCH` | `/api/v1/credentials/{id}` | змінити (порожній `secret` — лишити пароль) |
| `DELETE` | `/api/v1/credentials/{id}` | видалити разом із секретом |
**Секрет ніколи не повертається назовні.** Розшифрувати пароль заради
показу означає віддати його туди, звідки він уже не повернеться, тому
форма редагування показує порожнє поле: змінити пароль можна,
підглянути — ні. Порожній `secret` у `PATCH` означає «лишити як є».
Протокол доступу не змінюється після створення: зміна `snmp_v2c` на
`ssh` перетворює запис на інший об'єкт, і чесніше завести новий.
**Комплект доступів живе годину** (`CredentialTTL`). Зонд просить новий
за десять хвилин до кінця терміну і не частіше, ніж раз на хвилину.
Без цього поновлення зонд працював рівно годину: `Credentials()` свідомо
не віддає прострочені, щоб не блокувати облікові записи на пристроях, —
і після цього кожна перевірка падала з «немає креденшелів».
## Профілі збору конфігу
`ncm.profiles` описує, як зняти конфіг із конкретної платформи: які
команди виконати, за яким промптом ловити кінець виводу, що вирізати з
diff (`scrub_patterns`) і що замаскувати перед записом у Git
(`redact_patterns`).
Вбудовано **147 платформ на 67 вендорів** — Cisco, Huawei, Juniper,
MikroTik, Eltex, D-Link, HP, Brocade, Extreme, Alcatel, Allied Telesis,
Qtech, ZTE, BDCOM та інші.
Джерело істини — `db/profiles/catalog.json`; міграція з нього
породжується збіркою. Додати платформу означає відредагувати каталог,
а не писати SQL. Подробиці — `db/profiles/README.md`.
Промпти й команди вимкнення пейджера задані за родиною CLI (`cisco`,
`huawei`, `juniper`, `mikrotik`, `eltex`, …) і перевіряються на живому
залізі: одна родина покриває десятки платформ, і дрібні відхилення
трапляються. Команди збору натомість специфічні для платформи.
Тенант може завести власний профіль (`ncm.profiles` із заповненим
`tenant_id`) — вбудовані при цьому лишаються недоторканими.
### Збір конфігу
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `POST` | `/api/v1/devices/{id}/collect-config` | зібрати зараз (`ncm:write`) |
| `GET` | `/api/v1/devices/{id}/config-jobs` | історія збору |
| `GET` | `/api/v1/devices/{id}/configs` | версії конфігу |
| `GET` | `/api/v1/configs/{id}` | текст версії |
| `GET` | `/api/v1/configs/{id}/diff` | порівняння з попередньою (`?from=…`) |
| `GET` | `/api/v1/devices/{id}/backup-policy` | розклад бекапу хоста |
| `PUT` | `/api/v1/devices/{id}/backup-policy` | змінити розклад (`ncm:write`) |
| `GET` | `/api/v1/ncm-profiles` | довідник профілів для форми |
Ланцюг такий: REST кладе рядок у `ncm.jobs` зі станом `queued`
диспетчер усередині AgentService забирає його, якщо потрібний зонд на
зв'язку, і штовхає `ConfigJob` у живу сесію → зонд заходить по
SSH/Telnet, виконує команди профілю й вивантажує результат стрімом →
сервер звіряє sha256, дедуплікує за `content_hash` і закриває завдання.
**Черга в БД, а не прямий виклик**, бо REST і AgentService — різні
процеси, і живу сесію зонда тримає лише другий. Черга робить передачу
явною й переживає перезапуск обох. Вибірка йде під `FOR UPDATE SKIP
LOCKED`: два екземпляри AgentService не надішлють одне завдання двічі.
Повторний збір незміненого конфігу дає статус `unchanged` — пристрій
опитано, конфіг звірено, нового коміту не потрібно. Це успіх, а не
відсутність результату, і окремий статус потрібен, щоб у журналі було
видно, коли конфіг востаннє **справді** мінявся.
### Розклад
Розклад буває спільний і поштучний.
| Метод | Шлях | Призначення |
|-------|------|-------------|
| `GET` | `/api/v1/ncm/backup-defaults` | спільний розклад тенанта |
| `PUT` | `/api/v1/ncm/backup-defaults` | змінити спільний |
Спільний розклад заводить політику кожному придатному хосту з
`follows_default = true` і протягує на них свій cron. Хост, якому задали
власний розклад через `PUT /devices/{id}/backup-policy`, прапорець
втрачає — і зміни спільного його більше не чіпають. Це не порівняння
значень, а саме прапорець: власний розклад може випадково збігтися зі
спільним, і тоді зміна спільного мовчки потягла б за собою хост, який
навмисно налаштували окремо.
`apply_to_all` повертає під спільний розклад усіх, включно з тими, хто
має власний. Руйнівно, тому окремим прапорцем, а не побічним ефектом
збереження. Відповідь містить `following_count` і `custom_count` — без
цих двох чисел форма не каже головного: кого саме зачепить зміна.
Вимкнення спільного розкладу зупиняє лише тих, хто йому слідує. Хост із
власним розкладом налаштували свідомо.
`PUT /api/v1/devices/{id}/backup-policy` приймає:
```json
{
"enabled": true,
"cron": "0 3 * * *",
"profile_id": "",
"credential_id": ""
}
```
Порожні `profile_id` і `credential_id` означають «підібрати
автоматично»: профіль — за виробником хоста, доступ — прив'язаний до
хоста. Явно заданий завжди виграє: так лікується прошивка, що
поводиться не як решта родини.
**Вираз cron розбирається власним парсером** (`internal/cronx`),
п'ятипольовий, як у crontab: хвилина, година, день місяця, місяць,
день тижня. Підтримані `*`, списки `1,2`, діапазони `8-20`, кроки
`*/15` і `8-20/4`, назви місяців і днів (`jan`, `mon`), неділя і як
`0`, і як `7`. Якщо задані одночасно день місяця й день тижня,
підходить збіг за **будь-яким** — це правило самого cron, інакше
«щоп'ятниці та першого числа» не записати.
Некоректний вираз відхиляється одразу з `400 bad_cron` і людським
поясненням (`розклад: години: 99 поза межами 0..23`) — краще сказати це
у формі, ніж мовчки не робити бекапів.
Планувальник усередині AgentService прокидається раз на хвилину (cron
дрібніший за хвилину не буває) під advisory-блокуванням, тож у кластері
розклад розкручує рівно один екземпляр. Порядок кроків — спершу
перенести `next_backup_at`, потім поставити завдання: падіння між ними
коштує одного пропущеного бекапу, а зворотний порядок дав би
нескінченну чергу однакових завдань.
`next_backup_at IS NULL` вважається «час настав»: так виглядає щойно
збережена політика, і чекати добу до першого бекапу означало б не
зробити його тоді, коли він найпотрібніший. З цієї ж причини будь-яка
зміна політики обнуляє позначку.
Зависле в `running` завдання повертається у відмову за десять хвилин:
зонд міг зникнути разом із ним, і без цього хост лишився б без бекапів
назавжди.
### Порівняння версій
`GET /api/v1/configs/{id}/diff` без параметрів порівнює версію з
попередньою — саме це питання ставлять у дев'яти випадках із десяти:
«що змінилось цього разу». Явне `?from=` дає порівняння з будь-якою
іншою версією.
```jsonc
{
"hunks": [{
"old_start": 8, "old_lines": 3, "new_start": 8, "new_lines": 4,
"lines": [
{"op": "=", "old_num": 8, "new_num": 8, "text": "ID=debian"},
{"op": "+", "new_num": 11, "text": "new-ct"}
]
}],
"lines_added": 1,
"lines_removed": 0
}
```
Результат кешується в `ncm.diffs`: порівняння двох конкретних версій
незмінне назавжди, і рахувати його щоразу при відкритті сторінки —
це палити процесор на відому відповідь. Підсумок `+N/M` дозаписується
й у саму версію, щоб список історії показував його без розшифровки двох
тіл на кожен рядок.
Перша зібрана версія повертає `{"first": true}` — порівнювати нема з
чим, і це не помилка.
Дуже великі версії, що розійшлися повністю, дають `"truncated": true` і
грубу заміну блоку замість порядкових змін: точне порівняння там
коштувало б квадратичного часу, а користі не дало б — людині однаково
доведеться читати весь блок. Чесна позначка краща за правдоподібний,
але вигаданий diff.
Тіло конфігу лежить у БД зашифрованим (`core.secrets`), тому без ключа
процес відповідає `503`, а не порожнім рядком: мовчазна порожнеча
виглядала б як «пристрій віддав порожній конфіг».
## Групи й доступ до хостів
Два незалежні виміри, які не можна змішувати в одному списку прав:
- **Роль** відповідає на питання «що людині вільно робити» — `maps:write`,
`alerts:ack`, `users:write`.
- **Група доступу** відповідає на «над якими хостами». Інженер над однією
філією та інженер над усією мережею мають однакову роль і різний доступ.
Група доступу (`core.user_groups`) містить людей і видає права на групи хостів
(`inv.device_groups`) одним із трьох рівнів: `read`, `write`, `deny`.
### Три правила обчислення
1. **Хто не входить у жодну групу — не обмежений групами взагалі.** Це свідомо
не по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова
інсталяція починається з питання «чому порожньо». Тут звуження вмикається
тоді, коли його справді налаштували, а до того доступ визначає роль.
2. **Заборона перемагає дозвіл.** Хост у двох групах, де одна дає читання, а
друга забороняє, лишається невидимим — інакше заборону можна обійти,
додавши об'єкт у будь-яку іншу групу.
3. **Серед дозволів виграє найширший.** Читання в одній групі й запис у другій
дають запис.
Обчислення живе в БД (`core.accessible_devices`) і читається раз на запит:
питати про кожен хост окремо означало б перетворити список на N запитів рівно
тоді, коли хостів багато.
Звуження діє на `/api/v1/devices`, `/api/v1/alerts`, лічильники алертів і
операції запису (редагування хоста, заглушення). Алерт **без** пристрою
(наприклад, про сам зонд) видно всім: сховати його від обмеженого користувача
означало б приховати аварію, до якої групи не мають стосунку.
### Живий прогін груп
```
1) вхід за логіном 'admin' 200, роль owner
2) вхід тим самим, але поштою 200 — сумісність збережена
3) створено дві групи хостів 201/201
4) хости розкладено gateway→Магістраль, snmp-host→Доступ
5) новий хост через API 201
6) лічильники груп Доступ=2, Магістраль=1
7) група доступу для 'eng' лише «Доступ», рівень read
8) що бачить eng snmp-host, test-host — обидва writable=false
gateway зник із вибірки
9) алерти під eng 1 замість 3
10) eng редагує чужий хост 403
11) видалення хоста власником 204
```
## Алерти
Движок правил живе всередині `netpulse-api` і обчислюється раз на
`-alert-interval` (за замовчуванням 30 с). Кілька екземплярів API за
балансувальником безпечні: тік бере `pg_try_advisory_lock`, тож правила
рахує рівно один — дедуплікацію алертів захищає індекс, а от сповіщення
пішли б у кількох копіях.
Движок не тримає стану між тіками. Вікно `for_seconds` — це запит по
часу до TSDB, а не лічильник у пам'яті, тому перезапуск процесу нічого
не збиває.
### Умови правил
```jsonc
// Усі виміри вікна мають задовольняти умову — це і є антифлап.
{"metric": "loss_pct", "op": ">", "value": 20}
// Агрегат за вікно порівнюється один раз.
{"metric": "rtt_avg_ms", "op": ">", "value": 150, "agg": "avg"}
// Даних немає взагалі — моніторинг, що мовчить про мертвий зонд,
// показує зелену мапу мертвої мережі.
{"metric": "no_data"}
// Довільна серія з ts.samples.
{"metric_key": "cpu.util", "op": ">", "value": 85, "agg": "avg"}
```
`op`: `> >= < <= == !=`. `agg`: `avg min max sum count last`.
Метрики: для `icmp``rtt_avg_ms`, `rtt_min_ms`, `rtt_max_ms`,
`jitter_ms`, `loss_pct`, `reachable`; для `interface``in_bps`,
`out_bps`, `in_pps`, `out_pps`, `util_in_pct`, `util_out_pct`,
`in_errors`, `out_errors`, `in_discards`, `out_discards`, `oper_up`.
Списки закриті навмисно: значення з `condition` потрапляє в текст
запиту, і будь-яке послаблення тут перетворюється на SQL-ін'єкцію через
JSON у таблиці правил.
`selector` обмежує область: `device_ids`, `group_ids`, `site_ids`,
`kinds`, `vendors`, `tags`. Порожній означає «до всього» — найчастіший
випадок, і вимагати для нього переліку означало б ламати правило щоразу,
коли додається пристрій.
### Кореляція за топологією
Правило з `depends_on_topology` (типово увімкнено) не піднімає алерти на
пристроях, що стоять за іншим недоступним пристроєм. Причина аварії — на
межі зони недоступності: у кого лишився хоч один живий сусід, той упав
сам; хто оточений виключно мертвими — наслідок.
Пристрій без жодного відомого зв'язку завжди лишається причиною:
топологія про нього нічого не знає, тож списати його на чужу аварію
було б вигадкою. Повністю мертвий острів алертує весь — краще зайвий
шум, ніж мовчазне ковтання цілого сегмента.
### Придушення
- **Вікна обслуговування** (`alr.maintenance_windows`) — за розкладом;
вікно без селектора накриває весь тенант.
- **Ручне заглушення** (`alr.mutes`) — кнопка в UI, стеля 7 днів.
Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути
моніторинг назавжди.
Придушений алерт лишається видимим у списку (окремим фільтром), але не
надсилає сповіщень.
### Куди йде алерт
Порядок вирішення: **канали самого правила → маршрути тенанта → усі
придатні канали.** Кожен наступний крок — це відповідь на «а якщо нічого
не налаштовано», і останній навмисно не мовчить.
Правило приймає в тілі:
```jsonc
{
"channel_ids": ["…"], // порожньо — за маршрутами тенанта
"notify_on_resolve": true, // «впало» без «піднялося» знецінює саме себе
"notify_schedule": {"tz": "Europe/Kyiv",
"quiet": [{"from": "23:00", "to": "07:00"}]},
"selector": {"group_ids": ["…"]} // порожньо — усі хости
}
```
**Канали правила перекривають маршрути повністю.** Інакше «шліть це
черговому» перетворювалося б на «шліть це черговому і ще туди, куди
вирішить спільна політика».
Тиха година правила глушить усе, крім `disaster` — те саме правило, що
в маршрутах. Вимкнений канал не отримує алерт навіть тоді, коли правило
назвало його явно: вимкнення — це рішення про канал, а не про правило.
`PUT /api/v1/alert-rules/{id}` замінює правило цілком.
**`enabled` на правці не домислюється.** Поля немає в тілі — стан
перемикача лишається таким, яким був. Раніше сервер підставляв `true`
кожному, хто поля не надіслав, і правка вимкненого правила мовчки його
вмикала. На СТВОРЕННІ відсутнє поле досі означає «увімкнене»: правило,
заведене вимкненим, не робить нічого й виглядає як забуте.
**Вимкнення правила гасить його активні алерти**і через `PATCH`, і
через `PUT`. Без цього вони висіли б у `firing` вічно: вимкнене правило
випадає з вибірки движка, тобто закрити їх немає кому, а драбина
ескалації справно будила б за ними людей тижнями.
### Ескалація
Сповіщення, надіслане один раз, нічого не гарантує: черговий може спати.
Драбина ескалації відповідає на питання «а якщо ніхто не прочитав» —
через N хвилин мовчання піднімається наступний за списком.
```jsonc
{
"name": "Нічне чергування",
"steps": [ // after_min рахується від ПОЧАТКУ алерту
{"after_min": 15, "channel_ids": ["…черговий"]},
{"after_min": 45, "channel_ids": ["…керівник зміни"]}
],
"repeat_after_min": 60, // 0 — не повторювати драбину
"max_repeats": 2,
"respect_quiet_hours": false // false (типово) — драбина йде і в тиху годину
}
```
Драбина прив'язується **до правила** (`escalation_policy_id` у тілі
правила). Порожньо — без ескалації, і це типове значення: після
оновлення жоден кабінет не починає будити людей сам собою. Прив'язка до
серйозності дала б одну драбину на всі `high` у кабінеті (а `high` на
тестовому комутаторі й на ядрі — різні люди), прив'язка до групи хостів
— однакову драбину для «завантаження порту» й «пристрій не відповідає».
Правила проходження:
- **Стан алерту перевіряється перед КОЖНОЮ сходинкою**, а не один раз при
взведенні. Підтверджений або закритий алерт зупиняє драбину — ескалація
не воскрешає мертве.
- **Заглушення й вікно обслуговування сходинку не витрачають**, а
відкладають: вікно на пів години інакше тихо роззброїло б драбину до
кінця життя алерту. Відкладання обмежене стелею життя драбини.
- **Драбина взводиться лише тоді, коли перше сповіщення справді пішло.**
Якщо каналів не знайшлось (поріг серйозності, вимкнений канал,
порожній перелік), ескалації не буде: інакше о 15-й хвилині пішло б
те, що на нульовій свідомо не надсилали.
- **Тиха година до цієї умови не належить**: канали є, просто зараз ніч.
Драбина взводиться в будь-якому разі, а `respect_quiet_hours` вирішує,
що вона робить із сходинкою, яка припала на тиху годину правила:
`false` (типово) — доставляє, бо тиха година стримує ПЕРШЕ сповіщення,
а драбина йде саме тоді, коли на перше ніхто не відповів; `true`
відкладає до кінця тихої години, не витрачаючи сходинки (як заглушення
й вікно обслуговування). Серйозність `disaster` проходить за будь-якого
значення — той самий виняток, що вже діє для тихої години маршруту й
правила. Відсутнє поле в запиті = `false`.
- **Подієві алерти** (`syslog`, `ncm`, `compliance`) проходять драбину
один раз, без повторів. Повтор — це ставка на те, що проблема триває, а
її можна робити лише там, де існування алерту саме по собі є доказом:
метричний алерт зникає, щойно умова перестала виконуватись, подієвий —
ні.
- Стан драбини живе в базі (`alr.alert_escalations`), а рішення пишеться
до надсилання. Перезапуск процесу посеред драбини не подвоює сходинку;
ціна — падіння між записом і надсиланням коштує однієї сходинки (та
сама угода, що й для черги подієвих алертів).
Журнал сходинок (`alr.escalation_steps`) фіксує і надсилання, і
НЕнадсилання з причиною — «сходинку 2 пропущено: підтверджено о 02:47».
Без цього на питання «чому мене розбудили» відповіді немає.
У `GET /api/v1/alerts` кожен алерт із живою драбиною має поле
`escalation` — назва драбини, скільки сходинок пройдено, коли наступна.
### Канали й маршрути
Канал зберігає несекретну частину в `config`, а токен — у
`core.secrets`, зашифрований тим самим кільцем, що й паролі від
обладнання. Перелік каналів секрети не розшифровує взагалі.
Тенант **без жодного маршруту** отримує сповіщення в усі придатні
канали: підключили Telegram — має працювати без додаткових налаштувань.
Маршрути з'являються тоді, коли треба розділити потоки.
Тихі години маршруту глушать усе, крім `disaster`: сенс чергування в
тому, щоб його підняли.
Вебхуки на внутрішні адреси заблоковані (SSRF), бо адресу задає
користувач тенанта, а запит іде з сервера. У self-hosted це знімається
прапорцем `-allow-private-webhooks` — там внутрішня мережа належить тому
самому, хто налаштовує вебхук.
### Живий прогін алертів
```
1) вхід власника 200
2) активні алерти 200, 1 алерт: gateway 0.98 мс > 0.5
snmp-host (0.08 мс) не спрацював — поріг посередині
3) правила 200, 3 правила, «no_data» і «помилки на порту» мовчать
4) канал webhook створено 201
5) перевірка каналу 200, ok=true — повідомлення дійшло
6) нове правило 201
7) після тіку 3 алерти, сповіщень по 1
8) підтвердження 200, стан=acknowledged
9) глядач пробує підтвердити 403
10) глядач читає алерти 200
дедуплікація: за три тіки алертів 3→3, сповіщень 2→2
вимкнення правила його алерти закрито, чужий ack не зачеплено
повернення правила алерти піднялись знову
заглушення пристрою рівно його алерт → suppressed (mute)
видалення правила його алерти закрито, сиріт не лишилось
доставок за прогін 5 на 5 подій — жодного повтору
```
### Живий прогін входу
```
1) вхід власника 200, роль owner, прав 20, кукі: ['np_refresh']
2) хто я 200, admin@netpulse.local
3) власник читає команду 200, учасників 2
4) вхід інженера maps:write=True, users:write=False
5) інженер пробує читати команду 403 forbidden
6) інженер читає мапи 200, мап 1
7) невірний пароль 401 bad_credentials
8) вихід 204; refresh після виходу — 401 no_session
```
Права так само доводяться до UI: під глядачем (`devices:read`, `maps:read`,
`alerts:read`) у шапці лишається сам вибір мапи й вихід — кнопки «Добудувати» та
«Відкотити» зникають, полотно стає нередагованим, а блок зондів у бічній панелі не
показується взагалі.
## Чого ще немає
- **Історія метрик**: `GET /api/v1/metrics` для графіків із `ts.samples_5m` не написано.
- **Ескалації**: `alr.escalation_policies` і повторні сповіщення не реалізовані —
сповіщення надсилається один раз при піднятті алерту.
- **Web Push і Telegram-кнопки**: `alr.push_subscriptions` порожня, а `callback_data`
кнопок Ack/Mute у Telegram нікуди не приходить — бот-приймач не написано.
- **Правила з джерел `syslog`, `trap`, `ncm`, `compliance`**: движок їх свідомо
пропускає, бо вони обробляються подіями, а не опитуванням.
- **Редагування правил**: `PATCH` міняє лише `enabled`; умову правила змінюють
перестворенням.
- **Завантаження підкладок**: `map_backgrounds.storage_key` можна задати патчем, але
самого прийому файлів (S3/MinIO) ще немає.
- **Спільне редагування** обмежене оптимістичним блокуванням: одночасний драг двома
людьми закінчиться 409 для того, хто спізнився. Для повноцінної співпраці потрібен
CRDT — свідомо відкладено.