Netpulse_SasS/HISTORY.md
byrsapty 3124fe3163
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Відповідність конфігів вимогам: правила, прогін і знахідки
Чотири види правил над зібраними конфігами — має містити, не має
містити, збіг за виразом, немає збігу. jsonpath зі схеми свідомо не
реалізовано: він для конфігів у JSON, а писати його без жодного такого
пристрою під рукою означало б писати навмання.

Перевірка читає вже зібране й не створює сесій до заліза, тому прогін
синхронний і безкоштовний для мережі. Хост без конфігу пропускається,
а не рахується проваленим: «ще не збирали» і «не відповідає» — різні
речі, і плутати їх означає ховати справжні знахідки.

Знахідка показує рядок і його номер. Для правил «має бути» рядка немає,
і таблиця так і пише: нічого — саме це й проблема.

Вираз компілюється при збереженні, а не під час перевірки, інакше про
друкарську помилку дізнаються з правила, яке мовчки нічого не знаходить.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:05:07 +03:00

185 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 є, сторінки немає.


2026-08-16 — Візуальний diff конфігів

Збір працював, але подивитись на зібране було ніде: жодної сторінки, а lines_added завжди 0 — порівняння ніхто не рахував.

Створено

  • server/internal/difftext/ — порядкове порівняння з ділянками й контекстом; 10 тестів.
  • server/internal/store/ncm_read.go — читання версій, розшифровка тіла, порівняння з кешем.
  • Три ендпоїнти: версії, текст, diff.
  • web/src/pages/ConfigsPage.tsx — хости зліва, історія й diff справа.

Прийняті рішення

Власне порівняння, а не бібліотека. Потрібен рівно один алгоритм на рядках із виводом у формі, яку розуміє UI. Зовнішня залежність принесла б підтримку слів, символів, кольорів у терміналі — десяток речей, які тут ніколи не знадобляться.

Спільний початок і кінець відкидаються до основного алгоритму. У конфігах змінюється кілька рядків із тисячі; без цього кроку квадратична таблиця будувалася б там, де досить порівняти десяток рядків.

Занадто великі й повністю різні тексти позначаються truncated. Понад чотири мільйони клітинок — це вже пара конфігів, що розійшлися повністю, і точність там нічого не дає: людині однаково читати весь блок. Чесна позначка краща за правдоподібний, але вигаданий diff.

Зміни поруч зливаються в одну ділянку. Інакше сусідні правки давали б дві ділянки з дубльованим контекстом між ними.

Порівняння кешується в ncm.diffs. Diff двох конкретних версій незмінний назавжди; рахувати його при кожному відкритті сторінки — це палити процесор на відому відповідь. Підсумок +N/M заразом дозаписується у version, щоб список історії не розшифровував два тіла на кожен рядок.

Порівняння з попередньою версією відкривається одразу. Питання завжди одне — «що змінилось цього разу», — і вибір «з чим порівняти» був би зайвим кроком перед відповіддю.

Перевірено наживо

Створили другу версію, змінивши команду профілю (cat /etc/os-releasecat /etc/os-release /etc/hostname):

історія            2 версії, друга is_change=true
diff               @@ 8,3 +8,4 @@, один доданий рядок «new-ct» з контекстом
лічильник          +1 0 дозаписався в історію після обчислення
повний текст       292 байти, читається

Чого ще немає

Тригера за Syslog. Git-двигуна: commit_sha поки дорівнює хешу вмісту, справжнього репозиторію немає. Порівняння з довільною версією в UI (API вміє через ?from=, кнопки немає).


Планувальник бекапів за cron

Збір конфігів працював, але лише руками. Це не NCM: сенс бекапу в тому, що він робиться сам, а «зайти й натиснути» — це та сама відсутність бекапу, просто з кращим самопочуттям.

Свій парсер cron замість бібліотеки

robfig/cron зробив би це за десять рядків, але тягнув би залежність заради того, що вміщується в одному файлі й не змінюється з 1975 року. Розбір — у бітові маски uint64 на поле, пошук наступного запуску — покроково по хвилинах із запобіжником у чотири роки: перебір дешевший за арифметику з календарем і не має її крайніх випадків.

Правило dom/dow — об'єднання, не перетин. Якщо задані і день місяця, і день тижня, підходить збіг за будь-яким. Це виглядає неочікувано, доки не спробуєш записати «щоп'ятниці та першого числа» — іншого способу немає.

Неможливий розклад чесно відмовляє. 0 0 30 2 * — 30 лютого не буває; запобіжник у чотири роки перетворює це на помилку, а не на зациклений процес. 29 лютого при цьому знаходиться правильно (2028).

11 тестів на парсер пройшли з першого запуску.

Планувальник

Тік раз на хвилину — дрібніше cron не буває. Advisory-блокування (0x6e70_6263, «npbc»), бо інакше кожен екземпляр сервера в кластері поставив би своє завдання на той самий хост.

Порядок кроків важливий: спершу перенести next_backup_at, потім ставити завдання. Падіння між кроками коштує одного пропущеного бекапу. Зворотний порядок дав би нескінченну чергу однакових завдань — а це кладе і пристрій, і сервер.

Некоректний cron відсувається на добу, а не пишеться в журнал щохвилини. Скарга має лишитись помітною, але не перетворитись на шум, у якому потонуть справжні помилки.

next_backup_at IS NULL = «час настав». Так виглядає щойно збережена політика. З цієї ж причини будь-яка зміна політики обнуляє позначку: розклад міг стати частішим, і чекати за старим було б неправильно.

Форма

Чотири готові розклади покривають майже все, що справді налаштовують; довільний cron лишився, але не першим, що бачить людина.

Знайдена й виправлена помилка: вибір «свій розклад…» нічого не робив. Обробник select мав вигляд e.target.value && setCron(...), а значення цього пункту — порожній рядок, тож умова відсікала саме той випадок, заради якого пункт існує. Режим тепер тримається окремим прапорцем, а не виводиться з виразу: інакше поле не відкривалось би й тоді, коли власний вираз випадково збігся з пресетом.

Перевірено наживо

Розклад * * * * * на stand-host:

trigger  |  status   | створено
schedule | unchanged | 09:39:08
наступний | останній
09:40:00  | 09:20:31

У браузері: вибір «свій розклад…» відкриває поле з поточним виразом; 0 99 * * * відхиляється сервером із текстом «розклад: години: 99 поза межами 0..23» і модалка лишається відкритою; */30 * * * * зберігається, і заголовок хоста стає «за розкладом */30 * * * *». Розклад повернуто на 0 3 * * *.


Етап 6. Шаблони опитування

Досі кожен OID заводився руками на кожному хості. Те, що знімається з Mikrotik, однакове на всіх Mikrotik — але цей факт жив у голові інженера й повторювався стільки разів, скільки в мережі пристроїв.

Схема

tpl.templatestpl.itemstpl.device_templates. Вбудовані шаблони — tenant_id IS NULL, той самий прийом, що в ncm.profiles.

Елемент — це одна метрика, а не чек. Тримати в шаблоні «чек» означало б змішати те, що описує людина (метрику), з тим, що вигідно машині (пачку OID в одному PDU).

У tpl.items немає власного tenant_id. Він завжди дорівнював би шаблоновому, а дублювання ключа ізоляції — це запрошення до розбіжності. Видимість успадковується від шаблону через EXISTS.

Реконсиляція

Елементи групуються за (шаблон, тип, інтервал) в один snmp.get. Сотня окремих чеків замість однієї пачки — це сотня SNMP-сесій там, де досить кількох PDU. Інтервал у ключі групування, бо пачка ходить цілком.

Порядок ключів фіксується сортуванням. params потрапляє в хеш плану, і «однаковий шаблон дав інший хеш» через порядок обходу map — це перезалив плану на кожному тіку звірки.

core.checks.template_id + частковий унікальний індекс. Спільний checks_uniq містить md5(params), тож будь-яка правка списку OID виглядала б як новий чек.

ListDeviceChecks і SetDeviceChecks тепер обходять чеки шаблонів. Інакше вони показувалися б у формі ручних перевірок і зникали на кожне збереження, щоб за секунду з'явитися знову.

Звірка планів — те, чого бракувало весь час

Чеки міняє REST-процес, а живу сесію зонда тримає AgentService. Досі зміна доїжджала до зонда лише при обриві зв'язку — тобто, за нормальної роботи, ніколи. Тік раз на п'ять секунд звіряє хеш плану в базі з тим, що зараз у зонда, і перезаливає повний план при розбіжності.

Повний план, а не дельта: дельта не знає, що зникло.

Знайдено живими прогонами

Креденшели не їхали разом із планом. Хост, приписаний зонду вже після його підключення, отримував задачі й падав на кожній із «немає SNMP-креденшелів». Пачка доступів видається на Hello, а тоді цього хоста в ній ще не було. Тепер іде разом із планом.

Форма хоста відв'язувала зонд. Поле «Зонд» починалося порожнім, підпис обіцяв «— не змінювати —», а сервер трактував порожнє значення буквально. Будь-яке збереження форми лишало хост у списку й мовчки припиняло його опитувати. Помітили, коли після збереження шаблону в журналі з'явилось «план зонда оновлено, задач: 0». Форма тепер тягне поточний зонд, а підпис каже правду: «— без зонда —».

hrProcessorLoad.1 у вбудованому шаблоні — здогадка, а не адреса. Це таблиця, індексована процесором; на net-snmp вона віддала «No Such Instance». Свій же коментар у міграції казав, що неперевірений шаблон гірший за відсутній — прибрали з базового, лишили у вендорному Mikrotik, де індекс фіксований.

Перевірено наживо

Шаблон, створений повністю через веб (OID введено без крапки на початку — сервер дописав її сам), прив'язаний до хоста в тій самій формі:

чеки хоста      qa-netsnmp 60с   qa.users ← .1.3.6.1.2.1.25.1.5.0
                snmp-generic 60с sys.uptime_sec ← .1.3.6.1.2.1.1.3.0
                snmp-host-resources 300с sys.users; sys.processes  (два OID, одна пачка)
план зонда      оновлено за секунди, без переп'єднання
метрики         sys.uptime_sec = 862334.20 s   (snmpget: 9 днів 22:46 — збігається)
                sys.processes = 41, qa.users = 0
вбудований      DELETE → 403 builtin, PUT → 403 builtin
власний         DELETE → 204, породжені ним чеки зникли каскадом

Чого ще немає

Автопризначення шаблону за sysObjectID. Прототипів (шаблон, що успадковує інший). Тригерів усередині шаблону — поки правила алертів заводяться окремо. snmp.walk як тип елемента: таблиці з динамічним індексом (CPU по ядрах, диски, сенсори) шаблон описати не вміє.


Історія метрик і те, що знайшлося дорогою

Система збирала телеметрію в ts.samples і не показувала її ніде: щоб побачити зібране, треба було йти в psql. Шаблони, зроблені на минулому кроці, збирали метрики в нікуди.

Джерело обирається за кроком. Читати сирі точки за місяць — це мільйони рядків заради трьохсот пікселів; брати годинні бакети на вікні в п'ять хвилин — це графік з однієї точки. Роллапи ts.samples_5m і ts.samples_1h уже існували з Етапу 1, лишалось ними скористатися.

Крок рахується з бажаної кількості точок, а не приходить ззовні. Інакше вузьке вікно з дрібним кроком повернуло б десятки тисяч точок, з яких екран покаже сотні.

Пропуск і нуль — різні речі. Значення точки — вказівник: лінія, проведена через діру в даних, каже «все було добре», хоча насправді нічого не відомо. Це найгірший різновид брехні в моніторингі.

series_id приходить від клієнта, тож належність перевіряється явно. Таблиці ts.* не під RLS (несумісно зі стисненням), і без цієї перевірки чужий ідентифікатор віддав би чужі дані.

Графік без бібліотеки

recharts коштує понад сотню кілобайт стисненого коду, а потрібна з них одна ламана. SVG до того ж масштабується під будь-який контейнер без переобчислення на ресайз.

Ряди групуються за одиницею виміру. Відсотки й біти на секунду на спільній осі перетворюють графік на пряму лінію біля нуля.

Пропуск розриває лінію. Кожен відрізок — окремий M…L…, і між ними лишається порожнеча — саме те, що сталося насправді.

Метрика, яка не буває від'ємною, не отримує від'ємну вісь. Відступ знизу корисний для читабельності, але «3 % втрат» читається як помилка даних.

Перевірено наживо

6 год        крок 72 с, джерело сирі дані
тиждень      крок 2016 с, джерело роллап 5m
snmp-host    if.in_bps · eth0 = 18.5k bps, sys.processes = 41,
             sys.uptime = 87.5M ticks — усе з підписом порту й одиницею

Знайдено живим прогоном: зонд працює рівно годину

Метрики SNMP замовкли о 12:05 — рівно через годину після того, як зонд отримав доступи. У журналі — жодного слова.

CredentialTTL дорівнює годині, і Credentials() свідомо не віддає прострочені: інакше зонд довбав би комутатори старим паролем і заблокував обліковий запис. Правильне рішення. Але поновлення не просив ніхто: повідомлення CredentialRequest є в контракті з Етапу 2, сервер його обробляє, агент — не надсилає. Тобто будь-яка інсталяція припиняла збирати SNMP через годину після старту й мовчала про це.

Тепер зонд просить нову пачку за десять хвилин до кінця терміну, не частіше ніж раз на хвилину. Причина в запиті розрізняє expiring і expired — за журналом видно, чи встигли.


Шаблон перестав бути «набором OID»

Шаблон описував лише snmp.get. Пінг заводився руками — і це змушувало пам'ятати, що саме шаблон покриває, а що ні.

Тепер елемент має params (те саме, що лягає в core.checks.params), а oid і metric_key стали необов'язковими. Пачкою в один PDU збираються тільки OID: два пінги з різними параметрами — це просто два пінги. Для негрупованих типів елемент відповідає окремому чеку, і його слід у core.checks.template_item_key дозволяє впізнати рядок.

Ключ елемента, а не його id. Збереження шаблону перезаписує елементи цілком, тож id живуть недовго, а ключ стабільний за визначенням.

Вбудований шаблон «Доступність (ICMP)» — той, який чіпляють першим.

Обмін шаблонами

Експорт: усі одним файлом, окремий шаблон кнопкою на картці, і ще один вхід — просто з форми редагування. Імпорт: глобальний (файл або вставлений текст) і локальний, що замінює вміст відкритого шаблону.

Формат свій. Zabbix-YAML описує елемент ключем виду snmp.get[...], з препроцесингом, value maps і тригерами — нічого з цього тут немає, і вдавати сумісність означало б мовчки втрачати половину імпортованого.

Ідентифікатори з документа прибираються: на іншому стенді вони нічого не значать, а лишені створюють ілюзію, що імпорт «відновить те саме».


Спільний розклад бекапів

Розклад існував лише поштучно: щоб бекапити сто пристроїв, треба було сто разів відкрити форму.

Спільний розклад заводить політику кожному придатному хосту з follows_default = true. Хост, якому задали власний розклад, прапорець втрачає.

Прапорець, а не порівняння значень. Власний розклад може випадково збігтися зі спільним, і тоді зміна спільного мовчки потягла б за собою хост, який навмисно налаштували окремо.

Вимкнення спільного зупиняє лише тих, хто йому слідує. Форма показує following_count і custom_count: без цих двох чисел вона не каже головного — кого саме зачепить зміна.


Куди йде алерт

Правило вміло сказати «за яких умов», але не «кому». Маршрути (alr.routes) вирішують інше завдання — спільну політику на всі правила разом, і для звичайного «це правило важливе, шліть черговому в Telegram» вони заважкі.

Порядок вирішення: канали правила → маршрути тенанта → усі придатні канали.

Канали правила перекривають маршрути повністю. Інакше «шліть це черговому» перетворювалося б на «шліть це черговому і ще туди, куди вирішить спільна політика» — тобто на щось, чого людина не просила.

Разом із каналами правило отримало тихі години (той самий формат, що в маршрутах — щоб не заводити другий діалект того самого поняття), вибір груп хостів і перемикач повідомлень про відновлення.


Дрібниці, які насправді не дрібниці

Відступи в картках шаблонів. Card навмисно без внутрішнього відступу — на інших сторінках його діти самі малюють px-4 py-2.5 і роздільники на всю ширину. На сторінці шаблонів картка — звичайний блок тексту, і клас загубився. Текст стояв упритул до рамки.

Українська множина. «1 метрик» — дрібниця, з якої складається враження, що інтерфейс писали не для людей. plural(n, one, few, many) з правилом 1114.

Інтервал опитування вводиться руками. Список сам по собі не годиться: рано чи пізно комусь потрібні 45 секунд, і відсутність такої можливості робить продукт «майже підходящим». Ручний ввід сам по собі теж не годиться: у дев'яти випадках із десяти значення є в списку.

Підтвердження показує наслідки, а не питає «Ви впевнені?». «Видалити групу?» і «Видалити групу? 34 хости втратять межі доступу» — різні питання, і людина відповідає на них по-різному. Вбудований confirm() другого не вміє, тому свій діалог.

Кнопки видалення стали кнопками. Сірий у кутку рядка формально існував і фактично не знаходився — користувач повідомив, що видалення користувачів «немає».


«Просить логін після кожного оновлення сторінки»

Дві різні поломки в одному симптомі.

Перша: користувач без пошти не міг поновити сесію взагалі. Разом із входом за логіном пошта стала необовʼязковою, але RotateSession читав email::text без COALESCE. Scan падав, помилка перетворювалась на «сесія недійсна» — і такі люди могли увійти, але після перезавантаження сторінки летіли на форму входу знову й знову. Живий прогін: свіжий користувач без адреси, перший же /auth/refresh → 401.

Друга: ротація токена не переживала подвійного обміну. React у режимі розробки виконує ефекти двічі, тож відновлення сесії йшло двома запитами поспіль: перший обмінював токен і відкликав старий, другий приносив уже відкликаний. Це не лише про режим розробки — дві вкладки, відкриті одночасно, дають рівно ту саму гонку в проді.

Тепер core.sessions.replaced_by тримає ланцюг, і щойно відкликаний токен веде до свого наступника. Вікно — 30 секунд: вистачає на подвійний обмін від того самого клієнта й замало для реального повтору перехопленого токена, той приходить хвилинами пізніше. Перевірено: обмін старим токеном одразу → 200, він же через дві хвилини → 401.

Плюс засувка на клієнті: відновлення сесії рівно одне на завантаження сторінки.


Однакові відступи

Сторінки розповзлися: одні малювали тіло з p-3 md:p-4, інші клали p-4 на внутрішній блок, треті не мали відступу взагалі — і текст стояв упритул до бічної панелі.

PageBody тепер один на всіх: відступи, прокрутка й min-h-0 в одному місці. Заміряно в браузері: усі десять сторінок дають рівно 16 px від краю.


Мапа: видалення й вигляд вузла

Видалення мапи — кнопка була відсутня, хоча ендпоїнт існував із Етапу 4. З підтвердженням, яке пояснює, що саме зникне: схема, а не хости й дані.

Форма вузла. Картка, пігулка або крапка. Крапка потрібна не для краси: на схемі з сотні вузлів важливо бачити стан усіх, а не читати сотню підписів. Плюс товщина рамки, колір заливки, приховування підпису й цифр пінга.

Аварія й попередження світяться. Рамка іншого кольору не помітна периферійним зором, а ореол (box-shadow у два шари) видно навіть на віддаленому масштабі, коли підписи вже не читаються. Червоний лишається тільки за обривом — якщо ним підсвічувати ще й «невідомо», оператор перестане на нього реагувати.


Дашборди

Схема core.dashboards і core.dashboard_widgets лежала з Етапу 1 і не мала жодного рядка коду. Тепер має.

Сітка — дванадцять колонок. 12 ділиться на 2, 3, 4 і 6, тож половина, третина й чверть ширини задаються цілими числами, а не «41.6 %».

Плитки замінюються цілком. Дашборд редагується як єдине полотно, і часткові оновлення дали б спосіб отримати розкладку, якої людина не бачила.

Перший дашборд стає головним сам. Інакше людина створює його й не розуміє, чому головна сторінка досі порожня.

Кожна плитка вантажить своє. Спільний завантажувач на дашборд виглядав би охайніше, але змусив би плитку «текст» чекати на запит метрик сусідки.

Види: графік, число, шкала, список алертів, сітка хостів, текст. Перевірений список видів дублюється на сервері навмисно — збережена плитка, яку ніхто не малює, виглядає як зламаний дашборд.

Права окремі від мап. Дашборд збирає дані з усього тенанта, і «може дивитись мапу» не означає «може дивитись зведення по всіх майданчиках». Видані тим ролям, які вже мають відповідний рівень доступу до мап.


Esc і клік повз вікно

Жодна модалка не закривалася нічим, крім хрестика.

Слухач Esc — на document, а не на панелі: фокус може стояти в полі вводу, у випадному списку або взагалі ніде, і вимагати спершу «влучити» кудись — це рівно те, чого від Esc не чекають.

Клік повз панель закриває за mousedown, а не за click: інакше виділення тексту, доведене мишею за межі вікна, закривало б форму разом із набраним.

Заголовок вікна став липким: у довгих формах (шаблон із десятком метрик) хрестик інакше їхав за межі екрана.


Редагування там, де його не було

Канали сповіщень взагалі не редагувалися — лише створювались і видалялись. Вид каналу лишається незмінним: telegram із конфігом вебхука — це інший обʼєкт, і чесніше завести новий.

Протокол доступу тепер змінюється. З одним застереженням: секрет зашифрований під видом старого протоколу, тож при зміні пароль треба ввести заново. Сервер відмовляє з поясненням, кнопка в формі теж не дає зберегти — краще сказати це до спроби, ніж після.


Картка хоста в стилі Zabbix

Форма була однією довгою простинею з інтервалами опитування посередині. Стала вкладками: Хост, Шаблони, Доступи, Збір конфігів, Ручні перевірки — у порядку частоти звернень, а не у порядку появи в коді.

Інтервалів опитування тут більше немає. Вони живуть у шаблонах. Два джерела правди про одне число рано чи пізно розійдуться, і розійдуться саме тоді, коли треба буде швидко зрозуміти, чому хост опитується не так, як написано.

Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек. Перевірено: створений через форму хост дістав icmp.ping з інтервалом 30 с саме від шаблону, разом із групою, обраною в пікері.

Групи, шаблони й доступи обираються пошуком. Список галочок працює на пʼяти позиціях і перестає працювати на пʼятдесяти. Обране показане чипами зверху, тож вибір видно цілком, не гортаючи. Backspace у порожньому полі прибирає останній чип — так поводяться всі поля з тегами, і руки це вже знають.

Ручні перевірки лишились окремою вкладкою. Вони потрібні для одиничних випадків, але це вже не типовий шлях, і в першому екрані їм нема чого робити.


Графіки описуються в шаблоні

«Завантаження каналу — це вхід і вихід на одній осі» — властивість класу пристроїв, а не окремого хоста. Малювати той самий графік руками на кожному означає повторювати одне рішення стільки разів, скільки в мережі заліза.

tpl.graphs тримає назву, вид і перелік ключів метрик, а не посилань на елементи шаблону: графік має право показувати й те, що прийшло з іншого шаблону — саме так виглядає «трафік поруч із помилками на тому ж порту».

Види: лінії, з заливкою, з накопиченням, стовпчики, шкала, число.

Накопичення обирає людина, а не евристика. Сума має сенс там, де вона сама щось означає — трафік по портах, місце на дисках. На відсотках завантаження це була б нісенітниця.

Жорсткі межі осі з шаблону перекривають пораховані. «Завантаження від 0 до 100» має виглядати однаково на всіх хостах, інакше графіки не порівняти очима.

Графік без жодного знайденого ряду не показується. Порожня рамка з підписом «Памʼять» на хості, де памʼять не збирається, — це обіцянка даних, яких немає.

Перевірено наживо

snmp-host   «Час роботи» (stat)   → 877.6k s
gateway     «Втрати пакетів» (area) → вісь жорстко 0100 з шаблону
gateway     «Час відгуку» (line)   → дві лінії, вісь 02.6 з даних

Знайдено живим прогоном: полотно вбивало інтерфейс

Після додавання групового виділення сторінка перестала реагувати — Maximum update depth exceeded у SelectionListener. React Flow викликає onSelectionChange на кожному рендері, а мій обробник щоразу створював новий масив: колбек міняв стан сторінки, сторінка перемальовувала полотно, полотно кликало колбек. Цикл зʼїдав головний потік, і мертвими ставали всі сторінки, не лише мапа.

Тепер набір віддається назовні лише коли справді змінився — порівняння за склеєним рядком ідентифікаторів.


Вигляд мапи

Схема виглядала як сітка сірих прямокутників на чорному. Причини були конкретні, і кожна лікується окремо.

«? → ?» на кожній лінії. Так виглядав підпис ребра, коли автовиявлення ще не зіставило порти. Читалося це як зламаний інтерфейс, хоча означало лише «поки невідомо». Тепер невідомий порт не друкується взагалі — порожнеча чесніша, вона нічого не обіцяє. Так само зі швидкістю: «— з 10.0 Гбіт/с» зникає, поки трафік не виміряний.

Вузол став карткою з ієрархією. Градієнт замість пласкої заливки, кольорова смуга стану зліва, значок і підпис в одному рядку, крапка стану праворуч. Смуга несе стан навіть тоді, коли рамку перефарбували під майданчик: колір рамки — рішення про схему, колір смуги — факт про пристрій.

Пульсація лише за обривом. Рух там, де все гаразд, навчає не дивитись на рух узагалі.

Порти показуються на наведення. Вісім завжди видимих кружечків на кожному вузлі перетворюють схему на россип точок.

Фон — радіальна підсвітка й дві сітки. Дрібна дає відчуття масштабу, велика — орієнтацію; одна або рябить, або не читається, залежно від кроку, а крок задає людина.

Специфічність довелося підняти. Стилі React Flow приходять з .tsx і потрапляють у документ пізніше за index.css, тож одного класу було замало — правила стали .react-flow.netpulse-canvas.


Вбудовані шаблони редагуються

Раніше відповіддю було «вбудований не редагується — зробіть копію». Це межа нашої моделі даних, а не відповідь на питання людини, яка хоче підправити OID під своє залізо.

Тепер правка вбудованого шаблону створює власну копію тенанта з тим самим ключем, а список показує її замість оригіналу. Оригінал лишається недоторканим для решти тенантів. Форма попереджає про це прямим текстом до збереження.

Плюс окрема кнопка «Копія» — для випадку, коли потрібні обидва варіанти: «як було» і «як хочу». Ключ отримує суфікс, бо копія з тим самим ключем сховала б оригінал.

Ключі елементів і графіків виводяться на сервері. Прямий виклик API без key давав 500 від перевірки домену core.slug. Ключ потрібен базі, але жодного рішення не несе — вимагати його від клієнта означало відповідати помилкою там, де можна просто зберегти.


Вибір шаблонів картками

Пошуковий рядок із чипами добре працює для груп — там важлива лише назва. Для шаблонів важливо ще й що всередині: скільки метрик, для якого виробника, що воно робить. Список чипів цього не показує, тож людина відмічала навмання й ішла перевіряти на іншу сторінку.

Тепер картка з назвою, описом і лічильником метрик. Обрані спливають угору — інакше після вибору картка лишається десь у середині списку, і незрозуміло, чи вибір зарахувався.


Мобільна сітка карток

На телефоні картка вилазила за екран і обрізалась. Причина не в брейкпойнтах: елемент грід-сітки типово має min-width: auto й не стискається нижче ширини вмісту. min-w-0 на картці — і 410 px стали 351 px рівно за шириною сітки.


Межа помилок навколо полотна

Цикл рендеру в React Flow одного разу вже поклав увесь застосунок: мертвими стали всі сторінки, і людині лишалося перезавантажити вкладку. Причину виправлено, але наслідок треба було обмежити — тепер полотно падає саме, а навігація й решта інтерфейсу лишаються живими.


Етап 8. Реєстрація зонда

Найбільше вузьке місце до запуску: агент заводився INSERT-ом у базу, а токен вписувався в командний рядок руками. Поставити зонд у клієнта було неможливо — людина зі стійки не має доступу до бази й не повинна його мати.

Одноразове запрошення

core.agent_enrollments: sha256 токена, підказка імені, набір модулів, строк. Токен існує рівно один раз — у відповіді на створення.

Уся видача в одній транзакції під FOR UPDATE. Два агенти, стартовані з однієї скопійованої команди, інакше створили б два зонди з одного запрошення. Перевірено живим прогоном: другий отримує PermissionDenied.

Ім'я підбирається суфіксом, а не відмовою. Людина, яка ставить п'ятий агент на однакових машинах, не має вигадувати імена — вона хоче, щоб він просто запрацював.

Знайдено живим прогоном

Реєстрація не проходила автентифікацію. Мій же коментар стверджував, що окремий gRPC-сервіс сам собою виводить виклик з-під інтерсептора. Це неправда: інтерсептор реєструється на весь сервер. Перший же запуск агента дав Unauthenticated: немає токена зонда — на виклику, яким токен і видається. Виняток тепер заданий явно, повним префіксом сервісу.

Запуск із самим посвідченням падав. validate() вимагав -agent-id, не знаючи про файл посвідчення, тож агент із валідним /etc/netpulse/agent.json відмовлявся стартувати. Перевірка переїхала в main, де враховані всі три джерела: прапорці, файл і реєстрація.

Керування зондом

Ім'я, модулі й ліміти правляться з картки. Форма каже прямим текстом, що зміни доїдуть при наступному підключенні: мовчазна затримка виглядає як «не зберіглося».

Ліміти зливаються в наявний jsonb через ||, а не заміняють його: там можуть лежати поля, яких форма не знає, і затирати їх мовчки — найшвидший спосіб зламати те, чого не бачив.

Видалення зонда лишає хости без зонда, а не видаляє їх: інакше заміна заліза, на якому стояв агент, коштувала б усієї історії.

Перевірено наскрізно

запрошення   POST /agent-enrollments → np_enr_…, модулі [icmp snmp]
реєстрація   agent -enroll → «зареєстровано як "QA зонд"»
посвідчення  /tmp/np-agent-id.json, права 0600, токен np_agt_…
сесія        встановлено одразу після реєстрації
повтор       той самий токен → PermissionDenied
перезапуск   лише -identity, без токена → та сама сесія
керування    PATCH модулі й ліміти → 204, значення застосовані

Зв'язки на мапі

Порти на вузлі

Чотири боки, на кожному пара портів з однаковим ім'ям: l, r, t, b. React Flow розрізняє джерело й ціль за типом, тож одне ім'я обслуговує обидва напрямки. Розійдуться імена (наприклад l і l-s) — збережене ребро не знайде пари, і лінія просто не намалюється, без жодної помилки.

Обгортка портів — Fragment, а не <span>: <span> стає елементом flex-розкладки й розсуває вміст вузла. Чотири такі обгортки з'їдали підпис, і на схемі лишались картки без імен.

Бік підключення

Лінія без явної прив'язки більше не чіпляється до лівого краю навпомацки. autoSides() рахує бік із взаємного розташування вузлів: більша різниця по X — пара «праворуч → ліворуч», більша по Y — «знизу → зверху». React Flow сам найкоротший бік не шукає.

Бік правиться в інспекторі: авто | ліворуч | праворуч | зверху | знизу. Три стани в патчі, а не два — порожньо не чіпає, auto знімає прив'язку, решта задає конкретний бік:

source_handle = CASE $19 WHEN ''     THEN source_handle
                         WHEN 'auto' THEN NULL
                         ELSE $19 END

Раніше UPDATE боків не чіпав узагалі: лінію, що причепилась не до того краю, лишалось хіба видалити й намалювати заново.

Типовий вигляд вузла

Крапка, підпис знизу, дрібний текст, без значка й без цифр пінгу. Картка з рамкою читається на схемі з десяти вузлів і перетворюється на сітку прямокутників уже на п'ятдесяти.

Підпис вузла бере ім'я хоста, якщо власного немає: COALESCE(NULLIF(n.label,''), d.name, ''). Хости, додані кнопкою «+ Хости», приходили без label і виглядали як безіменні крапки.

Полотно

Ліва кнопка возить полотно завжди — і в перегляді, і в редагуванні. Рамка виділення вмикається Ctrl або Shift. Спершу було навпаки, і це зламало найчастішу дію: гортання схеми, більшої за екран.

fitView при відкритті та fitSignal після масового додавання хостів. Збережений viewport не годиться як типовий: вузли, додані з іншого екрана, опиняються за його межами, і мапа відкривається порожньою, хоча в ній десяток хостів.

Хибний слід

Півдня пішло на «ребра не малюються»: у полотні нуль шляхів, усі вузли з visibility: hidden. Причина виявилась не в коді — вкладка браузера, якою я перевіряв, не компонувала кадри (document.hidden === true). Без кадрів не спрацьовує ані requestAnimationFrame, ані ResizeObserver; React Flow не міряє вузли, а невиміряний вузол лишається схованим і не дає порахувати геометрію лінії.

Проба показала це прямо: observe — 12 викликів, fire — жодного.

Висновок на майбутнє: перед тим як шукати винного в коді, перевіряти, чи взагалі сторінка малюється.

Перевірено наскрізно

створення   POST боки b/t → збережено b/t
правка      боки r/l → застосовано (раніше лишались b/t)
скидання    боки auto → NULL, підпис не втрачено
колір       "" → NULL, повертається розрахунок за станом
видалення   edges.remove → ребра немає
дублікат    та сама пара вузлів → 400 «такий запис уже існує»

Пакування

Два образи, а не пʼять

deploy/Dockerfile.server збирає з одного модуля всі команди — api, server, migrate, user, secret. Розкладати їх по окремих образах означало б пʼять разів качати ту саму базу й дати API та колектору можливість розʼїхатись версіями саме там, де це найдорожче: вони ходять в одну схему БД.

deploy/Dockerfile.agent — окремо. Зонд їде в чужу мережу, і DSN, ключі шифрування та команди заведення користувачів не повинні бути в тому образі навіть як невикористані файли.

Контекст збірки обох — корінь репозиторію: server і agent посилаються на ../gen/go через replace, і вужчий контекст їх не збере.

Інтерфейс лягає в дерево до збірки Go: httpapi віддає його через //go:embed, а embed читає файли на етапі компіляції, не в рантаймі.

Стек

docker-compose.yml піднімає БД, кеш, міграції, API, колектор і Caddy. Назовні дивиться лише проксі.

Міграції — окремою службою з condition: service_completed_successfully, а не на старті API: API піднімається в кількох примірниках, і накочування схеми зі старту означало б гонку між ними.

TLS знімає Caddy, всередині мережі — h2c. Прострочений сертифікат на системі, яка сама має повідомляти про проблеми, — найгірший спосіб дізнатись про проблему.

Зондам виділено окремий порт 9443 замість розрізняння gRPC і HTTP за шляхом на 443: зайва крихкість там, де порт коштує нічого.

Дві пастки, знайдені при написанні

Том на неіснуючому шляху. VOLUME /var/lib/netpulse без попереднього mkdir + chown docker створює власністю root. Зонд під непривілейованим користувачем реєструється успішно, але посвідчення не записує — і після перезапуску знову просить запрошення, ніби нічого не було.

.gitignore без прив'язки до кореня. Рядки netpulse-agent і netpulse-server мали ловити зібрані бінарники, а ловили ще й каталоги cmd/netpulse-agent і cmd/netpulse-server — з кодом усередині.

Наслідок виявився гіршим за очікуваний: agent/cmd/netpulse-agent/main.go (280 рядків) і server/cmd/netpulse-server/main.go (215 рядків) ніколи не були в git. Свіжий клон не збирався — у ньому не було ані точки входу зонда, ані точки входу колектора. Локально все працювало, бо файли лежали на диску, і git status про них мовчав саме тому, що вони ігнорувались.

Виправлено на /netpulse-*, /server/netpulse-*, /agent/netpulse-*; обидва файли додано.

Урок: шаблон у .gitignore без / на початку — це «будь-де в дереві», і збіг із каталогом він ловить так само охоче, як із файлом. Перевірка коштує однієї команди: git check-ignore -v <шлях>.

Бекап

deploy/README.md описує процедуру повністю. Головне, що з неї не можна викинути: бекап — це три речі, а не одна. Дамп БД, NETPULSE_DEK і NETPULSE_JWT_SECRET. Без ключа шифрування з дампа не дістати жодного збереженого пароля — у БД лежить самий шифротекст, і виглядатиме це після відновлення як зламані креденшели, а не як втрачений ключ.

Відновлення TimescaleDB вимагає рамки timescaledb_pre_restore() / timescaledb_post_restore(): без неї фонові процеси агрегації втручаються в наливання даних.

CI

.forgejo/workflows/ci.yml — три роботи паралельно: фронтенд, сервер, зонд. Вони ламаються незалежно, і чекати збірки Go заради помилки типізації в TypeScript — марно витрачений час на кожному пуші.

gofmt -l перевіряється на порожнечу виводу, а не за кодом виходу: він друкує список і виходить нулем, тож крива форма інакше проїжджає в main непоміченою.

Зонд крос-збирається під пʼять платформ — він їде на чуже залізо, і перевіряти треба ті цілі, які обіцяємо, а не лише ту, де крутиться CI.

Перевірено

Docker на стенді немає, тож перевірено те, на що спираються образи:

npm run build            → web/dist з assets/
dist → server/webui/dist → go build ./cmd/... : 5 бінарників
netpulse-api             → / віддає SPA, max-age=300
                           /assets/*.js — immutable, 1 рік
                           /map → 200 (маршрут SPA)
                           /api/v1/nope → JSON 404, не index.html
netpulse-migrate -dry-run → «схема актуальна»
крос-збірка зонда        → linux/amd64, arm64, arm; windows/amd64; darwin/arm64
YAML                     → compose і workflow розбираються, злиття якорів працює

Самі образи не збиралися: docker недоступний ні локально, ні на стенді.

Git-двигун NCM

Сховище

go-git, чистий Go без cgo — статичний бінарник лишається статичним. Голий репозиторій на тенанта, гілка на пристрій: refs/heads/device/<device_id>, файл <пристрій>/<тип>.cfg.

Гілка за ідентифікатором, а не за іменем: пристрій перейменовують, і історія не має від цього розсипатись на дві. Шлях навпаки за іменем — у дереві його читають очима. Так було в коді до цього рівно навпаки, і перше ж перейменування дало б порожню гілку замість історії.

Голий репозиторій означає, що дерева доводиться складати руками: індексу немає, а go-git дає для цього об'єкти, але не зручності worktree. putPath рекурсивно перебудовує дерево по частинах шляху.

Записи в дереві мусять бути впорядковані, причому каталог порівнюється так, ніби його ім'я закінчується скісною рискою. Порушиш — той самий вміст дасть інший хеш, а git fsck назве дерево пошкодженим. Саме тому серед тестів є прогін справжнього git fsck --strict: go-git такий об'єкт приймає й читає, а git — ні, і розійшлись би вони мовчки.

Однаковий вміст нового коміту не створює. Інакше кожен нічний бекап незмінного пристрою додавав би порожню зміну, і за місяць історія складалась би з тридцяти однакових записів, серед яких треба шукати одну справжню правку.

Новий репозиторій отримує гілку main з поясненням розкладки: без неї HEAD показує на ненароджену гілку, і git fsck та git clone про це кажуть — виглядає як пошкодження, хоча все ціле.

Два сховища, і чому обидва

Тіло конфігу лежить зашифрованим у core.secrets і відповідає на «який конфіг зараз». Git відповідає на «що і коли змінилось за півроку». Друге питання ставлять під час розбору аварії, і відповідь не має залежати від того, чи не почистив таблицю ретеншен.

Помилка Git не губить бекап: конфіг уже знято з пристрою, і викидати його через проблему з диском — найдорожчий спосіб відреагувати. Версія лягає в базу з контентним хешем, несправність видно в журналі.

netpulse-gitsync

Переливає збережені версії в Git у хронологічному порядку, з часом збору замість часу переливання — інакше вся історія злипається в одну хвилину.

Потрібна двічі за життя інсталяції: коли версіювання вмикають на системі, яка вже місяцями збирає конфіги, і коли диск із репозиторієм втрачено. Друге дає корисну властивість: репозиторій повністю відтворюється з бази, тож бекапити том із Git не обов'язково. Зворотне невірно, і саме тому джерелом істини лишається дамп БД.

Чотири поламані тести, знайдені дорогою

CI, який я написав минулим кроком, уперше запустив тести з DSN — і вони не пройшли. Три причини, і лише одна виявилась моєю помилкою в коді.

Машинний токен не міг читати мапи. ACL мап фільтрує за групами користувача, а в токена користувача немає: порожній рядок ішов у запит як uuid, і /api/v1/maps відповідав п'ятисоткою на кожен виклик із токеном. Перевірка на NULL обов'язкова саме перевіркою, а не COALESCE: map_access_level не STRICT і з NULL-користувачем чесно доходить до deny. Токен не належить до груп, тож його межі задають scopes, які вже перевірив обробник.

Тест ротації сесії перевіряв поведінку, яку я свідомо змінив. Старий refresh-токен тепер живе ще 30 секунд — це і є те виправлення, після якого сторінка перестала просити логін при кожному перезавантаженні. Тест переписано: у вікні повторний обмін має проходити, поза вікном — ні. Щоб не чекати наживо, позначка відкликання відсувається в БД.

Тест підробленого токена був випадковим. Він псував ОСТАННІЙ символ підпису, а останній символ base64url несе лише 4 значущі біти з шести. 'A' і 'B' на цьому місці дають ті самі 32 байти, токен лишається дійсним — і тест падав приблизно в кожному третьому запуску. Псуємо перший символ підпису.

Тест входу без членства не знав про логіни. username став обов'язковим разом із входом за іменем, а тест вставляв користувача напряму без нього.

Урок: тест, який ніхто не запускає, з часом перевіряє не те, що здається. CI з базою був потрібен не для майбутніх помилок, а щоб побачити накопичені.

Перевірено наскрізно

gitstore          6 тестів, серед них git fsck --strict на дереві, зібраному вручну
netpulse-gitsync  2 наявні версії stand-host → 2 коміти, база оновлена
                  гілка device/371f89fc…, шлях stand-host/running.cfg
живий збір        два бекапи QA-хоста з різним вмістом
                  → «конфіг прийнято змінився=true commit=d3b061017ce9…»
                  git log: два коміти, старіший — батько
                  git diff: -1 +2 рядки
                  API diff: ті самі рядки
git fsck --strict чисто на обох гілках
тести             server ×2 і agent — зелені

Тригери в шаблонах

Чому вони переїхали

Правила сповіщень жили окремою сторінкою, і це було неправильно. «Процесор вище 85% пʼять хвилин — це проблема» описує клас пристроїв, а не окремий хост. Заводити те саме правило руками на кожен комутатор означає повторювати одне рішення стільки разів, скільки в мережі заліза, і забути про половину при наступній зміні порога.

Тепер тригер описується там само, де перевірки, які дають йому дані. Сторінка «Правила» лишається — але для того, для чого справді потрібна: разових правил на конкретний хост і правил, що перетинають класи.

Одне правило, а не правило на хост

Тригер шаблону розгортається в одне правило alr.rules із селектором {"template_ids":[...]}. Хост, якому щойно призначили шаблон, одразу підпадає під його тригери — перегенеровувати нічого не треба.

Правило на кожен хост дало б тисячі рядків, які довелося б тримати в синхроні з призначеннями шаблонів, а кожне розходження виглядало б як «алерт не спрацював» — найгірший спосіб дізнатись про помилку.

Селектор отримав нове поле template_ids; решта механізму сповіщень не змінилась зовсім — тригер шаблону і є звичайне правило, просто описане один раз.

Форма на вкладках

Загальне · Перевірки · Графіки · Тригери, з лічильником у підписі кожної. Лічильник не прикраса: він єдиний спосіб побачити, що на сусідній вкладці щось є, не клацнувши по ній.

Смуга вкладок поїхала в спільний Tabs — той самий вигляд уже був руками зроблений у картці хоста, і третя копія була б зайвою.

Умова тригера редагується полями, а не JSON-ом: «cpu.util_pct більше 85 протягом 5 хв» — те, що людина тримає в голові, і змушувати її перекладати це у фігурні дужки означає перекладати на неї роботу форми. JSON лишився запасним виходом для джерел, яким полів ще немає.

Умова пінгу зберігається одним рядком ("loss_pct >") — так її розуміє движок; форма розбирає його на поле й оператор і збирає назад.

Правила з шаблону — тільки для читання

У списку правил вони підписані «із шаблону «…»», кнопок «Змінити» й «Видалити» не мають. Дати правити їх там означало б показати зміну, яку наступна звірка мовчки відкотить.

Клон копіює тригери вимкненими

Клонують, щоб щось змінити. Копія з живими тригерами почала б слати ті самі сповіщення вдвічі — рівно з моменту, коли її призначать першому хосту, і не пояснивши, звідки дубль.

Дві помилки, знайдені живим прогоном

Два шаблони з однаковою назвою ламали збереження. Імʼя правила унікальне в межах тенанта, а імена шаблонів — ні. Два шаблони «Однакова назва» з однойменним тригером давали помилку унікальності, і збереження другого падало пʼятисоткою. Тепер при збігу до імені додається ключ шаблону — але лише при збігу, інакше він заважав би читати список.

Зайнятий ключ шаблону теж давав «внутрішню помилку». Це помилка людини, а не сервера: тепер 409 із текстом «шаблон із ключем «…» уже є».

Перевірено наскрізно

міграція        0024 на живій БД, два вбудовані тригери до icmp-basic
тригер          PUT /templates/{id}/triggers → правило зʼявилось
селектор        {"template_ids":["<tpl>"]}, важливість і витримка збережені
читання         GET /templates/{id} повертає тригери назад
вимкнення       enabled=false → правило прибрано
збіг імен       два шаблони «Однакова назва» → «Однакова назва [qa-b]: …»
клон            тригери скопійовані, усі вимкнені, правил не породили
видалення       шаблон видалено → правила зникли каскадом
валідація       невідоме джерело → 400 з поясненням
тести           server (з базою) і web build — зелені

Три правки інтерфейсу

«Правила» пішли з бічної панелі

Тригери переїхали в шаблони, а окремий пункт меню лишився й далі пропонував заводити правила там, звідки їх щойно прибрали.

Тепер це вкладка поруч з «Алертами»: Алерти · Правила. Розділ лишився — разові правила на конкретний хост нікуди не діваються, — але не займає рядок у меню як рівноправна тема.

Вкладки маршрутні, а не станові: посилання лишається посиланням, із середньою кнопкою, «відкрити в новій вкладці» й адресою в рядку браузера. Тому це окремий компонент SectionTabs, а не той самий Tabs, що перемикає вміст форми.

Підзаголовок сторінки правил тепер прямо каже, де правити тригери шаблонів.

Кнопки перестали бути текстом

У кнопок типово user-select: auto — підпис лишається виділюваним текстом, і в нього можна поставити текстовий курсор. Він блимає всередині кнопки, ніби туди щось вводять. На дотик гірше: довге натискання виділяє слово замість натиснути.

Правило на елемент, а не на компонент — кнопки трапляються й поза <Button>: у React Flow, у нативних select і details.

button, [role='button'], summary {
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

Бічна панель згортається у значки

Кнопка внизу самої панелі, а не в шапці: згортання стосується панелі, і шукати перемикач в іншому кутку екрана — зайвий крок.

Ширина 13rem → 3.75rem, підписи ховаються, значки лишаються по центру, підказка з назвою зʼявляється тільки у згорнутому вигляді — поруч із видимим підписом вона повторювала б його. Лічильник алертів переїжджає в кут значка.

Стан живе в localStorage: людина обирає його раз під ширину свого монітора, а не заново на кожній сторінці.

Дірка, знайдена дорогою: вбудований шаблон нікого не сповіщав

icmp-basic отримав тригери в 0024, але перетворює тригери на правила лише збереження шаблону — а вбудований шаблон нікому не належить і не зберігається. Хост із ним збирав пінг і мовчав про недоступність: тобто коробка виглядала робочою й не робила головного.

Тепер звірка правил іде й при зміні набору шаблонів хоста.

І одразу поруч — помилка в самому 0024: унікальний індекс правил стояв на парі (template_id, trigger_key) без тенанта. Для власних шаблонів цього досить, для вбудованих — ні: другий кабінет, який призначив «icmp-basic», отримав би помилку унікальності замість правила. Виглядало б це як «алерти не працюють у нових кабінетах».

Перевірено наживо

меню            /rules прибрано; лишилось 12 пунктів
вкладки         Алерти · Правила на обох сторінках
кнопки          user-select: none, cursor: pointer
згортання       md:w-52 → md:w-[3.75rem], підписи сховані,
                підказки зʼявились, стан переживає перезавантаження
вбудований      призначення icmp-basic хосту → 2 правила
                «Доступність (ICMP): Хост недоступний / Втрати пакетів»
список правил   рядки з шаблону підписані «із шаблону», кнопок правки немає
міграції        25 на чистій БД, тести сервера з базою — зелені

Меню: групи і центрування

Групи

Дванадцять пунктів у стовпчик означали читати всі дванадцять, щоб знайти один. Тепер вони згруповані за питанням, з яким людина відкриває меню:

Огляд               що зараз відбувається — щодня й багато разів
Мережа              що в мене є — коли додають або шукають залізо
Налаштування збору  як воно налаштоване — рідко, зазвичай при заведенні
Організація         хто має доступ

Група, у якій ролі не доступний жоден пункт, зникає разом із заголовком: заголовок над порожнечею обіцяє розділ, якого немає.

У згорнутому вигляді заголовок замінює риска — текст туди не влазить, а межа між групами потрібна, інакше значки зливаються в одну стрічку. Назва групи переїжджає в підказку: Мережа · Хости.

Значки не по центру

Виміряв — розмітка давала зсув 0.4 пікселя, тобто річ була не в ній. Винних виявилось двоє.

Смуга прокрутки. Вона забирає ширину справа й зсуває всю колонку значків уліво. У згорнутому вигляді це видно одразу, бо є з чим порівнювати — рівний край панелі. З групами пунктів стало більше, і прокрутка з випадкової стала звичною. Смугу сховано, прокрутка лишилась колесом і клавіатурою.

Значок у вузькій коробці. Було w-4 text-center з накладеним md:px-3: ширина 1rem проти паддінгів 1.5rem дає нульову ширину вмісту, і гліф центрується випадково — а емодзі різної ширини роблять це помітним на кожному другому. Тепер це квадрат h-5 w-5 з власним центруванням через flex.

Після виправлення всі дванадцять значків і кнопка згортання дають зсув рівно 0 від центру панелі.

Мірка, яка збрехала

Проміжні заміри показували ширину 60 px при класі md:w-52 — виглядало як зламане правило CSS. Після чесного перезавантаження — 208 px, як і має бути. Це артефакт гарячої заміни модулів у vite: клас на елементі вже новий, а перехід ширини лишився від попереднього стану.

Урок той самий, що з невидимою вкладкою: перш ніж шукати винного в коді, перевірити, що вимірюєш те, що думаєш.

Syslog доведено до кінця

Транспорт

Приймач на зонді був написаний минулого разу й лежав без діла. Тепер він під'єднаний: logsLoop віддає накопичене окремим стрімом StreamLogs, а не контрольним каналом — сплеск логів під час аварії не має заважати heartbeat і командам. Саме тому в контракті ці стріми й розділені.

Пачка збирається півсекунди. Це компроміс: тригер бекапу має спрацьовувати відчутно швидше за хвилинний цикл, а слати кожен рядок окремо немає сенсу.

Невідправлене повертається в чергу й доїде наступною сесією. Приймач живе поза сесією: обрив зв'язку з сервером не зупиняє збір журналу — саме заради цього черга й існує.

Зіставлення хоста

За адресою джерела, на зонді. У сервера немає контексту мережі клієнта, а один і той самий приватний діапазон трапляється в десятках кабінетів. Зонд же має свіжий список своїх хостів із плану задач.

Не знайшли — подія все одно доїде з порожнім device_id і заповненим source_ip. Викинути журнал через незнайому адресу означало б утратити рівно те, що показує появу нового заліза в мережі.

Тригер бекапу

Зразок звіряє Postgres, а не Go: політика зберігає його рядком у базі, і тягнути всі політики в пам'ять заради кожної пачки журналу означало б робити роботу там, де для неї немає даних.

Типовий зразок покриває три родини — Cisco %SYS-5-CONFIG_I, HP/Huawei CFGCHG, Juniper і MikroTik commit complete. Навмисно широкий: зайвий бекап коштує кількох секунд сесії, пропущений — цілої зміни, про яку ніхто не дізнається.

Сплеск однакових рядків не перетворюється на сплеск сесій до пристрою: EnqueueConfigJob уже відсіює другий queued для того самого хоста.

Перевірено наскрізно

Окремий QA-зонд через запрошення, щоб не чіпати робочий:

реєстрація   -enroll → «приймач syslog слухає адреса=127.0.0.1:5514»
план         хост прив'язано до QA-зонда → tasks=1
подія        <189>… %SYS-5-CONFIG_I: Configured from console by admin
             надіслано з 127.0.0.5
розбір       severity 5, hostname rtr-qa, текст цілий (не порізаний у tag)
зіставлення  device_id = qa-syslog-host за адресою джерела
запис        ts.syslog
тригер       ncm.jobs: trigger=syslog, status=queued
негатив      %LINK-3-UPDOWN і вхід ssh записані, завдань не додали
RFC5424      tag sshd[991], structured data np.user=admin, msgid ID9

Заразом: увесь репозиторій під gofmt

CI, який я написав два кроки тому, перевіряє gofmt -l. Прогнав його чесно — 29 файлів із реальними порушеннями, накопиченими за весь проєкт: неправильний порядок імпортів, збите вирівнювання полів структур і ключів у літералах. Перший же запуск CI впав би на них.

Прогнав gofmt -w по всьому дереву разом із нормалізацією кінців рядків. Через це коміт зачіпає більше файлів, ніж сама можливість: правки формату й нового коду в тих самих файлах не розділити на два коміти без проміжного стану, який не збирається.

Урок: правило, яке ніхто не запускав, не виконується — воно лише здається виконаним.

Режим NOC TV

Чому окрема сторінка, а не режим кабінету

Телевізор нікуди не залогиниш. Сесія протермінується, браузер оновиться, і о шостій ранку на стіні висітиме форма входу замість карти мережі — рівно тоді, коли на неї дивляться.

Тому /tv/<токен> розгалужується до перевірки входу, ще в точці входу застосунку. Гілка виділена в окремий компонент: інакше набір хуків залежав би від адреси, а React вимагає незмінного порядку.

Що дає токен і чого не дає

Спокуса була зробити просто: видати посилання, яке мінтить сесію лише на читання, і показувати весь кабінет через звичайне API. Це на порядок менше коду й значно гірші наслідки — лінк, залишений у відкритому браузері в кімнаті, куди заходять різні люди, став би ключем до всього.

Тому публічний зріз вузький і окремий:

GET /api/v1/tv/{token}                          розкладка дашборда
GET /api/v1/tv/{token}/alerts                   активні алерти
GET /api/v1/tv/{token}/devices                  хости
GET /api/v1/tv/{token}/devices/{id}/metrics     лише хости з цього дашборда

Останній рядок — головний. Список хостів для метрик береться з описів плиток: чужий хост за токеном не дістати навіть підбором ідентифікатора. Неіснуючий і відкликаний токен дають однакову відповідь — різниця між ними була б підказкою тому, хто перебирає.

Плитки однакові, джерела різні

Замість другого комплекту плиток для телевізора — підміна джерела даних через контекст. У кабінеті це звичайне API під сесією, на телевізорі — вузький публічний зріз під токеном.

Алерти в цю абстракцію свідомо не входять: у кабінеті вони приходять живим потоком WebSocket, на телевізорі — опитуванням, і зводити два різні механізми до спільного інтерфейсу означало б програти обом.

Втрата зв'язку не гасить стіну: лишається остання картинка з позначкою, відколи вона стара. Мовчазна застаріла картинка небезпечніша за порожню — на неї дивляться й вірять.

Дві помилки, знайдені дорогою

Вбудовані тригери нічого не сповіщали. Умову icmp я взяв із прикладу в коментарі до 0007 — {"op":"loss_pct >","value":20}. Движок такої форми ніколи не розумів: він читає metric, op і value окремими полями. Кожен тік писав у журнал «невідома метрика "" для джерела icmp», а правило мовчало. Найгірша поломка для моніторингу: система виглядає налаштованою, тригери на місці, а про недоступність хоста не скаже ніхто.

Виправляти 0024 не став — застосована міграція незмінна, і рант її контрольної суми не дарма зупинив запуск, коли я спробував. Полагодило нову форму окреме 0026; редактор тригерів тепер теж пише metric і op роздільно, а список параметрів пінгу звірено з движком.

База стенда виявилась у SQL_ASCII. Спроба зберегти плитку з кирилицею всередині jsonb дала «unsupported Unicode escape sequence». SQL_ASCII не кодування, а його відсутність: сервер просто пропускає байти. Тексти в колонках при цьому виглядають цілими, і саме тому проблему помічають пізно — ламається інше: jsonb із \uXXXX-екранованим не-ASCII (а так шле JSON половина HTTP-бібліотек), lower() і сортування українських імен.

Виправити після наливання даних можна лише перестворенням бази, тож перевірка стоїть перед першою міграцією, а не в README. compose тепер задає POSTGRES_INITDB_ARGS явно.

Перевірено наскрізно

видача        POST /dashboards/{id}/public-link → /tv/np_tv_…
без входу     дашборд 200, алерти 200, хости 200
чужий хост    метрики → 404 «цього хоста немає на дашборді»
вигаданий     токен → 404 «посилання недійсне» (те саме, що й відкликаний)
запис         DELETE на публічний шлях → 404; видача без токена → 401
відкликання   204, старе посилання одразу 404
браузер       /tv/<токен> без сесії: без меню, без форми входу,
              три плитки з живими даними, лічильник алертів, годинник
кодування     SQL_ASCII → міграція зупиняється з поясненням;
              свіжа UTF8 → 26 міграцій

Відповідність конфігів вимогам

Що це відповідає

Питання, яке в мережі задають постійно, а перевіряють руками раз на рік: «чи на всіх комутаторах закритий telnet», «чи скрізь наш NTP», «чи не лишився десь community public». Руками це знаходить половину.

Чотири види правил: має містити, не має містити, збіг за виразом, немає збігу. П'ятий із схеми — jsonpath — свідомо не реалізовано й не показано в інтерфейсі: він потрібен лише для конфігів у JSON, а тягнути заради цього залежність без жодного пристрою під рукою означало б писати код навмання.

Перевірка нічого не коштує мережі

Читаються вже зібрані конфіги, жодної сесії до заліза не створюється. Через це прогін і синхронний, і кнопка «Перевірити» стоїть без попереджень: її можна тиснути скільки завгодно й одразу після правки правила.

Хост без жодного зібраного конфігу пропускається, а не рахується проваленим: «ще не збирали» і «не відповідає вимозі» — різні речі, і плутати їх означає ховати справжні знахідки серед шуму. Пропущені рахуються окремо.

Порядково, а не по всьому тексту

Знахідку треба показати людині, і «рядок 412» — відповідь, з якою можна щось зробити, на відміну від «десь у конфігу є». Для правил «має бути» рядок навпаки порожній, і таблиця чесно пише: «нічого — саме це й проблема».

Дрібниці, які легко проґавити

Регулярний вираз компілюється при збереженні, а не під час перевірки: інакше про друкарську помилку людина дізнається не з форми, а з правила, яке мовчки нічого не знаходить.

Зміна зразка стирає старі результати того ж правила: вони відповідали на питання, якого вже ніхто не ставить.

Результати фільтруються за доступом до хостів — перевірка конфігів не має стати обхідним шляхом до інвентарю чужої філії.

Перевірено наскрізно

На справжньому конфігу stand-host (cat /etc/os-release /etc/hostname):

має містити ID=debian            → пройдено, рядок 7
не має містити transport input   → пройдено (у конфігу такого немає)
має містити ntp server 10.0.0.1  → порушення, рядка немає
немає збігу VERSION_ID="13"      → порушення, рядок 3

прогін            rules 3, checks 3, failed 1, skipped 9
фільтр            «лише проблеми» лишає саме знахідки
валідація         невідомий вид → 400; зламаний вираз → 400 з текстом
                  помилки; порожній зразок → 400
інтерфейс         вкладка поруч із «Конфіги», обидві таблиці з живими
                  даними, «нічого — саме це й проблема» на місці