# 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 } ``` Драбина прив'язується **до правила** (`escalation_policy_id` у тілі правила). Порожньо — без ескалації, і це типове значення: після оновлення жоден кабінет не починає будити людей сам собою. Прив'язка до серйозності дала б одну драбину на всі `high` у кабінеті (а `high` на тестовому комутаторі й на ядрі — різні люди), прив'язка до групи хостів — однакову драбину для «завантаження порту» й «пристрій не відповідає». Правила проходження: - **Стан алерту перевіряється перед КОЖНОЮ сходинкою**, а не один раз при взведенні. Підтверджений або закритий алерт зупиняє драбину — ескалація не воскрешає мертве. - **Заглушення й вікно обслуговування сходинку не витрачають**, а відкладають: вікно на пів години інакше тихо роззброїло б драбину до кінця життя алерту. Відкладання обмежене стелею життя драбини. - **Драбина взводиться лише тоді, коли перше сповіщення справді пішло.** Якщо каналів не знайшлось (тиха година, поріг серйозності, вимкнений канал), ескалації не буде: інакше о 15-й хвилині пішло б те, що на нульовій свідомо не надсилали. - **Подієві алерти** (`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 — свідомо відкладено.