Netpulse_SasS/server/API.md
byrsapty 96692ec9df Вхід за логіном, групи хостів і права доступу
Логін:
- core.users.username замість пошти як ідентифікатор: у мережевій
  інсталяції половина акаунтів технічні (noc, monitoring, oncall) і
  скриньки не мають узагалі. Пошта лишилась необов'язковим полем
- наявним користувачам логін виведено з пошти, збіги розведено
  суфіксом: мовчки злити admin@a.com і admin@b.com в один логін —
  це втрата акаунта, а не міграція
- сервер шукає за логіном і за поштою, тому звичка вводити email
  нікого не відхиляє

Групи (модель Zabbix):
- групи хостів і групи доступу; права read/write/deny на групу хостів
- роль каже, що вільно робити; група — над якими хостами. Інженер над
  філією та інженер над усією мережею мають однакову роль і різний
  доступ
- хто не входить у жодну групу, групами не обмежений — свідомо не
  по-заббіксівськи: там кожна нова інсталяція починається з питання
  «чому порожньо»
- заборона перемагає дозвіл, інакше її обійти додаванням у сусідню групу
- фільтр накладається в самому запиті, а не після вибірки

Хости: додавання, редагування, м'яке видалення, прив'язка до груп,
фільтр за групою в таблиці.

Мапа: створення мапи з транслітерацією slug, інспектор вузла — підпис,
значок, розмір, ширина, колір рамки, закріплення.

Виправлено за скаргами:
- перемикач вилазив на 14px за трек: у ручки не заданий left, а
  статичну позицію зсуває типове text-align: center у <button>
- сторінка правил не оновлювала «активних»: після повернення правила
  алерти піднімаються наступним тіком движка, тобто ПІСЛЯ нашого
  перечитування — бракувало підписки на живі події
- канал із секретом показувався як «секрету немає»

Живий прогін: eng у групі з read на «Доступ» бачить 2 хости замість 3,
обидва без запису, алертів 1 замість 3, редагування чужого хоста 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:00:04 +03:00

40 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 інвентар (звужений групами доступу)
POST /api/v1/devices додати хост (devices:write)
PATCH /api/v1/devices/{id} змінити хост і його групи
DELETE /api/v1/devices/{id} мʼяко видалити хост
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)
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/ws WebSocket: події та завантаження каналів

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.

Три обмеження вшиті навмисно й не обходяться параметрами: роль 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

Групи й доступ до хостів

Два незалежні виміри, які не можна змішувати в одному списку прав:

  • Роль відповідає на питання «що людині вільно робити» — 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 днів. Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути моніторинг назавжди.

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

Канали й маршрути

Канал зберігає несекретну частину в 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 — свідомо відкладено.