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

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

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

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

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

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

91 KiB
Raw Blame History

NetPulse API — REST і WebSocket для фронтенду

go build -o netpulse-api ./cmd/netpulse-api
./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:

./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:

{
  "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 віддає РЯДКИ, на які зразок наліг, із номерами:

{
  "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 — вхід

{"login": "admin", "password": "…", "tenant_id": "…"}  // tenant_id — опційно

Поле login приймає і логін, і пошту. Старе email теж лишається робочим: інтеграції, написані до перейменування, не мають ламатися через назву ключа.

Успіх:

{
  "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:

{"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

{"tenant_id": "…", "user_id": "…", "email": "admin@acme.io", "permissions": [...]}

Для машинного токена замість user_id/email приходить token_name.

Формат логіна: 364 символи з латиниці, цифр, крапки, дефіса й підкреслення; перший і останній символ — літера або цифра. Перевіряється і в БД (CHECK), і в API — відмова БД виглядає як «violates check constraint», і людині з неї нічого не зрозуміло.

GET|POST|PATCH|DELETE /api/v1/team

Керування учасниками; усе, крім читання, потребує users:write.

PATCH міняє роль, профіль і пароль одним запитом:

{"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} — головний запит продукту

Одним викликом повертає готове до рендеру полотно: мапу, підкладки, вузли, ребраі живий стан. Це не оптимізація: без підмішаного статусу мапа малювалася б сірою й лише потім доганяла кольори сотнею дозапитів.

{
  "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} — редактор

{
  "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:

{"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 — автопобудова

Додає на мапу пристрої, що беруть участь у виявлених лінках, і ребра між ними. Ідемпотентна: наявні вузли не дублюються, а координати не чіпаються — інакше кожен запуск скидав би розкладку, яку оператор робив руками.

Нові вузли розставляються сіткою. Осмислену розкладку дає лише клієнт (він знає розміри полотна й алгоритм), а сервер має покласти їх хоч кудись, але не в одну точку.

{"nodes_added": 2, "edges_added": 1, "revision": 2}

POST /api/v1/maps/{id}/undo — відкат

Повертає полотно до попереднього знімка з topo.map_revisions.

{"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 приймає перевірки одразу:

{
  "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, щоб форма зберігалася одним запитом.

{
  "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 приймає:

{
  "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= дає порівняння з будь-якою іншою версією.

{
  "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, а не лічильник у пам'яті, тому перезапуск процесу нічого не збиває.

Умови правил

// Усі виміри вікна мають задовольняти умову — це і є антифлап.
{"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. Метрики: для icmprtt_avg_ms, rtt_min_ms, rtt_max_ms, jitter_ms, loss_pct, reachable; для interfacein_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 днів. Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути моніторинг назавжди.

Придушений алерт лишається видимим у списку (окремим фільтром), але не надсилає сповіщень.

Куди йде алерт

Порядок вирішення: канали самого правила → маршрути тенанта → усі придатні канали. Кожен наступний крок — це відповідь на «а якщо нічого не налаштовано», і останній навмисно не мовчить.

Правило приймає в тілі:

{
  "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 хвилин мовчання піднімається наступний за списком.

{
  "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 — свідомо відкладено.