Профілі були даними без виконавця. Тепер ланцюг замкнено: черга в БД → диспетчер у AgentService → зонд по SSH/Telnet → вивантаження стрімом → звірка sha256 і дедуплікація. Агент (internal/ncmx): - усе побудовано навколо пошуку промпту: у консолі немає ані коду завершення, ані довжини відповіді - промпт шукається лише в хвості 512 байтів, інакше "banner motd #" обривав би збір на середині конфігу - дедлайн на паузу між байтами, а не на всю операцію: збір із шасі триває хвилини, а тиша означає завислий пристрій або хибний regex - ключі SSH обладнання не звіряються свідомо: залізо міняє їх з кожною прошивкою, і known_hosts на сотні пристроїв означав би не збирати конфіги зовсім Сервер: черга ncm.jobs із FOR UPDATE SKIP LOCKED (два екземпляри не надішлють одне завдання двічі), диспетчер, прибирання завислих. Знайдено живим прогоном: - сервер ігнорував поле encoding: агент стискав gzip і рахував sha256 від оригіналу, сервер рахував від стиснених байтів і відхиляв кожен бекап. Поле в контракті з Етапу 2, реалізації не було — інтеграційний тест користувався "none" і повз дірку проходив - промпт обрізався не по рядку: шаблон [>#]\s*$ ловить лише символ, і в конфізі лишалось ім'я пристрою окремим рядком - у конфіг потрапляли escape-послідовності bash і подвоєні \r\r\n від PTY: 295 байтів / 12 рядків замість 286 / 10. Після виправлення — 285 / 10, різниця лише у фінальному переводі рядка Живий прогін проти стенду: success, конфіг 285 байтів, повторний збір — unchanged без другої версії. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
89 KiB
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
Прийняті архітектурні рішення
- Фізична топологія ≠ візуальна.
topo.links(що є в мережі) окремо відtopo.map_edges(як намальовано). Один лінк — на багатьох мапах. - Зв'язки port→port через FK на
inv.interfaces, не текстом. Пара нормалізована черезLEAST/GREATEST, щоб A→B і B→A не дублювались. - Автовиявлення в два кроки: сирі
topo.neighbors(LLDP/CDP/ARP/FDB +confidence) → резолвер →topo.links. Прапорецьis_pinnedзахищає ручні лінки. - Дві моделі метрик: узагальнена
ts.series+ts.samples(плагіни реєструютьmetric_keyбез DDL) і широкіts.icmp_samples/ts.if_countersдля гарячих шляхів мапи. - Анімація трафіку має ланцюг даних:
if_counters.util_out_pct→ viewtopo.link_live→map_edges.animation. - Ліміти тарифу перевіряються двічі:
bill.entitlementsв API + тригери в БД. - Секрети лише шифровані (
core.secrets: ciphertext/nonce/auth_tag/key_id, AES-GCM-256, DEK у KMS). - Тіло конфігів у Git, метадані в БД (
ncm.configs.commit_sha+content_hash). - 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
window— зарезервоване слово.core.sla_targets.window→period_kind.core.check_types.keyбув доменомcore.slug, але ключі мають виглядicmp.ping— крапка не проходить. Замінено наtextз власним CHECK^[a-z0-9]+(\.[a-z0-9_]+)+$; так самоcore.checks.check_type.- Домен
core.slugне пропускав підкреслення, а ключі фіч —http_checks,auto_discovery. Регекс розширено до[a-z0-9_-]. - 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
Прийняті рішення
- Форма контракту випливає з одного обмеження: усі з'єднання ініціює агент.
«Команда з сервера» — це повідомлення у зустрічному напрямку вже відкритого
агентом bidi-стріму
Control, а не RPC у бік агента. - Чотири окремі стріми (Control / Telemetry / Logs / Config), а не один: пачка семплів не має блокувати heartbeat, сплеск syslog під час аварії не має топити телеметрію.
- Інтернування серій. Агент реєструє серію раз під
series_ref, далі шле лише номер. Заміряно: 66 → 25 байт на семпл.series_refживе в межах сесії. - Швидкості рахує агент (лише він знає точний інтервал опитування), але шле й сирі лічильники — щоб сервер міг перерахувати заднім числом.
- Scrub/redact конфігів — на сервері. Агент віддає сирий текст; правила
живуть у
ncm.profilesі змінюються без оновлення агентів у полі. - Топологію зводить сервер. Агент доповідає лише «на порту X бачу chassis Y».
Task.params_json— непрозорі байти. Новий плагін не потребує зміни .proto. Модуль-виконавець виводиться з префіксаcheck_typeдо крапки.- At-least-once + upsert. Дедуплікацію дають PK схеми БД
(ts, device_id)тощо. - Зворотний тиск диктує сервер у
Welcomeі кожномуTelemetryAck. - Самооновлення підписане 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
Прийняті рішення
- Модулі вкомпільовані, без динамічного завантаження. Один бінарник має працювати на Alpine, Windows і роутері з musl. «Активація» = дозвіл сервера.
- Креденшели беруться на момент виконання, не з плану: у них TTL, і прострочені не віддаються взагалі — інакше агент заблокує обліковий запис на половині комутаторів клієнта.
- Розклад вирівняний по сітці інтервалу, тому після рестарту задача повертається у свій слот, а не з'їжджає.
- Буфер викидає найстаріше. Після відновлення зв'язку цінніший поточний стан. Зміни статусу викидаються останніми й пролазять у батч першими.
- Швидкості інтерфейсів рахує агент (знає фактичний інтервал); при
перевороті лічильника —
counter_resetзамість стрибка на терабіт. - Один писар у контрольний стрім — gRPC не допускає паралельних Send.
GOMEMLIMIT48 МБ у коді: хай 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() — перереєстрація серій на початку сесії.
Прийняті рішення
- Ізоляція тенантів робиться двічі: RLS (
SET LOCAL app.tenant_id) плюс явний предикатtenant_id. Не перестраховка: RLS не працює на гіпертаблях, а саме туди йде вся телеметрія. schedule_offset— чиста функція відcheck_id, тож будь-який вузол сервера дає те саме значення.- Хеш плану — лише з полів, що впливають на поведінку. Зміна опису пристрою не змушує переливати 50 000 задач.
- Токен каже, ЯКИЙ це зонд; сертифікат — що він має право говорити. Чужий
agent_idпри валідному токені →PermissionDenied. - DEK не покидає сервер. Дамп БД без ключів не дає жодного пароля. Комплект креденшелів із TTL 1 год.
- Запис телеметрії —
ON CONFLICT DO NOTHING(at-least-once). - Статус пристрою — один запит із умовним записом в історію, інакше два
воркери наввипередки писали б неіснуючі переходи
up→up. - Впевненість зіставлення спадає за надійністю ознаки: chassis-id 95 → MAC 90 → IP 80 → sysName 60. Останнє низьке навмисно: sysName вводить людина.
- Перереєстрація серій замість обнулення нумерації. Спершу агент мав би скидати 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.
Прийняті рішення
- SNMP-транспорт винесено в
snmpx— ним користуються два модулі, а два незалежні набори однієї логіки це два незалежні набори багів. - Звіти автовиявлення йдуть окремим RPC, не телеметричним стрімом: вони рідкі, великі й не прив'язані до моменту часу так, як метрики.
- Черга звітів коротка й витісняє найстаріший. Знімок топології актуальний рівно доти, доки описує поточний стан; накопичувати застарілі немає сенсу.
ifHighSpeedмає пріоритет надifSpeed: 32-бітне поле впирається в 4.29 Гбіт/с, і на 10G порт показував би неправильний знаменник дляutil_pct.ifNameперекриваєifDescr: самеifNameвіддає LLDP як port-id, тому саме за ним зійдеться лінк.netpulse-secretяк окремий інструмент — секрети не можна вставити звичайним SQL, а UI ще немає.
Знайдено живим прогоном (обидва — справжні помилки)
- Префікс типу чека не збігався з ключем модуля. У сіді було
topo.discoverпри плагініtopologyіssl.expiryпри плагініhttp. Агент маршрутизує задачі саме за префіксом, тож зонд відхилив би їх як адресовані неіснуючому модулю. Перейменовано наtopology.discoverіhttp.ssl_expiry, а інваріант закріплено обмеженням у БДcheck_types_prefix_matches_plugin— щоб наступна така неузгодженість не доїхала до поля. - Унікальний індекс
topo.neighborsсхлопував ARP-сусідів. Ключ складався зchassis_id+port_id, яких в ARP і FDB немає взагалі: з двох сусідів на одному порту зберігався один. Доданоremote_macдо індексу.
Перевірено проти справжнього SNMP-агента
На стенді піднято snmpd + lldpd (LLDP-MIB через AgentX). Живий прогін
агент → сервер → БД:
- інвентар портів зі справжнього
ifTable:loіeth0, MACbc:24:11:07:68:67, 10 Гбіт/с саме черезifHighSpeed; - 2 сусіди зі справжньої ARP-таблиці, обидва з локальним портом
eth0; - шлюз
192.168.1.1зіставлено за MAC із впевненістю 90; - лінк
snmp-host:eth0 → gatewayзведено,capacity_bps10 Гбіт/с; - три проходи поспіль — лінк один, дублікатів немає;
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.
Прийняті рішення
- Чек оновлюється, а не задвоюється. Унікальний індекс
core.checksвключаєmd5(params), тому наївний upsert плодив би новий рядок на кожну зміну складу портів. Шукаємо існуючий чек за(device_id, 'snmp.if'). - Склад портів порівнюється як множина. Postgres не гарантує порядок
ключів у
jsonb— пряме порівняння рядків давало б хибну зміну на кожному обході, і агент отримував би новий план щоразу. - Без SNMP-креденшела чек не створюється: він лише щохвилини писав би помилку автентифікації. Інтерфейси при цьому все одно зберігаються — вони потрібні мапі незалежно від того, чи є чим їх опитувати.
- Loopback і
notPresentвідсіюються — графіка не дають, місце в PDU займають. Ліміт 256 портів на чек: далі опитування не вкладається у власний таймаут. - Зміна штовхається живому зонду. Чекати наступного перепідключення — це години порожніх графіків після кожного нового комутатора.
Знайдено живим прогоном
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_octets625 246 266,in_bps11 938; util_out_pct0.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
Прийняті рішення
- API — окремий процес від AgentService. Зонди й браузери мають різні профілі
навантаження, периметри й цикли релізів. Спільний лише шар
store. - Стан мапи віддається одним викликом разом зі статусами. Без цього мапа малювалася б сірою й доганяла кольори сотнею дозапитів.
link_statusвиводиться з кінців лінка, а не читається з колонки.topo.links.statusніхто не підтримує; писати туди означало б оновлювати всі лінки пристрою на кожну зміну статусу. Стан лінка — похідна величина.- Події пишуться тією ж транзакцією, що й зміна. Інакше WebSocket міг би розповісти про перехід, якого в базі ще (або вже) немає.
- Опитування outbox замість LISTEN/NOTIFY. NOTIFY не переживає падіння підписника й обмежений 8 КБ; тут потрібна гарантія, що зміна статусу не загубиться між перезапусками API.
- Дві частоти розсилки: статус — подія (миттєво), завантаження — величина (раз на 5 с). Частіше за оновлення лічильників (60 с) — це та сама цифра по колу.
- Токен WebSocket їде підпротоколом, бо браузер не дозволяє довільні заголовки; в URL він не потрапляє, а отже й у логи проксі.
- Підписник, що не встигає читати, відключається, а не гальмує решту.
Перевірено
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.
Прийняті рішення
- Усі скалярні поля патча — вказівники; nil означає «не чіпати». Перетягування шле лише x/y, і якби відсутні поля трактувались як порожні, кожен рух миші стирав би стиль, розмір і прив'язку до пристрою.
- Ребро може посилатися на вузол, створений тим же патчем, за
client_id— інакше зв'язок до нового вузла вимагав би двох запитів і проміжного стану. - Оптимістичне блокування за
revision. Той, хто спізнився, отримує 409, а не тихо затирає чужу правку: у NOC над однією мапою працюють кілька людей. - Знімок пишеться тією ж транзакцією, що й зміна. Інакше після збою в історії лишався б крок, якого в мапі немає, і відкат ламав би її. Зберігаються останні 50.
- Невідоме поле в тілі — 400. Мовчки проковтнути друкарську помилку клієнта означає, що правка «збереглася», але не застосувалась.
- Автопобудова ідемпотентна: наявні вузли не дублюються, координати не чіпаються — інакше кожен запуск скидав би ручну розкладку.
- Ліміт тарифу → 402, а не 500: UI має показати пропозицію змінити тариф.
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).
Прийняті рішення
- Сервер — джерело істини, крім моменту перетягування. Поки вузол тягнуть,
позицію диктує миша; синхронізація пропускає вузли з
dragging. - Зберігаємо на відпусканні й лише якщо вузол зрушив.
- Успішний патч застосовується до локального стану — інакше вузол «повертається» при першій же події.
- Конфлікт ревізій не приховується: 409 → повідомлення + перечитування.
- Швидкість анімації обернено пропорційна завантаженню, при нулі анімації немає взагалі.
- Стан з'єднання завжди на екрані: замерзла мапа виглядає як здорова.
Знайдено роботою з живим UI (обидві виправлені)
- Ревізія росла від самих кліків. За час, поки я робив скріншоти, мапа
пройшла 2 → 5: React Flow віддає
onNodeDragStopна будь-яке натискання, і кожен клік писав порожню ревізію, змушуючи всі відкриті полотна перечитуватись. - Вузол відкочувався після збереження. Хук оновлював лише номер ревізії, тож
наступна подія
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
Прийняті рішення
- Відкат — нова ревізія, а не відмотування лічильника. Інакше клієнт із номером 10 після повернення до 9 отримав би «свою» ревізію знову актуальною й тихо перезаписав відкочене.
- Ідентифікатори вузлів зберігаються при відкаті — ребра прив'язуються назад самі, а виділення в UI й зовнішні посилання не ламаються.
- Порядок відновлення — ребра геть → вузли геть → вузли назад → ребра назад: зовнішні ключі не дозволяють інакше.
- Намальоване рукою ребро не прив'язується до
topo.links: лінія на полотні — це подання, а не факт про мережу. Автовиявлення прив'яже саме. - Видалення не перелічує ребра вузла: на полотні їх прибирає React Flow, у базі — каскад FK.
Знайдено флак у тестах (виправлено)
TestSchedulerRunsTaskAndFillsCredentials падав приблизно раз на п'ять під
навантаженням: тест перевіряв канал статусів знімком (for len(ch) > 0),
а STATE_SUCCEEDED надсилається вже ПІСЛЯ запису в sink. На завантаженій машині
проміжок розширювався. Замінено на очікування з дедлайном; 8 прогонів під
штучним навантаженням — 0 падінь.
Перевірено наживо
Повний ланцюг проти працюючого стека: намальовано зв'язок (ребер 1 → 2) → відкат (2 → 1) → видалення вузла (2 вузли → 1, ребер 0) → відкат повернув той самий id вузла, ребро й живий стан лінка. UI показує обидві кнопки й підказку про жести.
Далі
План на наступні етапи винесено в окремий документ — 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). - У браузері: форма входу → інженер входить → мапа з живими 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 є, сторінки
немає.