# NetPulse — журнал розробки Стислий лог: що зроблено, які рішення прийняті, що далі. Мета — щоб наступна сесія не перечитувала весь код. --- ## 2026-08-14 — Етап 1: схема БД ### Створено ``` netpulse/ ├── HISTORY.md ← цей файл ├── docker-compose.yml TimescaleDB 2.17/pg16 + DragonflyDB └── db/ ├── README.md ERD, ключові рішення, ізоляція тенантів ├── migrate.ps1 накат міграцій + schema_migrations └── migrations/ ├── 0001_core.sql tenants, users, RBAC, secrets, audit ├── 0002_inventory.sql sites, devices, interfaces, credentials ├── 0003_agents_plugins.sql plugins, agents, check_types, checks, outbox ├── 0004_topology.sql neighbors, links, maps, nodes, edges, backgrounds ├── 0005_telemetry_timescale.sql hypertables, CAGG, compression, retention ├── 0006_ncm.sql repos, profiles, jobs, configs, diffs, rollback ├── 0007_alerting.sql rules, alerts, channels, routes, maintenance ├── 0008_dashboards.sql dashboards, widgets, SLA ├── 0009_billing_licensing.sql plans, subscriptions, entitlements, invoices, licenses ├── 0010_seed.sql довідники └── 0011_rls.sql Row Level Security ``` ### Прийняті архітектурні рішення 1. **Фізична топологія ≠ візуальна.** `topo.links` (що є в мережі) окремо від `topo.map_edges` (як намальовано). Один лінк — на багатьох мапах. 2. **Зв'язки port→port через FK** на `inv.interfaces`, не текстом. Пара нормалізована через `LEAST/GREATEST`, щоб A→B і B→A не дублювались. 3. **Автовиявлення в два кроки:** сирі `topo.neighbors` (LLDP/CDP/ARP/FDB + `confidence`) → резолвер → `topo.links`. Прапорець `is_pinned` захищає ручні лінки. 4. **Дві моделі метрик:** узагальнена `ts.series`+`ts.samples` (плагіни реєструють `metric_key` без DDL) і широкі `ts.icmp_samples`/`ts.if_counters` для гарячих шляхів мапи. 5. **Анімація трафіку має ланцюг даних:** `if_counters.util_out_pct` → view `topo.link_live` → `map_edges.animation`. 6. **Ліміти тарифу перевіряються двічі:** `bill.entitlements` в API + тригери в БД. 7. **Секрети лише шифровані** (`core.secrets`: ciphertext/nonce/auth_tag/key_id, AES-GCM-256, DEK у KMS). 8. **Тіло конфігів у Git, метадані в БД** (`ncm.configs.commit_sha` + `content_hash`). 9. **RLS за замовчуванням** на кожній таблиці з `tenant_id`; порожній `app.tenant_id` → порожній результат. ### Проблеми, на які натрапив (щоб не повторювати) - **Порядок seed ↔ RLS.** Спершу RLS був 0010, seed 0011 — це ламається: після `FORCE ROW LEVEL SECURITY` навіть власник схеми не вставить довідники з `tenant_id IS NULL`. Файли переставлені місцями. - **CAGG не працюють у транзакції.** `CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)` падає всередині транзакційного блоку. `migrate.ps1` детектить це по вмісту файлу й вимикає `--single-transaction` для 0005. ### Помилки, знайдені прогоном на живому Postgres 1. `window` — зарезервоване слово. `core.sla_targets.window` → `period_kind`. 2. `core.check_types.key` був доменом `core.slug`, але ключі мають вигляд `icmp.ping` — крапка не проходить. Замінено на `text` з власним CHECK `^[a-z0-9]+(\.[a-z0-9_]+)+$`; так само `core.checks.check_type`. 3. Домен `core.slug` не пропускав підкреслення, а ключі фіч — `http_checks`, `auto_discovery`. Регекс розширено до `[a-z0-9_-]`. 4. **RLS блокує не CAGG, а стиснення.** Початкове припущення «CAGG + RLS несумісні» виявилось хибним. Реальна відмова TimescaleDB 2.29: `operation not supported on hypertables that have columnstore enabled` — тобто конфлікт саме з compression. Тому в 0011 виключено **всі** hypertables (через `timescaledb_information.hypertables`), а не три захардкоджені. ### Перевірено на живому стенді Debian 13 (LXC, 192.168.1.203) / PostgreSQL 17.11 / TimescaleDB 2.29.1. Усі 11 міграцій — на чисту БД без помилок. Створено: 81 таблиця, 12 hypertables, 6 continuous aggregates, 25 фонових job-ів, 63 RLS-політики, 220 індексів. `db/tests/smoke.sql` — 8 функціональних перевірок, усі PASS: нормалізація пари лінків, CHECK на device-ноду, дедуплікація алертів, ліміт пристроїв тарифу Free, стан полотна мапи одним запитом, ребро port→port з живим `util_pct`, роллап CAGG, ізоляція тенантів RLS. Бази на сервері: `netpulse` (схема + smoke-дані). Перестворити: `sudo -u postgres dropdb netpulse && createdb -O netpulse netpulse`, далі міграції. --- ## 2026-08-14 — Етап 2 (частина 1): protobuf-контракт агент↔сервер ### Створено ``` netpulse/ ├── buf.yaml, buf.gen.yaml ├── proto/ │ ├── README.md контракт: сервіси, потоки, семантика, безпека │ └── netpulse/v1/ │ ├── common.proto Status, Transport, Error, DeviceTarget, Credential, AgentHealth │ ├── agent.proto EnrollmentService, AgentService, ControlUp/ControlDown │ ├── telemetry.proto SeriesDescriptor, MetricSample, IcmpResult, InterfaceCounters │ ├── discovery.proto NeighborRecord, InterfaceRecord, DiscoveredDevice │ ├── ncm.proto ConfigJob, ConfigUpload (header/chunk/trailer), ConfigApplyJob │ └── logs.proto SyslogEntry, SnmpTrap, LogBatch ├── gen/go/ згенерований код (комітиться) └── test/contract/ наскрізні gRPC-тести на bufconn ``` ### Прийняті рішення 1. **Форма контракту випливає з одного обмеження:** усі з'єднання ініціює агент. «Команда з сервера» — це повідомлення у зустрічному напрямку вже відкритого агентом bidi-стріму `Control`, а не RPC у бік агента. 2. **Чотири окремі стріми** (Control / Telemetry / Logs / Config), а не один: пачка семплів не має блокувати heartbeat, сплеск syslog під час аварії не має топити телеметрію. 3. **Інтернування серій.** Агент реєструє серію раз під `series_ref`, далі шле лише номер. Заміряно: 66 → 25 байт на семпл. `series_ref` живе в межах сесії. 4. **Швидкості рахує агент** (лише він знає точний інтервал опитування), але шле й сирі лічильники — щоб сервер міг перерахувати заднім числом. 5. **Scrub/redact конфігів — на сервері.** Агент віддає сирий текст; правила живуть у `ncm.profiles` і змінюються без оновлення агентів у полі. 6. **Топологію зводить сервер.** Агент доповідає лише «на порту X бачу chassis Y». 7. **`Task.params_json` — непрозорі байти.** Новий плагін не потребує зміни .proto. Модуль-виконавець виводиться з префікса `check_type` до крапки. 8. **At-least-once + upsert.** Дедуплікацію дають PK схеми БД `(ts, device_id)` тощо. 9. **Зворотний тиск диктує сервер** у `Welcome` і кожному `TelemetryAck`. 10. **Самооновлення підписане Ed25519** — інакше компрометація CDN = RCE в мережі кожного клієнта. ### Перевірено на стенді Debian 13 (192.168.1.203): protoc 3.21.12, Go 1.24.4, buf 1.72.0. - `protoc` — усі 6 файлів валідні; - `buf lint` (STANDARD) — без зауважень; - генерація Go+gRPC, `go build`, `go vet` — чисто; - `go test ./test/contract/...` — **5/5 PASS**: рукостискання й push плану задач, інтернування серій, реакція на невідомий `series_ref`, чанкування конфігу зі звіркою sha256, відмова при пошкодженій контрольній сумі. Дрібниця для наступного разу: `buf.yaml` довелось звільнити від `RPC_REQUEST_STANDARD_NAME` та сусідніх правил — вони припускають пари запит-відповідь, а `ControlUp`/`ControlDown` це незалежні потоки подій. --- ## 2026-08-14 — Етап 2 (частина 2): Go-агент ### Створено ``` netpulse/ ├── .gitignore, .gitattributes (репозиторій: LF усюди, крім .ps1) └── agent/ ├── README.md будова, рішення, параметри чеків ├── go.mod replace → ../gen/go ├── cmd/netpulse-agent/ точка входу, GOMEMLIMIT, keepalive └── internal/ ├── config/ прапорці + NETPULSE_*, mTLS ├── module/ контракт модуля, реєстр, маршрутизація ├── telemetry/ interner.go (series_ref) + buffer.go ├── scheduler/ min-heap, семафор, schedule_offset ├── session/ gRPC-клієнт, реконект, ack, план задач └── modules/icmp, /snmp ``` ### Прийняті рішення 1. **Модулі вкомпільовані, без динамічного завантаження.** Один бінарник має працювати на Alpine, Windows і роутері з musl. «Активація» = дозвіл сервера. 2. **Креденшели беруться на момент виконання**, не з плану: у них TTL, і прострочені не віддаються взагалі — інакше агент заблокує обліковий запис на половині комутаторів клієнта. 3. **Розклад вирівняний по сітці інтервалу**, тому після рестарту задача повертається у свій слот, а не з'їжджає. 4. **Буфер викидає найстаріше.** Після відновлення зв'язку цінніший поточний стан. Зміни статусу викидаються останніми й пролазять у батч першими. 5. **Швидкості інтерфейсів рахує агент** (знає фактичний інтервал); при перевороті лічильника — `counter_reset` замість стрибка на терабіт. 6. **Один писар у контрольний стрім** — gRPC не допускає паралельних Send. 7. **`GOMEMLIMIT` 48 МБ у коді:** хай GC працює агресивніше, ніж OOM killer осліпить моніторинг саме тоді, коли він потрібен. ### Перевірено на стенді Debian 13, Go 1.25.13. `go vet` чисто, `go test ./... -race` — усі пакети ok. Релізний бінарник (`CGO_ENABLED=0 -trimpath -s -w`): **12 МБ**, базовий **RSS 11.6 МБ** у циклі реконекту (бюджет 30 МБ). ### Не перевірено - `TestPingLoopback` під звичайним користувачем в unprivileged LXC пропускається: ядро не дає ані unprivileged-, ані raw-сокета, `sysctl ping_group_range` недоступний. **Під root на тому ж стенді тест проходить** — ICMP-модуль перевірений проти реального сокета. У проді потрібен `CAP_NET_RAW`. - Модуль **snmp не перевірявся проти живого пристрою** — на стенді немає SNMP-агента. Компілюється й проходить vet; логіка перевороту лічильників і `util_pct` чекає на реальне обладнання. ### Репозиторій `https://git.zotac.keenetic.link/zotac/Netpulse_SasS.git` (Forgejo). Читання анонімне, **push вимагає токена** — Forgejo не пускає навіть у публічний репозиторій без автентифікації (`Credentials are incorrect`). Коміти лежать локально в `main` і чекають на токен. --- ## 2026-08-14 — Етап 2 (частина 3): серверна сторона AgentService ### Створено ``` netpulse/server/ ├── README.md рішення, параметри, стан перевірки ├── cmd/netpulse-server/ TLS, keepalive, m'яка зупинка, keyring із -dek └── internal/ ├── crypto/ AES-GCM-256, keyring із ротацією ключів ├── store/ agents, plan, credentials, telemetry, discovery, ncm └── grpcapi/ AgentService + перехоплювачі автентифікації ``` Плюс правки в агенті: `-token` і передача його в метаданих кожного виклику; `Interner.MarkAllPending()` — перереєстрація серій на початку сесії. ### Прийняті рішення 1. **Ізоляція тенантів робиться двічі:** RLS (`SET LOCAL app.tenant_id`) плюс явний предикат `tenant_id`. Не перестраховка: RLS не працює на гіпертаблях, а саме туди йде вся телеметрія. 2. **`schedule_offset` — чиста функція від `check_id`**, тож будь-який вузол сервера дає те саме значення. 3. **Хеш плану — лише з полів, що впливають на поведінку.** Зміна опису пристрою не змушує переливати 50 000 задач. 4. **Токен каже, ЯКИЙ це зонд; сертифікат — що він має право говорити.** Чужий `agent_id` при валідному токені → `PermissionDenied`. 5. **DEK не покидає сервер.** Дамп БД без ключів не дає жодного пароля. Комплект креденшелів із TTL 1 год. 6. **Запис телеметрії — `ON CONFLICT DO NOTHING`** (at-least-once). 7. **Статус пристрою — один запит із умовним записом в історію**, інакше два воркери наввипередки писали б неіснуючі переходи `up→up`. 8. **Впевненість зіставлення спадає за надійністю ознаки:** chassis-id 95 → MAC 90 → IP 80 → sysName 60. Останнє низьке навмисно: sysName вводить людина. 9. **Перереєстрація серій замість обнулення нумерації.** Спершу агент мав би скидати interner на реконекті, але це викидало б увесь накопичений за час обриву буфер — саме ті дані, заради яких він накопичувався. ### Перевірено на стенді 11 інтеграційних тестів проти **живої БД зі схемою Етапу 1** і справжнього gRPC — усі PASS з `-race`. Найцінніше: повтор батчу не дублює ані рядки, ані переходи в історії; зустрічний звіт B→A не створює другий лінк; ручний (`is_pinned`) лінк не затирається; тіло конфігу лежить зашифрованим. **Живий наскрізний прогін** (справжній агент + справжній сервер + БД, 40 с, `icmp.ping` кожні 5 с проти 127.0.0.1): 5 ICMP-семплів, метрики `icmp.rtt_avg` 0.108 мс / jitter / loss, статус `unknown → up` з причиною `icmp` і рівно одним переходом в історії, heartbeat із RSS 11.6 МБ і `dropped_samples=0`, зонд позначений `offline` після зупинки, clock skew −0.9 мс. ### Знайдено під час перевірки Приведення типу на місці (`$1::text`) **не розв'язує** конфлікт виведення типів у Postgres, а нав'язує тип обом уживанням параметра. Коли `$1` потрібен і як `uuid` для колонки, і як `text` для конкатенації, кастувати треба протилежне уживання: `VALUES ($1::uuid, …, '/шлях/' || $1 || '.git')`. Та сама пастка двічі: у сіді тесту (`VALUES ($1, $1, …)` для `core.slug` і `text`) і в `ncm.repos`. ### Чого ще немає - Git-двигун (libgit2): тіло конфігу шифрується в `core.secrets`, `commit_sha` тимчасово = hex контентного хеша. Дедуплікація й `prev_config_id` працюють, тож diff будується вже зараз. - `EnrollmentService` — зонди заводяться вставкою в `core.agents`. - Сервер не надсилає `TaskDelta` (лише повний план) і не ініціює `ConfigJob`. --- ## 2026-08-14 — Етап 2 (частина 4): модуль topology, автовиявлення наскрізь ### Створено ``` agent/internal/snmpx/ спільний SNMP-транспорт + розбір індексів OID agent/internal/modules/topology/ LLDP, CDP, ARP, FDB + інвентар портів server/cmd/netpulse-secret/ заведення шифрованих секретів із CLI ``` Плюс: `scheduler.OnDiscovery` і `TriggerNow`, доставка звітів у `session`, обробка `DiscoveryRequest`, реєстрація модуля в `main`. ### Прийняті рішення 1. **SNMP-транспорт винесено в `snmpx`** — ним користуються два модулі, а два незалежні набори однієї логіки це два незалежні набори багів. 2. **Звіти автовиявлення йдуть окремим RPC**, не телеметричним стрімом: вони рідкі, великі й не прив'язані до моменту часу так, як метрики. 3. **Черга звітів коротка й витісняє найстаріший.** Знімок топології актуальний рівно доти, доки описує поточний стан; накопичувати застарілі немає сенсу. 4. **`ifHighSpeed` має пріоритет над `ifSpeed`:** 32-бітне поле впирається в 4.29 Гбіт/с, і на 10G порт показував би неправильний знаменник для `util_pct`. 5. **`ifName` перекриває `ifDescr`:** саме `ifName` віддає LLDP як port-id, тому саме за ним зійдеться лінк. 6. **`netpulse-secret` як окремий інструмент** — секрети не можна вставити звичайним SQL, а UI ще немає. ### Знайдено живим прогоном (обидва — справжні помилки) 1. **Префікс типу чека не збігався з ключем модуля.** У сіді було `topo.discover` при плагіні `topology` і `ssl.expiry` при плагіні `http`. Агент маршрутизує задачі саме за префіксом, тож зонд відхилив би їх як адресовані неіснуючому модулю. Перейменовано на `topology.discover` і `http.ssl_expiry`, а інваріант закріплено обмеженням у БД `check_types_prefix_matches_plugin` — щоб наступна така неузгодженість не доїхала до поля. 2. **Унікальний індекс `topo.neighbors` схлопував ARP-сусідів.** Ключ складався з `chassis_id` + `port_id`, яких в ARP і FDB немає взагалі: з двох сусідів на одному порту зберігався один. Додано `remote_mac` до індексу. ### Перевірено проти справжнього SNMP-агента На стенді піднято `snmpd` + `lldpd` (LLDP-MIB через AgentX). Живий прогін агент → сервер → БД: - інвентар портів зі справжнього `ifTable`: `lo` і `eth0`, MAC `bc:24:11:07:68:67`, 10 Гбіт/с саме через `ifHighSpeed`; - 2 сусіди зі справжньої ARP-таблиці, обидва з локальним портом `eth0`; - шлюз `192.168.1.1` зіставлено за MAC із впевненістю 90; - лінк `snmp-host:eth0 → gateway` зведено, `capacity_bps` 10 Гбіт/с; - три проходи поспіль — лінк один, дублікатів немає; - `snmp.get` збирає `sys.uptime`, помилок чеків немає. Усі три набори тестів (агент, сервер, контракт) проходять з `-race`. ### Не перевірено - **LLDP і CDP на живих сусідах.** `lldpd` зареєстрував MIB, але сусідів немає — поруч немає другого пристрою, що шле LLDP. Розбір `lldpRemTable` і `cdpCacheTable` покритий лише тестами на індекси OID; ARP-гілка того самого коду перевірена на живих даних. - **`snmp.if`**: сервер поки не генерує для нього перелік інтерфейсів, тому чек нікуди не призначається. --- ## 2026-08-14 — Етап 2 (частина 5): автостворення snmp.if-чеків ### Створено `server/internal/store/autochecks.go` — з виявлених інтерфейсів формується `snmp.if`-чек і одразу штовхається живій сесії як `TaskDelta`. ### Прийняті рішення 1. **Чек оновлюється, а не задвоюється.** Унікальний індекс `core.checks` включає `md5(params)`, тому наївний upsert плодив би новий рядок на кожну зміну складу портів. Шукаємо існуючий чек за `(device_id, 'snmp.if')`. 2. **Склад портів порівнюється як множина.** Postgres не гарантує порядок ключів у `jsonb` — пряме порівняння рядків давало б хибну зміну на кожному обході, і агент отримував би новий план щоразу. 3. **Без SNMP-креденшела чек не створюється:** він лише щохвилини писав би помилку автентифікації. Інтерфейси при цьому все одно зберігаються — вони потрібні мапі незалежно від того, чи є чим їх опитувати. 4. **Loopback і `notPresent` відсіюються** — графіка не дають, місце в PDU займають. Ліміт 256 портів на чек: далі опитування не вкладається у власний таймаут. 5. **Зміна штовхається живому зонду.** Чекати наступного перепідключення — це години порожніх графіків після кожного нового комутатора. ### Знайдено живим прогоном **OID у запиті без провідної крапки, а `pdu.Name` — з нею.** Пошук у мапі результатів мовчки нічого не знаходив, і чек виглядав як «жоден інтерфейс не відповів». `snmp.get` працював, бо там уже була нормалізація, а `snmp.if` — ні. Канонізацію винесено в `snmpx.Normalize` і застосовано з обох боків у `GetUints`. ### Перевірено наскрізь проти справжнього SNMP Агент і сервер запущені як є, без жодного ручного кроку між ними: - `topology.discover` знайшов `lo` і `eth0` → `inv.interfaces`; - сервер створив `snmp.if`-чек на 1 порт (loopback відсіяно), 10 Гбіт/с; - дельта доїхала до живої сесії (`надіслано_наживо: true`); - агент опитав справжні HC-лічильники: `in_octets` 625 246 266, `in_bps` 11 938; - `util_out_pct` 0.000001 % — знаменник із `ifHighSpeed`; - помилок чеків немає. Це повний шлях даних для анімації трафіку на мапі. Усі три набори тестів проходять з `-race`. --- ## 2026-08-14 — Етап 3: REST/WebSocket API для UI ### Створено ``` server/ ├── API.md ендпоїнти, протокол WebSocket, приклади ├── cmd/netpulse-api/ окремий процес: HTTP + WS └── internal/ ├── httpapi/server.go роутер, Bearer-автентифікація, обробники ├── httpapi/ws.go hub, насос подій, насос завантаження каналів └── store/ ├── maps.go стан полотна з живими статусами ├── events.go читання core.event_outbox ├── inventory.go пристрої, зонди └── apitokens.go автентифікація токенів UI ``` ### Прийняті рішення 1. **API — окремий процес від AgentService.** Зонди й браузери мають різні профілі навантаження, периметри й цикли релізів. Спільний лише шар `store`. 2. **Стан мапи віддається одним викликом разом зі статусами.** Без цього мапа малювалася б сірою й доганяла кольори сотнею дозапитів. 3. **`link_status` виводиться з кінців лінка, а не читається з колонки.** `topo.links.status` ніхто не підтримує; писати туди означало б оновлювати всі лінки пристрою на кожну зміну статусу. Стан лінка — похідна величина. 4. **Події пишуться тією ж транзакцією, що й зміна.** Інакше WebSocket міг би розповісти про перехід, якого в базі ще (або вже) немає. 5. **Опитування outbox замість LISTEN/NOTIFY.** NOTIFY не переживає падіння підписника й обмежений 8 КБ; тут потрібна гарантія, що зміна статусу не загубиться між перезапусками API. 6. **Дві частоти розсилки:** статус — подія (миттєво), завантаження — величина (раз на 5 с). Частіше за оновлення лічильників (60 с) — це та сама цифра по колу. 7. **Токен WebSocket їде підпротоколом**, бо браузер не дозволяє довільні заголовки; в URL він не потрапляє, а отже й у логи проксі. 8. **Підписник, що не встигає читати, відключається**, а не гальмує решту. ### Перевірено 10 інтеграційних тестів проти живої БД, справжнього HTTP і WebSocket — усі з `-race`. Найцінніші: `TestWebSocketDeliversStatusChange` проганяє справжній батч телеметрії через `applyDeviceStatus` → outbox → hub → браузер; `TestLinkStatusFollowsEndpoints` перевіряє, що лінк червоніє від падіння кінця або порту без змін у `topo.links`. **Живий прогін** — агент, `netpulse-server` і `netpulse-api` разом проти справжнього `snmpd`: мапа з 2 вузлами `up` (RTT 0.113 і 0.827 мс), ребро з портом `eth0`, `лінк=up`, живий `util_pct`, зонд `online` з RSS 11.6 МБ і `dropped_samples=0`. `target_port` порожній — і це правильно: лінк знайдено через ARP, а ARP не повідомляє порт віддаленої сторони. ### Чого ще немає - Усі ендпоїнти read-only: редактор мапи потребує `PATCH` з оптимістичним блокуванням за `revision` (колонка є, обробника немає). - Немає `GET /api/v1/metrics` для графіків і віддачі `alr.alerts`. - Автопобудова мапи з `topo.links` — поки SQL-скрипт, не кнопка. --- ## 2026-08-14 — Етап 3 (частина 2): запис у мапу ### Створено ``` server/internal/store/maps_write.go патч полотна, знімки, автопобудова server/internal/httpapi/maps_write.go POST/PATCH/DELETE + /build ``` Ендпоїнти: `POST /api/v1/maps`, `PATCH /api/v1/maps/{id}`, `DELETE /api/v1/maps/{id}`, `POST /api/v1/maps/{id}/build`. ### Прийняті рішення 1. **Усі скалярні поля патча — вказівники; nil означає «не чіпати».** Перетягування шле лише x/y, і якби відсутні поля трактувались як порожні, кожен рух миші стирав би стиль, розмір і прив'язку до пристрою. 2. **Ребро може посилатися на вузол, створений тим же патчем**, за `client_id` — інакше зв'язок до нового вузла вимагав би двох запитів і проміжного стану. 3. **Оптимістичне блокування за `revision`.** Той, хто спізнився, отримує 409, а не тихо затирає чужу правку: у NOC над однією мапою працюють кілька людей. 4. **Знімок пишеться тією ж транзакцією, що й зміна.** Інакше після збою в історії лишався б крок, якого в мапі немає, і відкат ламав би її. Зберігаються останні 50. 5. **Невідоме поле в тілі — 400.** Мовчки проковтнути друкарську помилку клієнта означає, що правка «збереглася», але не застосувалась. 6. **Автопобудова ідемпотентна**: наявні вузли не дублюються, координати не чіпаються — інакше кожен запуск скидав би ручну розкладку. 7. **Ліміт тарифу → 402**, а не 500: UI має показати пропозицію змінити тариф. 8. **`map.updated` несе лише ревізію**, не патч. Розсилати дельти означало б тримати на сервері модель того, що бачить кожен клієнт — це вже CRDT, окрема задача. ### Знайдено тестами `revision <= $2 - $3` з двома нетипізованими параметрами дає `operator is not unique: unknown - unknown`. Postgres не може вивести оператор віднімання, коли обидва операнди — плейсхолдери. Потрібні явні касти. ### Перевірено 23 інтеграційні тести API (усі з `-race`), з них 12 нових на запис. Найцінніші: драг не затирає сусідні поля; друга вкладка зі старою ревізією отримує 409, а перша правка ціла; видалення вузла не лишає ребер у нікуди; повторна автопобудова не скидає ручну розкладку. **Живий прогін** проти даних, які агент зібрав сам: створення мапи → автопобудова (+2 вузли, +1 ребро) → драг (ревізія 3) → патч зі старою ревізією (409) → повторна побудова (+0/+0) → координати x=1500 y=640 збережені, 2 знімки в історії. --- ## 2026-08-14 — Етап 4: фронтенд, мапа в браузері ### Створено ``` web/ ├── README.md ├── package.json, vite.config.ts, tsconfig.json └── src/ ├── types.ts типи API ├── api/client.ts REST + ApiError (isConflict/isPlanLimit) ├── api/ws.ts WebSocket із реконектом ├── hooks/useLiveMap.ts завантаження, живі оновлення, запис └── components/ MapCanvas, DeviceNode, TrafficEdge ``` React 18 + React Flow 12 + Tailwind 4 + Vite 6. Збірка 345 КБ (112 КБ gzip). ### Прийняті рішення 1. **Сервер — джерело істини, крім моменту перетягування.** Поки вузол тягнуть, позицію диктує миша; синхронізація пропускає вузли з `dragging`. 2. **Зберігаємо на відпусканні й лише якщо вузол зрушив.** 3. **Успішний патч застосовується до локального стану** — інакше вузол «повертається» при першій же події. 4. **Конфлікт ревізій не приховується:** 409 → повідомлення + перечитування. 5. **Швидкість анімації обернено пропорційна завантаженню**, при нулі анімації немає взагалі. 6. **Стан з'єднання завжди на екрані:** замерзла мапа виглядає як здорова. ### Знайдено роботою з живим UI (обидві виправлені) 1. **Ревізія росла від самих кліків.** За час, поки я робив скріншоти, мапа пройшла 2 → 5: React Flow віддає `onNodeDragStop` на будь-яке натискання, і кожен клік писав порожню ревізію, змушуючи всі відкриті полотна перечитуватись. 2. **Вузол відкочувався після збереження.** Хук оновлював лише номер ревізії, тож наступна подія `device.status` перебудовувала список зі старими координатами. ### Перевірено в браузері проти повного стека Агент + сервер + API + справжній `snmpd`: два зелені вузли з живим RTT (0.05 і 1.04 мс), ребро `eth0 → ?` із `<0.01% з 10.0 Гбіт/с`, індикатор «наживо, 1 с тому», зонд `probe-snmp` linux/amd64 з RSS 11.1 МБ, кнопка «Добудувати з топології» додала 2 вузли й 1 ребро з `topo.links`. ### Обмеження перевірки - **Драг не відтворюється автоматизацією:** синтетичні події вказівника не запускають d3-drag у React Flow. Шлях «драг → PATCH → БД» покритий тестами сервера, але саме через UI лишається неперевіреним автоматично. - **Ребра не малюються у прихованій вкладці:** React Flow міряє вузли в `requestAnimationFrame`, який там не викликається. Вузли рендеряться, бо це звичайний DOM. Скріншот у згорнутій панелі непридатний для перевірки. --- ## 2026-08-14 — Етап 4 (частина 2): редактор доведено до робочого стану ### Створено ``` server/internal/store/maps_undo.go відкат до попереднього знімка server/internal/httpapi/maps_write.go POST /maps/{id}/undo web/src/components/MapCanvas.tsx onConnect, onDelete web/src/hooks/useLiveMap.ts undo + canUndo ``` ### Прийняті рішення 1. **Відкат — нова ревізія, а не відмотування лічильника.** Інакше клієнт із номером 10 після повернення до 9 отримав би «свою» ревізію знову актуальною й тихо перезаписав відкочене. 2. **Ідентифікатори вузлів зберігаються при відкаті** — ребра прив'язуються назад самі, а виділення в UI й зовнішні посилання не ламаються. 3. **Порядок відновлення** — ребра геть → вузли геть → вузли назад → ребра назад: зовнішні ключі не дозволяють інакше. 4. **Намальоване рукою ребро не прив'язується до `topo.links`:** лінія на полотні — це подання, а не факт про мережу. Автовиявлення прив'яже саме. 5. **Видалення не перелічує ребра вузла:** на полотні їх прибирає React Flow, у базі — каскад FK. ### Знайдено флак у тестах (виправлено) `TestSchedulerRunsTaskAndFillsCredentials` падав приблизно раз на п'ять під навантаженням: тест перевіряв канал статусів **знімком** (`for len(ch) > 0`), а `STATE_SUCCEEDED` надсилається вже ПІСЛЯ запису в sink. На завантаженій машині проміжок розширювався. Замінено на очікування з дедлайном; 8 прогонів під штучним навантаженням — 0 падінь. ### Перевірено наживо Повний ланцюг проти працюючого стека: намальовано зв'язок (ребер 1 → 2) → відкат (2 → 1) → видалення вузла (2 вузли → 1, ребер 0) → відкат повернув **той самий id вузла**, ребро й живий стан лінка. UI показує обидві кнопки й підказку про жести. ### Далі План на наступні етапи винесено в окремий документ — [ROADMAP.md](ROADMAP.md). Коротко: користувачі й права (схема є, коду немає) → алерти → шаблони опитування (нова підсистема `tpl.*`) → керування зондом із UI → NCM до кінця → мобільний адаптив і PWA. --- ## 2026-08-15 — Етап 5: користувачі, вхід і права Досі продукт умів усе, крім найпростішого: впустити людину. Доступ давав машинний токен у змінній збірки — тобто одні права на всіх і жодного способу відрізнити, хто що зробив. Цей етап закриває саме це. ### Створено - `db/migrations/0012_auth.sql` — три RLS-політики винятку для шляху входу, гіпертаблиця `core.login_attempts`, два індекси на `core.sessions`. - `server/internal/auth/password.go` — argon2id (2 проходи, 64 МБ, 32 байти), вивід у форматі PHC, звірка через `subtle.ConstantTimeCompare`. - `server/internal/auth/token.go` — власний HS256 JWT: `Sign`, `Verify`. - `server/internal/store/users.go` — автентифікація, сесії, членства, команда. - `server/internal/httpapi/principal.go` — `Principal` замість старого `authenticated`; людина й машина зводяться до одного набору прав. - `server/internal/httpapi/auth.go`, `users.go` — 9 нових ендпоїнтів. - `server/cmd/netpulse-user/main.go` — CLI для першого власника. - `web/src/api/session.ts`, `components/LoginPage.tsx`, переписані `client.ts` і `App.tsx`. - `server/internal/httpapi/auth_test.go` — 11 тестів. ### Прийняті рішення **Заголовок JWT звіряється байт у байт, а не парситься.** Класична атака підміни алгоритму (`alg: none`, `alg: HS256` замість RS256) можлива лише там, де сервер питає токен, яким алгоритмом його перевіряти. Тут константа `{"alg":"HS256","typ":"JWT"}` порівнюється як рядок — питати нічого й нема в кого. Підпис перевіряється **до** строку дії: інакше протермінований підроблений токен відрізнявся б за текстом помилки від протермінованого справжнього. **Права читаються з БД на кожному запиті, а не беруться з токена.** Класти їх у claims було б швидше на одне звернення, але тоді відкликана роль жила б до кінця TTL. Порожній набір прав — це не «нічого не можна», а сигнал, що членство зникло: такий токен відхиляється повністю. **Access-токен живе в замиканні модуля, не в `localStorage`.** З localStorage його забирає будь-який XSS; із замикання — ні. Ціною є втрата токена при перезавантаженні сторінки, тому на старті робиться тихий `refresh` по кукі. **Refresh-токен ротується.** Стара сесія відкликається, видається нова. Це не дає викраденій кукі жити паралельно з живою: другий власник отримає `401 no_session` — крадіжка стає видимою подією, а не тихою. **Один refresh на всі паралельні запити.** Перший же екран робить чотири запити одразу. Без черги з одного обміну ротація зробила б усі, крім першого, недійсними, і людину викидало б на вхід рівно тоді, коли все гаразд. **Невідомий email і невірний пароль нерозрізненні** — однаковий код і однаковий час: на неіснуючому користувачі спалюється фіктивна перевірка argon2id. Без неї різниця в часі сама розказує, які адреси зареєстровані. **Три обмеження вшито в API, а не в UI**: роль `owner` не видається через HTTP (лише CLI), не можна змінити роль собі, не можна прибрати себе. Усі три захищають від одного — організації без жодного власника. **Перший власник заводиться CLI.** Публічна реєстрація в B2B-інсталяції — це не зручність, а дірка; «створи першого користувача через веб, поки нікого немає» — гонка, яку неможливо закрити чесно. ### Знайдено при написанні `FORCE ROW LEVEL SECURITY` робив вхід неможливим за побудовою: щоб знайти користувача за email, треба знати тенант, а тенант відомий лише після того, як користувача знайдено. Виправлено трьома політиками, які пускають `SELECT` **тільки** коли `core.current_tenant() IS NULL` — тобто рівно на шляху входу, де тенанта ще нема. Читання під уже виставленим тенантом лишається обмеженим як було. ### Перевірено - `go test ./... -race` — 37 тестів httpapi, з них 11 нових; `go vet` чисто. - `tsc --noEmit` чисто, `vite build` — 353 КБ JS (114 КБ gzip). - Живий прогін проти `netpulse_it`: 8 кроків від входу власника до відкликаної сесії — усі коди очікувані (розписано в [API.md](server/API.md)). - У браузері: форма входу → інженер входить → мапа з живими RTT і «наживо». Під глядачем кнопки редагування зникають, полотно нередаговане, блок зондів не показується. - Мобільний вигляд (375×812): бічна панель повністю за кадром, полотно на всю ширину, горизонтального скролу немає; бургер висуває панель поверх полотна із затемненням. ### Обмеження перевірки Вкладка браузера прихована, тому CSS-переходи стоять на `currentTime: 0` — виміри бічної панелі робилися після примусового `getAnimations().finish()`. Це артефакт середовища, не застосунку. Сторінки керування командою в UI ще немає — є API й методи клієнта (`team`, `roles`, `addMember`, `setRole`, `removeMember`). Додати користувача поки можна лише CLI або запитом. --- ## 2026-08-15 — Етап 9: алерти й сповіщення Система вміла малювати мапу, але мовчала, коли щось падало. Схема (`alr.*`) лежала готовою з Етапу 1 і повністю порожньою. Тепер вона працює. ### Створено - `server/internal/store/alerts.go` — правила, обчислення умов (icmp / interface / metric / no_data), побудова предикатів селектора. - `server/internal/store/alerts_state.go` — життєвий цикл алерту, придушення, граф топології й визначення першопричини. - `server/internal/store/alerts_query.go` — списки, ack, mute, CRUD правил. - `server/internal/store/alerts_channels.go` — канали, маршрути, тихі години, журнал доставки. - `server/internal/alerting/engine.go` — цикл обчислення під advisory-блокуванням. - `server/internal/alerting/notify.go` — Telegram, webhook, SMTP, шаблони повідомлень, SSRF-захист. - `server/internal/httpapi/alerts.go` — 12 ендпоїнтів. - `web/src/hooks/useAlerts.ts`, `components/AlertsPanel.tsx`, шина подій у `api/ws.ts`. - 20 нових тестів (11 у `store`, 9 в `alerting`). ### Прийняті рішення **Движок не має стану між тіками.** Вікно `for_seconds` — це запит по часу до TSDB, а не лічильник у пам'яті. Тому перезапуск процесу нічого не збиває, а два процеси дали б однаковий результат. Лічильник у пам'яті довелося б і зберігати, і відновлювати, і синхронізувати між екземплярами — три способи розійтися з реальністю замість нуля. **Дві семантики вікна.** Без `agg` умова має триматися **всі** виміри вікна — це і є антифлап: одна втрачена відповідь не будить людину. З `agg` порівнюється агрегат. Обидві потрібні: «недоступний три хвилини поспіль» і «середнє завантаження за 5 хв вище 85%» — різні питання. **Список метрик і операторів закритий.** Значення з `condition` потрапляє в текст запиту (агрегатну функцію не підставиш параметром), тому все, що йде в SQL, проходить whitelist, а все інше — лише параметром. Послаблення тут — це SQL-ін'єкція через JSON у таблиці правил. **Кореляція за межею зони недоступності.** Причина аварії — той, у кого лишився живий сусід; хто оточений мертвими — наслідок. Це те, заради чого будувалась `topo.links`: інакше падіння маршрутизатора дає сорок сповіщень, серед яких губиться єдине потрібне. Пристрій без зв'язків завжди лишається причиною — топологія про нього нічого не знає, і списати його на чужу аварію було б вигадкою. **Дедуплікація індексом, а не перевіркою в коді.** Частковий унікальний індекс `alerts_active_dedup_uniq` робить другий алерт на ту саму проблему неможливим навіть якщо два движки якимось чином працюють одночасно. **Сповіщення шле лише щойно піднятий і не придушений алерт.** Продовження вже відомої проблеми не є новиною. Перевірено прогоном: за три тіки кількість сповіщень не зросла. **Тенант без маршрутів отримує все в усі придатні канали.** Підключили Telegram — має працювати. Вимагати ще й маршрут означало б мовчати саме там, де налаштування щойно зроблене й здається повним. **Тиха година не глушить `disaster`.** Сенс чергування в тому, щоб його підняли. **SSRF-захист із виходом для self-hosted.** Адресу вебхука задає користувач тенанта, а запит іде з сервера — у SaaS це класичний вектор. Але в self-hosted вебхук майже завжди веде саме всередину, у корпоративний Mattermost. Тому заборона знімається прапорцем процесу, і рішення приймає адміністратор сервера, а не користувач тенанта. **Стеля ручного заглушення — тиждень.** Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути моніторинг назавжди. ### Знайдено роботою з живою системою (три справжні помилки) **Вимкнене або видалене правило лишало свої алерти висіти вічно.** `alr.alerts.rule_id` має `ON DELETE SET NULL`, а вимкнене правило випадає з вибірки движка — в обох випадках алерти ставали сиротами, яких нікому закрити. Виправлено закриттям алертів у тій самій транзакції, до видалення правила. **Перехід у «придушено» не публікував події.** Людина глушила пристрій, движок за 10 секунд переводив алерт у `suppressed`, а UI про це не дізнавався до перезавантаження сторінки. Причина: подія публікувалась лише для нових і закритих алертів. Додано `alert.updated` і читання попереднього стану через CTE у тому ж знімку, що й UPSERT — інакше відрізнити «стан змінився» від «проблема триває» неможливо. **Кнопки дій на телефоні були 26 px.** Виміряно в мобільному вигляді: для «Прийняти» і «Заглушити», які натискають пальцем уночі, це промахи. Піднято до 38 px на вузьких екранах, на десктопі щільність збережено. ### Перевірено наживо Проти справжніх даних (2 пристрої, 240 ICMP-вимірів за 10 хв): - Правило з порогом 0.5 мс підняло алерт на `gateway` (0.98 мс) і **не** підняло на `snmp-host` (0.08 мс) — поріг рівно посередині. - `no_data` і «помилки на порту» мовчали, як і мали. - Вебхук дійшов: 5 доставок на 5 подій, жодного повтору за багато тіків. - Заглушення пристрою придушило рівно його алерт, сусідній лишився активним. - Вимкнення правила закрило його алерти й не зачепило чужий `ack`. - Глядач отримує 403 на ack і 200 на читання. - У браузері: індикатор пульсує червоним, панель відкривається, ack оновлює список наживо, придушений алерт іде під окремий фільтр. - Мобільний вигляд 375 px: панель на весь екран, горизонтального скролу немає. ### Чого свідомо не робив Ескалацій (`alr.escalation_policies`) і повторних сповіщень немає — алерт сповіщає один раз. Кнопки Ack/Mute у Telegram відмальовуються, але приймача `callback_data` не написано. Web Push відкладено до Етапу 10 разом із PWA. Правила з джерел `syslog`/`trap`/`ncm` движок пропускає: вони обробляються подіями, а не опитуванням. --- ## 2026-08-15 — Етап 5+: повноцінний веб Питання «а повноцінний веб коли?» було справедливим. До цього моменту «веб» — це був **один екран**: логін, мапа й панель алертів. Ані навігації, ані сторінок; усе, що вміло API, доводилось викликати curl-ом. Цей етап закриває розрив між готовим бекендом і тим, що людина може натиснути. ### Створено - `web/src/components/AppShell.tsx` — каркас: бічна навігація, шапка, індикатор зв'язку, лічильник алертів. - `web/src/components/ui.tsx` — примітиви: `DataTable`, `Modal`, `Button`, `Field`, `StatusBadge`, `Toggle`, `Card`. - Сім сторінок у `web/src/pages/`: `MapPage`, `DevicesPage`, `AlertsPage`, `RulesPage`, `ChannelsPage`, `AgentsPage`, `TeamPage`, `ProfilePage`. - `web/src/hooks/useLiveRefresh.ts` — перечитування зі злиттям сплеску. - `react-router-dom` як залежність; `App.tsx` переписано на маршрути. ### Прийняті рішення **Пункт меню, на який немає права, не показується взагалі.** Кнопка, що завжди дає 403, гірша за її відсутність: вона обіцяє можливість, якої немає, і змушує людину гадати, що вона зробила не так. Пряме посилання при цьому лишається робочим — його можна отримати від колеги чи успадкувати після зміни ролі, тому маршрут показує зрозуміле пояснення з назвою потрібного права, а не порожній екран. **Стартова сторінка залежить від ролі.** Глядача без права на мапи вітати відмовою — поганий перший екран, тому домівкою стає перший доступний розділ. **Таблиця на телефоні перестає бути таблицею.** Горизонтальний скрол на 375 px — це спосіб зробити дані формально присутніми й фактично нечитабельними. Тому `DataTable` малює картки, а другорядні колонки позначаються `hideOnMobile`, щоб картка лишалась короткою. **Форма правила показує списком те саме, що приймає сервер.** Списки метрик дублюють whitelist бекенда свідомо: без цього про друкарську помилку людина дізнавалась би не з форми, а з правила, яке мовчки нічого не знаходить. ### Знайдено роботою з живим UI (три справжні помилки) **WebSocket жив усередині мапи.** Найсерйозніша з трьох. Сокет створював `useLiveMap`, тому на кожній сторінці, крім мапи, живих оновлень не було взагалі: лічильник алертів у шапці замерзав, щойно людина йшла з мапи, і показував стан на момент входу. Помітно це стало лише тоді, коли сторінок стало більше однієї. З'єднання винесено в модуль-одинак `live`, яким володіє оболонка; `useLiveMap` тепер лише слухає спільну шину. Заодно стан зв'язку видно з будь-якої сторінки — замерзлий інтерфейс виглядає точно так само, як справний. **Сторінка пристроїв перечитувала все на кожну подію алерту.** Виміряно лічильником запитів у браузері під штучним сплеском: одна аварія з десятком алертів давала десяток пар запитів — клієнт додавав навантаження рівно тоді, коли серверу найважче. Виправлено спільним хуком зі злиттям сплеску плюс переходом на алерти з оболонки замість власного опитувача: **той самий сплеск тепер коштує 2 запити замість дванадцяти**. **Канал із секретом показувався як «секрету немає».** `HasSecret` ставився всередині гілки розшифровки, а перелік для UI викликається без ключа навмисно. Наявність секрету — факт про канал, а не наслідок того, чи його зараз читають. ### Перевірено в браузері проти повного стека - Усі вісім сторінок рендеряться з живими даними: 2 пристрої, 5 правил, 1 канал, 1 зонд, 4 учасники. - Створення правила через форму: правил 4 → 5, умова записалась коректно (`loss_pct > 5, всі виміри 120 с`). - Створення користувача через форму: учасників 3 → 4. - Кнопка «Перевірити» на каналі — пробне повідомлення дійшло у приймач. - Глядач: у меню 5 пунктів замість 7, кнопки «+ Правило» немає, усі 10 перемикачів заблоковані, `/team` прямим посиланням дає екран із поясненням. - Мобільний 375 px: меню закрите, бургер висуває, перехід закриває його сам; таблиці стають картками; горизонтального скролу немає. - Мапа після переносу сокета працює: вузли з живим RTT, «наживо», лічильник часу оновлюється. ### Чого ще немає у вебі Дашбордів і графіків історії (потрібен `GET /api/v1/metrics`, якого немає), сторінки налаштувань зонда, редагування правила (лише створення й вимкнення), маршрутів сповіщень і вікон обслуговування (є в схемі й у движку, у UI — ні), NOC-режиму на телевізор. --- ## 2026-08-15 — Вхід за логіном, групи хостів і права доступу Три зауваження за одну сесію: перемикачі виглядають зламано, входити треба логіном, а не поштою, і бракує заббіксівського — груп, прав на них, додавання хостів і редагування мапи. Плюс два власні спостереження користувача про сторінку правил і мапу. ### Створено - `db/migrations/0013_groups_login.sql` — `core.users.username`, `core.user_groups`, `core.user_group_members`, `core.group_permissions`, функції `core.device_access_level` і `core.accessible_devices`, RLS на нові таблиці. - `server/internal/store/groups.go` — CRUD груп, хостів і обчислення `Scope`. - `server/internal/httpapi/groups.go` — 11 ендпоїнтів. - `web/src/pages/GroupsPage.tsx` — групи хостів і груп доступу на одному екрані. - `web/src/components/NodeInspector.tsx` — підпис, значок, розмір, ширина, колір, закріплення вузла. - Форма хоста (створення й редагування) і фільтр за групами на сторінці хостів. - Створення мапи з транслітерацією slug. ### Прийняті рішення **Логін замість пошти.** У мережевій інсталяції половина облікових записів технічні — `noc`, `monitoring`, `oncall`, — і скриньки не мають узагалі. Пошта лишилась необов'язковим полем для сповіщень. Сервер шукає за обома, тому людину, яка за звичкою ввела email, ніхто не відхиляє. **Наявним користувачам логін вивели з пошти**, а збіги розвели суфіксом за порядком створення. Мовчки злити `admin@a.com` і `admin@b.com` в один логін було б не міграцією, а втратою акаунта. Механізм одразу знадобився: на стенді жили залишки від тестових прогонів, і `admin` дістався саме їм. **Ролі й групи — два незалежні виміри.** Роль каже, що людині вільно робити; група доступу — над якими хостами. Інженер над однією філією та інженер над усією мережею мають однакову роль і різний доступ, і змішати це в один список прав неможливо без втрати сенсу. **Хто не входить у жодну групу — не обмежений групами.** Це свідомо не по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова інсталяція починається з питання «чому порожньо». Тут звуження вмикається тоді, коли його справді налаштували. **Заборона перемагає дозвіл.** Інакше її можна обійти, додавши хост у будь-яку іншу групу. **Фільтр видимості накладається в самому запиті**, а не після вибірки: відсіювати вже прочитане означало б тягнути з БД чужі рядки й покладатися на те, що жоден не проскочить у відповідь. **Правки вузла застосовуються кнопкою, а не на кожну літеру.** Кожне збереження — це нова ревізія полотна й подія для всіх, хто дивиться на ту саму мапу. ### Знайдено й виправлено **Перемикач вилазив за межі треку.** Скарга була «вигляд глюкнутий»; вимір показав причину: у ручки не заданий `left`, тож вона стає на статичну позицію, а та у `