go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на пристрій за ідентифікатором (ім'я змінюють, історія не має від цього розсипатись), файл за іменем. Однаковий вміст нового коміту не створює. netpulse-gitsync переливає накопичену історію й відтворює втрачений репозиторій із бази — тіла конфігів там і так лежать зашифрованими. У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів (?from=), інтерфейс — ні. Дорогою виправлено чотири тести, які CI запустив уперше з базою. Серед них справжня помилка: машинний токен не міг читати мапи — ACL мап отримував порожній рядок замість uuid і давав 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 KiB
NetPulse — журнал розробки
Стислий лог: що зроблено, які рішення прийняті, що далі. Мета — щоб наступна сесія не перечитувала весь код.
2026-08-14 — Етап 1: схема БД
Створено
netpulse/
├── HISTORY.md ← цей файл
├── docker-compose.yml TimescaleDB 2.17/pg16 + DragonflyDB
└── db/
├── README.md ERD, ключові рішення, ізоляція тенантів
├── migrate.ps1 накат міграцій + schema_migrations
└── migrations/
├── 0001_core.sql tenants, users, RBAC, secrets, audit
├── 0002_inventory.sql sites, devices, interfaces, credentials
├── 0003_agents_plugins.sql plugins, agents, check_types, checks, outbox
├── 0004_topology.sql neighbors, links, maps, nodes, edges, backgrounds
├── 0005_telemetry_timescale.sql hypertables, CAGG, compression, retention
├── 0006_ncm.sql repos, profiles, jobs, configs, diffs, rollback
├── 0007_alerting.sql rules, alerts, channels, routes, maintenance
├── 0008_dashboards.sql dashboards, widgets, SLA
├── 0009_billing_licensing.sql plans, subscriptions, entitlements, invoices, licenses
├── 0010_seed.sql довідники
└── 0011_rls.sql Row Level Security
Прийняті архітектурні рішення
- Фізична топологія ≠ візуальна.
topo.links(що є в мережі) окремо відtopo.map_edges(як намальовано). Один лінк — на багатьох мапах. - Зв'язки port→port через FK на
inv.interfaces, не текстом. Пара нормалізована черезLEAST/GREATEST, щоб A→B і B→A не дублювались. - Автовиявлення в два кроки: сирі
topo.neighbors(LLDP/CDP/ARP/FDB +confidence) → резолвер →topo.links. Прапорецьis_pinnedзахищає ручні лінки. - Дві моделі метрик: узагальнена
ts.series+ts.samples(плагіни реєструютьmetric_keyбез DDL) і широкіts.icmp_samples/ts.if_countersдля гарячих шляхів мапи. - Анімація трафіку має ланцюг даних:
if_counters.util_out_pct→ viewtopo.link_live→map_edges.animation. - Ліміти тарифу перевіряються двічі:
bill.entitlementsв API + тригери в БД. - Секрети лише шифровані (
core.secrets: ciphertext/nonce/auth_tag/key_id, AES-GCM-256, DEK у KMS). - Тіло конфігів у Git, метадані в БД (
ncm.configs.commit_sha+content_hash). - RLS за замовчуванням на кожній таблиці з
tenant_id; порожнійapp.tenant_id→ порожній результат.
Проблеми, на які натрапив (щоб не повторювати)
- Порядок seed ↔ RLS. Спершу RLS був 0010, seed 0011 — це ламається: після
FORCE ROW LEVEL SECURITYнавіть власник схеми не вставить довідники зtenant_id IS NULL. Файли переставлені місцями. - CAGG не працюють у транзакції.
CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)падає всередині транзакційного блоку.migrate.ps1детектить це по вмісту файлу й вимикає--single-transactionдля 0005.
Помилки, знайдені прогоном на живому Postgres
window— зарезервоване слово.core.sla_targets.window→period_kind.core.check_types.keyбув доменомcore.slug, але ключі мають виглядicmp.ping— крапка не проходить. Замінено наtextз власним CHECK^[a-z0-9]+(\.[a-z0-9_]+)+$; так самоcore.checks.check_type.- Домен
core.slugне пропускав підкреслення, а ключі фіч —http_checks,auto_discovery. Регекс розширено до[a-z0-9_-]. - RLS блокує не CAGG, а стиснення. Початкове припущення «CAGG + RLS несумісні» виявилось хибним. Реальна відмова TimescaleDB 2.29:
operation not supported on hypertables that have columnstore enabled— тобто конфлікт саме з compression. Тому в 0011 виключено всі hypertables (черезtimescaledb_information.hypertables), а не три захардкоджені.
Перевірено на живому стенді
Debian 13 (LXC, 192.168.1.203) / PostgreSQL 17.11 / TimescaleDB 2.29.1. Усі 11 міграцій — на чисту БД без помилок. Створено: 81 таблиця, 12 hypertables, 6 continuous aggregates, 25 фонових job-ів, 63 RLS-політики, 220 індексів.
db/tests/smoke.sql — 8 функціональних перевірок, усі PASS:
нормалізація пари лінків, CHECK на device-ноду, дедуплікація алертів,
ліміт пристроїв тарифу Free, стан полотна мапи одним запитом,
ребро port→port з живим util_pct, роллап CAGG, ізоляція тенантів RLS.
Бази на сервері: netpulse (схема + smoke-дані). Перестворити:
sudo -u postgres dropdb netpulse && createdb -O netpulse netpulse, далі міграції.
2026-08-14 — Етап 2 (частина 1): protobuf-контракт агент↔сервер
Створено
netpulse/
├── buf.yaml, buf.gen.yaml
├── proto/
│ ├── README.md контракт: сервіси, потоки, семантика, безпека
│ └── netpulse/v1/
│ ├── common.proto Status, Transport, Error, DeviceTarget, Credential, AgentHealth
│ ├── agent.proto EnrollmentService, AgentService, ControlUp/ControlDown
│ ├── telemetry.proto SeriesDescriptor, MetricSample, IcmpResult, InterfaceCounters
│ ├── discovery.proto NeighborRecord, InterfaceRecord, DiscoveredDevice
│ ├── ncm.proto ConfigJob, ConfigUpload (header/chunk/trailer), ConfigApplyJob
│ └── logs.proto SyslogEntry, SnmpTrap, LogBatch
├── gen/go/ згенерований код (комітиться)
└── test/contract/ наскрізні gRPC-тести на bufconn
Прийняті рішення
- Форма контракту випливає з одного обмеження: усі з'єднання ініціює агент.
«Команда з сервера» — це повідомлення у зустрічному напрямку вже відкритого
агентом bidi-стріму
Control, а не RPC у бік агента. - Чотири окремі стріми (Control / Telemetry / Logs / Config), а не один: пачка семплів не має блокувати heartbeat, сплеск syslog під час аварії не має топити телеметрію.
- Інтернування серій. Агент реєструє серію раз під
series_ref, далі шле лише номер. Заміряно: 66 → 25 байт на семпл.series_refживе в межах сесії. - Швидкості рахує агент (лише він знає точний інтервал опитування), але шле й сирі лічильники — щоб сервер міг перерахувати заднім числом.
- Scrub/redact конфігів — на сервері. Агент віддає сирий текст; правила
живуть у
ncm.profilesі змінюються без оновлення агентів у полі. - Топологію зводить сервер. Агент доповідає лише «на порту X бачу chassis Y».
Task.params_json— непрозорі байти. Новий плагін не потребує зміни .proto. Модуль-виконавець виводиться з префіксаcheck_typeдо крапки.- At-least-once + upsert. Дедуплікацію дають PK схеми БД
(ts, device_id)тощо. - Зворотний тиск диктує сервер у
Welcomeі кожномуTelemetryAck. - Самооновлення підписане Ed25519 — інакше компрометація CDN = RCE в мережі кожного клієнта.
Перевірено на стенді
Debian 13 (192.168.1.203): protoc 3.21.12, Go 1.24.4, buf 1.72.0.
protoc— усі 6 файлів валідні;buf lint(STANDARD) — без зауважень;- генерація Go+gRPC,
go build,go vet— чисто; go test ./test/contract/...— 5/5 PASS: рукостискання й push плану задач, інтернування серій, реакція на невідомийseries_ref, чанкування конфігу зі звіркою sha256, відмова при пошкодженій контрольній сумі.
Дрібниця для наступного разу: buf.yaml довелось звільнити від
RPC_REQUEST_STANDARD_NAME та сусідніх правил — вони припускають пари
запит-відповідь, а ControlUp/ControlDown це незалежні потоки подій.
2026-08-14 — Етап 2 (частина 2): Go-агент
Створено
netpulse/
├── .gitignore, .gitattributes (репозиторій: LF усюди, крім .ps1)
└── agent/
├── README.md будова, рішення, параметри чеків
├── go.mod replace → ../gen/go
├── cmd/netpulse-agent/ точка входу, GOMEMLIMIT, keepalive
└── internal/
├── config/ прапорці + NETPULSE_*, mTLS
├── module/ контракт модуля, реєстр, маршрутизація
├── telemetry/ interner.go (series_ref) + buffer.go
├── scheduler/ min-heap, семафор, schedule_offset
├── session/ gRPC-клієнт, реконект, ack, план задач
└── modules/icmp, /snmp
Прийняті рішення
- Модулі вкомпільовані, без динамічного завантаження. Один бінарник має працювати на Alpine, Windows і роутері з musl. «Активація» = дозвіл сервера.
- Креденшели беруться на момент виконання, не з плану: у них TTL, і прострочені не віддаються взагалі — інакше агент заблокує обліковий запис на половині комутаторів клієнта.
- Розклад вирівняний по сітці інтервалу, тому після рестарту задача повертається у свій слот, а не з'їжджає.
- Буфер викидає найстаріше. Після відновлення зв'язку цінніший поточний стан. Зміни статусу викидаються останніми й пролазять у батч першими.
- Швидкості інтерфейсів рахує агент (знає фактичний інтервал); при
перевороті лічильника —
counter_resetзамість стрибка на терабіт. - Один писар у контрольний стрім — gRPC не допускає паралельних Send.
GOMEMLIMIT48 МБ у коді: хай GC працює агресивніше, ніж OOM killer осліпить моніторинг саме тоді, коли він потрібен.
Перевірено на стенді
Debian 13, Go 1.25.13. go vet чисто, go test ./... -race — усі пакети ok.
Релізний бінарник (CGO_ENABLED=0 -trimpath -s -w): 12 МБ, базовий
RSS 11.6 МБ у циклі реконекту (бюджет 30 МБ).
Не перевірено
TestPingLoopbackпід звичайним користувачем в unprivileged LXC пропускається: ядро не дає ані unprivileged-, ані raw-сокета,sysctl ping_group_rangeнедоступний. Під root на тому ж стенді тест проходить — ICMP-модуль перевірений проти реального сокета. У проді потрібенCAP_NET_RAW.- Модуль snmp не перевірявся проти живого пристрою — на стенді немає
SNMP-агента. Компілюється й проходить vet; логіка перевороту лічильників
і
util_pctчекає на реальне обладнання.
Репозиторій
https://git.zotac.keenetic.link/zotac/Netpulse_SasS.git (Forgejo).
Читання анонімне, push вимагає токена — Forgejo не пускає навіть у
публічний репозиторій без автентифікації (Credentials are incorrect).
Коміти лежать локально в main і чекають на токен.
2026-08-14 — Етап 2 (частина 3): серверна сторона AgentService
Створено
netpulse/server/
├── README.md рішення, параметри, стан перевірки
├── cmd/netpulse-server/ TLS, keepalive, m'яка зупинка, keyring із -dek
└── internal/
├── crypto/ AES-GCM-256, keyring із ротацією ключів
├── store/ agents, plan, credentials, telemetry, discovery, ncm
└── grpcapi/ AgentService + перехоплювачі автентифікації
Плюс правки в агенті: -token і передача його в метаданих кожного виклику;
Interner.MarkAllPending() — перереєстрація серій на початку сесії.
Прийняті рішення
- Ізоляція тенантів робиться двічі: RLS (
SET LOCAL app.tenant_id) плюс явний предикатtenant_id. Не перестраховка: RLS не працює на гіпертаблях, а саме туди йде вся телеметрія. schedule_offset— чиста функція відcheck_id, тож будь-який вузол сервера дає те саме значення.- Хеш плану — лише з полів, що впливають на поведінку. Зміна опису пристрою не змушує переливати 50 000 задач.
- Токен каже, ЯКИЙ це зонд; сертифікат — що він має право говорити. Чужий
agent_idпри валідному токені →PermissionDenied. - DEK не покидає сервер. Дамп БД без ключів не дає жодного пароля. Комплект креденшелів із TTL 1 год.
- Запис телеметрії —
ON CONFLICT DO NOTHING(at-least-once). - Статус пристрою — один запит із умовним записом в історію, інакше два
воркери наввипередки писали б неіснуючі переходи
up→up. - Впевненість зіставлення спадає за надійністю ознаки: chassis-id 95 → MAC 90 → IP 80 → sysName 60. Останнє низьке навмисно: sysName вводить людина.
- Перереєстрація серій замість обнулення нумерації. Спершу агент мав би скидати interner на реконекті, але це викидало б увесь накопичений за час обриву буфер — саме ті дані, заради яких він накопичувався.
Перевірено на стенді
11 інтеграційних тестів проти живої БД зі схемою Етапу 1 і справжнього gRPC —
усі PASS з -race. Найцінніше: повтор батчу не дублює ані рядки, ані переходи в
історії; зустрічний звіт B→A не створює другий лінк; ручний (is_pinned) лінк не
затирається; тіло конфігу лежить зашифрованим.
Живий наскрізний прогін (справжній агент + справжній сервер + БД, 40 с,
icmp.ping кожні 5 с проти 127.0.0.1): 5 ICMP-семплів, метрики icmp.rtt_avg
0.108 мс / jitter / loss, статус unknown → up з причиною icmp і рівно одним
переходом в історії, heartbeat із RSS 11.6 МБ і dropped_samples=0, зонд
позначений offline після зупинки, clock skew −0.9 мс.
Знайдено під час перевірки
Приведення типу на місці ($1::text) не розв'язує конфлікт виведення типів у
Postgres, а нав'язує тип обом уживанням параметра. Коли $1 потрібен і як uuid
для колонки, і як text для конкатенації, кастувати треба протилежне уживання:
VALUES ($1::uuid, …, '/шлях/' || $1 || '.git'). Та сама пастка двічі: у сіді
тесту (VALUES ($1, $1, …) для core.slug і text) і в ncm.repos.
Чого ще немає
- Git-двигун (libgit2): тіло конфігу шифрується в
core.secrets,commit_shaтимчасово = hex контентного хеша. Дедуплікація йprev_config_idпрацюють, тож diff будується вже зараз. EnrollmentService— зонди заводяться вставкою вcore.agents.- Сервер не надсилає
TaskDelta(лише повний план) і не ініціюєConfigJob.
2026-08-14 — Етап 2 (частина 4): модуль topology, автовиявлення наскрізь
Створено
agent/internal/snmpx/ спільний SNMP-транспорт + розбір індексів OID
agent/internal/modules/topology/ LLDP, CDP, ARP, FDB + інвентар портів
server/cmd/netpulse-secret/ заведення шифрованих секретів із CLI
Плюс: scheduler.OnDiscovery і TriggerNow, доставка звітів у session,
обробка DiscoveryRequest, реєстрація модуля в main.
Прийняті рішення
- SNMP-транспорт винесено в
snmpx— ним користуються два модулі, а два незалежні набори однієї логіки це два незалежні набори багів. - Звіти автовиявлення йдуть окремим RPC, не телеметричним стрімом: вони рідкі, великі й не прив'язані до моменту часу так, як метрики.
- Черга звітів коротка й витісняє найстаріший. Знімок топології актуальний рівно доти, доки описує поточний стан; накопичувати застарілі немає сенсу.
ifHighSpeedмає пріоритет надifSpeed: 32-бітне поле впирається в 4.29 Гбіт/с, і на 10G порт показував би неправильний знаменник дляutil_pct.ifNameперекриваєifDescr: самеifNameвіддає LLDP як port-id, тому саме за ним зійдеться лінк.netpulse-secretяк окремий інструмент — секрети не можна вставити звичайним SQL, а UI ще немає.
Знайдено живим прогоном (обидва — справжні помилки)
- Префікс типу чека не збігався з ключем модуля. У сіді було
topo.discoverпри плагініtopologyіssl.expiryпри плагініhttp. Агент маршрутизує задачі саме за префіксом, тож зонд відхилив би їх як адресовані неіснуючому модулю. Перейменовано наtopology.discoverіhttp.ssl_expiry, а інваріант закріплено обмеженням у БДcheck_types_prefix_matches_plugin— щоб наступна така неузгодженість не доїхала до поля. - Унікальний індекс
topo.neighborsсхлопував ARP-сусідів. Ключ складався зchassis_id+port_id, яких в ARP і FDB немає взагалі: з двох сусідів на одному порту зберігався один. Доданоremote_macдо індексу.
Перевірено проти справжнього SNMP-агента
На стенді піднято snmpd + lldpd (LLDP-MIB через AgentX). Живий прогін
агент → сервер → БД:
- інвентар портів зі справжнього
ifTable:loіeth0, MACbc:24:11:07:68:67, 10 Гбіт/с саме черезifHighSpeed; - 2 сусіди зі справжньої ARP-таблиці, обидва з локальним портом
eth0; - шлюз
192.168.1.1зіставлено за MAC із впевненістю 90; - лінк
snmp-host:eth0 → gatewayзведено,capacity_bps10 Гбіт/с; - три проходи поспіль — лінк один, дублікатів немає;
snmp.getзбираєsys.uptime, помилок чеків немає.
Усі три набори тестів (агент, сервер, контракт) проходять з -race.
Не перевірено
- LLDP і CDP на живих сусідах.
lldpdзареєстрував MIB, але сусідів немає — поруч немає другого пристрою, що шле LLDP. РозбірlldpRemTableіcdpCacheTableпокритий лише тестами на індекси OID; ARP-гілка того самого коду перевірена на живих даних. snmp.if: сервер поки не генерує для нього перелік інтерфейсів, тому чек нікуди не призначається.
2026-08-14 — Етап 2 (частина 5): автостворення snmp.if-чеків
Створено
server/internal/store/autochecks.go — з виявлених інтерфейсів формується
snmp.if-чек і одразу штовхається живій сесії як TaskDelta.
Прийняті рішення
- Чек оновлюється, а не задвоюється. Унікальний індекс
core.checksвключаєmd5(params), тому наївний upsert плодив би новий рядок на кожну зміну складу портів. Шукаємо існуючий чек за(device_id, 'snmp.if'). - Склад портів порівнюється як множина. Postgres не гарантує порядок
ключів у
jsonb— пряме порівняння рядків давало б хибну зміну на кожному обході, і агент отримував би новий план щоразу. - Без SNMP-креденшела чек не створюється: він лише щохвилини писав би помилку автентифікації. Інтерфейси при цьому все одно зберігаються — вони потрібні мапі незалежно від того, чи є чим їх опитувати.
- Loopback і
notPresentвідсіюються — графіка не дають, місце в PDU займають. Ліміт 256 портів на чек: далі опитування не вкладається у власний таймаут. - Зміна штовхається живому зонду. Чекати наступного перепідключення — це години порожніх графіків після кожного нового комутатора.
Знайдено живим прогоном
OID у запиті без провідної крапки, а pdu.Name — з нею. Пошук у мапі
результатів мовчки нічого не знаходив, і чек виглядав як «жоден інтерфейс не
відповів». snmp.get працював, бо там уже була нормалізація, а snmp.if — ні.
Канонізацію винесено в snmpx.Normalize і застосовано з обох боків у GetUints.
Перевірено наскрізь проти справжнього SNMP
Агент і сервер запущені як є, без жодного ручного кроку між ними:
topology.discoverзнайшовloіeth0→inv.interfaces;- сервер створив
snmp.if-чек на 1 порт (loopback відсіяно), 10 Гбіт/с; - дельта доїхала до живої сесії (
надіслано_наживо: true); - агент опитав справжні HC-лічильники:
in_octets625 246 266,in_bps11 938; util_out_pct0.000001 % — знаменник ізifHighSpeed;- помилок чеків немає.
Це повний шлях даних для анімації трафіку на мапі. Усі три набори тестів
проходять з -race.
2026-08-14 — Етап 3: REST/WebSocket API для UI
Створено
server/
├── API.md ендпоїнти, протокол WebSocket, приклади
├── cmd/netpulse-api/ окремий процес: HTTP + WS
└── internal/
├── httpapi/server.go роутер, Bearer-автентифікація, обробники
├── httpapi/ws.go hub, насос подій, насос завантаження каналів
└── store/
├── maps.go стан полотна з живими статусами
├── events.go читання core.event_outbox
├── inventory.go пристрої, зонди
└── apitokens.go автентифікація токенів UI
Прийняті рішення
- API — окремий процес від AgentService. Зонди й браузери мають різні профілі
навантаження, периметри й цикли релізів. Спільний лише шар
store. - Стан мапи віддається одним викликом разом зі статусами. Без цього мапа малювалася б сірою й доганяла кольори сотнею дозапитів.
link_statusвиводиться з кінців лінка, а не читається з колонки.topo.links.statusніхто не підтримує; писати туди означало б оновлювати всі лінки пристрою на кожну зміну статусу. Стан лінка — похідна величина.- Події пишуться тією ж транзакцією, що й зміна. Інакше WebSocket міг би розповісти про перехід, якого в базі ще (або вже) немає.
- Опитування outbox замість LISTEN/NOTIFY. NOTIFY не переживає падіння підписника й обмежений 8 КБ; тут потрібна гарантія, що зміна статусу не загубиться між перезапусками API.
- Дві частоти розсилки: статус — подія (миттєво), завантаження — величина (раз на 5 с). Частіше за оновлення лічильників (60 с) — це та сама цифра по колу.
- Токен WebSocket їде підпротоколом, бо браузер не дозволяє довільні заголовки; в URL він не потрапляє, а отже й у логи проксі.
- Підписник, що не встигає читати, відключається, а не гальмує решту.
Перевірено
10 інтеграційних тестів проти живої БД, справжнього HTTP і WebSocket — усі з -race.
Найцінніші: TestWebSocketDeliversStatusChange проганяє справжній батч телеметрії
через applyDeviceStatus → outbox → hub → браузер; TestLinkStatusFollowsEndpoints
перевіряє, що лінк червоніє від падіння кінця або порту без змін у topo.links.
Живий прогін — агент, netpulse-server і netpulse-api разом проти справжнього
snmpd: мапа з 2 вузлами up (RTT 0.113 і 0.827 мс), ребро з портом eth0,
лінк=up, живий util_pct, зонд online з RSS 11.6 МБ і dropped_samples=0.
target_port порожній — і це правильно: лінк знайдено через ARP, а ARP не повідомляє
порт віддаленої сторони.
Чого ще немає
- Усі ендпоїнти read-only: редактор мапи потребує
PATCHз оптимістичним блокуванням заrevision(колонка є, обробника немає). - Немає
GET /api/v1/metricsдля графіків і віддачіalr.alerts. - Автопобудова мапи з
topo.links— поки SQL-скрипт, не кнопка.
2026-08-14 — Етап 3 (частина 2): запис у мапу
Створено
server/internal/store/maps_write.go патч полотна, знімки, автопобудова
server/internal/httpapi/maps_write.go POST/PATCH/DELETE + /build
Ендпоїнти: POST /api/v1/maps, PATCH /api/v1/maps/{id},
DELETE /api/v1/maps/{id}, POST /api/v1/maps/{id}/build.
Прийняті рішення
- Усі скалярні поля патча — вказівники; nil означає «не чіпати». Перетягування шле лише x/y, і якби відсутні поля трактувались як порожні, кожен рух миші стирав би стиль, розмір і прив'язку до пристрою.
- Ребро може посилатися на вузол, створений тим же патчем, за
client_id— інакше зв'язок до нового вузла вимагав би двох запитів і проміжного стану. - Оптимістичне блокування за
revision. Той, хто спізнився, отримує 409, а не тихо затирає чужу правку: у NOC над однією мапою працюють кілька людей. - Знімок пишеться тією ж транзакцією, що й зміна. Інакше після збою в історії лишався б крок, якого в мапі немає, і відкат ламав би її. Зберігаються останні 50.
- Невідоме поле в тілі — 400. Мовчки проковтнути друкарську помилку клієнта означає, що правка «збереглася», але не застосувалась.
- Автопобудова ідемпотентна: наявні вузли не дублюються, координати не чіпаються — інакше кожен запуск скидав би ручну розкладку.
- Ліміт тарифу → 402, а не 500: UI має показати пропозицію змінити тариф.
map.updatedнесе лише ревізію, не патч. Розсилати дельти означало б тримати на сервері модель того, що бачить кожен клієнт — це вже CRDT, окрема задача.
Знайдено тестами
revision <= $2 - $3 з двома нетипізованими параметрами дає
operator is not unique: unknown - unknown. Postgres не може вивести оператор
віднімання, коли обидва операнди — плейсхолдери. Потрібні явні касти.
Перевірено
23 інтеграційні тести API (усі з -race), з них 12 нових на запис.
Найцінніші: драг не затирає сусідні поля; друга вкладка зі старою ревізією отримує
409, а перша правка ціла; видалення вузла не лишає ребер у нікуди; повторна
автопобудова не скидає ручну розкладку.
Живий прогін проти даних, які агент зібрав сам: створення мапи → автопобудова (+2 вузли, +1 ребро) → драг (ревізія 3) → патч зі старою ревізією (409) → повторна побудова (+0/+0) → координати x=1500 y=640 збережені, 2 знімки в історії.
2026-08-14 — Етап 4: фронтенд, мапа в браузері
Створено
web/
├── README.md
├── package.json, vite.config.ts, tsconfig.json
└── src/
├── types.ts типи API
├── api/client.ts REST + ApiError (isConflict/isPlanLimit)
├── api/ws.ts WebSocket із реконектом
├── hooks/useLiveMap.ts завантаження, живі оновлення, запис
└── components/ MapCanvas, DeviceNode, TrafficEdge
React 18 + React Flow 12 + Tailwind 4 + Vite 6. Збірка 345 КБ (112 КБ gzip).
Прийняті рішення
- Сервер — джерело істини, крім моменту перетягування. Поки вузол тягнуть,
позицію диктує миша; синхронізація пропускає вузли з
dragging. - Зберігаємо на відпусканні й лише якщо вузол зрушив.
- Успішний патч застосовується до локального стану — інакше вузол «повертається» при першій же події.
- Конфлікт ревізій не приховується: 409 → повідомлення + перечитування.
- Швидкість анімації обернено пропорційна завантаженню, при нулі анімації немає взагалі.
- Стан з'єднання завжди на екрані: замерзла мапа виглядає як здорова.
Знайдено роботою з живим UI (обидві виправлені)
- Ревізія росла від самих кліків. За час, поки я робив скріншоти, мапа
пройшла 2 → 5: React Flow віддає
onNodeDragStopна будь-яке натискання, і кожен клік писав порожню ревізію, змушуючи всі відкриті полотна перечитуватись. - Вузол відкочувався після збереження. Хук оновлював лише номер ревізії, тож
наступна подія
device.statusперебудовувала список зі старими координатами.
Перевірено в браузері проти повного стека
Агент + сервер + API + справжній snmpd: два зелені вузли з живим RTT (0.05 і
1.04 мс), ребро eth0 → ? із <0.01% з 10.0 Гбіт/с, індикатор «наживо, 1 с тому»,
зонд probe-snmp linux/amd64 з RSS 11.1 МБ, кнопка «Добудувати з топології»
додала 2 вузли й 1 ребро з topo.links.
Обмеження перевірки
- Драг не відтворюється автоматизацією: синтетичні події вказівника не запускають d3-drag у React Flow. Шлях «драг → PATCH → БД» покритий тестами сервера, але саме через UI лишається неперевіреним автоматично.
- Ребра не малюються у прихованій вкладці: React Flow міряє вузли в
requestAnimationFrame, який там не викликається. Вузли рендеряться, бо це звичайний DOM. Скріншот у згорнутій панелі непридатний для перевірки.
2026-08-14 — Етап 4 (частина 2): редактор доведено до робочого стану
Створено
server/internal/store/maps_undo.go відкат до попереднього знімка
server/internal/httpapi/maps_write.go POST /maps/{id}/undo
web/src/components/MapCanvas.tsx onConnect, onDelete
web/src/hooks/useLiveMap.ts undo + canUndo
Прийняті рішення
- Відкат — нова ревізія, а не відмотування лічильника. Інакше клієнт із номером 10 після повернення до 9 отримав би «свою» ревізію знову актуальною й тихо перезаписав відкочене.
- Ідентифікатори вузлів зберігаються при відкаті — ребра прив'язуються назад самі, а виділення в UI й зовнішні посилання не ламаються.
- Порядок відновлення — ребра геть → вузли геть → вузли назад → ребра назад: зовнішні ключі не дозволяють інакше.
- Намальоване рукою ребро не прив'язується до
topo.links: лінія на полотні — це подання, а не факт про мережу. Автовиявлення прив'яже саме. - Видалення не перелічує ребра вузла: на полотні їх прибирає React Flow, у базі — каскад FK.
Знайдено флак у тестах (виправлено)
TestSchedulerRunsTaskAndFillsCredentials падав приблизно раз на п'ять під
навантаженням: тест перевіряв канал статусів знімком (for len(ch) > 0),
а STATE_SUCCEEDED надсилається вже ПІСЛЯ запису в sink. На завантаженій машині
проміжок розширювався. Замінено на очікування з дедлайном; 8 прогонів під
штучним навантаженням — 0 падінь.
Перевірено наживо
Повний ланцюг проти працюючого стека: намальовано зв'язок (ребер 1 → 2) → відкат (2 → 1) → видалення вузла (2 вузли → 1, ребер 0) → відкат повернув той самий id вузла, ребро й живий стан лінка. UI показує обидві кнопки й підказку про жести.
Далі
План на наступні етапи винесено в окремий документ — ROADMAP.md.
Коротко: користувачі й права (схема є, коду немає) → алерти → шаблони опитування
(нова підсистема tpl.*) → керування зондом із UI → NCM до кінця → мобільний
адаптив і PWA.
2026-08-15 — Етап 5: користувачі, вхід і права
Досі продукт умів усе, крім найпростішого: впустити людину. Доступ давав машинний токен у змінній збірки — тобто одні права на всіх і жодного способу відрізнити, хто що зробив. Цей етап закриває саме це.
Створено
db/migrations/0012_auth.sql— три RLS-політики винятку для шляху входу, гіпертаблицяcore.login_attempts, два індекси наcore.sessions.server/internal/auth/password.go— argon2id (2 проходи, 64 МБ, 32 байти), вивід у форматі PHC, звірка черезsubtle.ConstantTimeCompare.server/internal/auth/token.go— власний HS256 JWT:Sign,Verify.server/internal/store/users.go— автентифікація, сесії, членства, команда.server/internal/httpapi/principal.go—Principalзамість старогоauthenticated; людина й машина зводяться до одного набору прав.server/internal/httpapi/auth.go,users.go— 9 нових ендпоїнтів.server/cmd/netpulse-user/main.go— CLI для першого власника.web/src/api/session.ts,components/LoginPage.tsx, переписаніclient.tsіApp.tsx.server/internal/httpapi/auth_test.go— 11 тестів.
Прийняті рішення
Заголовок JWT звіряється байт у байт, а не парситься. Класична атака
підміни алгоритму (alg: none, alg: HS256 замість RS256) можлива лише там,
де сервер питає токен, яким алгоритмом його перевіряти. Тут константа
{"alg":"HS256","typ":"JWT"} порівнюється як рядок — питати нічого й нема в
кого. Підпис перевіряється до строку дії: інакше протермінований підроблений
токен відрізнявся б за текстом помилки від протермінованого справжнього.
Права читаються з БД на кожному запиті, а не беруться з токена. Класти їх у claims було б швидше на одне звернення, але тоді відкликана роль жила б до кінця TTL. Порожній набір прав — це не «нічого не можна», а сигнал, що членство зникло: такий токен відхиляється повністю.
Access-токен живе в замиканні модуля, не в localStorage. З localStorage
його забирає будь-який XSS; із замикання — ні. Ціною є втрата токена при
перезавантаженні сторінки, тому на старті робиться тихий refresh по кукі.
Refresh-токен ротується. Стара сесія відкликається, видається нова. Це не
дає викраденій кукі жити паралельно з живою: другий власник отримає
401 no_session — крадіжка стає видимою подією, а не тихою.
Один refresh на всі паралельні запити. Перший же екран робить чотири запити одразу. Без черги з одного обміну ротація зробила б усі, крім першого, недійсними, і людину викидало б на вхід рівно тоді, коли все гаразд.
Невідомий email і невірний пароль нерозрізненні — однаковий код і однаковий час: на неіснуючому користувачі спалюється фіктивна перевірка argon2id. Без неї різниця в часі сама розказує, які адреси зареєстровані.
Три обмеження вшито в API, а не в UI: роль owner не видається через
HTTP (лише CLI), не можна змінити роль собі, не можна прибрати себе. Усі три
захищають від одного — організації без жодного власника.
Перший власник заводиться CLI. Публічна реєстрація в B2B-інсталяції — це не зручність, а дірка; «створи першого користувача через веб, поки нікого немає» — гонка, яку неможливо закрити чесно.
Знайдено при написанні
FORCE ROW LEVEL SECURITY робив вхід неможливим за побудовою: щоб знайти
користувача за email, треба знати тенант, а тенант відомий лише після того, як
користувача знайдено. Виправлено трьома політиками, які пускають SELECT
тільки коли core.current_tenant() IS NULL — тобто рівно на шляху входу,
де тенанта ще нема. Читання під уже виставленим тенантом лишається обмеженим
як було.
Перевірено
go test ./... -race— 37 тестів httpapi, з них 11 нових;go vetчисто.tsc --noEmitчисто,vite build— 353 КБ JS (114 КБ gzip).- Живий прогін проти
netpulse_it: 8 кроків від входу власника до відкликаної сесії — усі коди очікувані (розписано в API.md). - У браузері: форма входу → інженер входить → мапа з живими RTT і «наживо». Під глядачем кнопки редагування зникають, полотно нередаговане, блок зондів не показується.
- Мобільний вигляд (375×812): бічна панель повністю за кадром, полотно на всю ширину, горизонтального скролу немає; бургер висуває панель поверх полотна із затемненням.
Обмеження перевірки
Вкладка браузера прихована, тому CSS-переходи стоять на currentTime: 0 —
виміри бічної панелі робилися після примусового getAnimations().finish().
Це артефакт середовища, не застосунку.
Сторінки керування командою в UI ще немає — є API й методи клієнта
(team, roles, addMember, setRole, removeMember). Додати користувача
поки можна лише CLI або запитом.
2026-08-15 — Етап 9: алерти й сповіщення
Система вміла малювати мапу, але мовчала, коли щось падало. Схема
(alr.*) лежала готовою з Етапу 1 і повністю порожньою. Тепер вона
працює.
Створено
server/internal/store/alerts.go— правила, обчислення умов (icmp / interface / metric / no_data), побудова предикатів селектора.server/internal/store/alerts_state.go— життєвий цикл алерту, придушення, граф топології й визначення першопричини.server/internal/store/alerts_query.go— списки, ack, mute, CRUD правил.server/internal/store/alerts_channels.go— канали, маршрути, тихі години, журнал доставки.server/internal/alerting/engine.go— цикл обчислення під advisory-блокуванням.server/internal/alerting/notify.go— Telegram, webhook, SMTP, шаблони повідомлень, SSRF-захист.server/internal/httpapi/alerts.go— 12 ендпоїнтів.web/src/hooks/useAlerts.ts,components/AlertsPanel.tsx, шина подій уapi/ws.ts.- 20 нових тестів (11 у
store, 9 вalerting).
Прийняті рішення
Движок не має стану між тіками. Вікно for_seconds — це запит по
часу до TSDB, а не лічильник у пам'яті. Тому перезапуск процесу нічого
не збиває, а два процеси дали б однаковий результат. Лічильник у
пам'яті довелося б і зберігати, і відновлювати, і синхронізувати між
екземплярами — три способи розійтися з реальністю замість нуля.
Дві семантики вікна. Без agg умова має триматися всі виміри
вікна — це і є антифлап: одна втрачена відповідь не будить людину. З
agg порівнюється агрегат. Обидві потрібні: «недоступний три хвилини
поспіль» і «середнє завантаження за 5 хв вище 85%» — різні питання.
Список метрик і операторів закритий. Значення з condition
потрапляє в текст запиту (агрегатну функцію не підставиш параметром),
тому все, що йде в SQL, проходить whitelist, а все інше — лише
параметром. Послаблення тут — це SQL-ін'єкція через JSON у таблиці
правил.
Кореляція за межею зони недоступності. Причина аварії — той, у кого
лишився живий сусід; хто оточений мертвими — наслідок. Це те, заради
чого будувалась topo.links: інакше падіння маршрутизатора дає сорок
сповіщень, серед яких губиться єдине потрібне. Пристрій без зв'язків
завжди лишається причиною — топологія про нього нічого не знає, і
списати його на чужу аварію було б вигадкою.
Дедуплікація індексом, а не перевіркою в коді. Частковий унікальний
індекс alerts_active_dedup_uniq робить другий алерт на ту саму
проблему неможливим навіть якщо два движки якимось чином працюють
одночасно.
Сповіщення шле лише щойно піднятий і не придушений алерт. Продовження вже відомої проблеми не є новиною. Перевірено прогоном: за три тіки кількість сповіщень не зросла.
Тенант без маршрутів отримує все в усі придатні канали. Підключили Telegram — має працювати. Вимагати ще й маршрут означало б мовчати саме там, де налаштування щойно зроблене й здається повним.
Тиха година не глушить disaster. Сенс чергування в тому, щоб
його підняли.
SSRF-захист із виходом для self-hosted. Адресу вебхука задає користувач тенанта, а запит іде з сервера — у SaaS це класичний вектор. Але в self-hosted вебхук майже завжди веде саме всередину, у корпоративний Mattermost. Тому заборона знімається прапорцем процесу, і рішення приймає адміністратор сервера, а не користувач тенанта.
Стеля ручного заглушення — тиждень. Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути моніторинг назавжди.
Знайдено роботою з живою системою (три справжні помилки)
Вимкнене або видалене правило лишало свої алерти висіти вічно.
alr.alerts.rule_id має ON DELETE SET NULL, а вимкнене правило
випадає з вибірки движка — в обох випадках алерти ставали сиротами,
яких нікому закрити. Виправлено закриттям алертів у тій самій
транзакції, до видалення правила.
Перехід у «придушено» не публікував події. Людина глушила пристрій,
движок за 10 секунд переводив алерт у suppressed, а UI про це не
дізнавався до перезавантаження сторінки. Причина: подія публікувалась
лише для нових і закритих алертів. Додано alert.updated і читання
попереднього стану через CTE у тому ж знімку, що й UPSERT — інакше
відрізнити «стан змінився» від «проблема триває» неможливо.
Кнопки дій на телефоні були 26 px. Виміряно в мобільному вигляді: для «Прийняти» і «Заглушити», які натискають пальцем уночі, це промахи. Піднято до 38 px на вузьких екранах, на десктопі щільність збережено.
Перевірено наживо
Проти справжніх даних (2 пристрої, 240 ICMP-вимірів за 10 хв):
- Правило з порогом 0.5 мс підняло алерт на
gateway(0.98 мс) і не підняло наsnmp-host(0.08 мс) — поріг рівно посередині. no_dataі «помилки на порту» мовчали, як і мали.- Вебхук дійшов: 5 доставок на 5 подій, жодного повтору за багато тіків.
- Заглушення пристрою придушило рівно його алерт, сусідній лишився активним.
- Вимкнення правила закрило його алерти й не зачепило чужий
ack. - Глядач отримує 403 на ack і 200 на читання.
- У браузері: індикатор пульсує червоним, панель відкривається, ack оновлює список наживо, придушений алерт іде під окремий фільтр.
- Мобільний вигляд 375 px: панель на весь екран, горизонтального скролу немає.
Чого свідомо не робив
Ескалацій (alr.escalation_policies) і повторних сповіщень немає —
алерт сповіщає один раз. Кнопки Ack/Mute у Telegram відмальовуються, але
приймача callback_data не написано. Web Push відкладено до Етапу 10
разом із PWA. Правила з джерел syslog/trap/ncm движок пропускає:
вони обробляються подіями, а не опитуванням.
2026-08-15 — Етап 5+: повноцінний веб
Питання «а повноцінний веб коли?» було справедливим. До цього моменту «веб» — це був один екран: логін, мапа й панель алертів. Ані навігації, ані сторінок; усе, що вміло API, доводилось викликати curl-ом. Цей етап закриває розрив між готовим бекендом і тим, що людина може натиснути.
Створено
web/src/components/AppShell.tsx— каркас: бічна навігація, шапка, індикатор зв'язку, лічильник алертів.web/src/components/ui.tsx— примітиви:DataTable,Modal,Button,Field,StatusBadge,Toggle,Card.- Сім сторінок у
web/src/pages/:MapPage,DevicesPage,AlertsPage,RulesPage,ChannelsPage,AgentsPage,TeamPage,ProfilePage. web/src/hooks/useLiveRefresh.ts— перечитування зі злиттям сплеску.react-router-domяк залежність;App.tsxпереписано на маршрути.
Прийняті рішення
Пункт меню, на який немає права, не показується взагалі. Кнопка, що завжди дає 403, гірша за її відсутність: вона обіцяє можливість, якої немає, і змушує людину гадати, що вона зробила не так. Пряме посилання при цьому лишається робочим — його можна отримати від колеги чи успадкувати після зміни ролі, тому маршрут показує зрозуміле пояснення з назвою потрібного права, а не порожній екран.
Стартова сторінка залежить від ролі. Глядача без права на мапи вітати відмовою — поганий перший екран, тому домівкою стає перший доступний розділ.
Таблиця на телефоні перестає бути таблицею. Горизонтальний скрол на
375 px — це спосіб зробити дані формально присутніми й фактично
нечитабельними. Тому DataTable малює картки, а другорядні колонки
позначаються hideOnMobile, щоб картка лишалась короткою.
Форма правила показує списком те саме, що приймає сервер. Списки метрик дублюють whitelist бекенда свідомо: без цього про друкарську помилку людина дізнавалась би не з форми, а з правила, яке мовчки нічого не знаходить.
Знайдено роботою з живим UI (три справжні помилки)
WebSocket жив усередині мапи. Найсерйозніша з трьох. Сокет
створював useLiveMap, тому на кожній сторінці, крім мапи, живих
оновлень не було взагалі: лічильник алертів у шапці замерзав, щойно
людина йшла з мапи, і показував стан на момент входу. Помітно це стало
лише тоді, коли сторінок стало більше однієї. З'єднання винесено в
модуль-одинак live, яким володіє оболонка; useLiveMap тепер лише
слухає спільну шину. Заодно стан зв'язку видно з будь-якої сторінки —
замерзлий інтерфейс виглядає точно так само, як справний.
Сторінка пристроїв перечитувала все на кожну подію алерту. Виміряно лічильником запитів у браузері під штучним сплеском: одна аварія з десятком алертів давала десяток пар запитів — клієнт додавав навантаження рівно тоді, коли серверу найважче. Виправлено спільним хуком зі злиттям сплеску плюс переходом на алерти з оболонки замість власного опитувача: той самий сплеск тепер коштує 2 запити замість дванадцяти.
Канал із секретом показувався як «секрету немає». HasSecret
ставився всередині гілки розшифровки, а перелік для UI викликається без
ключа навмисно. Наявність секрету — факт про канал, а не наслідок того,
чи його зараз читають.
Перевірено в браузері проти повного стека
- Усі вісім сторінок рендеряться з живими даними: 2 пристрої, 5 правил, 1 канал, 1 зонд, 4 учасники.
- Створення правила через форму: правил 4 → 5, умова записалась
коректно (
loss_pct > 5, всі виміри 120 с). - Створення користувача через форму: учасників 3 → 4.
- Кнопка «Перевірити» на каналі — пробне повідомлення дійшло у приймач.
- Глядач: у меню 5 пунктів замість 7, кнопки «+ Правило» немає, усі
10 перемикачів заблоковані,
/teamпрямим посиланням дає екран із поясненням. - Мобільний 375 px: меню закрите, бургер висуває, перехід закриває його сам; таблиці стають картками; горизонтального скролу немає.
- Мапа після переносу сокета працює: вузли з живим RTT, «наживо», лічильник часу оновлюється.
Чого ще немає у вебі
Дашбордів і графіків історії (потрібен GET /api/v1/metrics, якого
немає), сторінки налаштувань зонда, редагування правила (лише
створення й вимкнення), маршрутів сповіщень і вікон обслуговування
(є в схемі й у движку, у UI — ні), NOC-режиму на телевізор.
2026-08-15 — Вхід за логіном, групи хостів і права доступу
Три зауваження за одну сесію: перемикачі виглядають зламано, входити треба логіном, а не поштою, і бракує заббіксівського — груп, прав на них, додавання хостів і редагування мапи. Плюс два власні спостереження користувача про сторінку правил і мапу.
Створено
db/migrations/0013_groups_login.sql—core.users.username,core.user_groups,core.user_group_members,core.group_permissions, функціїcore.device_access_levelіcore.accessible_devices, RLS на нові таблиці.server/internal/store/groups.go— CRUD груп, хостів і обчисленняScope.server/internal/httpapi/groups.go— 11 ендпоїнтів.web/src/pages/GroupsPage.tsx— групи хостів і груп доступу на одному екрані.web/src/components/NodeInspector.tsx— підпис, значок, розмір, ширина, колір, закріплення вузла.- Форма хоста (створення й редагування) і фільтр за групами на сторінці хостів.
- Створення мапи з транслітерацією slug.
Прийняті рішення
Логін замість пошти. У мережевій інсталяції половина облікових записів
технічні — noc, monitoring, oncall, — і скриньки не мають узагалі. Пошта
лишилась необов'язковим полем для сповіщень. Сервер шукає за обома, тому людину,
яка за звичкою ввела email, ніхто не відхиляє.
Наявним користувачам логін вивели з пошти, а збіги розвели суфіксом за
порядком створення. Мовчки злити admin@a.com і admin@b.com в один логін
було б не міграцією, а втратою акаунта. Механізм одразу знадобився: на стенді
жили залишки від тестових прогонів, і admin дістався саме їм.
Ролі й групи — два незалежні виміри. Роль каже, що людині вільно робити; група доступу — над якими хостами. Інженер над однією філією та інженер над усією мережею мають однакову роль і різний доступ, і змішати це в один список прав неможливо без втрати сенсу.
Хто не входить у жодну групу — не обмежений групами. Це свідомо не по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова інсталяція починається з питання «чому порожньо». Тут звуження вмикається тоді, коли його справді налаштували.
Заборона перемагає дозвіл. Інакше її можна обійти, додавши хост у будь-яку іншу групу.
Фільтр видимості накладається в самому запиті, а не після вибірки: відсіювати вже прочитане означало б тягнути з БД чужі рядки й покладатися на те, що жоден не проскочить у відповідь.
Правки вузла застосовуються кнопкою, а не на кожну літеру. Кожне збереження — це нова ревізія полотна й подія для всіх, хто дивиться на ту саму мапу.
Знайдено й виправлено
Перемикач вилазив за межі треку. Скарга була «вигляд глюкнутий»; вимір
показав причину: у ручки не заданий left, тож вона стає на статичну позицію,
а та у <button> зсунута типовим text-align: center. Обчислений left
виходив 18px замість 2px, ручка виступала на 14px і накривала сусідній хрестик.
Сторінка правил не оновлювала лічильник «активних». Друга скарга, і причина не та, що здається. Після вимкнення правила його алерти закриваються одразу, а після повернення піднімаються лише наступним тіком движка — тобто через секунди ПІСЛЯ нашого перечитування. Сторінка не була підписана на живі події, тому показувала нулі до перезавантаження. Той самий недогляд, що раніше знайшовся на сторінці хостів; тепер обидві користуються спільним хуком.
Канал із секретом показувався як «секрету немає» — прапорець ставився всередині гілки розшифровки, а перелік для UI викликається без ключа навмисно.
Перевірено наживо
Прогін проти справжнього стенду:
вхід за логіном 'admin' 200, роль owner
вхід тим самим, але поштою 200 — сумісність збережена
дві групи хостів 201/201
хости розкладено gateway→Магістраль, snmp-host→Доступ
новий хост через API 201
група доступу для 'eng' лише «Доступ», рівень read
що бачить eng snmp-host і test-host, обидва writable=false;
gateway зник із вибірки
алерти під eng 1 замість 3
eng редагує чужий хост 403
У браузері: вхід логіном (у шапці «admin», не пошта); перемикачі вміщаються в
трек в обох станах із відступом 2px; правила оновлюються 2 → 0 → 2 без
перезавантаження; сторінка груп показує обидві половини; інспектор вузла змінив
підпис, значок і ширину до 220px із ревізією 13 → 14; створення мапи дало slug
kyiv-iadro з «Київ ядро» і порожнє полотно.
Чого ще немає
Динамічних груп (inv.device_groups.kind='dynamic' у схемі є, правило відбору
не читається), успадкування прав між групами, редагування груп хостів після
створення (лише створення й видалення), масових операцій над хостами.
2026-08-15 — Опитування хоста з форми й редагування користувачів
Зауваження було точним: «додаєш хост — чому не можна вказати, як його
опитувати». Форма створення хоста, яку я зробив раніше, збирала назву,
адресу, тип і групи — і не збирала головного. Хост, доданий через UI,
не опитувався взагалі: жодного рядка в core.checks. Наявні два
хости на стенді працювали лише тому, що їхні перевірки я засіяв через
SQL.
Створено
server/internal/store/checks.go— типи перевірок, перевірки хоста, доступи до обладнання.server/internal/httpapi/checks.go— 5 ендпоїнтів.web/src/components/ChecksEditor.tsx— редактор опитування, який будує поля параметрів ізparams_schema, що віддає сервер.UpdateUserProfileу store + розширенийPATCH /api/v1/team/{id}.EditUserFormна сторінці «Команда».
Прийняті рішення
Перевірки — частина створення хоста, а не окремий крок. Вимагати
другого запиту означало б зробити «хост, який не опитується» типовим
станом. Новий хост у формі одразу отримує icmp.ping: це єдина
перевірка, яка працює будь-де без налаштування.
Поля параметрів будуються з JSON Schema типу. Захардкодити їх у
фронтенді означало б забувати оновити його щоразу, коли плагін додає
новий тип. Сервер уже віддає params_schema — форма її і читає.
Правка перевірки йде за id. Унікальний індекс checks_uniq
включає md5(params), тому «видалити й вставити» на зміні інтервалу
спрацювало б, а на зміні параметрів створило б ДРУГУ перевірку того
самого типу.
Профіль редагується лише в того, хто працює тільки в цій
організації. core.users глобальна, і логін із паролем — власність
людини, а не тенанта. Адмін філії, який змінює пароль тому, хто тим
самим акаунтом заходить у сусідню організацію, ламає їй доступ там, і
вона про це не дізнається. Спроба дає 409 shared_user; роль і групи
локальні й редагуються завжди.
Порожнє поле у формі означає «не чіпати». Тому перевірки шлються лише коли форма справді їх завантажила: збереження форми, відкритої до завантаження, інакше стерло б усе опитування хоста.
Знайдено при написанні
core.plugins не має колонки enabled — активація на тенанта живе в
core.plugin_installs. Перший варіант запиту падав із
column p.enabled does not exist. Заодно з'ясувалося, що
plugin_installs порожня, тому доступність рахується як «плагін
базовий АБО явно ввімкнений»: вимагати «встановлення» для пінга
означало б зустрічати кожного нового клієнта порожнім списком перевірок.
Перевірено наживо
типи перевірок 8 доступних із 9 (modbus не базовий і не встановлений)
доступ SNMP створено 201
хост із трьома перевірками 201
що записалось icmp.ping 30 с, snmp.get 60 с, snmp.if 300 с
правка інтервалу і зняття однієї 10 с, params {"count": 5}, лишилось 2 — без задвоєння
хост без перевірок створюється, перевірок 0
профіль + пароль змінено 204
вхід новим паролем 200
вхід старим паролем 401 bad_credentials
перейменування логіна 204, вхід новим логіном 200
спроба змінити роль собі 403
У браузері: форма нового хоста показує icmp.ping із полями count і
packet_size, узятими зі схеми, і список із семи інших доступних типів;
доданий через UI хост записав icmp.ping з інтервалом 30 с і snmp.get
із двома OID.
Чого ще немає
Форми створення доступу (SNMP-community) у вебі — доступи заводяться через API, а у формі хоста лише прив'язуються. Перевірок на рівні групи хостів (у Zabbix це шаблони — Етап 6). Історії й графіків за зібраними метриками.
2026-08-15 — Каталог команд збору конфігу
Щоб знімати конфіги, треба знати команду для кожної платформи: у Cisco
це show running-config, у Huawei display current-configuration, у
MikroTik export, а Eltex SMG узагалі віддає конфіг через cat. Зібрав
це в каталог.
Створено
db/profiles/catalog.json— 147 платформ, 67 вендорів: команда збору, команда startup-конфігу, підготовка консолі, скільки службових рядків відкинути, альтернативи для інших моделей родини.db/profiles/build.py— збірка міграції з каталогу, з режимом--check.db/profiles/README.md— будова каталогу й як додати платформу.db/migrations/0014_ncm_profiles.sql— згенерований результат.
Прийняті рішення
Каталог — джерело істини, міграція породжується. Два описи одного й
того самого розійшлися б із першою ж правкою, і невідомо було б, який
справжній. Тому міграція має в шапці «ФАЙЛ ЗГЕНЕРОВАНО», а build.py --check уміє звірити, чи вона не відстала.
Каталог — код, а не дані клієнта. Він однаковий для всіх
інсталяцій, має переглядатись у code review і їхати разом із релізом.
Тенант при цьому може завести власний профіль через tenant_id —
вбудовані лишаються недоторканими.
Родина CLI, а не вендор. bdcom, arista, brocade і ще з десяток
говорять діалектом Cisco; h3c і 3com — діалектом Huawei. Тримати
промпт і вимкнення пейджера один раз на родину — різниця між правкою в
одному місці й правкою в сорока.
Пейджер додається лише мережевому CLI. Частина «мережевих»
пристроїв знімає конфіг шелом (cat /etc/config/cfg.yaml в Eltex SMG і
TAU, tail в Eltex RG). Відправити в bash terminal datadump означало
б гарантовану помилку на кожному зборі.
Лічильники в шапці обчислюються. Перший варіант мав зашите «148 платформ» поруч зі згенерованими 147 — розбіжність з'явилась одразу, а на першій доданій платформі стала б звичкою не вірити коментарям.
Перевірено
Міграція накочена на netpulse_it: 148 профілів (147 із каталогу плюс
раніше засіяні), 67 вендорів, дублікатів немає — унікальний індекс
ncm_profiles_key_uniq спрацював, а ON CONFLICT DO NOTHING зберіг
наявні. Вибірково звірено складні випадки: Cisco ASA (more system:running-config без службових changeto system), NXOS, FWSM,
Huawei VRP3, H3C, Juniper JUNOSe, Eltex SMG/TAU (без пейджера).
build.py --check після збірки каже «міграція актуальна» — генерація
детермінована.
Чого це ще не дає
Виконувати профілі нікому. Агентського модуля ncm (SSH/Telnet)
немає — це Етап 7. Зараз це готові дані, які чекають на виконавця.
2026-08-16 — Етап 7: збір конфігів запрацював
Профілі з попереднього кроку були даними без виконавця. Тепер ланцюг замкнено: кнопка в UI → черга → диспетчер → зонд → SSH → назад у БД.
Створено
agent/internal/ncmx/— знімання конфігу по CLI:session.go(розбір потоку),transport.go(SSH і Telnet),collect.go(виконання завдання).agent/internal/session/config_jobs.go— прийманняConfigJobі вивантаження стрімом.server/internal/store/ncm_jobs.go— черга, побудова завдання з профілю й доступу, закриття.server/internal/grpcapi/ncm_dispatch.go— диспетчер.- 16 тестів на розбір консолі.
Прийняті рішення
Усе будується навколо пошуку промпту. Консоль мережевого пристрою — не програмний інтерфейс: немає ані коду завершення, ані довжини відповіді. Єдиний спосіб зрозуміти, що команда відпрацювала — побачити знову запрошення.
Промпт шукається лише в хвості накопиченого (512 байтів). Конфіг
може містити рядок, схожий на запрошення — banner motd # трапляється
в кожній другій мережі, — і пошук по всьому тексту обривав би збір на
середині.
Дедлайн на паузу між байтами, а не на всю операцію. Збір із
великого шасі триває хвилини, і загальний ліміт довелося б ставити
навмання. Тиша ж означає одне з двох: пристрій завис або промпт не той.
Тому й помилка окрема — ErrPromptTimeout підказує, що лікується вона
не повтором, а виправленням prompt_regex.
Черга в БД між процесами. REST і AgentService — різні процеси;
живу сесію зонда тримає лише другий. FOR UPDATE SKIP LOCKED не дає
двом екземплярам надіслати одне завдання двічі.
Ключі SSH мережевого обладнання не звіряються. Свідомо: залізо перегенеровує ключ після кожної заміни прошивки, і known_hosts на сотні пристроїв означав би або вимикати перевірку щотижня, або не збирати конфіги зовсім. Захист тут дає сегмент керування, а не TOFU. Переліки алгоритмів навмисно широкі — інакше половина парку відпаде з «no common algorithm».
Знайдено живим прогоном (три справжні помилки)
Прогін ставили проти самого стенду: у нього є SSH, і cat /etc/os-release віддає текст так само, як консоль віддає конфіг.
Сервер ігнорував поле encoding. Агент стискав тіло gzip і чесно
рахував sha256 від оригіналу, а сервер рахував від стиснених байтів —
і відхиляв кожен бекап як «тіло не відповідає заявленому sha256». Поле
було в контракті з Етапу 2, реалізації не було ніколи: інтеграційний
тест користувався encoding: "none" і повз цю дірку проходив.
Промпт обрізався не по рядку. Типовий шаблон [>#]\s*$ збігається
лише з символом запрошення, тому в конфізі лишалось ім'я пристрою
окремим рядком: «…interface Gi0/1» + «sw1». Ріжемо весь рядок.
У конфіг потрапляло сміття терміналу. Перший успішний збір дав 295
байтів і 12 рядків там, де файл має 286 і 10. Транскрипт (який сам же
модуль і зберіг) показав причину: escape-послідовності bash
ESC[?2004l і подвоєні \r\r\n від псевдотерміналу — PTY додає свій
\r до пристроєвого \r\n, і кожен рядок подвоювався. Після
виправлення: 285 байтів і рівно 10 рядків, різниця з оригіналом лише у
фінальному переводі рядка, який відрізається свідомо.
Перевірено наживо
завдання в черзі → диспетчер забрав, зонд отримав
зонд зайшов по SSH → виконав команду профілю
вивантажив gzip-стрімом → сервер розпакував, звірив sha256
статус → success, конфіг 285 байтів / 10 рядків
повторний збір → unchanged, другої версії не створено
Чого ще немає
Планувальника за ncm.device_policies.cron — збір запускається лише
вручну або зовнішнім тригером. Тригера за Syslog-подією
(%SYS-5-CONFIG_I). Git-двигуна: конфіг лягає в БД зашифрованим, але
коміту в репозиторій ще немає, тому commit_sha порожній. Візуального
diff у вебі й кнопки «зібрати зараз» в інтерфейсі — API є, сторінки
немає.
2026-08-16 — Візуальний diff конфігів
Збір працював, але подивитись на зібране було ніде: жодної сторінки, а
lines_added завжди 0 — порівняння ніхто не рахував.
Створено
server/internal/difftext/— порядкове порівняння з ділянками й контекстом; 10 тестів.server/internal/store/ncm_read.go— читання версій, розшифровка тіла, порівняння з кешем.- Три ендпоїнти: версії, текст, diff.
web/src/pages/ConfigsPage.tsx— хости зліва, історія й diff справа.
Прийняті рішення
Власне порівняння, а не бібліотека. Потрібен рівно один алгоритм на рядках із виводом у формі, яку розуміє UI. Зовнішня залежність принесла б підтримку слів, символів, кольорів у терміналі — десяток речей, які тут ніколи не знадобляться.
Спільний початок і кінець відкидаються до основного алгоритму. У конфігах змінюється кілька рядків із тисячі; без цього кроку квадратична таблиця будувалася б там, де досить порівняти десяток рядків.
Занадто великі й повністю різні тексти позначаються truncated.
Понад чотири мільйони клітинок — це вже пара конфігів, що розійшлися
повністю, і точність там нічого не дає: людині однаково читати весь
блок. Чесна позначка краща за правдоподібний, але вигаданий diff.
Зміни поруч зливаються в одну ділянку. Інакше сусідні правки давали б дві ділянки з дубльованим контекстом між ними.
Порівняння кешується в ncm.diffs. Diff двох конкретних версій
незмінний назавжди; рахувати його при кожному відкритті сторінки — це
палити процесор на відому відповідь. Підсумок +N/−M заразом
дозаписується у version, щоб список історії не розшифровував два тіла
на кожен рядок.
Порівняння з попередньою версією відкривається одразу. Питання завжди одне — «що змінилось цього разу», — і вибір «з чим порівняти» був би зайвим кроком перед відповіддю.
Перевірено наживо
Створили другу версію, змінивши команду профілю (cat /etc/os-release
→ cat /etc/os-release /etc/hostname):
історія 2 версії, друга is_change=true
diff @@ −8,3 +8,4 @@, один доданий рядок «new-ct» з контекстом
лічильник +1 −0 дозаписався в історію після обчислення
повний текст 292 байти, читається
Чого ще немає
Тригера за Syslog. Git-двигуна: commit_sha поки дорівнює хешу вмісту,
справжнього репозиторію немає. Порівняння з довільною версією в UI (API
вміє через ?from=, кнопки немає).
Планувальник бекапів за cron
Збір конфігів працював, але лише руками. Це не NCM: сенс бекапу в тому, що він робиться сам, а «зайти й натиснути» — це та сама відсутність бекапу, просто з кращим самопочуттям.
Свій парсер cron замість бібліотеки
robfig/cron зробив би це за десять рядків, але тягнув би залежність
заради того, що вміщується в одному файлі й не змінюється з 1975 року.
Розбір — у бітові маски uint64 на поле, пошук наступного запуску —
покроково по хвилинах із запобіжником у чотири роки: перебір дешевший
за арифметику з календарем і не має її крайніх випадків.
Правило dom/dow — об'єднання, не перетин. Якщо задані і день місяця, і день тижня, підходить збіг за будь-яким. Це виглядає неочікувано, доки не спробуєш записати «щоп'ятниці та першого числа» — іншого способу немає.
Неможливий розклад чесно відмовляє. 0 0 30 2 * — 30 лютого не
буває; запобіжник у чотири роки перетворює це на помилку, а не на
зациклений процес. 29 лютого при цьому знаходиться правильно (2028).
11 тестів на парсер пройшли з першого запуску.
Планувальник
Тік раз на хвилину — дрібніше cron не буває. Advisory-блокування
(0x6e70_6263, «npbc»), бо інакше кожен екземпляр сервера в кластері
поставив би своє завдання на той самий хост.
Порядок кроків важливий: спершу перенести next_backup_at, потім
ставити завдання. Падіння між кроками коштує одного пропущеного
бекапу. Зворотний порядок дав би нескінченну чергу однакових завдань —
а це кладе і пристрій, і сервер.
Некоректний cron відсувається на добу, а не пишеться в журнал щохвилини. Скарга має лишитись помітною, але не перетворитись на шум, у якому потонуть справжні помилки.
next_backup_at IS NULL = «час настав». Так виглядає щойно
збережена політика. З цієї ж причини будь-яка зміна політики обнуляє
позначку: розклад міг стати частішим, і чекати за старим було б
неправильно.
Форма
Чотири готові розклади покривають майже все, що справді налаштовують; довільний cron лишився, але не першим, що бачить людина.
Знайдена й виправлена помилка: вибір «свій розклад…» нічого не
робив. Обробник select мав вигляд e.target.value && setCron(...), а
значення цього пункту — порожній рядок, тож умова відсікала саме той
випадок, заради якого пункт існує. Режим тепер тримається окремим
прапорцем, а не виводиться з виразу: інакше поле не відкривалось би й
тоді, коли власний вираз випадково збігся з пресетом.
Перевірено наживо
Розклад * * * * * на stand-host:
trigger | status | створено
schedule | unchanged | 09:39:08
наступний | останній
09:40:00 | 09:20:31
У браузері: вибір «свій розклад…» відкриває поле з поточним виразом;
0 99 * * * відхиляється сервером із текстом «розклад: години: 99 поза
межами 0..23» і модалка лишається відкритою; */30 * * * *
зберігається, і заголовок хоста стає «за розкладом */30 * * * *».
Розклад повернуто на 0 3 * * *.
Етап 6. Шаблони опитування
Досі кожен OID заводився руками на кожному хості. Те, що знімається з Mikrotik, однакове на всіх Mikrotik — але цей факт жив у голові інженера й повторювався стільки разів, скільки в мережі пристроїв.
Схема
tpl.templates → tpl.items → tpl.device_templates. Вбудовані
шаблони — tenant_id IS NULL, той самий прийом, що в ncm.profiles.
Елемент — це одна метрика, а не чек. Тримати в шаблоні «чек» означало б змішати те, що описує людина (метрику), з тим, що вигідно машині (пачку OID в одному PDU).
У tpl.items немає власного tenant_id. Він завжди дорівнював би
шаблоновому, а дублювання ключа ізоляції — це запрошення до
розбіжності. Видимість успадковується від шаблону через EXISTS.
Реконсиляція
Елементи групуються за (шаблон, тип, інтервал) в один snmp.get. Сотня
окремих чеків замість однієї пачки — це сотня SNMP-сесій там, де
досить кількох PDU. Інтервал у ключі групування, бо пачка ходить
цілком.
Порядок ключів фіксується сортуванням. params потрапляє в хеш
плану, і «однаковий шаблон дав інший хеш» через порядок обходу map — це
перезалив плану на кожному тіку звірки.
core.checks.template_id + частковий унікальний індекс. Спільний
checks_uniq містить md5(params), тож будь-яка правка списку OID
виглядала б як новий чек.
ListDeviceChecks і SetDeviceChecks тепер обходять чеки шаблонів.
Інакше вони показувалися б у формі ручних перевірок і зникали на кожне
збереження, щоб за секунду з'явитися знову.
Звірка планів — те, чого бракувало весь час
Чеки міняє REST-процес, а живу сесію зонда тримає AgentService. Досі зміна доїжджала до зонда лише при обриві зв'язку — тобто, за нормальної роботи, ніколи. Тік раз на п'ять секунд звіряє хеш плану в базі з тим, що зараз у зонда, і перезаливає повний план при розбіжності.
Повний план, а не дельта: дельта не знає, що зникло.
Знайдено живими прогонами
Креденшели не їхали разом із планом. Хост, приписаний зонду вже
після його підключення, отримував задачі й падав на кожній із «немає
SNMP-креденшелів». Пачка доступів видається на Hello, а тоді цього
хоста в ній ще не було. Тепер іде разом із планом.
Форма хоста відв'язувала зонд. Поле «Зонд» починалося порожнім, підпис обіцяв «— не змінювати —», а сервер трактував порожнє значення буквально. Будь-яке збереження форми лишало хост у списку й мовчки припиняло його опитувати. Помітили, коли після збереження шаблону в журналі з'явилось «план зонда оновлено, задач: 0». Форма тепер тягне поточний зонд, а підпис каже правду: «— без зонда —».
hrProcessorLoad.1 у вбудованому шаблоні — здогадка, а не адреса.
Це таблиця, індексована процесором; на net-snmp вона віддала «No Such
Instance». Свій же коментар у міграції казав, що неперевірений шаблон
гірший за відсутній — прибрали з базового, лишили у вендорному
Mikrotik, де індекс фіксований.
Перевірено наживо
Шаблон, створений повністю через веб (OID введено без крапки на початку — сервер дописав її сам), прив'язаний до хоста в тій самій формі:
чеки хоста qa-netsnmp 60с qa.users ← .1.3.6.1.2.1.25.1.5.0
snmp-generic 60с sys.uptime_sec ← .1.3.6.1.2.1.1.3.0
snmp-host-resources 300с sys.users; sys.processes (два OID, одна пачка)
план зонда оновлено за секунди, без переп'єднання
метрики sys.uptime_sec = 862334.20 s (snmpget: 9 днів 22:46 — збігається)
sys.processes = 41, qa.users = 0
вбудований DELETE → 403 builtin, PUT → 403 builtin
власний DELETE → 204, породжені ним чеки зникли каскадом
Чого ще немає
Автопризначення шаблону за sysObjectID. Прототипів (шаблон, що
успадковує інший). Тригерів усередині шаблону — поки правила алертів
заводяться окремо. snmp.walk як тип елемента: таблиці з динамічним
індексом (CPU по ядрах, диски, сенсори) шаблон описати не вміє.
Історія метрик і те, що знайшлося дорогою
Система збирала телеметрію в ts.samples і не показувала її ніде: щоб
побачити зібране, треба було йти в psql. Шаблони, зроблені на минулому
кроці, збирали метрики в нікуди.
Джерело обирається за кроком. Читати сирі точки за місяць — це
мільйони рядків заради трьохсот пікселів; брати годинні бакети на вікні
в п'ять хвилин — це графік з однієї точки. Роллапи ts.samples_5m і
ts.samples_1h уже існували з Етапу 1, лишалось ними скористатися.
Крок рахується з бажаної кількості точок, а не приходить ззовні. Інакше вузьке вікно з дрібним кроком повернуло б десятки тисяч точок, з яких екран покаже сотні.
Пропуск і нуль — різні речі. Значення точки — вказівник: лінія, проведена через діру в даних, каже «все було добре», хоча насправді нічого не відомо. Це найгірший різновид брехні в моніторингі.
series_id приходить від клієнта, тож належність перевіряється явно.
Таблиці ts.* не під RLS (несумісно зі стисненням), і без цієї
перевірки чужий ідентифікатор віддав би чужі дані.
Графік без бібліотеки
recharts коштує понад сотню кілобайт стисненого коду, а потрібна з них
одна ламана. SVG до того ж масштабується під будь-який контейнер без
переобчислення на ресайз.
Ряди групуються за одиницею виміру. Відсотки й біти на секунду на спільній осі перетворюють графік на пряму лінію біля нуля.
Пропуск розриває лінію. Кожен відрізок — окремий M…L…, і між ними
лишається порожнеча — саме те, що сталося насправді.
Метрика, яка не буває від'ємною, не отримує від'ємну вісь. Відступ знизу корисний для читабельності, але «−3 % втрат» читається як помилка даних.
Перевірено наживо
6 год крок 72 с, джерело сирі дані
тиждень крок 2016 с, джерело роллап 5m
snmp-host if.in_bps · eth0 = 18.5k bps, sys.processes = 41,
sys.uptime = 87.5M ticks — усе з підписом порту й одиницею
Знайдено живим прогоном: зонд працює рівно годину
Метрики SNMP замовкли о 12:05 — рівно через годину після того, як зонд отримав доступи. У журналі — жодного слова.
CredentialTTL дорівнює годині, і Credentials() свідомо не віддає
прострочені: інакше зонд довбав би комутатори старим паролем і
заблокував обліковий запис. Правильне рішення. Але поновлення не
просив ніхто: повідомлення CredentialRequest є в контракті з Етапу 2,
сервер його обробляє, агент — не надсилає. Тобто будь-яка інсталяція
припиняла збирати SNMP через годину після старту й мовчала про це.
Тепер зонд просить нову пачку за десять хвилин до кінця терміну, не
частіше ніж раз на хвилину. Причина в запиті розрізняє expiring і
expired — за журналом видно, чи встигли.
Шаблон перестав бути «набором OID»
Шаблон описував лише snmp.get. Пінг заводився руками — і це змушувало
пам'ятати, що саме шаблон покриває, а що ні.
Тепер елемент має params (те саме, що лягає в core.checks.params), а
oid і metric_key стали необов'язковими. Пачкою в один PDU
збираються тільки OID: два пінги з різними параметрами — це просто два
пінги. Для негрупованих типів елемент відповідає окремому чеку, і його
слід у core.checks.template_item_key дозволяє впізнати рядок.
Ключ елемента, а не його id. Збереження шаблону перезаписує елементи цілком, тож id живуть недовго, а ключ стабільний за визначенням.
Вбудований шаблон «Доступність (ICMP)» — той, який чіпляють першим.
Обмін шаблонами
Експорт: усі одним файлом, окремий шаблон кнопкою на картці, і ще один вхід — просто з форми редагування. Імпорт: глобальний (файл або вставлений текст) і локальний, що замінює вміст відкритого шаблону.
Формат свій. Zabbix-YAML описує елемент ключем виду snmp.get[...], з
препроцесингом, value maps і тригерами — нічого з цього тут немає, і
вдавати сумісність означало б мовчки втрачати половину імпортованого.
Ідентифікатори з документа прибираються: на іншому стенді вони нічого не значать, а лишені створюють ілюзію, що імпорт «відновить те саме».
Спільний розклад бекапів
Розклад існував лише поштучно: щоб бекапити сто пристроїв, треба було сто разів відкрити форму.
Спільний розклад заводить політику кожному придатному хосту з
follows_default = true. Хост, якому задали власний розклад, прапорець
втрачає.
Прапорець, а не порівняння значень. Власний розклад може випадково збігтися зі спільним, і тоді зміна спільного мовчки потягла б за собою хост, який навмисно налаштували окремо.
Вимкнення спільного зупиняє лише тих, хто йому слідує. Форма показує
following_count і custom_count: без цих двох чисел вона не каже
головного — кого саме зачепить зміна.
Куди йде алерт
Правило вміло сказати «за яких умов», але не «кому». Маршрути
(alr.routes) вирішують інше завдання — спільну політику на всі правила
разом, і для звичайного «це правило важливе, шліть черговому в Telegram»
вони заважкі.
Порядок вирішення: канали правила → маршрути тенанта → усі придатні канали.
Канали правила перекривають маршрути повністю. Інакше «шліть це черговому» перетворювалося б на «шліть це черговому і ще туди, куди вирішить спільна політика» — тобто на щось, чого людина не просила.
Разом із каналами правило отримало тихі години (той самий формат, що в маршрутах — щоб не заводити другий діалект того самого поняття), вибір груп хостів і перемикач повідомлень про відновлення.
Дрібниці, які насправді не дрібниці
Відступи в картках шаблонів. Card навмисно без внутрішнього
відступу — на інших сторінках його діти самі малюють px-4 py-2.5 і
роздільники на всю ширину. На сторінці шаблонів картка — звичайний блок
тексту, і клас загубився. Текст стояв упритул до рамки.
Українська множина. «1 метрик» — дрібниця, з якої складається
враження, що інтерфейс писали не для людей. plural(n, one, few, many)
з правилом 11–14.
Інтервал опитування вводиться руками. Список сам по собі не годиться: рано чи пізно комусь потрібні 45 секунд, і відсутність такої можливості робить продукт «майже підходящим». Ручний ввід сам по собі теж не годиться: у дев'яти випадках із десяти значення є в списку.
Підтвердження показує наслідки, а не питає «Ви впевнені?». «Видалити
групу?» і «Видалити групу? 34 хости втратять межі доступу» — різні
питання, і людина відповідає на них по-різному. Вбудований confirm()
другого не вміє, тому свій діалог.
Кнопки видалення стали кнопками. Сірий ✕ у кутку рядка формально
існував і фактично не знаходився — користувач повідомив, що видалення
користувачів «немає».
«Просить логін після кожного оновлення сторінки»
Дві різні поломки в одному симптомі.
Перша: користувач без пошти не міг поновити сесію взагалі. Разом із
входом за логіном пошта стала необовʼязковою, але RotateSession читав
email::text без COALESCE. Scan падав, помилка перетворювалась на
«сесія недійсна» — і такі люди могли увійти, але після перезавантаження
сторінки летіли на форму входу знову й знову. Живий прогін: свіжий
користувач без адреси, перший же /auth/refresh → 401.
Друга: ротація токена не переживала подвійного обміну. React у режимі розробки виконує ефекти двічі, тож відновлення сесії йшло двома запитами поспіль: перший обмінював токен і відкликав старий, другий приносив уже відкликаний. Це не лише про режим розробки — дві вкладки, відкриті одночасно, дають рівно ту саму гонку в проді.
Тепер core.sessions.replaced_by тримає ланцюг, і щойно відкликаний
токен веде до свого наступника. Вікно — 30 секунд: вистачає на
подвійний обмін від того самого клієнта й замало для реального повтору
перехопленого токена, той приходить хвилинами пізніше. Перевірено: обмін
старим токеном одразу → 200, він же через дві хвилини → 401.
Плюс засувка на клієнті: відновлення сесії рівно одне на завантаження сторінки.
Однакові відступи
Сторінки розповзлися: одні малювали тіло з p-3 md:p-4, інші клали
p-4 на внутрішній блок, треті не мали відступу взагалі — і текст стояв
упритул до бічної панелі.
PageBody тепер один на всіх: відступи, прокрутка й min-h-0 в одному
місці. Заміряно в браузері: усі десять сторінок дають рівно 16 px від
краю.
Мапа: видалення й вигляд вузла
Видалення мапи — кнопка була відсутня, хоча ендпоїнт існував із Етапу 4. З підтвердженням, яке пояснює, що саме зникне: схема, а не хости й дані.
Форма вузла. Картка, пігулка або крапка. Крапка потрібна не для краси: на схемі з сотні вузлів важливо бачити стан усіх, а не читати сотню підписів. Плюс товщина рамки, колір заливки, приховування підпису й цифр пінга.
Аварія й попередження світяться. Рамка іншого кольору не помітна
периферійним зором, а ореол (box-shadow у два шари) видно навіть на
віддаленому масштабі, коли підписи вже не читаються. Червоний лишається
тільки за обривом — якщо ним підсвічувати ще й «невідомо», оператор
перестане на нього реагувати.
Дашборди
Схема core.dashboards і core.dashboard_widgets лежала з Етапу 1 і не
мала жодного рядка коду. Тепер має.
Сітка — дванадцять колонок. 12 ділиться на 2, 3, 4 і 6, тож половина, третина й чверть ширини задаються цілими числами, а не «41.6 %».
Плитки замінюються цілком. Дашборд редагується як єдине полотно, і часткові оновлення дали б спосіб отримати розкладку, якої людина не бачила.
Перший дашборд стає головним сам. Інакше людина створює його й не розуміє, чому головна сторінка досі порожня.
Кожна плитка вантажить своє. Спільний завантажувач на дашборд виглядав би охайніше, але змусив би плитку «текст» чекати на запит метрик сусідки.
Види: графік, число, шкала, список алертів, сітка хостів, текст. Перевірений список видів дублюється на сервері навмисно — збережена плитка, яку ніхто не малює, виглядає як зламаний дашборд.
Права окремі від мап. Дашборд збирає дані з усього тенанта, і «може дивитись мапу» не означає «може дивитись зведення по всіх майданчиках». Видані тим ролям, які вже мають відповідний рівень доступу до мап.
Esc і клік повз вікно
Жодна модалка не закривалася нічим, крім хрестика.
Слухач Esc — на document, а не на панелі: фокус може стояти в полі
вводу, у випадному списку або взагалі ніде, і вимагати спершу «влучити»
кудись — це рівно те, чого від Esc не чекають.
Клік повз панель закриває за mousedown, а не за click: інакше
виділення тексту, доведене мишею за межі вікна, закривало б форму разом
із набраним.
Заголовок вікна став липким: у довгих формах (шаблон із десятком метрик) хрестик інакше їхав за межі екрана.
Редагування там, де його не було
Канали сповіщень взагалі не редагувалися — лише створювались і видалялись. Вид каналу лишається незмінним: telegram із конфігом вебхука — це інший обʼєкт, і чесніше завести новий.
Протокол доступу тепер змінюється. З одним застереженням: секрет зашифрований під видом старого протоколу, тож при зміні пароль треба ввести заново. Сервер відмовляє з поясненням, кнопка в формі теж не дає зберегти — краще сказати це до спроби, ніж після.
Картка хоста в стилі Zabbix
Форма була однією довгою простинею з інтервалами опитування посередині. Стала вкладками: Хост, Шаблони, Доступи, Збір конфігів, Ручні перевірки — у порядку частоти звернень, а не у порядку появи в коді.
Інтервалів опитування тут більше немає. Вони живуть у шаблонах. Два джерела правди про одне число рано чи пізно розійдуться, і розійдуться саме тоді, коли треба буде швидко зрозуміти, чому хост опитується не так, як написано.
Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек.
Перевірено: створений через форму хост дістав icmp.ping з інтервалом
30 с саме від шаблону, разом із групою, обраною в пікері.
Групи, шаблони й доступи обираються пошуком. Список галочок працює на пʼяти позиціях і перестає працювати на пʼятдесяти. Обране показане чипами зверху, тож вибір видно цілком, не гортаючи. Backspace у порожньому полі прибирає останній чип — так поводяться всі поля з тегами, і руки це вже знають.
Ручні перевірки лишились окремою вкладкою. Вони потрібні для одиничних випадків, але це вже не типовий шлях, і в першому екрані їм нема чого робити.
Графіки описуються в шаблоні
«Завантаження каналу — це вхід і вихід на одній осі» — властивість класу пристроїв, а не окремого хоста. Малювати той самий графік руками на кожному означає повторювати одне рішення стільки разів, скільки в мережі заліза.
tpl.graphs тримає назву, вид і перелік ключів метрик, а не
посилань на елементи шаблону: графік має право показувати й те, що
прийшло з іншого шаблону — саме так виглядає «трафік поруч із помилками
на тому ж порту».
Види: лінії, з заливкою, з накопиченням, стовпчики, шкала, число.
Накопичення обирає людина, а не евристика. Сума має сенс там, де вона сама щось означає — трафік по портах, місце на дисках. На відсотках завантаження це була б нісенітниця.
Жорсткі межі осі з шаблону перекривають пораховані. «Завантаження від 0 до 100» має виглядати однаково на всіх хостах, інакше графіки не порівняти очима.
Графік без жодного знайденого ряду не показується. Порожня рамка з підписом «Памʼять» на хості, де памʼять не збирається, — це обіцянка даних, яких немає.
Перевірено наживо
snmp-host «Час роботи» (stat) → 877.6k s
gateway «Втрати пакетів» (area) → вісь жорстко 0–100 з шаблону
gateway «Час відгуку» (line) → дві лінії, вісь 0–2.6 з даних
Знайдено живим прогоном: полотно вбивало інтерфейс
Після додавання групового виділення сторінка перестала реагувати —
Maximum update depth exceeded у SelectionListener. React Flow
викликає onSelectionChange на кожному рендері, а мій обробник щоразу
створював новий масив: колбек міняв стан сторінки, сторінка
перемальовувала полотно, полотно кликало колбек. Цикл зʼїдав головний
потік, і мертвими ставали всі сторінки, не лише мапа.
Тепер набір віддається назовні лише коли справді змінився — порівняння за склеєним рядком ідентифікаторів.
Вигляд мапи
Схема виглядала як сітка сірих прямокутників на чорному. Причини були конкретні, і кожна лікується окремо.
«? → ?» на кожній лінії. Так виглядав підпис ребра, коли автовиявлення ще не зіставило порти. Читалося це як зламаний інтерфейс, хоча означало лише «поки невідомо». Тепер невідомий порт не друкується взагалі — порожнеча чесніша, вона нічого не обіцяє. Так само зі швидкістю: «— з 10.0 Гбіт/с» зникає, поки трафік не виміряний.
Вузол став карткою з ієрархією. Градієнт замість пласкої заливки, кольорова смуга стану зліва, значок і підпис в одному рядку, крапка стану праворуч. Смуга несе стан навіть тоді, коли рамку перефарбували під майданчик: колір рамки — рішення про схему, колір смуги — факт про пристрій.
Пульсація лише за обривом. Рух там, де все гаразд, навчає не дивитись на рух узагалі.
Порти показуються на наведення. Вісім завжди видимих кружечків на кожному вузлі перетворюють схему на россип точок.
Фон — радіальна підсвітка й дві сітки. Дрібна дає відчуття масштабу, велика — орієнтацію; одна або рябить, або не читається, залежно від кроку, а крок задає людина.
Специфічність довелося підняти. Стилі React Flow приходять з .tsx і
потрапляють у документ пізніше за index.css, тож одного класу було
замало — правила стали .react-flow.netpulse-canvas.
Вбудовані шаблони редагуються
Раніше відповіддю було «вбудований не редагується — зробіть копію». Це межа нашої моделі даних, а не відповідь на питання людини, яка хоче підправити OID під своє залізо.
Тепер правка вбудованого шаблону створює власну копію тенанта з тим самим ключем, а список показує її замість оригіналу. Оригінал лишається недоторканим для решти тенантів. Форма попереджає про це прямим текстом до збереження.
Плюс окрема кнопка «Копія» — для випадку, коли потрібні обидва варіанти: «як було» і «як хочу». Ключ отримує суфікс, бо копія з тим самим ключем сховала б оригінал.
Ключі елементів і графіків виводяться на сервері. Прямий виклик API
без key давав 500 від перевірки домену core.slug. Ключ потрібен базі,
але жодного рішення не несе — вимагати його від клієнта означало
відповідати помилкою там, де можна просто зберегти.
Вибір шаблонів картками
Пошуковий рядок із чипами добре працює для груп — там важлива лише назва. Для шаблонів важливо ще й що всередині: скільки метрик, для якого виробника, що воно робить. Список чипів цього не показує, тож людина відмічала навмання й ішла перевіряти на іншу сторінку.
Тепер картка з назвою, описом і лічильником метрик. Обрані спливають угору — інакше після вибору картка лишається десь у середині списку, і незрозуміло, чи вибір зарахувався.
Мобільна сітка карток
На телефоні картка вилазила за екран і обрізалась. Причина не в
брейкпойнтах: елемент грід-сітки типово має min-width: auto й не
стискається нижче ширини вмісту. min-w-0 на картці — і 410 px стали
351 px рівно за шириною сітки.
Межа помилок навколо полотна
Цикл рендеру в React Flow одного разу вже поклав увесь застосунок: мертвими стали всі сторінки, і людині лишалося перезавантажити вкладку. Причину виправлено, але наслідок треба було обмежити — тепер полотно падає саме, а навігація й решта інтерфейсу лишаються живими.
Етап 8. Реєстрація зонда
Найбільше вузьке місце до запуску: агент заводився INSERT-ом у базу, а
токен вписувався в командний рядок руками. Поставити зонд у клієнта було
неможливо — людина зі стійки не має доступу до бази й не повинна його
мати.
Одноразове запрошення
core.agent_enrollments: sha256 токена, підказка імені, набір модулів,
строк. Токен існує рівно один раз — у відповіді на створення.
Уся видача в одній транзакції під FOR UPDATE. Два агенти,
стартовані з однієї скопійованої команди, інакше створили б два зонди з
одного запрошення. Перевірено живим прогоном: другий отримує
PermissionDenied.
Ім'я підбирається суфіксом, а не відмовою. Людина, яка ставить п'ятий агент на однакових машинах, не має вигадувати імена — вона хоче, щоб він просто запрацював.
Знайдено живим прогоном
Реєстрація не проходила автентифікацію. Мій же коментар стверджував,
що окремий gRPC-сервіс сам собою виводить виклик з-під інтерсептора. Це
неправда: інтерсептор реєструється на весь сервер. Перший же запуск
агента дав Unauthenticated: немає токена зонда — на виклику, яким
токен і видається. Виняток тепер заданий явно, повним префіксом сервісу.
Запуск із самим посвідченням падав. validate() вимагав -agent-id,
не знаючи про файл посвідчення, тож агент із валідним
/etc/netpulse/agent.json відмовлявся стартувати. Перевірка переїхала в
main, де враховані всі три джерела: прапорці, файл і реєстрація.
Керування зондом
Ім'я, модулі й ліміти правляться з картки. Форма каже прямим текстом, що зміни доїдуть при наступному підключенні: мовчазна затримка виглядає як «не зберіглося».
Ліміти зливаються в наявний jsonb через ||, а не заміняють його: там
можуть лежати поля, яких форма не знає, і затирати їх мовчки — найшвидший
спосіб зламати те, чого не бачив.
Видалення зонда лишає хости без зонда, а не видаляє їх: інакше заміна заліза, на якому стояв агент, коштувала б усієї історії.
Перевірено наскрізно
запрошення POST /agent-enrollments → np_enr_…, модулі [icmp snmp]
реєстрація agent -enroll → «зареєстровано як "QA зонд"»
посвідчення /tmp/np-agent-id.json, права 0600, токен np_agt_…
сесія встановлено одразу після реєстрації
повтор той самий токен → PermissionDenied
перезапуск лише -identity, без токена → та сама сесія
керування PATCH модулі й ліміти → 204, значення застосовані
Зв'язки на мапі
Порти на вузлі
Чотири боки, на кожному пара портів з однаковим ім'ям: l, r, t,
b. React Flow розрізняє джерело й ціль за типом, тож одне ім'я обслуговує
обидва напрямки. Розійдуться імена (наприклад l і l-s) — збережене
ребро не знайде пари, і лінія просто не намалюється, без жодної помилки.
Обгортка портів — Fragment, а не <span>: <span> стає елементом
flex-розкладки й розсуває вміст вузла. Чотири такі обгортки з'їдали підпис,
і на схемі лишались картки без імен.
Бік підключення
Лінія без явної прив'язки більше не чіпляється до лівого краю навпомацки.
autoSides() рахує бік із взаємного розташування вузлів: більша різниця по
X — пара «праворуч → ліворуч», більша по Y — «знизу → зверху». React Flow
сам найкоротший бік не шукає.
Бік правиться в інспекторі: авто | ліворуч | праворуч | зверху | знизу.
Три стани в патчі, а не два — порожньо не чіпає, auto знімає прив'язку,
решта задає конкретний бік:
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 — зелені