Netpulse_SasS/HISTORY.md
byrsapty 3816e2cfcf Етап 7: збір конфігів запрацював наскрізно
Профілі були даними без виконавця. Тепер ланцюг замкнено: черга в БД →
диспетчер у 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>
2026-08-24 12:02:50 +03:00

89 KiB
Raw Blame History

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_livemap_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.windowperiod_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 і eth0inv.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. Коротко: користувачі й права (схема є, коду немає) → алерти → шаблони опитування (нова підсистема 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.goPrincipal замість старого 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.sqlcore.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 є, сторінки немає.