Два образи замість пʼяти: серверні команди їдуть з одного модуля й мусять ходити в схему БД однією версією, а зонд лишається окремо — у чужій мережі йому не місце з DSN і ключами шифрування. Стек піднімає БД, кеш, міграції, API, колектор і Caddy з автоматичним TLS. Міграції окремою службою, бо API піднімається в кількох примірниках і гонка за схему нікому не потрібна. deploy/README.md описує бекап як три речі: дамп, DEK і JWT-ключ. Без DEK дамп не відновлюється — у БД лише шифротекст. Дорогою виправлено .gitignore: голі "netpulse-agent" і "netpulse-server" ігнорували ще й каталоги cmd/ з кодом команд. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2152 lines
147 KiB
Markdown
2152 lines
147 KiB
Markdown
# 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`, тож вона стає на статичну позицію,
|
||
а та у `<button>` зсунута типовим `text-align: center`. Обчислений `left`
|
||
виходив 18px замість 2px, ручка виступала на 14px і накривала сусідній хрестик.
|
||
|
||
**Сторінка правил не оновлювала лічильник «активних».** Друга скарга, і причина
|
||
не та, що здається. Після вимкнення правила його алерти закриваються одразу, а
|
||
після повернення піднімаються лише наступним тіком движка — тобто через секунди
|
||
ПІСЛЯ нашого перечитування. Сторінка не була підписана на живі події, тому
|
||
показувала нулі до перезавантаження. Той самий недогляд, що раніше знайшовся на
|
||
сторінці хостів; тепер обидві користуються спільним хуком.
|
||
|
||
**Канал із секретом показувався як «секрету немає»** — прапорець ставився
|
||
всередині гілки розшифровки, а перелік для UI викликається без ключа навмисно.
|
||
|
||
### Перевірено наживо
|
||
|
||
Прогін проти справжнього стенду:
|
||
|
||
```
|
||
вхід за логіном 'admin' 200, роль owner
|
||
вхід тим самим, але поштою 200 — сумісність збережена
|
||
дві групи хостів 201/201
|
||
хости розкладено gateway→Магістраль, snmp-host→Доступ
|
||
новий хост через API 201
|
||
група доступу для 'eng' лише «Доступ», рівень read
|
||
що бачить eng snmp-host і test-host, обидва writable=false;
|
||
gateway зник із вибірки
|
||
алерти під eng 1 замість 3
|
||
eng редагує чужий хост 403
|
||
```
|
||
|
||
У браузері: вхід логіном (у шапці «admin», не пошта); перемикачі вміщаються в
|
||
трек в обох станах із відступом 2px; правила оновлюються 2 → 0 → 2 без
|
||
перезавантаження; сторінка груп показує обидві половини; інспектор вузла змінив
|
||
підпис, значок і ширину до 220px із ревізією 13 → 14; створення мапи дало slug
|
||
`kyiv-iadro` з «Київ ядро» і порожнє полотно.
|
||
|
||
### Чого ще немає
|
||
|
||
Динамічних груп (`inv.device_groups.kind='dynamic'` у схемі є, правило відбору
|
||
не читається), успадкування прав між групами, редагування груп хостів після
|
||
створення (лише створення й видалення), масових операцій над хостами.
|
||
|
||
---
|
||
|
||
## 2026-08-15 — Опитування хоста з форми й редагування користувачів
|
||
|
||
Зауваження було точним: «додаєш хост — чому не можна вказати, як його
|
||
опитувати». Форма створення хоста, яку я зробив раніше, збирала назву,
|
||
адресу, тип і групи — і не збирала головного. Хост, доданий через UI,
|
||
не опитувався **взагалі**: жодного рядка в `core.checks`. Наявні два
|
||
хости на стенді працювали лише тому, що їхні перевірки я засіяв через
|
||
SQL.
|
||
|
||
### Створено
|
||
|
||
- `server/internal/store/checks.go` — типи перевірок, перевірки хоста,
|
||
доступи до обладнання.
|
||
- `server/internal/httpapi/checks.go` — 5 ендпоїнтів.
|
||
- `web/src/components/ChecksEditor.tsx` — редактор опитування, який
|
||
будує поля параметрів із `params_schema`, що віддає сервер.
|
||
- `UpdateUserProfile` у store + розширений `PATCH /api/v1/team/{id}`.
|
||
- `EditUserForm` на сторінці «Команда».
|
||
|
||
### Прийняті рішення
|
||
|
||
**Перевірки — частина створення хоста, а не окремий крок.** Вимагати
|
||
другого запиту означало б зробити «хост, який не опитується» типовим
|
||
станом. Новий хост у формі одразу отримує `icmp.ping`: це єдина
|
||
перевірка, яка працює будь-де без налаштування.
|
||
|
||
**Поля параметрів будуються з JSON Schema типу.** Захардкодити їх у
|
||
фронтенді означало б забувати оновити його щоразу, коли плагін додає
|
||
новий тип. Сервер уже віддає `params_schema` — форма її і читає.
|
||
|
||
**Правка перевірки йде за `id`.** Унікальний індекс `checks_uniq`
|
||
включає `md5(params)`, тому «видалити й вставити» на зміні інтервалу
|
||
спрацювало б, а на зміні параметрів створило б ДРУГУ перевірку того
|
||
самого типу.
|
||
|
||
**Профіль редагується лише в того, хто працює тільки в цій
|
||
організації.** `core.users` глобальна, і логін із паролем — власність
|
||
людини, а не тенанта. Адмін філії, який змінює пароль тому, хто тим
|
||
самим акаунтом заходить у сусідню організацію, ламає їй доступ там, і
|
||
вона про це не дізнається. Спроба дає `409 shared_user`; роль і групи
|
||
локальні й редагуються завжди.
|
||
|
||
**Порожнє поле у формі означає «не чіпати».** Тому перевірки шлються
|
||
лише коли форма справді їх завантажила: збереження форми, відкритої до
|
||
завантаження, інакше стерло б усе опитування хоста.
|
||
|
||
### Знайдено при написанні
|
||
|
||
`core.plugins` не має колонки `enabled` — активація на тенанта живе в
|
||
`core.plugin_installs`. Перший варіант запиту падав із
|
||
`column p.enabled does not exist`. Заодно з'ясувалося, що
|
||
`plugin_installs` порожня, тому доступність рахується як «плагін
|
||
базовий АБО явно ввімкнений»: вимагати «встановлення» для пінга
|
||
означало б зустрічати кожного нового клієнта порожнім списком перевірок.
|
||
|
||
### Перевірено наживо
|
||
|
||
```
|
||
типи перевірок 8 доступних із 9 (modbus не базовий і не встановлений)
|
||
доступ SNMP створено 201
|
||
хост із трьома перевірками 201
|
||
що записалось icmp.ping 30 с, snmp.get 60 с, snmp.if 300 с
|
||
правка інтервалу і зняття однієї 10 с, params {"count": 5}, лишилось 2 — без задвоєння
|
||
хост без перевірок створюється, перевірок 0
|
||
профіль + пароль змінено 204
|
||
вхід новим паролем 200
|
||
вхід старим паролем 401 bad_credentials
|
||
перейменування логіна 204, вхід новим логіном 200
|
||
спроба змінити роль собі 403
|
||
```
|
||
|
||
У браузері: форма нового хоста показує `icmp.ping` із полями `count` і
|
||
`packet_size`, узятими зі схеми, і список із семи інших доступних типів;
|
||
доданий через UI хост записав `icmp.ping` з інтервалом 30 с і `snmp.get`
|
||
із двома OID.
|
||
|
||
### Чого ще немає
|
||
|
||
Форми створення доступу (SNMP-community) у вебі — доступи заводяться
|
||
через API, а у формі хоста лише прив'язуються. Перевірок на рівні групи
|
||
хостів (у Zabbix це шаблони — Етап 6). Історії й графіків за зібраними
|
||
метриками.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 2026-08-15 — Каталог команд збору конфігу
|
||
|
||
Щоб знімати конфіги, треба знати команду для кожної платформи: у Cisco
|
||
це `show running-config`, у Huawei `display current-configuration`, у
|
||
MikroTik `export`, а Eltex SMG узагалі віддає конфіг через `cat`. Зібрав
|
||
це в каталог.
|
||
|
||
### Створено
|
||
|
||
- `db/profiles/catalog.json` — 147 платформ, 67 вендорів: команда збору,
|
||
команда startup-конфігу, підготовка консолі, скільки службових рядків
|
||
відкинути, альтернативи для інших моделей родини.
|
||
- `db/profiles/build.py` — збірка міграції з каталогу, з режимом
|
||
`--check`.
|
||
- `db/profiles/README.md` — будова каталогу й як додати платформу.
|
||
- `db/migrations/0014_ncm_profiles.sql` — згенерований результат.
|
||
|
||
### Прийняті рішення
|
||
|
||
**Каталог — джерело істини, міграція породжується.** Два описи одного й
|
||
того самого розійшлися б із першою ж правкою, і невідомо було б, який
|
||
справжній. Тому міграція має в шапці «ФАЙЛ ЗГЕНЕРОВАНО», а `build.py
|
||
--check` уміє звірити, чи вона не відстала.
|
||
|
||
**Каталог — код, а не дані клієнта.** Він однаковий для всіх
|
||
інсталяцій, має переглядатись у code review і їхати разом із релізом.
|
||
Тенант при цьому може завести власний профіль через `tenant_id` —
|
||
вбудовані лишаються недоторканими.
|
||
|
||
**Родина CLI, а не вендор.** `bdcom`, `arista`, `brocade` і ще з десяток
|
||
говорять діалектом Cisco; `h3c` і `3com` — діалектом Huawei. Тримати
|
||
промпт і вимкнення пейджера один раз на родину — різниця між правкою в
|
||
одному місці й правкою в сорока.
|
||
|
||
**Пейджер додається лише мережевому CLI.** Частина «мережевих»
|
||
пристроїв знімає конфіг шелом (`cat /etc/config/cfg.yaml` в Eltex SMG і
|
||
TAU, `tail` в Eltex RG). Відправити в bash `terminal datadump` означало
|
||
б гарантовану помилку на кожному зборі.
|
||
|
||
**Лічильники в шапці обчислюються.** Перший варіант мав зашите «148
|
||
платформ» поруч зі згенерованими 147 — розбіжність з'явилась одразу, а
|
||
на першій доданій платформі стала б звичкою не вірити коментарям.
|
||
|
||
### Перевірено
|
||
|
||
Міграція накочена на `netpulse_it`: 148 профілів (147 із каталогу плюс
|
||
раніше засіяні), 67 вендорів, дублікатів немає — унікальний індекс
|
||
`ncm_profiles_key_uniq` спрацював, а `ON CONFLICT DO NOTHING` зберіг
|
||
наявні. Вибірково звірено складні випадки: Cisco ASA (`more
|
||
system:running-config` без службових `changeto system`), NXOS, FWSM,
|
||
Huawei VRP3, H3C, Juniper JUNOSe, Eltex SMG/TAU (без пейджера).
|
||
|
||
`build.py --check` після збірки каже «міграція актуальна» — генерація
|
||
детермінована.
|
||
|
||
### Чого це ще не дає
|
||
|
||
**Виконувати профілі нікому.** Агентського модуля `ncm` (SSH/Telnet)
|
||
немає — це Етап 7. Зараз це готові дані, які чекають на виконавця.
|
||
|
||
---
|
||
|
||
## 2026-08-16 — Етап 7: збір конфігів запрацював
|
||
|
||
Профілі з попереднього кроку були даними без виконавця. Тепер ланцюг
|
||
замкнено: кнопка в UI → черга → диспетчер → зонд → SSH → назад у БД.
|
||
|
||
### Створено
|
||
|
||
- `agent/internal/ncmx/` — знімання конфігу по CLI: `session.go`
|
||
(розбір потоку), `transport.go` (SSH і Telnet), `collect.go`
|
||
(виконання завдання).
|
||
- `agent/internal/session/config_jobs.go` — приймання `ConfigJob` і
|
||
вивантаження стрімом.
|
||
- `server/internal/store/ncm_jobs.go` — черга, побудова завдання з
|
||
профілю й доступу, закриття.
|
||
- `server/internal/grpcapi/ncm_dispatch.go` — диспетчер.
|
||
- 16 тестів на розбір консолі.
|
||
|
||
### Прийняті рішення
|
||
|
||
**Усе будується навколо пошуку промпту.** Консоль мережевого пристрою —
|
||
не програмний інтерфейс: немає ані коду завершення, ані довжини
|
||
відповіді. Єдиний спосіб зрозуміти, що команда відпрацювала — побачити
|
||
знову запрошення.
|
||
|
||
**Промпт шукається лише в хвості накопиченого (512 байтів).** Конфіг
|
||
може містити рядок, схожий на запрошення — `banner motd #` трапляється
|
||
в кожній другій мережі, — і пошук по всьому тексту обривав би збір на
|
||
середині.
|
||
|
||
**Дедлайн на паузу між байтами, а не на всю операцію.** Збір із
|
||
великого шасі триває хвилини, і загальний ліміт довелося б ставити
|
||
навмання. Тиша ж означає одне з двох: пристрій завис або промпт не той.
|
||
Тому й помилка окрема — `ErrPromptTimeout` підказує, що лікується вона
|
||
не повтором, а виправленням `prompt_regex`.
|
||
|
||
**Черга в БД між процесами.** REST і AgentService — різні процеси;
|
||
живу сесію зонда тримає лише другий. `FOR UPDATE SKIP LOCKED` не дає
|
||
двом екземплярам надіслати одне завдання двічі.
|
||
|
||
**Ключі SSH мережевого обладнання не звіряються.** Свідомо: залізо
|
||
перегенеровує ключ після кожної заміни прошивки, і known_hosts на сотні
|
||
пристроїв означав би або вимикати перевірку щотижня, або не збирати
|
||
конфіги зовсім. Захист тут дає сегмент керування, а не TOFU. Переліки
|
||
алгоритмів навмисно широкі — інакше половина парку відпаде з «no common
|
||
algorithm».
|
||
|
||
### Знайдено живим прогоном (три справжні помилки)
|
||
|
||
Прогін ставили проти самого стенду: у нього є SSH, і `cat
|
||
/etc/os-release` віддає текст так само, як консоль віддає конфіг.
|
||
|
||
**Сервер ігнорував поле `encoding`.** Агент стискав тіло gzip і чесно
|
||
рахував sha256 від оригіналу, а сервер рахував від стиснених байтів —
|
||
і відхиляв кожен бекап як «тіло не відповідає заявленому sha256». Поле
|
||
було в контракті з Етапу 2, реалізації не було ніколи: інтеграційний
|
||
тест користувався `encoding: "none"` і повз цю дірку проходив.
|
||
|
||
**Промпт обрізався не по рядку.** Типовий шаблон `[>#]\s*$` збігається
|
||
лише з символом запрошення, тому в конфізі лишалось ім'я пристрою
|
||
окремим рядком: «…interface Gi0/1» + «sw1». Ріжемо весь рядок.
|
||
|
||
**У конфіг потрапляло сміття терміналу.** Перший успішний збір дав 295
|
||
байтів і 12 рядків там, де файл має 286 і 10. Транскрипт (який сам же
|
||
модуль і зберіг) показав причину: escape-послідовності bash
|
||
`ESC[?2004l` і подвоєні `\r\r\n` від псевдотерміналу — PTY додає свій
|
||
`\r` до пристроєвого `\r\n`, і кожен рядок подвоювався. Після
|
||
виправлення: 285 байтів і рівно 10 рядків, різниця з оригіналом лише у
|
||
фінальному переводі рядка, який відрізається свідомо.
|
||
|
||
### Перевірено наживо
|
||
|
||
```
|
||
завдання в черзі → диспетчер забрав, зонд отримав
|
||
зонд зайшов по SSH → виконав команду профілю
|
||
вивантажив gzip-стрімом → сервер розпакував, звірив sha256
|
||
статус → success, конфіг 285 байтів / 10 рядків
|
||
повторний збір → unchanged, другої версії не створено
|
||
```
|
||
|
||
### Чого ще немає
|
||
|
||
Планувальника за `ncm.device_policies.cron` — збір запускається лише
|
||
вручну або зовнішнім тригером. Тригера за Syslog-подією
|
||
(`%SYS-5-CONFIG_I`). Git-двигуна: конфіг лягає в БД зашифрованим, але
|
||
коміту в репозиторій ще немає, тому `commit_sha` порожній. Візуального
|
||
diff у вебі й кнопки «зібрати зараз» в інтерфейсі — API є, сторінки
|
||
немає.
|
||
|
||
---
|
||
|
||
## 2026-08-16 — Візуальний diff конфігів
|
||
|
||
Збір працював, але подивитись на зібране було ніде: жодної сторінки, а
|
||
`lines_added` завжди 0 — порівняння ніхто не рахував.
|
||
|
||
### Створено
|
||
|
||
- `server/internal/difftext/` — порядкове порівняння з ділянками й
|
||
контекстом; 10 тестів.
|
||
- `server/internal/store/ncm_read.go` — читання версій, розшифровка
|
||
тіла, порівняння з кешем.
|
||
- Три ендпоїнти: версії, текст, diff.
|
||
- `web/src/pages/ConfigsPage.tsx` — хости зліва, історія й diff справа.
|
||
|
||
### Прийняті рішення
|
||
|
||
**Власне порівняння, а не бібліотека.** Потрібен рівно один алгоритм на
|
||
рядках із виводом у формі, яку розуміє UI. Зовнішня залежність принесла
|
||
б підтримку слів, символів, кольорів у терміналі — десяток речей, які
|
||
тут ніколи не знадобляться.
|
||
|
||
**Спільний початок і кінець відкидаються до основного алгоритму.** У
|
||
конфігах змінюється кілька рядків із тисячі; без цього кроку
|
||
квадратична таблиця будувалася б там, де досить порівняти десяток
|
||
рядків.
|
||
|
||
**Занадто великі й повністю різні тексти позначаються `truncated`.**
|
||
Понад чотири мільйони клітинок — це вже пара конфігів, що розійшлися
|
||
повністю, і точність там нічого не дає: людині однаково читати весь
|
||
блок. Чесна позначка краща за правдоподібний, але вигаданий diff.
|
||
|
||
**Зміни поруч зливаються в одну ділянку.** Інакше сусідні правки давали
|
||
б дві ділянки з дубльованим контекстом між ними.
|
||
|
||
**Порівняння кешується в `ncm.diffs`.** Diff двох конкретних версій
|
||
незмінний назавжди; рахувати його при кожному відкритті сторінки — це
|
||
палити процесор на відому відповідь. Підсумок `+N/−M` заразом
|
||
дозаписується у version, щоб список історії не розшифровував два тіла
|
||
на кожен рядок.
|
||
|
||
**Порівняння з попередньою версією відкривається одразу.** Питання
|
||
завжди одне — «що змінилось цього разу», — і вибір «з чим порівняти»
|
||
був би зайвим кроком перед відповіддю.
|
||
|
||
### Перевірено наживо
|
||
|
||
Створили другу версію, змінивши команду профілю (`cat /etc/os-release`
|
||
→ `cat /etc/os-release /etc/hostname`):
|
||
|
||
```
|
||
історія 2 версії, друга is_change=true
|
||
diff @@ −8,3 +8,4 @@, один доданий рядок «new-ct» з контекстом
|
||
лічильник +1 −0 дозаписався в історію після обчислення
|
||
повний текст 292 байти, читається
|
||
```
|
||
|
||
### Чого ще немає
|
||
|
||
Тригера за Syslog. Git-двигуна: `commit_sha` поки дорівнює хешу вмісту,
|
||
справжнього репозиторію немає. Порівняння з довільною версією в UI (API
|
||
вміє через `?from=`, кнопки немає).
|
||
|
||
---
|
||
|
||
## Планувальник бекапів за cron
|
||
|
||
Збір конфігів працював, але лише руками. Це не NCM: сенс бекапу в
|
||
тому, що він робиться сам, а «зайти й натиснути» — це та сама
|
||
відсутність бекапу, просто з кращим самопочуттям.
|
||
|
||
### Свій парсер cron замість бібліотеки
|
||
|
||
`robfig/cron` зробив би це за десять рядків, але тягнув би залежність
|
||
заради того, що вміщується в одному файлі й не змінюється з 1975 року.
|
||
Розбір — у бітові маски `uint64` на поле, пошук наступного запуску —
|
||
покроково по хвилинах із запобіжником у чотири роки: перебір дешевший
|
||
за арифметику з календарем і не має її крайніх випадків.
|
||
|
||
**Правило dom/dow — об'єднання, не перетин.** Якщо задані і день
|
||
місяця, і день тижня, підходить збіг за будь-яким. Це виглядає
|
||
неочікувано, доки не спробуєш записати «щоп'ятниці та першого числа» —
|
||
іншого способу немає.
|
||
|
||
**Неможливий розклад чесно відмовляє.** `0 0 30 2 *` — 30 лютого не
|
||
буває; запобіжник у чотири роки перетворює це на помилку, а не на
|
||
зациклений процес. 29 лютого при цьому знаходиться правильно (2028).
|
||
|
||
11 тестів на парсер пройшли з першого запуску.
|
||
|
||
### Планувальник
|
||
|
||
Тік раз на хвилину — дрібніше cron не буває. Advisory-блокування
|
||
(`0x6e70_6263`, «npbc»), бо інакше кожен екземпляр сервера в кластері
|
||
поставив би своє завдання на той самий хост.
|
||
|
||
**Порядок кроків важливий: спершу перенести `next_backup_at`, потім
|
||
ставити завдання.** Падіння між кроками коштує одного пропущеного
|
||
бекапу. Зворотний порядок дав би нескінченну чергу однакових завдань —
|
||
а це кладе і пристрій, і сервер.
|
||
|
||
**Некоректний cron відсувається на добу, а не пишеться в журнал
|
||
щохвилини.** Скарга має лишитись помітною, але не перетворитись на шум,
|
||
у якому потонуть справжні помилки.
|
||
|
||
**`next_backup_at IS NULL` = «час настав».** Так виглядає щойно
|
||
збережена політика. З цієї ж причини будь-яка зміна політики обнуляє
|
||
позначку: розклад міг стати частішим, і чекати за старим було б
|
||
неправильно.
|
||
|
||
### Форма
|
||
|
||
Чотири готові розклади покривають майже все, що справді налаштовують;
|
||
довільний cron лишився, але не першим, що бачить людина.
|
||
|
||
**Знайдена й виправлена помилка:** вибір «свій розклад…» нічого не
|
||
робив. Обробник `select` мав вигляд `e.target.value && setCron(...)`, а
|
||
значення цього пункту — порожній рядок, тож умова відсікала саме той
|
||
випадок, заради якого пункт існує. Режим тепер тримається окремим
|
||
прапорцем, а не виводиться з виразу: інакше поле не відкривалось би й
|
||
тоді, коли власний вираз випадково збігся з пресетом.
|
||
|
||
### Перевірено наживо
|
||
|
||
Розклад `* * * * *` на stand-host:
|
||
|
||
```
|
||
trigger | status | створено
|
||
schedule | unchanged | 09:39:08
|
||
наступний | останній
|
||
09:40:00 | 09:20:31
|
||
```
|
||
|
||
У браузері: вибір «свій розклад…» відкриває поле з поточним виразом;
|
||
`0 99 * * *` відхиляється сервером із текстом «розклад: години: 99 поза
|
||
межами 0..23» і модалка лишається відкритою; `*/30 * * * *`
|
||
зберігається, і заголовок хоста стає «за розкладом */30 * * * *».
|
||
Розклад повернуто на `0 3 * * *`.
|
||
|
||
---
|
||
|
||
## Етап 6. Шаблони опитування
|
||
|
||
Досі кожен OID заводився руками на кожному хості. Те, що знімається з
|
||
Mikrotik, однакове на всіх Mikrotik — але цей факт жив у голові
|
||
інженера й повторювався стільки разів, скільки в мережі пристроїв.
|
||
|
||
### Схема
|
||
|
||
`tpl.templates` → `tpl.items` → `tpl.device_templates`. Вбудовані
|
||
шаблони — `tenant_id IS NULL`, той самий прийом, що в `ncm.profiles`.
|
||
|
||
**Елемент — це одна метрика, а не чек.** Тримати в шаблоні «чек»
|
||
означало б змішати те, що описує людина (метрику), з тим, що вигідно
|
||
машині (пачку OID в одному PDU).
|
||
|
||
**У `tpl.items` немає власного `tenant_id`.** Він завжди дорівнював би
|
||
шаблоновому, а дублювання ключа ізоляції — це запрошення до
|
||
розбіжності. Видимість успадковується від шаблону через `EXISTS`.
|
||
|
||
### Реконсиляція
|
||
|
||
Елементи групуються за (шаблон, тип, інтервал) в один `snmp.get`. Сотня
|
||
окремих чеків замість однієї пачки — це сотня SNMP-сесій там, де
|
||
досить кількох PDU. Інтервал у ключі групування, бо пачка ходить
|
||
цілком.
|
||
|
||
**Порядок ключів фіксується сортуванням.** `params` потрапляє в хеш
|
||
плану, і «однаковий шаблон дав інший хеш» через порядок обходу map — це
|
||
перезалив плану на кожному тіку звірки.
|
||
|
||
**`core.checks.template_id` + частковий унікальний індекс.** Спільний
|
||
`checks_uniq` містить `md5(params)`, тож будь-яка правка списку OID
|
||
виглядала б як новий чек.
|
||
|
||
**`ListDeviceChecks` і `SetDeviceChecks` тепер обходять чеки шаблонів.**
|
||
Інакше вони показувалися б у формі ручних перевірок і зникали на кожне
|
||
збереження, щоб за секунду з'явитися знову.
|
||
|
||
### Звірка планів — те, чого бракувало весь час
|
||
|
||
Чеки міняє REST-процес, а живу сесію зонда тримає AgentService. Досі
|
||
зміна доїжджала до зонда лише при обриві зв'язку — тобто, за нормальної
|
||
роботи, ніколи. Тік раз на п'ять секунд звіряє хеш плану в базі з тим,
|
||
що зараз у зонда, і перезаливає повний план при розбіжності.
|
||
|
||
Повний план, а не дельта: дельта не знає, що зникло.
|
||
|
||
### Знайдено живими прогонами
|
||
|
||
**Креденшели не їхали разом із планом.** Хост, приписаний зонду вже
|
||
після його підключення, отримував задачі й падав на кожній із «немає
|
||
SNMP-креденшелів». Пачка доступів видається на `Hello`, а тоді цього
|
||
хоста в ній ще не було. Тепер іде разом із планом.
|
||
|
||
**Форма хоста відв'язувала зонд.** Поле «Зонд» починалося порожнім,
|
||
підпис обіцяв «— не змінювати —», а сервер трактував порожнє значення
|
||
буквально. Будь-яке збереження форми лишало хост у списку й мовчки
|
||
припиняло його опитувати. Помітили, коли після збереження шаблону в
|
||
журналі з'явилось «план зонда оновлено, задач: 0». Форма тепер тягне
|
||
поточний зонд, а підпис каже правду: «— без зонда —».
|
||
|
||
**`hrProcessorLoad.1` у вбудованому шаблоні — здогадка, а не адреса.**
|
||
Це таблиця, індексована процесором; на net-snmp вона віддала «No Such
|
||
Instance». Свій же коментар у міграції казав, що неперевірений шаблон
|
||
гірший за відсутній — прибрали з базового, лишили у вендорному
|
||
Mikrotik, де індекс фіксований.
|
||
|
||
### Перевірено наживо
|
||
|
||
Шаблон, створений повністю через веб (OID введено без крапки на
|
||
початку — сервер дописав її сам), прив'язаний до хоста в тій самій
|
||
формі:
|
||
|
||
```
|
||
чеки хоста qa-netsnmp 60с qa.users ← .1.3.6.1.2.1.25.1.5.0
|
||
snmp-generic 60с sys.uptime_sec ← .1.3.6.1.2.1.1.3.0
|
||
snmp-host-resources 300с sys.users; sys.processes (два OID, одна пачка)
|
||
план зонда оновлено за секунди, без переп'єднання
|
||
метрики sys.uptime_sec = 862334.20 s (snmpget: 9 днів 22:46 — збігається)
|
||
sys.processes = 41, qa.users = 0
|
||
вбудований DELETE → 403 builtin, PUT → 403 builtin
|
||
власний DELETE → 204, породжені ним чеки зникли каскадом
|
||
```
|
||
|
||
### Чого ще немає
|
||
|
||
Автопризначення шаблону за `sysObjectID`. Прототипів (шаблон, що
|
||
успадковує інший). Тригерів усередині шаблону — поки правила алертів
|
||
заводяться окремо. `snmp.walk` як тип елемента: таблиці з динамічним
|
||
індексом (CPU по ядрах, диски, сенсори) шаблон описати не вміє.
|
||
|
||
---
|
||
|
||
## Історія метрик і те, що знайшлося дорогою
|
||
|
||
Система збирала телеметрію в `ts.samples` і не показувала її ніде: щоб
|
||
побачити зібране, треба було йти в `psql`. Шаблони, зроблені на минулому
|
||
кроці, збирали метрики в нікуди.
|
||
|
||
**Джерело обирається за кроком.** Читати сирі точки за місяць — це
|
||
мільйони рядків заради трьохсот пікселів; брати годинні бакети на вікні
|
||
в п'ять хвилин — це графік з однієї точки. Роллапи `ts.samples_5m` і
|
||
`ts.samples_1h` уже існували з Етапу 1, лишалось ними скористатися.
|
||
|
||
**Крок рахується з бажаної кількості точок, а не приходить ззовні.**
|
||
Інакше вузьке вікно з дрібним кроком повернуло б десятки тисяч точок, з
|
||
яких екран покаже сотні.
|
||
|
||
**Пропуск і нуль — різні речі.** Значення точки — вказівник: лінія,
|
||
проведена через діру в даних, каже «все було добре», хоча насправді
|
||
нічого не відомо. Це найгірший різновид брехні в моніторингі.
|
||
|
||
**`series_id` приходить від клієнта, тож належність перевіряється явно.**
|
||
Таблиці `ts.*` не під RLS (несумісно зі стисненням), і без цієї
|
||
перевірки чужий ідентифікатор віддав би чужі дані.
|
||
|
||
### Графік без бібліотеки
|
||
|
||
`recharts` коштує понад сотню кілобайт стисненого коду, а потрібна з них
|
||
одна ламана. SVG до того ж масштабується під будь-який контейнер без
|
||
переобчислення на ресайз.
|
||
|
||
**Ряди групуються за одиницею виміру.** Відсотки й біти на секунду на
|
||
спільній осі перетворюють графік на пряму лінію біля нуля.
|
||
|
||
**Пропуск розриває лінію.** Кожен відрізок — окремий `M…L…`, і між ними
|
||
лишається порожнеча — саме те, що сталося насправді.
|
||
|
||
**Метрика, яка не буває від'ємною, не отримує від'ємну вісь.** Відступ
|
||
знизу корисний для читабельності, але «−3 % втрат» читається як помилка
|
||
даних.
|
||
|
||
### Перевірено наживо
|
||
|
||
```
|
||
6 год крок 72 с, джерело сирі дані
|
||
тиждень крок 2016 с, джерело роллап 5m
|
||
snmp-host if.in_bps · eth0 = 18.5k bps, sys.processes = 41,
|
||
sys.uptime = 87.5M ticks — усе з підписом порту й одиницею
|
||
```
|
||
|
||
### Знайдено живим прогоном: зонд працює рівно годину
|
||
|
||
Метрики SNMP замовкли о 12:05 — рівно через годину після того, як зонд
|
||
отримав доступи. У журналі — жодного слова.
|
||
|
||
`CredentialTTL` дорівнює годині, і `Credentials()` свідомо не віддає
|
||
прострочені: інакше зонд довбав би комутатори старим паролем і
|
||
заблокував обліковий запис. Правильне рішення. Але **поновлення не
|
||
просив ніхто**: повідомлення `CredentialRequest` є в контракті з Етапу 2,
|
||
сервер його обробляє, агент — не надсилає. Тобто будь-яка інсталяція
|
||
припиняла збирати SNMP через годину після старту й мовчала про це.
|
||
|
||
Тепер зонд просить нову пачку за десять хвилин до кінця терміну, не
|
||
частіше ніж раз на хвилину. Причина в запиті розрізняє `expiring` і
|
||
`expired` — за журналом видно, чи встигли.
|
||
|
||
---
|
||
|
||
## Шаблон перестав бути «набором OID»
|
||
|
||
Шаблон описував лише `snmp.get`. Пінг заводився руками — і це змушувало
|
||
пам'ятати, що саме шаблон покриває, а що ні.
|
||
|
||
Тепер елемент має `params` (те саме, що лягає в `core.checks.params`), а
|
||
`oid` і `metric_key` стали необов'язковими. Пачкою в один PDU
|
||
збираються тільки OID: два пінги з різними параметрами — це просто два
|
||
пінги. Для негрупованих типів елемент відповідає окремому чеку, і його
|
||
слід у `core.checks.template_item_key` дозволяє впізнати рядок.
|
||
|
||
**Ключ елемента, а не його id.** Збереження шаблону перезаписує елементи
|
||
цілком, тож id живуть недовго, а ключ стабільний за визначенням.
|
||
|
||
Вбудований шаблон «Доступність (ICMP)» — той, який чіпляють першим.
|
||
|
||
### Обмін шаблонами
|
||
|
||
Експорт: усі одним файлом, окремий шаблон кнопкою на картці, і ще один
|
||
вхід — просто з форми редагування. Імпорт: глобальний (файл або
|
||
вставлений текст) і локальний, що замінює вміст відкритого шаблону.
|
||
|
||
Формат свій. Zabbix-YAML описує елемент ключем виду `snmp.get[...]`, з
|
||
препроцесингом, value maps і тригерами — нічого з цього тут немає, і
|
||
вдавати сумісність означало б мовчки втрачати половину імпортованого.
|
||
|
||
Ідентифікатори з документа прибираються: на іншому стенді вони нічого не
|
||
значать, а лишені створюють ілюзію, що імпорт «відновить те саме».
|
||
|
||
---
|
||
|
||
## Спільний розклад бекапів
|
||
|
||
Розклад існував лише поштучно: щоб бекапити сто пристроїв, треба було
|
||
сто разів відкрити форму.
|
||
|
||
Спільний розклад заводить політику кожному придатному хосту з
|
||
`follows_default = true`. Хост, якому задали власний розклад, прапорець
|
||
втрачає.
|
||
|
||
**Прапорець, а не порівняння значень.** Власний розклад може випадково
|
||
збігтися зі спільним, і тоді зміна спільного мовчки потягла б за собою
|
||
хост, який навмисно налаштували окремо.
|
||
|
||
**Вимкнення спільного зупиняє лише тих, хто йому слідує.** Форма показує
|
||
`following_count` і `custom_count`: без цих двох чисел вона не каже
|
||
головного — кого саме зачепить зміна.
|
||
|
||
---
|
||
|
||
## Куди йде алерт
|
||
|
||
Правило вміло сказати «за яких умов», але не «кому». Маршрути
|
||
(`alr.routes`) вирішують інше завдання — спільну політику на всі правила
|
||
разом, і для звичайного «це правило важливе, шліть черговому в Telegram»
|
||
вони заважкі.
|
||
|
||
Порядок вирішення: канали правила → маршрути тенанта → усі придатні
|
||
канали.
|
||
|
||
**Канали правила перекривають маршрути повністю.** Інакше «шліть це
|
||
черговому» перетворювалося б на «шліть це черговому і ще туди, куди
|
||
вирішить спільна політика» — тобто на щось, чого людина не просила.
|
||
|
||
Разом із каналами правило отримало тихі години (той самий формат, що в
|
||
маршрутах — щоб не заводити другий діалект того самого поняття), вибір
|
||
груп хостів і перемикач повідомлень про відновлення.
|
||
|
||
---
|
||
|
||
## Дрібниці, які насправді не дрібниці
|
||
|
||
**Відступи в картках шаблонів.** `Card` навмисно без внутрішнього
|
||
відступу — на інших сторінках його діти самі малюють `px-4 py-2.5` і
|
||
роздільники на всю ширину. На сторінці шаблонів картка — звичайний блок
|
||
тексту, і клас загубився. Текст стояв упритул до рамки.
|
||
|
||
**Українська множина.** «1 метрик» — дрібниця, з якої складається
|
||
враження, що інтерфейс писали не для людей. `plural(n, one, few, many)`
|
||
з правилом 11–14.
|
||
|
||
**Інтервал опитування вводиться руками.** Список сам по собі не годиться:
|
||
рано чи пізно комусь потрібні 45 секунд, і відсутність такої можливості
|
||
робить продукт «майже підходящим». Ручний ввід сам по собі теж не
|
||
годиться: у дев'яти випадках із десяти значення є в списку.
|
||
|
||
**Підтвердження показує наслідки, а не питає «Ви впевнені?».** «Видалити
|
||
групу?» і «Видалити групу? 34 хости втратять межі доступу» — різні
|
||
питання, і людина відповідає на них по-різному. Вбудований `confirm()`
|
||
другого не вміє, тому свій діалог.
|
||
|
||
**Кнопки видалення стали кнопками.** Сірий `✕` у кутку рядка формально
|
||
існував і фактично не знаходився — користувач повідомив, що видалення
|
||
користувачів «немає».
|
||
|
||
---
|
||
|
||
## «Просить логін після кожного оновлення сторінки»
|
||
|
||
Дві різні поломки в одному симптомі.
|
||
|
||
**Перша: користувач без пошти не міг поновити сесію взагалі.** Разом із
|
||
входом за логіном пошта стала необовʼязковою, але `RotateSession` читав
|
||
`email::text` без `COALESCE`. Scan падав, помилка перетворювалась на
|
||
«сесія недійсна» — і такі люди могли увійти, але після перезавантаження
|
||
сторінки летіли на форму входу знову й знову. Живий прогін: свіжий
|
||
користувач без адреси, перший же `/auth/refresh` → 401.
|
||
|
||
**Друга: ротація токена не переживала подвійного обміну.** React у
|
||
режимі розробки виконує ефекти двічі, тож відновлення сесії йшло двома
|
||
запитами поспіль: перший обмінював токен і відкликав старий, другий
|
||
приносив уже відкликаний. Це не лише про режим розробки — дві вкладки,
|
||
відкриті одночасно, дають рівно ту саму гонку в проді.
|
||
|
||
Тепер `core.sessions.replaced_by` тримає ланцюг, і щойно відкликаний
|
||
токен веде до свого наступника. Вікно — 30 секунд: вистачає на
|
||
подвійний обмін від того самого клієнта й замало для реального повтору
|
||
перехопленого токена, той приходить хвилинами пізніше. Перевірено: обмін
|
||
старим токеном одразу → 200, він же через дві хвилини → 401.
|
||
|
||
Плюс засувка на клієнті: відновлення сесії рівно одне на завантаження
|
||
сторінки.
|
||
|
||
---
|
||
|
||
## Однакові відступи
|
||
|
||
Сторінки розповзлися: одні малювали тіло з `p-3 md:p-4`, інші клали
|
||
`p-4` на внутрішній блок, треті не мали відступу взагалі — і текст стояв
|
||
упритул до бічної панелі.
|
||
|
||
`PageBody` тепер один на всіх: відступи, прокрутка й `min-h-0` в одному
|
||
місці. Заміряно в браузері: усі десять сторінок дають рівно 16 px від
|
||
краю.
|
||
|
||
---
|
||
|
||
## Мапа: видалення й вигляд вузла
|
||
|
||
**Видалення мапи** — кнопка була відсутня, хоча ендпоїнт існував із
|
||
Етапу 4. З підтвердженням, яке пояснює, що саме зникне: схема, а не
|
||
хости й дані.
|
||
|
||
**Форма вузла.** Картка, пігулка або крапка. Крапка потрібна не для
|
||
краси: на схемі з сотні вузлів важливо бачити стан усіх, а не читати
|
||
сотню підписів. Плюс товщина рамки, колір заливки, приховування підпису
|
||
й цифр пінга.
|
||
|
||
**Аварія й попередження світяться.** Рамка іншого кольору не помітна
|
||
периферійним зором, а ореол (`box-shadow` у два шари) видно навіть на
|
||
віддаленому масштабі, коли підписи вже не читаються. Червоний лишається
|
||
тільки за обривом — якщо ним підсвічувати ще й «невідомо», оператор
|
||
перестане на нього реагувати.
|
||
|
||
---
|
||
|
||
## Дашборди
|
||
|
||
Схема `core.dashboards` і `core.dashboard_widgets` лежала з Етапу 1 і не
|
||
мала жодного рядка коду. Тепер має.
|
||
|
||
**Сітка — дванадцять колонок.** 12 ділиться на 2, 3, 4 і 6, тож
|
||
половина, третина й чверть ширини задаються цілими числами, а не
|
||
«41.6 %».
|
||
|
||
**Плитки замінюються цілком.** Дашборд редагується як єдине полотно, і
|
||
часткові оновлення дали б спосіб отримати розкладку, якої людина не
|
||
бачила.
|
||
|
||
**Перший дашборд стає головним сам.** Інакше людина створює його й не
|
||
розуміє, чому головна сторінка досі порожня.
|
||
|
||
**Кожна плитка вантажить своє.** Спільний завантажувач на дашборд
|
||
виглядав би охайніше, але змусив би плитку «текст» чекати на запит
|
||
метрик сусідки.
|
||
|
||
Види: графік, число, шкала, список алертів, сітка хостів, текст.
|
||
Перевірений список видів дублюється на сервері навмисно — збережена
|
||
плитка, яку ніхто не малює, виглядає як зламаний дашборд.
|
||
|
||
**Права окремі від мап.** Дашборд збирає дані з усього тенанта, і «може
|
||
дивитись мапу» не означає «може дивитись зведення по всіх майданчиках».
|
||
Видані тим ролям, які вже мають відповідний рівень доступу до мап.
|
||
|
||
---
|
||
|
||
## Esc і клік повз вікно
|
||
|
||
Жодна модалка не закривалася нічим, крім хрестика.
|
||
|
||
Слухач Esc — на `document`, а не на панелі: фокус може стояти в полі
|
||
вводу, у випадному списку або взагалі ніде, і вимагати спершу «влучити»
|
||
кудись — це рівно те, чого від Esc не чекають.
|
||
|
||
Клік повз панель закриває **за `mousedown`, а не за `click`**: інакше
|
||
виділення тексту, доведене мишею за межі вікна, закривало б форму разом
|
||
із набраним.
|
||
|
||
Заголовок вікна став липким: у довгих формах (шаблон із десятком
|
||
метрик) хрестик інакше їхав за межі екрана.
|
||
|
||
---
|
||
|
||
## Редагування там, де його не було
|
||
|
||
**Канали сповіщень** взагалі не редагувалися — лише створювались і
|
||
видалялись. Вид каналу лишається незмінним: telegram із конфігом
|
||
вебхука — це інший обʼєкт, і чесніше завести новий.
|
||
|
||
**Протокол доступу** тепер змінюється. З одним застереженням: секрет
|
||
зашифрований під видом старого протоколу, тож при зміні пароль треба
|
||
ввести заново. Сервер відмовляє з поясненням, кнопка в формі теж не
|
||
дає зберегти — краще сказати це до спроби, ніж після.
|
||
|
||
---
|
||
|
||
## Картка хоста в стилі Zabbix
|
||
|
||
Форма була однією довгою простинею з інтервалами опитування посередині.
|
||
Стала вкладками: Хост, Шаблони, Доступи, Збір конфігів, Ручні перевірки —
|
||
у порядку частоти звернень, а не у порядку появи в коді.
|
||
|
||
**Інтервалів опитування тут більше немає.** Вони живуть у шаблонах. Два
|
||
джерела правди про одне число рано чи пізно розійдуться, і розійдуться
|
||
саме тоді, коли треба буде швидко зрозуміти, чому хост опитується не
|
||
так, як написано.
|
||
|
||
**Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек.**
|
||
Перевірено: створений через форму хост дістав `icmp.ping` з інтервалом
|
||
30 с саме від шаблону, разом із групою, обраною в пікері.
|
||
|
||
**Групи, шаблони й доступи обираються пошуком.** Список галочок працює
|
||
на пʼяти позиціях і перестає працювати на пʼятдесяти. Обране показане
|
||
чипами зверху, тож вибір видно цілком, не гортаючи. Backspace у
|
||
порожньому полі прибирає останній чип — так поводяться всі поля з
|
||
тегами, і руки це вже знають.
|
||
|
||
**Ручні перевірки лишились окремою вкладкою.** Вони потрібні для
|
||
одиничних випадків, але це вже не типовий шлях, і в першому екрані їм
|
||
нема чого робити.
|
||
|
||
---
|
||
|
||
## Графіки описуються в шаблоні
|
||
|
||
«Завантаження каналу — це вхід і вихід на одній осі» — властивість класу
|
||
пристроїв, а не окремого хоста. Малювати той самий графік руками на
|
||
кожному означає повторювати одне рішення стільки разів, скільки в мережі
|
||
заліза.
|
||
|
||
`tpl.graphs` тримає назву, вид і перелік **ключів метрик**, а не
|
||
посилань на елементи шаблону: графік має право показувати й те, що
|
||
прийшло з іншого шаблону — саме так виглядає «трафік поруч із помилками
|
||
на тому ж порту».
|
||
|
||
Види: лінії, з заливкою, з накопиченням, стовпчики, шкала, число.
|
||
|
||
**Накопичення обирає людина, а не евристика.** Сума має сенс там, де
|
||
вона сама щось означає — трафік по портах, місце на дисках. На
|
||
відсотках завантаження це була б нісенітниця.
|
||
|
||
**Жорсткі межі осі з шаблону перекривають пораховані.** «Завантаження
|
||
від 0 до 100» має виглядати однаково на всіх хостах, інакше графіки не
|
||
порівняти очима.
|
||
|
||
**Графік без жодного знайденого ряду не показується.** Порожня рамка з
|
||
підписом «Памʼять» на хості, де памʼять не збирається, — це обіцянка
|
||
даних, яких немає.
|
||
|
||
### Перевірено наживо
|
||
|
||
```
|
||
snmp-host «Час роботи» (stat) → 877.6k s
|
||
gateway «Втрати пакетів» (area) → вісь жорстко 0–100 з шаблону
|
||
gateway «Час відгуку» (line) → дві лінії, вісь 0–2.6 з даних
|
||
```
|
||
|
||
---
|
||
|
||
## Знайдено живим прогоном: полотно вбивало інтерфейс
|
||
|
||
Після додавання групового виділення сторінка перестала реагувати —
|
||
`Maximum update depth exceeded` у `SelectionListener`. React Flow
|
||
викликає `onSelectionChange` на кожному рендері, а мій обробник щоразу
|
||
створював новий масив: колбек міняв стан сторінки, сторінка
|
||
перемальовувала полотно, полотно кликало колбек. Цикл зʼїдав головний
|
||
потік, і мертвими ставали **всі** сторінки, не лише мапа.
|
||
|
||
Тепер набір віддається назовні лише коли справді змінився — порівняння
|
||
за склеєним рядком ідентифікаторів.
|
||
|
||
---
|
||
|
||
## Вигляд мапи
|
||
|
||
Схема виглядала як сітка сірих прямокутників на чорному. Причини були
|
||
конкретні, і кожна лікується окремо.
|
||
|
||
**«? → ?» на кожній лінії.** Так виглядав підпис ребра, коли
|
||
автовиявлення ще не зіставило порти. Читалося це як зламаний інтерфейс,
|
||
хоча означало лише «поки невідомо». Тепер невідомий порт не друкується
|
||
взагалі — порожнеча чесніша, вона нічого не обіцяє. Так само зі
|
||
швидкістю: «— з 10.0 Гбіт/с» зникає, поки трафік не виміряний.
|
||
|
||
**Вузол став карткою з ієрархією.** Градієнт замість пласкої заливки,
|
||
кольорова смуга стану зліва, значок і підпис в одному рядку, крапка
|
||
стану праворуч. Смуга несе стан навіть тоді, коли рамку перефарбували
|
||
під майданчик: колір рамки — рішення про схему, колір смуги — факт про
|
||
пристрій.
|
||
|
||
**Пульсація лише за обривом.** Рух там, де все гаразд, навчає не
|
||
дивитись на рух узагалі.
|
||
|
||
**Порти показуються на наведення.** Вісім завжди видимих кружечків на
|
||
кожному вузлі перетворюють схему на россип точок.
|
||
|
||
**Фон — радіальна підсвітка й дві сітки.** Дрібна дає відчуття масштабу,
|
||
велика — орієнтацію; одна або рябить, або не читається, залежно від
|
||
кроку, а крок задає людина.
|
||
|
||
**Специфічність довелося підняти.** Стилі React Flow приходять з .tsx і
|
||
потрапляють у документ пізніше за index.css, тож одного класу було
|
||
замало — правила стали `.react-flow.netpulse-canvas`.
|
||
|
||
---
|
||
|
||
## Вбудовані шаблони редагуються
|
||
|
||
Раніше відповіддю було «вбудований не редагується — зробіть копію». Це
|
||
межа нашої моделі даних, а не відповідь на питання людини, яка хоче
|
||
підправити OID під своє залізо.
|
||
|
||
Тепер правка вбудованого шаблону створює **власну копію тенанта з тим
|
||
самим ключем**, а список показує її замість оригіналу. Оригінал лишається
|
||
недоторканим для решти тенантів. Форма попереджає про це прямим текстом
|
||
до збереження.
|
||
|
||
Плюс окрема кнопка «Копія» — для випадку, коли потрібні обидва варіанти:
|
||
«як було» і «як хочу». Ключ отримує суфікс, бо копія з тим самим ключем
|
||
сховала б оригінал.
|
||
|
||
**Ключі елементів і графіків виводяться на сервері.** Прямий виклик API
|
||
без `key` давав 500 від перевірки домену `core.slug`. Ключ потрібен базі,
|
||
але жодного рішення не несе — вимагати його від клієнта означало
|
||
відповідати помилкою там, де можна просто зберегти.
|
||
|
||
---
|
||
|
||
## Вибір шаблонів картками
|
||
|
||
Пошуковий рядок із чипами добре працює для груп — там важлива лише
|
||
назва. Для шаблонів важливо ще й що всередині: скільки метрик, для якого
|
||
виробника, що воно робить. Список чипів цього не показує, тож людина
|
||
відмічала навмання й ішла перевіряти на іншу сторінку.
|
||
|
||
Тепер картка з назвою, описом і лічильником метрик. Обрані спливають
|
||
угору — інакше після вибору картка лишається десь у середині списку, і
|
||
незрозуміло, чи вибір зарахувався.
|
||
|
||
---
|
||
|
||
## Мобільна сітка карток
|
||
|
||
На телефоні картка вилазила за екран і обрізалась. Причина не в
|
||
брейкпойнтах: елемент грід-сітки типово має `min-width: auto` й не
|
||
стискається нижче ширини вмісту. `min-w-0` на картці — і 410 px стали
|
||
351 px рівно за шириною сітки.
|
||
|
||
---
|
||
|
||
## Межа помилок навколо полотна
|
||
|
||
Цикл рендеру в React Flow одного разу вже поклав увесь застосунок:
|
||
мертвими стали всі сторінки, і людині лишалося перезавантажити вкладку.
|
||
Причину виправлено, але наслідок треба було обмежити — тепер полотно
|
||
падає саме, а навігація й решта інтерфейсу лишаються живими.
|
||
|
||
---
|
||
|
||
## Етап 8. Реєстрація зонда
|
||
|
||
Найбільше вузьке місце до запуску: агент заводився `INSERT`-ом у базу, а
|
||
токен вписувався в командний рядок руками. Поставити зонд у клієнта було
|
||
неможливо — людина зі стійки не має доступу до бази й не повинна його
|
||
мати.
|
||
|
||
### Одноразове запрошення
|
||
|
||
`core.agent_enrollments`: sha256 токена, підказка імені, набір модулів,
|
||
строк. Токен існує рівно один раз — у відповіді на створення.
|
||
|
||
**Уся видача в одній транзакції під `FOR UPDATE`.** Два агенти,
|
||
стартовані з однієї скопійованої команди, інакше створили б два зонди з
|
||
одного запрошення. Перевірено живим прогоном: другий отримує
|
||
`PermissionDenied`.
|
||
|
||
**Ім'я підбирається суфіксом, а не відмовою.** Людина, яка ставить
|
||
п'ятий агент на однакових машинах, не має вигадувати імена — вона хоче,
|
||
щоб він просто запрацював.
|
||
|
||
### Знайдено живим прогоном
|
||
|
||
**Реєстрація не проходила автентифікацію.** Мій же коментар стверджував,
|
||
що окремий gRPC-сервіс сам собою виводить виклик з-під інтерсептора. Це
|
||
неправда: інтерсептор реєструється на весь сервер. Перший же запуск
|
||
агента дав `Unauthenticated: немає токена зонда` — на виклику, яким
|
||
токен і видається. Виняток тепер заданий явно, повним префіксом сервісу.
|
||
|
||
**Запуск із самим посвідченням падав.** `validate()` вимагав `-agent-id`,
|
||
не знаючи про файл посвідчення, тож агент із валідним
|
||
`/etc/netpulse/agent.json` відмовлявся стартувати. Перевірка переїхала в
|
||
`main`, де враховані всі три джерела: прапорці, файл і реєстрація.
|
||
|
||
### Керування зондом
|
||
|
||
Ім'я, модулі й ліміти правляться з картки. Форма каже прямим текстом, що
|
||
зміни доїдуть при наступному підключенні: мовчазна затримка виглядає як
|
||
«не зберіглося».
|
||
|
||
Ліміти зливаються в наявний `jsonb` через `||`, а не заміняють його: там
|
||
можуть лежати поля, яких форма не знає, і затирати їх мовчки — найшвидший
|
||
спосіб зламати те, чого не бачив.
|
||
|
||
Видалення зонда лишає хости без зонда, а не видаляє їх: інакше заміна
|
||
заліза, на якому стояв агент, коштувала б усієї історії.
|
||
|
||
### Перевірено наскрізно
|
||
|
||
```
|
||
запрошення POST /agent-enrollments → np_enr_…, модулі [icmp snmp]
|
||
реєстрація agent -enroll → «зареєстровано як "QA зонд"»
|
||
посвідчення /tmp/np-agent-id.json, права 0600, токен np_agt_…
|
||
сесія встановлено одразу після реєстрації
|
||
повтор той самий токен → PermissionDenied
|
||
перезапуск лише -identity, без токена → та сама сесія
|
||
керування PATCH модулі й ліміти → 204, значення застосовані
|
||
```
|
||
|
||
|
||
## Зв'язки на мапі
|
||
|
||
### Порти на вузлі
|
||
|
||
Чотири боки, на кожному пара портів з однаковим ім'ям: `l`, `r`, `t`,
|
||
`b`. React Flow розрізняє джерело й ціль за типом, тож одне ім'я обслуговує
|
||
обидва напрямки. Розійдуться імена (наприклад `l` і `l-s`) — збережене
|
||
ребро не знайде пари, і лінія просто не намалюється, без жодної помилки.
|
||
|
||
Обгортка портів — `Fragment`, а не `<span>`: `<span>` стає елементом
|
||
flex-розкладки й розсуває вміст вузла. Чотири такі обгортки з'їдали підпис,
|
||
і на схемі лишались картки без імен.
|
||
|
||
### Бік підключення
|
||
|
||
Лінія без явної прив'язки більше не чіпляється до лівого краю навпомацки.
|
||
`autoSides()` рахує бік із взаємного розташування вузлів: більша різниця по
|
||
X — пара «праворуч → ліворуч», більша по Y — «знизу → зверху». React Flow
|
||
сам найкоротший бік не шукає.
|
||
|
||
Бік правиться в інспекторі: `авто | ліворуч | праворуч | зверху | знизу`.
|
||
Три стани в патчі, а не два — порожньо не чіпає, `auto` знімає прив'язку,
|
||
решта задає конкретний бік:
|
||
|
||
```sql
|
||
source_handle = CASE $19 WHEN '' THEN source_handle
|
||
WHEN 'auto' THEN NULL
|
||
ELSE $19 END
|
||
```
|
||
|
||
Раніше `UPDATE` боків не чіпав узагалі: лінію, що причепилась не до того
|
||
краю, лишалось хіба видалити й намалювати заново.
|
||
|
||
### Типовий вигляд вузла
|
||
|
||
Крапка, підпис знизу, дрібний текст, без значка й без цифр пінгу. Картка з
|
||
рамкою читається на схемі з десяти вузлів і перетворюється на сітку
|
||
прямокутників уже на п'ятдесяти.
|
||
|
||
Підпис вузла бере ім'я хоста, якщо власного немає:
|
||
`COALESCE(NULLIF(n.label,''), d.name, '')`. Хости, додані кнопкою
|
||
«+ Хости», приходили без `label` і виглядали як безіменні крапки.
|
||
|
||
### Полотно
|
||
|
||
Ліва кнопка возить полотно завжди — і в перегляді, і в редагуванні. Рамка
|
||
виділення вмикається Ctrl або Shift. Спершу було навпаки, і це зламало
|
||
найчастішу дію: гортання схеми, більшої за екран.
|
||
|
||
`fitView` при відкритті та `fitSignal` після масового додавання хостів.
|
||
Збережений viewport не годиться як типовий: вузли, додані з іншого екрана,
|
||
опиняються за його межами, і мапа відкривається порожньою, хоча в ній
|
||
десяток хостів.
|
||
|
||
### Хибний слід
|
||
|
||
Півдня пішло на «ребра не малюються»: у полотні нуль шляхів, усі вузли з
|
||
`visibility: hidden`. Причина виявилась не в коді — вкладка браузера, якою
|
||
я перевіряв, не компонувала кадри (`document.hidden === true`). Без кадрів
|
||
не спрацьовує ані `requestAnimationFrame`, ані `ResizeObserver`; React Flow
|
||
не міряє вузли, а невиміряний вузол лишається схованим і не дає порахувати
|
||
геометрію лінії.
|
||
|
||
Проба показала це прямо: `observe` — 12 викликів, `fire` — жодного.
|
||
|
||
Висновок на майбутнє: перед тим як шукати винного в коді, перевіряти, чи
|
||
взагалі сторінка малюється.
|
||
|
||
### Перевірено наскрізно
|
||
|
||
```
|
||
створення POST боки b/t → збережено b/t
|
||
правка боки r/l → застосовано (раніше лишались b/t)
|
||
скидання боки auto → NULL, підпис не втрачено
|
||
колір "" → NULL, повертається розрахунок за станом
|
||
видалення edges.remove → ребра немає
|
||
дублікат та сама пара вузлів → 400 «такий запис уже існує»
|
||
```
|
||
|
||
## Пакування
|
||
|
||
### Два образи, а не пʼять
|
||
|
||
`deploy/Dockerfile.server` збирає з одного модуля всі команди — `api`,
|
||
`server`, `migrate`, `user`, `secret`. Розкладати їх по окремих образах
|
||
означало б пʼять разів качати ту саму базу й дати API та колектору
|
||
можливість розʼїхатись версіями саме там, де це найдорожче: вони ходять
|
||
в одну схему БД.
|
||
|
||
`deploy/Dockerfile.agent` — окремо. Зонд їде в чужу мережу, і DSN, ключі
|
||
шифрування та команди заведення користувачів не повинні бути в тому
|
||
образі навіть як невикористані файли.
|
||
|
||
Контекст збірки обох — корінь репозиторію: `server` і `agent` посилаються
|
||
на `../gen/go` через `replace`, і вужчий контекст їх не збере.
|
||
|
||
Інтерфейс лягає в дерево до збірки Go: `httpapi` віддає його через
|
||
`//go:embed`, а embed читає файли на етапі компіляції, не в рантаймі.
|
||
|
||
### Стек
|
||
|
||
`docker-compose.yml` піднімає БД, кеш, міграції, API, колектор і Caddy.
|
||
Назовні дивиться лише проксі.
|
||
|
||
Міграції — окремою службою з `condition: service_completed_successfully`,
|
||
а не на старті API: API піднімається в кількох примірниках, і накочування
|
||
схеми зі старту означало б гонку між ними.
|
||
|
||
TLS знімає Caddy, всередині мережі — h2c. Прострочений сертифікат на
|
||
системі, яка сама має повідомляти про проблеми, — найгірший спосіб
|
||
дізнатись про проблему.
|
||
|
||
Зондам виділено окремий порт 9443 замість розрізняння gRPC і HTTP за
|
||
шляхом на 443: зайва крихкість там, де порт коштує нічого.
|
||
|
||
### Дві пастки, знайдені при написанні
|
||
|
||
**Том на неіснуючому шляху.** `VOLUME /var/lib/netpulse` без попереднього
|
||
`mkdir` + `chown` docker створює власністю root. Зонд під непривілейованим
|
||
користувачем реєструється успішно, але посвідчення не записує — і після
|
||
перезапуску знову просить запрошення, ніби нічого не було.
|
||
|
||
**`.gitignore` без прив'язки до кореня.** Рядки `netpulse-agent` і
|
||
`netpulse-server` мали ловити зібрані бінарники, а ловили ще й каталоги
|
||
`cmd/netpulse-agent` і `cmd/netpulse-server`. Наявні файли лишались у
|
||
git як уже відстежувані, тож помітно це стало б лише тоді, коли новий
|
||
файл команди мовчки не потрапив би в коміт. Виправлено на `/netpulse-*`,
|
||
`/server/netpulse-*`, `/agent/netpulse-*`.
|
||
|
||
### Бекап
|
||
|
||
`deploy/README.md` описує процедуру повністю. Головне, що з неї не можна
|
||
викинути: бекап — це три речі, а не одна. Дамп БД, `NETPULSE_DEK` і
|
||
`NETPULSE_JWT_SECRET`. Без ключа шифрування з дампа не дістати жодного
|
||
збереженого пароля — у БД лежить самий шифротекст, і виглядатиме це після
|
||
відновлення як зламані креденшели, а не як втрачений ключ.
|
||
|
||
Відновлення TimescaleDB вимагає рамки `timescaledb_pre_restore()` /
|
||
`timescaledb_post_restore()`: без неї фонові процеси агрегації
|
||
втручаються в наливання даних.
|
||
|
||
### CI
|
||
|
||
`.forgejo/workflows/ci.yml` — три роботи паралельно: фронтенд, сервер,
|
||
зонд. Вони ламаються незалежно, і чекати збірки Go заради помилки
|
||
типізації в TypeScript — марно витрачений час на кожному пуші.
|
||
|
||
`gofmt -l` перевіряється на порожнечу виводу, а не за кодом виходу: він
|
||
друкує список і виходить нулем, тож крива форма інакше проїжджає в main
|
||
непоміченою.
|
||
|
||
Зонд крос-збирається під пʼять платформ — він їде на чуже залізо, і
|
||
перевіряти треба ті цілі, які обіцяємо, а не лише ту, де крутиться CI.
|
||
|
||
### Перевірено
|
||
|
||
Docker на стенді немає, тож перевірено те, на що спираються образи:
|
||
|
||
```
|
||
npm run build → web/dist з assets/
|
||
dist → server/webui/dist → go build ./cmd/... : 5 бінарників
|
||
netpulse-api → / віддає SPA, max-age=300
|
||
/assets/*.js — immutable, 1 рік
|
||
/map → 200 (маршрут SPA)
|
||
/api/v1/nope → JSON 404, не index.html
|
||
netpulse-migrate -dry-run → «схема актуальна»
|
||
крос-збірка зонда → linux/amd64, arm64, arm; windows/amd64; darwin/arm64
|
||
YAML → compose і workflow розбираються, злиття якорів працює
|
||
```
|
||
|
||
Самі образи не збиралися: docker недоступний ні локально, ні на стенді.
|