Асиметрія: аварія о 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>
1325 lines
91 KiB
Markdown
1325 lines
91 KiB
Markdown
# 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`.
|
||
|
||
Формат логіна: 3–64 символи з латиниці, цифр, крапки, дефіса й підкреслення;
|
||
перший і останній символ — літера або цифра. Перевіряється і в БД (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 — свідомо відкладено.
|