«Просить логін після кожного оновлення» — дві поломки в одному симптомі. Перша: RotateSession читав email::text без COALESCE, а пошта стала необов'язковою разом із входом за логіном. Користувач без адреси міг увійти й не міг поновити сесію взагалі. Друга: ротація не переживала подвійного обміну. React у режимі розробки виконує ефекти двічі, дві вкладки в проді дають ту саму гонку: перший запит відкликав токен, другий приносив уже відкликаний. Тепер core.sessions.replaced_by тримає ланцюг, вікно 30 секунд — вистачає на гонку від того самого клієнта й замало для реального повтору. PageBody один на всіх сторінках: відступи й прокрутка більше не залежать від того, хто писав сторінку. Заміряно — рівно 16 px скрізь. Мапа: видалення (ендпоїнт існував із Етапу 4, кнопки не було) з підтвердженням, і вигляд вузла — форма (картка/пігулка/крапка), товщина рамки, заливка, приховування підпису й цифр. Аварія й попередження світяться ореолом: рамка іншого кольору не помітна периферійним зором. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| migrations | ||
| profiles | ||
| tests | ||
| migrate.ps1 | ||
| README.md | ||
NetPulse — схема бази даних (Етап 1)
PostgreSQL 16+ / TimescaleDB 2.14+. Міграції в migrations/, накочуються по порядку номерів.
docker compose up -d db
pwsh ./db/migrate.ps1
Схеми (namespaces)
| Схема | Призначення |
|---|---|
core |
Ядро: тенанти, users, RBAC, секрети, агенти, плагіни, чеки, дашборди, аудит |
inv |
Інвентар: сайти, групи, пристрої, інтерфейси, IP, підмережі, креденшели |
topo |
Топологія: сирі сусіди (LLDP/CDP/ARP), зведені лінки, мапи, вузли, ребра, підкладки |
ts |
Time-series: hypertables + continuous aggregates |
ncm |
Config management: репо, профілі, задачі, версії конфігів, diff, compliance |
alr |
Alerting: правила, алерти, канали, ескалації, вікна обслуговування |
bill |
Тарифи, підписки, entitlements, usage, інвойси, ліцензійні ключі |
Порядок міграцій
| # | Файл | Що містить |
|---|---|---|
| 0001 | 0001_core.sql |
Розширення, домени, tenants, users, RBAC, секрети (AES-GCM), audit_log |
| 0002 | 0002_inventory.sql |
Sites, groups, devices, interfaces, addresses, subnets, credentials |
| 0003 | 0003_agents_plugins.sql |
Реєстр плагінів, агенти, типи чеків, checks, event outbox |
| 0004 | 0004_topology.sql |
Neighbors → links → maps → nodes/edges → backgrounds → revisions |
| 0005 | 0005_telemetry_timescale.sql |
Hypertables, CAGG, compression, retention, view'и для мапи |
| 0006 | 0006_ncm.sql |
Git-репо, профілі збору, jobs, configs, diffs, compliance, rollback |
| 0007 | 0007_alerting.sql |
Rules, alerts, channels, push, routes, maintenance, mutes |
| 0008 | 0008_dashboards.sql |
Дашборди, віджети (в т.ч. map), saved views, SLA |
| 0009 | 0009_billing_licensing.sql |
Plans, subscriptions, entitlements, usage, invoices, license keys |
| 0010 | 0010_seed.sql |
Довідники: права, ролі, плагіни, чеки, тарифи, NCM-профілі |
| 0011 | 0011_rls.sql |
Row Level Security + ролі БД |
0010 навмисно йде перед 0011: після
FORCE ROW LEVEL SECURITYнавіть власник схеми не зможе вставити довідникові рядки зtenant_id IS NULL.
ERD: ядро топології
erDiagram
TENANTS ||--o{ DEVICES : ""
TENANTS ||--o{ MAPS : ""
SITES ||--o{ DEVICES : ""
DEVICES ||--o{ INTERFACES : ""
DEVICES ||--o{ NEIGHBORS : "виявлено з"
NEIGHBORS }o--|| LINKS : "резолвиться в"
INTERFACES ||--o{ LINKS : "a_if / b_if"
MAPS ||--o{ MAP_NODES : ""
MAPS ||--o{ MAP_EDGES : ""
MAPS ||--o{ MAP_BACKGROUNDS : "floor plan / OSM / rack"
MAPS ||--o{ MAP_REVISIONS : "undo/redo"
DEVICES ||--o{ MAP_NODES : "kind='device'"
MAP_NODES ||--o{ MAP_EDGES : "source/target"
LINKS ||--o{ MAP_EDGES : "статус і трафік"
INTERFACES ||--o{ IF_COUNTERS : "трафік → анімація"
DEVICES ||--o{ ICMP_SAMPLES : "колір вузла"
Ключові рішення
1. Фізична топологія відокремлена від візуальної.
topo.links — те, що існує в мережі (виявлено LLDP/CDP або створено вручну).
topo.map_edges — як це намальовано на конкретній мапі. Один лінк може бути показаний на N мапах з різними стилями; видалення мапи не чіпає топологію. map_edges.link_id — джерело статусу й трафіку для лінії.
2. Порт-у-порт зв'язки.
І topo.links, і topo.map_edges посилаються на inv.interfaces, тому Switch1:Port1 → Router1:eth0 — це FK, а не текст. Унікальний індекс links_pair_uniq нормалізує пару через LEAST/GREATEST, щоб A→B і B→A не дублювались.
3. Автовиявлення не затирає ручну роботу.
topo.neighbors зберігає сире, як його віддав агент, плюс resolved_* і confidence. Резолвер зводить це в topo.links. Прапорець is_pinned захищає підтверджені людиною лінки від перезапису.
4. Дві моделі метрик.
ts.series+ts.samples— узагальнена (Prometheus-подібна): будь-який плагін реєструє свійmetric_keyбез зміни DDL.ts.icmp_samplesіts.if_counters— широкі таблиці для гарячих шляхів. Це те, що читає мапа на кожному тику WebSocket, і денормалізація тут окупається.
5. Анімація трафіку має конкретне джерело.
if_counters.util_out_pct (% від interfaces.speed_bps) → topo.link_live (гірша з двох сторін) → map_edges.animation.speed_source='utilization'. Кольори порогів — у map_edges.thresholds.
6. Продуктивність мапи на 1000+ вузлів.
map_nodes має GiST-індекс по point(x,y) для viewport-culling, map_edges — індекси по source/target. Повний стан полотна тягнеться одним запитом (nodes + edges + backgrounds + останні статуси через ts.device_last_icmp і topo.link_live), далі дельти йдуть по WebSocket.
7. Ліміти тарифу перевіряються двічі.
bill.entitlements — матеріалізовані права тенанта (читає API на кожному запиті, кешується в Redis). Плюс тригери bill.assert_device_limit() і bill.assert_map_node_limit() як друга лінія оборони на рівні БД.
8. Секрети ніколи не лежать відкрито.
core.secrets зберігає лише ciphertext + nonce + auth_tag + key_id (AES-GCM-256, шифрування на боці застосунку, DEK у KMS/Vault). Паролі SSH/SNMPv3, приватні ключі, токени каналів і тіла конфігів — усе через цю таблицю.
9. Тіло конфігів — у Git, метадані — у БД.
ncm.configs тримає commit_sha/blob_sha/path та content_hash для швидкої відповіді «змінилось?». Diff-и кешуються в ncm.diffs, щоб Telegram Mini App не рахував їх щоразу.
Ізоляція тенантів
API виставляє на кожній транзакції:
SET LOCAL app.tenant_id = '<uuid>';
Політика tenant_isolation (USING + WITH CHECK) вмикається автоматично на кожній таблиці з колонкою tenant_id. Якщо змінна не виставлена — core.current_tenant() повертає NULL і запит дає порожній результат: це навмисно безпечніше за «усі тенанти».
Свідомі винятки, які треба тримати в голові:
- Усі 12 hypertables — RLS вимкнено. TimescaleDB відмовляє:
operation not supported on hypertables that have columnstore enabled, тобто RLS несумісний зі стисненням, а стиснення увімкнене на всіх гарячих time-series таблицях. Виключено гіпертаблі цілком, а не вибірково — інакше набір захищених таблиць мовчки залежав би від того, на які з них уже накотили compression policy. Ізоляція для них: доступ лише через JOIN зinv.devices/inv.interfaces/ts.series(усі під RLS) плюс обов'язковий предикатtenant_idу репозиторному шарі API. - Зв'язкові таблиці без власного
tenant_id(core.role_permissions,inv.device_group_members,inv.device_tags,inv.device_credentials,bill.invoice_lines,topo.map_shares) покладаються на FK-каскад від захищеного батька. netpulse_workerмаєBYPASSRLS— воркери агрегації, білінгу й retention працюють поверх тенантів.
Стан перевірки
Схема прогнана на живому стенді: Debian 13 / PostgreSQL 17.11 / TimescaleDB 2.29.1. Усі 11 міграцій накотились на чисту БД без помилок.
| Об'єкт | Кількість |
|---|---|
| Таблиць | 81 |
| Hypertables | 12 |
| Continuous aggregates | 6 |
| Фонових job-ів (compression/retention/refresh) | 25 |
| RLS-політик | 63 |
| Індексів | 220 |
tests/smoke.sql перевіряє не лише синтаксис, а й поведінку моделі:
psql -d netpulse -f db/tests/smoke.sql
| Перевірка | Результат |
|---|---|
Дзеркальний лінк B→A відхилено (links_pair_uniq) |
PASS |
kind='device' без device_id відхилено (map_nodes_kind_ref_chk) |
PASS |
Дубль активного алерту відхилено (alerts_active_dedup_uniq) |
PASS |
| 16-й пристрій на тарифі Free відхилено тригером | PASS |
| Стан полотна мапи одним запитом (вузли + статуси + RTT) | PASS |
Ребро з портами Gi0/1 → ether1 і живим util_pct = 78% |
PASS |
CAGG if_counters_5m рахує роллапи |
PASS |
RLS: ACME бачить 15 пристроїв, Globex — 0, без app.tenant_id — 0 |
PASS |