Netpulse_SasS/HISTORY.md
byrsapty cedf1d261d
All checks were successful
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m18s
CI / server (push) Successful in 1m50s
CI / agent (push) Successful in 1m2s
Ескалації: журнал більше не бреше, ack не воскрешає драбину
Три вади, знайдені рецензією, яких щасливий шлях показати не міг:

* outcome='sent' писався до доставки; помилка читання каналів клала в
  кеш порожню мапу й з'їдала всі сходинки кабінету за тік — усі зі
  слідом «надіслано». Канали тепер читаються до просування стану,
  журнал пишеться після доставки, з правдою.
* UPDATE не мав stopped_at IS NULL — підтвердження алерту посеред
  партії не рятувало людину від дзвінка.
* час брався раз на партію.

Плюс суміжне: UpdateRule не гасив алертів вимкненого правила, сервер
домислював enabled на оновленні, channel_ids сходинок не звірялись із
каналами кабінету (зокрема чужого).

І scripts/dbtest.sh — тести проти бази перестали мовчки пропускатись.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 17:20:18 +03:00

541 KiB
Raw Blame History

NetPulse — журнал розробки

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


2026-08-14 — Етап 1: схема БД

Створено

netpulse/
├── HISTORY.md                    ← цей файл
├── docker-compose.yml            TimescaleDB 2.17/pg16 + DragonflyDB
└── db/
    ├── README.md                 ERD, ключові рішення, ізоляція тенантів
    ├── migrate.ps1               накат міграцій + schema_migrations
    └── migrations/
        ├── 0001_core.sql                 tenants, users, RBAC, secrets, audit
        ├── 0002_inventory.sql            sites, devices, interfaces, credentials
        ├── 0003_agents_plugins.sql       plugins, agents, check_types, checks, outbox
        ├── 0004_topology.sql             neighbors, links, maps, nodes, edges, backgrounds
        ├── 0005_telemetry_timescale.sql  hypertables, CAGG, compression, retention
        ├── 0006_ncm.sql                  repos, profiles, jobs, configs, diffs, rollback
        ├── 0007_alerting.sql             rules, alerts, channels, routes, maintenance
        ├── 0008_dashboards.sql           dashboards, widgets, SLA
        ├── 0009_billing_licensing.sql    plans, subscriptions, entitlements, invoices, licenses
        ├── 0010_seed.sql                 довідники
        └── 0011_rls.sql                  Row Level Security

Прийняті архітектурні рішення

  1. Фізична топологія ≠ візуальна. topo.links (що є в мережі) окремо від topo.map_edges (як намальовано). Один лінк — на багатьох мапах.
  2. Зв'язки port→port через FK на inv.interfaces, не текстом. Пара нормалізована через LEAST/GREATEST, щоб A→B і B→A не дублювались.
  3. Автовиявлення в два кроки: сирі topo.neighbors (LLDP/CDP/ARP/FDB + confidence) → резолвер → topo.links. Прапорець is_pinned захищає ручні лінки.
  4. Дві моделі метрик: узагальнена ts.series+ts.samples (плагіни реєструють metric_key без DDL) і широкі ts.icmp_samples/ts.if_counters для гарячих шляхів мапи.
  5. Анімація трафіку має ланцюг даних: if_counters.util_out_pct → view topo.link_livemap_edges.animation.
  6. Ліміти тарифу перевіряються двічі: bill.entitlements в API + тригери в БД.
  7. Секрети лише шифровані (core.secrets: ciphertext/nonce/auth_tag/key_id, AES-GCM-256, DEK у KMS).
  8. Тіло конфігів у Git, метадані в БД (ncm.configs.commit_sha + content_hash).
  9. RLS за замовчуванням на кожній таблиці з tenant_id; порожній app.tenant_id → порожній результат.

Проблеми, на які натрапив (щоб не повторювати)

  • Порядок seed ↔ RLS. Спершу RLS був 0010, seed 0011 — це ламається: після FORCE ROW LEVEL SECURITY навіть власник схеми не вставить довідники з tenant_id IS NULL. Файли переставлені місцями.
  • CAGG не працюють у транзакції. CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) падає всередині транзакційного блоку. migrate.ps1 детектить це по вмісту файлу й вимикає --single-transaction для 0005.

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

  1. window — зарезервоване слово. core.sla_targets.windowperiod_kind.
  2. core.check_types.key був доменом core.slug, але ключі мають вигляд icmp.ping — крапка не проходить. Замінено на text з власним CHECK ^[a-z0-9]+(\.[a-z0-9_]+)+$; так само core.checks.check_type.
  3. Домен core.slug не пропускав підкреслення, а ключі фіч — http_checks, auto_discovery. Регекс розширено до [a-z0-9_-].
  4. RLS блокує не CAGG, а стиснення. Початкове припущення «CAGG + RLS несумісні» виявилось хибним. Реальна відмова TimescaleDB 2.29: operation not supported on hypertables that have columnstore enabled — тобто конфлікт саме з compression. Тому в 0011 виключено всі hypertables (через timescaledb_information.hypertables), а не три захардкоджені.

Перевірено на живому стенді

Debian 13 (LXC, 192.168.1.203) / PostgreSQL 17.11 / TimescaleDB 2.29.1. Усі 11 міграцій — на чисту БД без помилок. Створено: 81 таблиця, 12 hypertables, 6 continuous aggregates, 25 фонових job-ів, 63 RLS-політики, 220 індексів.

db/tests/smoke.sql — 8 функціональних перевірок, усі PASS: нормалізація пари лінків, CHECK на device-ноду, дедуплікація алертів, ліміт пристроїв тарифу Free, стан полотна мапи одним запитом, ребро port→port з живим util_pct, роллап CAGG, ізоляція тенантів RLS.

Бази на сервері: netpulse (схема + smoke-дані). Перестворити: sudo -u postgres dropdb netpulse && createdb -O netpulse netpulse, далі міграції.


2026-08-14 — Етап 2 (частина 1): protobuf-контракт агент↔сервер

Створено

netpulse/
├── buf.yaml, buf.gen.yaml
├── proto/
│   ├── README.md              контракт: сервіси, потоки, семантика, безпека
│   └── netpulse/v1/
│       ├── common.proto       Status, Transport, Error, DeviceTarget, Credential, AgentHealth
│       ├── agent.proto        EnrollmentService, AgentService, ControlUp/ControlDown
│       ├── telemetry.proto    SeriesDescriptor, MetricSample, IcmpResult, InterfaceCounters
│       ├── discovery.proto    NeighborRecord, InterfaceRecord, DiscoveredDevice
│       ├── ncm.proto          ConfigJob, ConfigUpload (header/chunk/trailer), ConfigApplyJob
│       └── logs.proto         SyslogEntry, SnmpTrap, LogBatch
├── gen/go/                    згенерований код (комітиться)
└── test/contract/             наскрізні gRPC-тести на bufconn

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

  1. Форма контракту випливає з одного обмеження: усі з'єднання ініціює агент. «Команда з сервера» — це повідомлення у зустрічному напрямку вже відкритого агентом bidi-стріму Control, а не RPC у бік агента.
  2. Чотири окремі стріми (Control / Telemetry / Logs / Config), а не один: пачка семплів не має блокувати heartbeat, сплеск syslog під час аварії не має топити телеметрію.
  3. Інтернування серій. Агент реєструє серію раз під series_ref, далі шле лише номер. Заміряно: 66 → 25 байт на семпл. series_ref живе в межах сесії.
  4. Швидкості рахує агент (лише він знає точний інтервал опитування), але шле й сирі лічильники — щоб сервер міг перерахувати заднім числом.
  5. Scrub/redact конфігів — на сервері. Агент віддає сирий текст; правила живуть у ncm.profiles і змінюються без оновлення агентів у полі.
  6. Топологію зводить сервер. Агент доповідає лише «на порту X бачу chassis Y».
  7. Task.params_json — непрозорі байти. Новий плагін не потребує зміни .proto. Модуль-виконавець виводиться з префікса check_type до крапки.
  8. At-least-once + upsert. Дедуплікацію дають PK схеми БД (ts, device_id) тощо.
  9. Зворотний тиск диктує сервер у Welcome і кожному TelemetryAck.
  10. Самооновлення підписане Ed25519 — інакше компрометація CDN = RCE в мережі кожного клієнта.

Перевірено на стенді

Debian 13 (192.168.1.203): protoc 3.21.12, Go 1.24.4, buf 1.72.0.

  • protocусі 6 файлів валідні;
  • buf lint (STANDARD) — без зауважень;
  • генерація Go+gRPC, go build, go vet — чисто;
  • go test ./test/contract/...5/5 PASS: рукостискання й push плану задач, інтернування серій, реакція на невідомий series_ref, чанкування конфігу зі звіркою sha256, відмова при пошкодженій контрольній сумі.

Дрібниця для наступного разу: buf.yaml довелось звільнити від RPC_REQUEST_STANDARD_NAME та сусідніх правил — вони припускають пари запит-відповідь, а ControlUp/ControlDown це незалежні потоки подій.


2026-08-14 — Етап 2 (частина 2): Go-агент

Створено

netpulse/
├── .gitignore, .gitattributes    (репозиторій: LF усюди, крім .ps1)
└── agent/
    ├── README.md                 будова, рішення, параметри чеків
    ├── go.mod                    replace → ../gen/go
    ├── cmd/netpulse-agent/       точка входу, GOMEMLIMIT, keepalive
    └── internal/
        ├── config/               прапорці + NETPULSE_*, mTLS
        ├── module/               контракт модуля, реєстр, маршрутизація
        ├── telemetry/            interner.go (series_ref) + buffer.go
        ├── scheduler/            min-heap, семафор, schedule_offset
        ├── session/              gRPC-клієнт, реконект, ack, план задач
        └── modules/icmp, /snmp

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

  1. Модулі вкомпільовані, без динамічного завантаження. Один бінарник має працювати на Alpine, Windows і роутері з musl. «Активація» = дозвіл сервера.
  2. Креденшели беруться на момент виконання, не з плану: у них TTL, і прострочені не віддаються взагалі — інакше агент заблокує обліковий запис на половині комутаторів клієнта.
  3. Розклад вирівняний по сітці інтервалу, тому після рестарту задача повертається у свій слот, а не з'їжджає.
  4. Буфер викидає найстаріше. Після відновлення зв'язку цінніший поточний стан. Зміни статусу викидаються останніми й пролазять у батч першими.
  5. Швидкості інтерфейсів рахує агент (знає фактичний інтервал); при перевороті лічильника — counter_reset замість стрибка на терабіт.
  6. Один писар у контрольний стрім — gRPC не допускає паралельних Send.
  7. GOMEMLIMIT 48 МБ у коді: хай GC працює агресивніше, ніж OOM killer осліпить моніторинг саме тоді, коли він потрібен.

Перевірено на стенді

Debian 13, Go 1.25.13. go vet чисто, go test ./... -raceусі пакети ok. Релізний бінарник (CGO_ENABLED=0 -trimpath -s -w): 12 МБ, базовий RSS 11.6 МБ у циклі реконекту (бюджет 30 МБ).

Не перевірено

  • TestPingLoopback під звичайним користувачем в unprivileged LXC пропускається: ядро не дає ані unprivileged-, ані raw-сокета, sysctl ping_group_range недоступний. Під root на тому ж стенді тест проходить — ICMP-модуль перевірений проти реального сокета. У проді потрібен CAP_NET_RAW.
  • Модуль snmp не перевірявся проти живого пристрою — на стенді немає SNMP-агента. Компілюється й проходить vet; логіка перевороту лічильників і util_pct чекає на реальне обладнання.

Репозиторій

https://git.zotac.keenetic.link/zotac/Netpulse_SasS.git (Forgejo). Читання анонімне, push вимагає токена — Forgejo не пускає навіть у публічний репозиторій без автентифікації (Credentials are incorrect). Коміти лежать локально в main і чекають на токен.


2026-08-14 — Етап 2 (частина 3): серверна сторона AgentService

Створено

netpulse/server/
├── README.md                  рішення, параметри, стан перевірки
├── cmd/netpulse-server/       TLS, keepalive, m'яка зупинка, keyring із -dek
└── internal/
    ├── crypto/                AES-GCM-256, keyring із ротацією ключів
    ├── store/                 agents, plan, credentials, telemetry, discovery, ncm
    └── grpcapi/               AgentService + перехоплювачі автентифікації

Плюс правки в агенті: -token і передача його в метаданих кожного виклику; Interner.MarkAllPending() — перереєстрація серій на початку сесії.

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

  1. Ізоляція тенантів робиться двічі: RLS (SET LOCAL app.tenant_id) плюс явний предикат tenant_id. Не перестраховка: RLS не працює на гіпертаблях, а саме туди йде вся телеметрія.
  2. schedule_offset — чиста функція від check_id, тож будь-який вузол сервера дає те саме значення.
  3. Хеш плану — лише з полів, що впливають на поведінку. Зміна опису пристрою не змушує переливати 50 000 задач.
  4. Токен каже, ЯКИЙ це зонд; сертифікат — що він має право говорити. Чужий agent_id при валідному токені → PermissionDenied.
  5. DEK не покидає сервер. Дамп БД без ключів не дає жодного пароля. Комплект креденшелів із TTL 1 год.
  6. Запис телеметрії — ON CONFLICT DO NOTHING (at-least-once).
  7. Статус пристрою — один запит із умовним записом в історію, інакше два воркери наввипередки писали б неіснуючі переходи up→up.
  8. Впевненість зіставлення спадає за надійністю ознаки: chassis-id 95 → MAC 90 → IP 80 → sysName 60. Останнє низьке навмисно: sysName вводить людина.
  9. Перереєстрація серій замість обнулення нумерації. Спершу агент мав би скидати interner на реконекті, але це викидало б увесь накопичений за час обриву буфер — саме ті дані, заради яких він накопичувався.

Перевірено на стенді

11 інтеграційних тестів проти живої БД зі схемою Етапу 1 і справжнього gRPC — усі PASS з -race. Найцінніше: повтор батчу не дублює ані рядки, ані переходи в історії; зустрічний звіт B→A не створює другий лінк; ручний (is_pinned) лінк не затирається; тіло конфігу лежить зашифрованим.

Живий наскрізний прогін (справжній агент + справжній сервер + БД, 40 с, icmp.ping кожні 5 с проти 127.0.0.1): 5 ICMP-семплів, метрики icmp.rtt_avg 0.108 мс / jitter / loss, статус unknown → up з причиною icmp і рівно одним переходом в історії, heartbeat із RSS 11.6 МБ і dropped_samples=0, зонд позначений offline після зупинки, clock skew 0.9 мс.

Знайдено під час перевірки

Приведення типу на місці ($1::text) не розв'язує конфлікт виведення типів у Postgres, а нав'язує тип обом уживанням параметра. Коли $1 потрібен і як uuid для колонки, і як text для конкатенації, кастувати треба протилежне уживання: VALUES ($1::uuid, …, '/шлях/' || $1 || '.git'). Та сама пастка двічі: у сіді тесту (VALUES ($1, $1, …) для core.slug і text) і в ncm.repos.

Чого ще немає

  • Git-двигун (libgit2): тіло конфігу шифрується в core.secrets, commit_sha тимчасово = hex контентного хеша. Дедуплікація й prev_config_id працюють, тож diff будується вже зараз.
  • EnrollmentService — зонди заводяться вставкою в core.agents.
  • Сервер не надсилає TaskDelta (лише повний план) і не ініціює ConfigJob.

2026-08-14 — Етап 2 (частина 4): модуль topology, автовиявлення наскрізь

Створено

agent/internal/snmpx/                 спільний SNMP-транспорт + розбір індексів OID
agent/internal/modules/topology/      LLDP, CDP, ARP, FDB + інвентар портів
server/cmd/netpulse-secret/           заведення шифрованих секретів із CLI

Плюс: scheduler.OnDiscovery і TriggerNow, доставка звітів у session, обробка DiscoveryRequest, реєстрація модуля в main.

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

  1. SNMP-транспорт винесено в snmpx — ним користуються два модулі, а два незалежні набори однієї логіки це два незалежні набори багів.
  2. Звіти автовиявлення йдуть окремим RPC, не телеметричним стрімом: вони рідкі, великі й не прив'язані до моменту часу так, як метрики.
  3. Черга звітів коротка й витісняє найстаріший. Знімок топології актуальний рівно доти, доки описує поточний стан; накопичувати застарілі немає сенсу.
  4. ifHighSpeed має пріоритет над ifSpeed: 32-бітне поле впирається в 4.29 Гбіт/с, і на 10G порт показував би неправильний знаменник для util_pct.
  5. ifName перекриває ifDescr: саме ifName віддає LLDP як port-id, тому саме за ним зійдеться лінк.
  6. netpulse-secret як окремий інструмент — секрети не можна вставити звичайним SQL, а UI ще немає.

Знайдено живим прогоном (обидва — справжні помилки)

  1. Префікс типу чека не збігався з ключем модуля. У сіді було topo.discover при плагіні topology і ssl.expiry при плагіні http. Агент маршрутизує задачі саме за префіксом, тож зонд відхилив би їх як адресовані неіснуючому модулю. Перейменовано на topology.discover і http.ssl_expiry, а інваріант закріплено обмеженням у БД check_types_prefix_matches_plugin — щоб наступна така неузгодженість не доїхала до поля.
  2. Унікальний індекс topo.neighbors схлопував ARP-сусідів. Ключ складався з chassis_id + port_id, яких в ARP і FDB немає взагалі: з двох сусідів на одному порту зберігався один. Додано remote_mac до індексу.

Перевірено проти справжнього SNMP-агента

На стенді піднято snmpd + lldpd (LLDP-MIB через AgentX). Живий прогін агент → сервер → БД:

  • інвентар портів зі справжнього ifTable: lo і eth0, MAC bc:24:11:07:68:67, 10 Гбіт/с саме через ifHighSpeed;
  • 2 сусіди зі справжньої ARP-таблиці, обидва з локальним портом eth0;
  • шлюз 192.168.1.1 зіставлено за MAC із впевненістю 90;
  • лінк snmp-host:eth0 → gateway зведено, capacity_bps 10 Гбіт/с;
  • три проходи поспіль — лінк один, дублікатів немає;
  • snmp.get збирає sys.uptime, помилок чеків немає.

Усі три набори тестів (агент, сервер, контракт) проходять з -race.

Не перевірено

  • LLDP і CDP на живих сусідах. lldpd зареєстрував MIB, але сусідів немає — поруч немає другого пристрою, що шле LLDP. Розбір lldpRemTable і cdpCacheTable покритий лише тестами на індекси OID; ARP-гілка того самого коду перевірена на живих даних.
  • snmp.if: сервер поки не генерує для нього перелік інтерфейсів, тому чек нікуди не призначається.

2026-08-14 — Етап 2 (частина 5): автостворення snmp.if-чеків

Створено

server/internal/store/autochecks.go — з виявлених інтерфейсів формується snmp.if-чек і одразу штовхається живій сесії як TaskDelta.

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

  1. Чек оновлюється, а не задвоюється. Унікальний індекс core.checks включає md5(params), тому наївний upsert плодив би новий рядок на кожну зміну складу портів. Шукаємо існуючий чек за (device_id, 'snmp.if').
  2. Склад портів порівнюється як множина. Postgres не гарантує порядок ключів у jsonb — пряме порівняння рядків давало б хибну зміну на кожному обході, і агент отримував би новий план щоразу.
  3. Без SNMP-креденшела чек не створюється: він лише щохвилини писав би помилку автентифікації. Інтерфейси при цьому все одно зберігаються — вони потрібні мапі незалежно від того, чи є чим їх опитувати.
  4. Loopback і notPresent відсіюються — графіка не дають, місце в PDU займають. Ліміт 256 портів на чек: далі опитування не вкладається у власний таймаут.
  5. Зміна штовхається живому зонду. Чекати наступного перепідключення — це години порожніх графіків після кожного нового комутатора.

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

OID у запиті без провідної крапки, а pdu.Name — з нею. Пошук у мапі результатів мовчки нічого не знаходив, і чек виглядав як «жоден інтерфейс не відповів». snmp.get працював, бо там уже була нормалізація, а snmp.if — ні. Канонізацію винесено в snmpx.Normalize і застосовано з обох боків у GetUints.

Перевірено наскрізь проти справжнього SNMP

Агент і сервер запущені як є, без жодного ручного кроку між ними:

  • topology.discover знайшов lo і eth0inv.interfaces;
  • сервер створив snmp.if-чек на 1 порт (loopback відсіяно), 10 Гбіт/с;
  • дельта доїхала до живої сесії (надісланоаживо: true);
  • агент опитав справжні HC-лічильники: in_octets 625 246 266, in_bps 11 938;
  • util_out_pct 0.000001 % — знаменник із ifHighSpeed;
  • помилок чеків немає.

Це повний шлях даних для анімації трафіку на мапі. Усі три набори тестів проходять з -race.


2026-08-14 — Етап 3: REST/WebSocket API для UI

Створено

server/
├── API.md                        ендпоїнти, протокол WebSocket, приклади
├── cmd/netpulse-api/             окремий процес: HTTP + WS
└── internal/
    ├── httpapi/server.go         роутер, Bearer-автентифікація, обробники
    ├── httpapi/ws.go             hub, насос подій, насос завантаження каналів
    └── store/
        ├── maps.go               стан полотна з живими статусами
        ├── events.go             читання core.event_outbox
        ├── inventory.go          пристрої, зонди
        └── apitokens.go          автентифікація токенів UI

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

  1. API — окремий процес від AgentService. Зонди й браузери мають різні профілі навантаження, периметри й цикли релізів. Спільний лише шар store.
  2. Стан мапи віддається одним викликом разом зі статусами. Без цього мапа малювалася б сірою й доганяла кольори сотнею дозапитів.
  3. link_status виводиться з кінців лінка, а не читається з колонки. topo.links.status ніхто не підтримує; писати туди означало б оновлювати всі лінки пристрою на кожну зміну статусу. Стан лінка — похідна величина.
  4. Події пишуться тією ж транзакцією, що й зміна. Інакше WebSocket міг би розповісти про перехід, якого в базі ще (або вже) немає.
  5. Опитування outbox замість LISTEN/NOTIFY. NOTIFY не переживає падіння підписника й обмежений 8 КБ; тут потрібна гарантія, що зміна статусу не загубиться між перезапусками API.
  6. Дві частоти розсилки: статус — подія (миттєво), завантаження — величина (раз на 5 с). Частіше за оновлення лічильників (60 с) — це та сама цифра по колу.
  7. Токен WebSocket їде підпротоколом, бо браузер не дозволяє довільні заголовки; в URL він не потрапляє, а отже й у логи проксі.
  8. Підписник, що не встигає читати, відключається, а не гальмує решту.

Перевірено

10 інтеграційних тестів проти живої БД, справжнього HTTP і WebSocket — усі з -race. Найцінніші: TestWebSocketDeliversStatusChange проганяє справжній батч телеметрії через applyDeviceStatus → outbox → hub → браузер; TestLinkStatusFollowsEndpoints перевіряє, що лінк червоніє від падіння кінця або порту без змін у topo.links.

Живий прогін — агент, netpulse-server і netpulse-api разом проти справжнього snmpd: мапа з 2 вузлами up (RTT 0.113 і 0.827 мс), ребро з портом eth0, лінк=up, живий util_pct, зонд online з RSS 11.6 МБ і dropped_samples=0.

target_port порожній — і це правильно: лінк знайдено через ARP, а ARP не повідомляє порт віддаленої сторони.

Чого ще немає

  • Усі ендпоїнти read-only: редактор мапи потребує PATCH з оптимістичним блокуванням за revision (колонка є, обробника немає).
  • Немає GET /api/v1/metrics для графіків і віддачі alr.alerts.
  • Автопобудова мапи з topo.links — поки SQL-скрипт, не кнопка.

2026-08-14 — Етап 3 (частина 2): запис у мапу

Створено

server/internal/store/maps_write.go    патч полотна, знімки, автопобудова
server/internal/httpapi/maps_write.go  POST/PATCH/DELETE + /build

Ендпоїнти: POST /api/v1/maps, PATCH /api/v1/maps/{id}, DELETE /api/v1/maps/{id}, POST /api/v1/maps/{id}/build.

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

  1. Усі скалярні поля патча — вказівники; nil означає «не чіпати». Перетягування шле лише x/y, і якби відсутні поля трактувались як порожні, кожен рух миші стирав би стиль, розмір і прив'язку до пристрою.
  2. Ребро може посилатися на вузол, створений тим же патчем, за client_id — інакше зв'язок до нового вузла вимагав би двох запитів і проміжного стану.
  3. Оптимістичне блокування за revision. Той, хто спізнився, отримує 409, а не тихо затирає чужу правку: у NOC над однією мапою працюють кілька людей.
  4. Знімок пишеться тією ж транзакцією, що й зміна. Інакше після збою в історії лишався б крок, якого в мапі немає, і відкат ламав би її. Зберігаються останні 50.
  5. Невідоме поле в тілі — 400. Мовчки проковтнути друкарську помилку клієнта означає, що правка «збереглася», але не застосувалась.
  6. Автопобудова ідемпотентна: наявні вузли не дублюються, координати не чіпаються — інакше кожен запуск скидав би ручну розкладку.
  7. Ліміт тарифу → 402, а не 500: UI має показати пропозицію змінити тариф.
  8. map.updated несе лише ревізію, не патч. Розсилати дельти означало б тримати на сервері модель того, що бачить кожен клієнт — це вже CRDT, окрема задача.

Знайдено тестами

revision <= $2 - $3 з двома нетипізованими параметрами дає operator is not unique: unknown - unknown. Postgres не може вивести оператор віднімання, коли обидва операнди — плейсхолдери. Потрібні явні касти.

Перевірено

23 інтеграційні тести API (усі з -race), з них 12 нових на запис. Найцінніші: драг не затирає сусідні поля; друга вкладка зі старою ревізією отримує 409, а перша правка ціла; видалення вузла не лишає ребер у нікуди; повторна автопобудова не скидає ручну розкладку.

Живий прогін проти даних, які агент зібрав сам: створення мапи → автопобудова (+2 вузли, +1 ребро) → драг (ревізія 3) → патч зі старою ревізією (409) → повторна побудова (+0/+0) → координати x=1500 y=640 збережені, 2 знімки в історії.


2026-08-14 — Етап 4: фронтенд, мапа в браузері

Створено

web/
├── README.md
├── package.json, vite.config.ts, tsconfig.json
└── src/
    ├── types.ts              типи API
    ├── api/client.ts         REST + ApiError (isConflict/isPlanLimit)
    ├── api/ws.ts             WebSocket із реконектом
    ├── hooks/useLiveMap.ts   завантаження, живі оновлення, запис
    └── components/           MapCanvas, DeviceNode, TrafficEdge

React 18 + React Flow 12 + Tailwind 4 + Vite 6. Збірка 345 КБ (112 КБ gzip).

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

  1. Сервер — джерело істини, крім моменту перетягування. Поки вузол тягнуть, позицію диктує миша; синхронізація пропускає вузли з dragging.
  2. Зберігаємо на відпусканні й лише якщо вузол зрушив.
  3. Успішний патч застосовується до локального стану — інакше вузол «повертається» при першій же події.
  4. Конфлікт ревізій не приховується: 409 → повідомлення + перечитування.
  5. Швидкість анімації обернено пропорційна завантаженню, при нулі анімації немає взагалі.
  6. Стан з'єднання завжди на екрані: замерзла мапа виглядає як здорова.

Знайдено роботою з живим UI (обидві виправлені)

  1. Ревізія росла від самих кліків. За час, поки я робив скріншоти, мапа пройшла 2 → 5: React Flow віддає onNodeDragStop на будь-яке натискання, і кожен клік писав порожню ревізію, змушуючи всі відкриті полотна перечитуватись.
  2. Вузол відкочувався після збереження. Хук оновлював лише номер ревізії, тож наступна подія device.status перебудовувала список зі старими координатами.

Перевірено в браузері проти повного стека

Агент + сервер + API + справжній snmpd: два зелені вузли з живим RTT (0.05 і 1.04 мс), ребро eth0 → ? із <0.01% з 10.0 Гбіт/с, індикатор «наживо, 1 с тому», зонд probe-snmp linux/amd64 з RSS 11.1 МБ, кнопка «Добудувати з топології» додала 2 вузли й 1 ребро з topo.links.

Обмеження перевірки

  • Драг не відтворюється автоматизацією: синтетичні події вказівника не запускають d3-drag у React Flow. Шлях «драг → PATCH → БД» покритий тестами сервера, але саме через UI лишається неперевіреним автоматично.
  • Ребра не малюються у прихованій вкладці: React Flow міряє вузли в requestAnimationFrame, який там не викликається. Вузли рендеряться, бо це звичайний DOM. Скріншот у згорнутій панелі непридатний для перевірки.

2026-08-14 — Етап 4 (частина 2): редактор доведено до робочого стану

Створено

server/internal/store/maps_undo.go       відкат до попереднього знімка
server/internal/httpapi/maps_write.go    POST /maps/{id}/undo
web/src/components/MapCanvas.tsx         onConnect, onDelete
web/src/hooks/useLiveMap.ts              undo + canUndo

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

  1. Відкат — нова ревізія, а не відмотування лічильника. Інакше клієнт із номером 10 після повернення до 9 отримав би «свою» ревізію знову актуальною й тихо перезаписав відкочене.
  2. Ідентифікатори вузлів зберігаються при відкатіребра прив'язуються назад самі, а виділення в UI й зовнішні посилання не ламаються.
  3. Порядок відновленняребра геть → вузли геть → вузли назад → ребра назад: зовнішні ключі не дозволяють інакше.
  4. Намальоване рукою ребро не прив'язується до topo.links: лінія на полотні — це подання, а не факт про мережу. Автовиявлення прив'яже саме.
  5. Видалення не перелічує ребра вузла: на полотні їх прибирає React Flow, у базі — каскад FK.

Знайдено флак у тестах (виправлено)

TestSchedulerRunsTaskAndFillsCredentials падав приблизно раз на п'ять під навантаженням: тест перевіряв канал статусів знімком (for len(ch) > 0), а STATE_SUCCEEDED надсилається вже ПІСЛЯ запису в sink. На завантаженій машині проміжок розширювався. Замінено на очікування з дедлайном; 8 прогонів під штучним навантаженням — 0 падінь.

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

Повний ланцюг проти працюючого стека: намальовано зв'язок (ребер 1 → 2) → відкат (2 → 1) → видалення вузла (2 вузли → 1, ребер 0) → відкат повернув той самий id вузла, ребро й живий стан лінка. UI показує обидві кнопки й підказку про жести.

Далі

План на наступні етапи винесено в окремий документ — ROADMAP.md. Коротко: користувачі й права (схема є, коду немає) → алерти → шаблони опитування (нова підсистема tpl.*) → керування зондом із UI → NCM до кінця → мобільний адаптив і PWA.


2026-08-15 — Етап 5: користувачі, вхід і права

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

Створено

  • db/migrations/0012_auth.sql — три RLS-політики винятку для шляху входу, гіпертаблиця core.login_attempts, два індекси на core.sessions.
  • server/internal/auth/password.go — argon2id (2 проходи, 64 МБ, 32 байти), вивід у форматі PHC, звірка через subtle.ConstantTimeCompare.
  • server/internal/auth/token.go — власний HS256 JWT: Sign, Verify.
  • server/internal/store/users.go — автентифікація, сесії, членства, команда.
  • server/internal/httpapi/principal.goPrincipal замість старого authenticated; людина й машина зводяться до одного набору прав.
  • server/internal/httpapi/auth.go, users.go — 9 нових ендпоїнтів.
  • server/cmd/netpulse-user/main.go — CLI для першого власника.
  • web/src/api/session.ts, components/LoginPage.tsx, переписані client.ts і App.tsx.
  • server/internal/httpapi/auth_test.go — 11 тестів.

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

Заголовок JWT звіряється байт у байт, а не парситься. Класична атака підміни алгоритму (alg: none, alg: HS256 замість RS256) можлива лише там, де сервер питає токен, яким алгоритмом його перевіряти. Тут константа {"alg":"HS256","typ":"JWT"} порівнюється як рядок — питати нічого й нема в кого. Підпис перевіряється до строку дії: інакше протермінований підроблений токен відрізнявся б за текстом помилки від протермінованого справжнього.

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

Access-токен живе в замиканні модуля, не в localStorage. З localStorage його забирає будь-який XSS; із замикання — ні. Ціною є втрата токена при перезавантаженні сторінки, тому на старті робиться тихий refresh по кукі.

Refresh-токен ротується. Стара сесія відкликається, видається нова. Це не дає викраденій кукі жити паралельно з живою: другий власник отримає 401 no_session — крадіжка стає видимою подією, а не тихою.

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

Невідомий email і невірний пароль нерозрізненні — однаковий код і однаковий час: на неіснуючому користувачі спалюється фіктивна перевірка argon2id. Без неї різниця в часі сама розказує, які адреси зареєстровані.

Три обмеження вшито в API, а не в UI: роль owner не видається через HTTP (лише CLI), не можна змінити роль собі, не можна прибрати себе. Усі три захищають від одного — організації без жодного власника.

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

Знайдено при написанні

FORCE ROW LEVEL SECURITY робив вхід неможливим за побудовою: щоб знайти користувача за email, треба знати тенант, а тенант відомий лише після того, як користувача знайдено. Виправлено трьома політиками, які пускають SELECT тільки коли core.current_tenant() IS NULL — тобто рівно на шляху входу, де тенанта ще нема. Читання під уже виставленим тенантом лишається обмеженим як було.

Перевірено

  • go test ./... -race — 37 тестів httpapi, з них 11 нових; go vet чисто.
  • tsc --noEmit чисто, vite build — 353 КБ JS (114 КБ gzip).
  • Живий прогін проти netpulse_it: 8 кроків від входу власника до відкликаної сесії — усі коди очікувані (розписано в API.md).
  • У браузері: форма входу → інженер входить → мапа з живими RTT і «наживо». Під глядачем кнопки редагування зникають, полотно нередаговане, блок зондів не показується.
  • Мобільний вигляд (375×812): бічна панель повністю за кадром, полотно на всю ширину, горизонтального скролу немає; бургер висуває панель поверх полотна із затемненням.

Обмеження перевірки

Вкладка браузера прихована, тому CSS-переходи стоять на currentTime: 0 — виміри бічної панелі робилися після примусового getAnimations().finish(). Це артефакт середовища, не застосунку.

Сторінки керування командою в UI ще немає — є API й методи клієнта (team, roles, addMember, setRole, removeMember). Додати користувача поки можна лише CLI або запитом.


2026-08-15 — Етап 9: алерти й сповіщення

Система вміла малювати мапу, але мовчала, коли щось падало. Схема (alr.*) лежала готовою з Етапу 1 і повністю порожньою. Тепер вона працює.

Створено

  • server/internal/store/alerts.go — правила, обчислення умов (icmp / interface / metric / no_data), побудова предикатів селектора.
  • server/internal/store/alerts_state.go — життєвий цикл алерту, придушення, граф топології й визначення першопричини.
  • server/internal/store/alerts_query.go — списки, ack, mute, CRUD правил.
  • server/internal/store/alerts_channels.go — канали, маршрути, тихі години, журнал доставки.
  • server/internal/alerting/engine.go — цикл обчислення під advisory-блокуванням.
  • server/internal/alerting/notify.go — Telegram, webhook, SMTP, шаблони повідомлень, SSRF-захист.
  • server/internal/httpapi/alerts.go — 12 ендпоїнтів.
  • web/src/hooks/useAlerts.ts, components/AlertsPanel.tsx, шина подій у api/ws.ts.
  • 20 нових тестів (11 у store, 9 в alerting).

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

Движок не має стану між тіками. Вікно for_seconds — це запит по часу до TSDB, а не лічильник у пам'яті. Тому перезапуск процесу нічого не збиває, а два процеси дали б однаковий результат. Лічильник у пам'яті довелося б і зберігати, і відновлювати, і синхронізувати між екземплярами — три способи розійтися з реальністю замість нуля.

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

Список метрик і операторів закритий. Значення з condition потрапляє в текст запиту (агрегатну функцію не підставиш параметром), тому все, що йде в SQL, проходить whitelist, а все інше — лише параметром. Послаблення тут — це SQL-ін'єкція через JSON у таблиці правил.

Кореляція за межею зони недоступності. Причина аварії — той, у кого лишився живий сусід; хто оточений мертвими — наслідок. Це те, заради чого будувалась topo.links: інакше падіння маршрутизатора дає сорок сповіщень, серед яких губиться єдине потрібне. Пристрій без зв'язків завжди лишається причиною — топологія про нього нічого не знає, і списати його на чужу аварію було б вигадкою.

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

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

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

Тиха година не глушить disaster. Сенс чергування в тому, щоб його підняли.

SSRF-захист із виходом для self-hosted. Адресу вебхука задає користувач тенанта, а запит іде з сервера — у SaaS це класичний вектор. Але в self-hosted вебхук майже завжди веде саме всередину, у корпоративний Mattermost. Тому заборона знімається прапорцем процесу, і рішення приймає адміністратор сервера, а не користувач тенанта.

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

Знайдено роботою з живою системою (три справжні помилки)

Вимкнене або видалене правило лишало свої алерти висіти вічно. alr.alerts.rule_id має ON DELETE SET NULL, а вимкнене правило випадає з вибірки движка — в обох випадках алерти ставали сиротами, яких нікому закрити. Виправлено закриттям алертів у тій самій транзакції, до видалення правила.

Перехід у «придушено» не публікував події. Людина глушила пристрій, движок за 10 секунд переводив алерт у suppressed, а UI про це не дізнавався до перезавантаження сторінки. Причина: подія публікувалась лише для нових і закритих алертів. Додано alert.updated і читання попереднього стану через CTE у тому ж знімку, що й UPSERT — інакше відрізнити «стан змінився» від «проблема триває» неможливо.

Кнопки дій на телефоні були 26 px. Виміряно в мобільному вигляді: для «Прийняти» і «Заглушити», які натискають пальцем уночі, це промахи. Піднято до 38 px на вузьких екранах, на десктопі щільність збережено.

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

Проти справжніх даних (2 пристрої, 240 ICMP-вимірів за 10 хв):

  • Правило з порогом 0.5 мс підняло алерт на gateway (0.98 мс) і не підняло на snmp-host (0.08 мс) — поріг рівно посередині.
  • no_data і «помилки на порту» мовчали, як і мали.
  • Вебхук дійшов: 5 доставок на 5 подій, жодного повтору за багато тіків.
  • Заглушення пристрою придушило рівно його алерт, сусідній лишився активним.
  • Вимкнення правила закрило його алерти й не зачепило чужий ack.
  • Глядач отримує 403 на ack і 200 на читання.
  • У браузері: індикатор пульсує червоним, панель відкривається, ack оновлює список наживо, придушений алерт іде під окремий фільтр.
  • Мобільний вигляд 375 px: панель на весь екран, горизонтального скролу немає.

Чого свідомо не робив

Ескалацій (alr.escalation_policies) і повторних сповіщень немає — алерт сповіщає один раз. Кнопки Ack/Mute у Telegram відмальовуються, але приймача callback_data не написано. Web Push відкладено до Етапу 10 разом із PWA. Правила з джерел syslog/trap/ncm движок пропускає: вони обробляються подіями, а не опитуванням.


2026-08-15 — Етап 5+: повноцінний веб

Питання «а повноцінний веб коли?» було справедливим. До цього моменту «веб» — це був один екран: логін, мапа й панель алертів. Ані навігації, ані сторінок; усе, що вміло API, доводилось викликати curl-ом. Цей етап закриває розрив між готовим бекендом і тим, що людина може натиснути.

Створено

  • web/src/components/AppShell.tsx — каркас: бічна навігація, шапка, індикатор зв'язку, лічильник алертів.
  • web/src/components/ui.tsx — примітиви: DataTable, Modal, Button, Field, StatusBadge, Toggle, Card.
  • Сім сторінок у web/src/pages/: MapPage, DevicesPage, AlertsPage, RulesPage, ChannelsPage, AgentsPage, TeamPage, ProfilePage.
  • web/src/hooks/useLiveRefresh.ts — перечитування зі злиттям сплеску.
  • react-router-dom як залежність; App.tsx переписано на маршрути.

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

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

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

Таблиця на телефоні перестає бути таблицею. Горизонтальний скрол на 375 px — це спосіб зробити дані формально присутніми й фактично нечитабельними. Тому DataTable малює картки, а другорядні колонки позначаються hideOnMobile, щоб картка лишалась короткою.

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

Знайдено роботою з живим UI (три справжні помилки)

WebSocket жив усередині мапи. Найсерйозніша з трьох. Сокет створював useLiveMap, тому на кожній сторінці, крім мапи, живих оновлень не було взагалі: лічильник алертів у шапці замерзав, щойно людина йшла з мапи, і показував стан на момент входу. Помітно це стало лише тоді, коли сторінок стало більше однієї. З'єднання винесено в модуль-одинак live, яким володіє оболонка; useLiveMap тепер лише слухає спільну шину. Заодно стан зв'язку видно з будь-якої сторінки — замерзлий інтерфейс виглядає точно так само, як справний.

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

Канал із секретом показувався як «секрету немає». HasSecret ставився всередині гілки розшифровки, а перелік для UI викликається без ключа навмисно. Наявність секрету — факт про канал, а не наслідок того, чи його зараз читають.

Перевірено в браузері проти повного стека

  • Усі вісім сторінок рендеряться з живими даними: 2 пристрої, 5 правил, 1 канал, 1 зонд, 4 учасники.
  • Створення правила через форму: правил 4 → 5, умова записалась коректно (loss_pct > 5, всі виміри 120 с).
  • Створення користувача через форму: учасників 3 → 4.
  • Кнопка «Перевірити» на каналі — пробне повідомлення дійшло у приймач.
  • Глядач: у меню 5 пунктів замість 7, кнопки «+ Правило» немає, усі 10 перемикачів заблоковані, /team прямим посиланням дає екран із поясненням.
  • Мобільний 375 px: меню закрите, бургер висуває, перехід закриває його сам; таблиці стають картками; горизонтального скролу немає.
  • Мапа після переносу сокета працює: вузли з живим RTT, «наживо», лічильник часу оновлюється.

Чого ще немає у вебі

Дашбордів і графіків історії (потрібен GET /api/v1/metrics, якого немає), сторінки налаштувань зонда, редагування правила (лише створення й вимкнення), маршрутів сповіщень і вікон обслуговування (є в схемі й у движку, у UI — ні), NOC-режиму на телевізор.


2026-08-15 — Вхід за логіном, групи хостів і права доступу

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

Створено

  • db/migrations/0013_groups_login.sqlcore.users.username, core.user_groups, core.user_group_members, core.group_permissions, функції core.device_access_level і core.accessible_devices, RLS на нові таблиці.
  • server/internal/store/groups.go — CRUD груп, хостів і обчислення Scope.
  • server/internal/httpapi/groups.go — 11 ендпоїнтів.
  • web/src/pages/GroupsPage.tsx — групи хостів і груп доступу на одному екрані.
  • web/src/components/NodeInspector.tsx — підпис, значок, розмір, ширина, колір, закріплення вузла.
  • Форма хоста (створення й редагування) і фільтр за групами на сторінці хостів.
  • Створення мапи з транслітерацією slug.

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

Логін замість пошти. У мережевій інсталяції половина облікових записів технічні — noc, monitoring, oncall, — і скриньки не мають узагалі. Пошта лишилась необов'язковим полем для сповіщень. Сервер шукає за обома, тому людину, яка за звичкою ввела email, ніхто не відхиляє.

Наявним користувачам логін вивели з пошти, а збіги розвели суфіксом за порядком створення. Мовчки злити admin@a.com і admin@b.com в один логін було б не міграцією, а втратою акаунта. Механізм одразу знадобився: на стенді жили залишки від тестових прогонів, і admin дістався саме їм.

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

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

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

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

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

Знайдено й виправлено

Перемикач вилазив за межі треку. Скарга була «вигляд глюкнутий»; вимір показав причину: у ручки не заданий left, тож вона стає на статичну позицію, а та у <button> зсунута типовим text-align: center. Обчислений left виходив 18px замість 2px, ручка виступала на 14px і накривала сусідній хрестик.

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

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

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

Прогін проти справжнього стенду:

вхід за логіном 'admin'          200, роль owner
вхід тим самим, але поштою       200 — сумісність збережена
дві групи хостів                 201/201
хости розкладено                 gateway→Магістраль, snmp-host→Доступ
новий хост через API             201
група доступу для 'eng'          лише «Доступ», рівень read
що бачить eng                    snmp-host і test-host, обидва writable=false;
                                 gateway зник із вибірки
алерти під eng                   1 замість 3
eng редагує чужий хост           403

У браузері: вхід логіном (у шапці «admin», не пошта); перемикачі вміщаються в трек в обох станах із відступом 2px; правила оновлюються 2 → 0 → 2 без перезавантаження; сторінка груп показує обидві половини; інспектор вузла змінив підпис, значок і ширину до 220px із ревізією 13 → 14; створення мапи дало slug kyiv-iadro з «Київ ядро» і порожнє полотно.

Чого ще немає

Динамічних груп (inv.device_groups.kind='dynamic' у схемі є, правило відбору не читається), успадкування прав між групами, редагування груп хостів після створення (лише створення й видалення), масових операцій над хостами.


2026-08-15 — Опитування хоста з форми й редагування користувачів

Зауваження було точним: «додаєш хост — чому не можна вказати, як його опитувати». Форма створення хоста, яку я зробив раніше, збирала назву, адресу, тип і групи — і не збирала головного. Хост, доданий через UI, не опитувався взагалі: жодного рядка в core.checks. Наявні два хости на стенді працювали лише тому, що їхні перевірки я засіяв через SQL.

Створено

  • server/internal/store/checks.go — типи перевірок, перевірки хоста, доступи до обладнання.
  • server/internal/httpapi/checks.go — 5 ендпоїнтів.
  • web/src/components/ChecksEditor.tsx — редактор опитування, який будує поля параметрів із params_schema, що віддає сервер.
  • UpdateUserProfile у store + розширений PATCH /api/v1/team/{id}.
  • EditUserForm на сторінці «Команда».

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

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

Поля параметрів будуються з JSON Schema типу. Захардкодити їх у фронтенді означало б забувати оновити його щоразу, коли плагін додає новий тип. Сервер уже віддає params_schema — форма її і читає.

Правка перевірки йде за id. Унікальний індекс checks_uniq включає md5(params), тому «видалити й вставити» на зміні інтервалу спрацювало б, а на зміні параметрів створило б ДРУГУ перевірку того самого типу.

Профіль редагується лише в того, хто працює тільки в цій організації. core.users глобальна, і логін із паролем — власність людини, а не тенанта. Адмін філії, який змінює пароль тому, хто тим самим акаунтом заходить у сусідню організацію, ламає їй доступ там, і вона про це не дізнається. Спроба дає 409 shared_user; роль і групи локальні й редагуються завжди.

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

Знайдено при написанні

core.plugins не має колонки enabled — активація на тенанта живе в core.plugin_installs. Перший варіант запиту падав із column p.enabled does not exist. Заодно з'ясувалося, що plugin_installs порожня, тому доступність рахується як «плагін базовий АБО явно ввімкнений»: вимагати «встановлення» для пінга означало б зустрічати кожного нового клієнта порожнім списком перевірок.

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

типи перевірок                    8 доступних із 9 (modbus не базовий і не встановлений)
доступ SNMP створено              201
хост із трьома перевірками        201
що записалось                     icmp.ping 30 с, snmp.get 60 с, snmp.if 300 с
правка інтервалу і зняття однієї  10 с, params {"count": 5}, лишилось 2 — без задвоєння
хост без перевірок                створюється, перевірок 0
профіль + пароль змінено          204
вхід новим паролем                200
вхід старим паролем               401 bad_credentials
перейменування логіна             204, вхід новим логіном 200
спроба змінити роль собі          403

У браузері: форма нового хоста показує icmp.ping із полями count і packet_size, узятими зі схеми, і список із семи інших доступних типів; доданий через UI хост записав icmp.ping з інтервалом 30 с і snmp.get із двома OID.

Чого ще немає

Форми створення доступу (SNMP-community) у вебі — доступи заводяться через API, а у формі хоста лише прив'язуються. Перевірок на рівні групи хостів (у Zabbix це шаблони — Етап 6). Історії й графіків за зібраними метриками.



2026-08-15 — Каталог команд збору конфігу

Щоб знімати конфіги, треба знати команду для кожної платформи: у Cisco це show running-config, у Huawei display current-configuration, у MikroTik export, а Eltex SMG узагалі віддає конфіг через cat. Зібрав це в каталог.

Створено

  • db/profiles/catalog.json — 147 платформ, 67 вендорів: команда збору, команда startup-конфігу, підготовка консолі, скільки службових рядків відкинути, альтернативи для інших моделей родини.
  • db/profiles/build.py — збірка міграції з каталогу, з режимом --check.
  • db/profiles/README.md — будова каталогу й як додати платформу.
  • db/migrations/0014_ncm_profiles.sql — згенерований результат.

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

Каталог — джерело істини, міграція породжується. Два описи одного й того самого розійшлися б із першою ж правкою, і невідомо було б, який справжній. Тому міграція має в шапці «ФАЙЛ ЗГЕНЕРОВАНО», а build.py --check уміє звірити, чи вона не відстала.

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

Родина CLI, а не вендор. bdcom, arista, brocade і ще з десяток говорять діалектом Cisco; h3c і 3com — діалектом Huawei. Тримати промпт і вимкнення пейджера один раз на родину — різниця між правкою в одному місці й правкою в сорока.

Пейджер додається лише мережевому CLI. Частина «мережевих» пристроїв знімає конфіг шелом (cat /etc/config/cfg.yaml в Eltex SMG і TAU, tail в Eltex RG). Відправити в bash terminal datadump означало б гарантовану помилку на кожному зборі.

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

Перевірено

Міграція накочена на netpulse_it: 148 профілів (147 із каталогу плюс раніше засіяні), 67 вендорів, дублікатів немає — унікальний індекс ncm_profiles_key_uniq спрацював, а ON CONFLICT DO NOTHING зберіг наявні. Вибірково звірено складні випадки: Cisco ASA (more system:running-config без службових changeto system), NXOS, FWSM, Huawei VRP3, H3C, Juniper JUNOSe, Eltex SMG/TAU (без пейджера).

build.py --check після збірки каже «міграція актуальна» — генерація детермінована.

Чого це ще не дає

Виконувати профілі нікому. Агентського модуля ncm (SSH/Telnet) немає — це Етап 7. Зараз це готові дані, які чекають на виконавця.


2026-08-16 — Етап 7: збір конфігів запрацював

Профілі з попереднього кроку були даними без виконавця. Тепер ланцюг замкнено: кнопка в UI → черга → диспетчер → зонд → SSH → назад у БД.

Створено

  • agent/internal/ncmx/ — знімання конфігу по CLI: session.go (розбір потоку), transport.go (SSH і Telnet), collect.go (виконання завдання).
  • agent/internal/session/config_jobs.go — приймання ConfigJob і вивантаження стрімом.
  • server/internal/store/ncm_jobs.go — черга, побудова завдання з профілю й доступу, закриття.
  • server/internal/grpcapi/ncm_dispatch.go — диспетчер.
  • 16 тестів на розбір консолі.

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

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

Промпт шукається лише в хвості накопиченого (512 байтів). Конфіг може містити рядок, схожий на запрошення — banner motd # трапляється в кожній другій мережі, — і пошук по всьому тексту обривав би збір на середині.

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

Черга в БД між процесами. REST і AgentService — різні процеси; живу сесію зонда тримає лише другий. FOR UPDATE SKIP LOCKED не дає двом екземплярам надіслати одне завдання двічі.

Ключі SSH мережевого обладнання не звіряються. Свідомо: залізо перегенеровує ключ після кожної заміни прошивки, і known_hosts на сотні пристроїв означав би або вимикати перевірку щотижня, або не збирати конфіги зовсім. Захист тут дає сегмент керування, а не TOFU. Переліки алгоритмів навмисно широкі — інакше половина парку відпаде з «no common algorithm».

Знайдено живим прогоном (три справжні помилки)

Прогін ставили проти самого стенду: у нього є SSH, і cat /etc/os-release віддає текст так само, як консоль віддає конфіг.

Сервер ігнорував поле encoding. Агент стискав тіло gzip і чесно рахував sha256 від оригіналу, а сервер рахував від стиснених байтів — і відхиляв кожен бекап як «тіло не відповідає заявленому sha256». Поле було в контракті з Етапу 2, реалізації не було ніколи: інтеграційний тест користувався encoding: "none" і повз цю дірку проходив.

Промпт обрізався не по рядку. Типовий шаблон [>#]\s*$ збігається лише з символом запрошення, тому в конфізі лишалось ім'я пристрою окремим рядком: «…interface Gi0/1» + «sw1». Ріжемо весь рядок.

У конфіг потрапляло сміття терміналу. Перший успішний збір дав 295 байтів і 12 рядків там, де файл має 286 і 10. Транскрипт (який сам же модуль і зберіг) показав причину: escape-послідовності bash ESC[?2004l і подвоєні \r\r\n від псевдотерміналу — PTY додає свій \r до пристроєвого \r\n, і кожен рядок подвоювався. Після виправлення: 285 байтів і рівно 10 рядків, різниця з оригіналом лише у фінальному переводі рядка, який відрізається свідомо.

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

завдання в черзі          →  диспетчер забрав, зонд отримав
зонд зайшов по SSH        →  виконав команду профілю
вивантажив gzip-стрімом   →  сервер розпакував, звірив sha256
статус                    →  success, конфіг 285 байтів / 10 рядків
повторний збір            →  unchanged, другої версії не створено

Чого ще немає

Планувальника за ncm.device_policies.cron — збір запускається лише вручну або зовнішнім тригером. Тригера за Syslog-подією (%SYS-5-CONFIG_I). Git-двигуна: конфіг лягає в БД зашифрованим, але коміту в репозиторій ще немає, тому commit_sha порожній. Візуального diff у вебі й кнопки «зібрати зараз» в інтерфейсі — API є, сторінки немає.


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

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

Створено

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

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

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

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

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

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

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

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

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

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

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

Чого ще немає

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


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

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

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

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

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

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

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

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

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

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

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

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

Форма

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

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

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

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

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

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


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

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

Схема

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Чого ще немає

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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


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

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

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

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

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


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

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

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

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

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

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


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

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

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

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

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

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


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

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

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


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

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

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

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


Дашборди

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

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

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

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

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

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

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


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

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

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

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

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


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

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

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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


Вигляд мапи

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

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

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

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

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

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

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


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

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

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

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

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


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

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

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


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

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


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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Полотно

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

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

Хибний слід

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

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

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

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

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

Пакування

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

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

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

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

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

Стек

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

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

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

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

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

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

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

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

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

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

Бекап

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

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

CI

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

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

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

Перевірено

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

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

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

Git-двигун NCM

Сховище

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

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

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

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

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

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

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

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

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

netpulse-gitsync

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Групи

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

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

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

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

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

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

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

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

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

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

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

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

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

Транспорт

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Режим NOC TV

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Автопризначення шаблонів за sysObjectID

Пристрій сам каже, що він таке

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

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

Збіг за префіксом, і чому саме так

Виробник кодує в sysObjectID і серію, і модель: у Cisco це 1.3.6.1.4.1.9.1.<модель>, і моделей там тисячі. Вимагати повного збігу означало б заводити рядок на кожну коробку — тобто ту саму ручну роботу, від якої тікаємо.

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

Дванадцять вбудованих правил: Net-SNMP, UCD, Windows, Cisco, Huawei, MikroTik, D-Link, Eltex, HP, Netgear, Dell, Juniper.

Тільки додаємо

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

Порожнє поле в системній групі не стирає те, що знали: пристрій міг не віддати частину через ACL на своєму SNMP-агенті.

Проto довелося розширити

DiscoveredDevice описував знахідку сканування й не мав device_id. Зіставляти за адресою не можна: за одним NAT цілком може стояти кілька хостів із тією самою адресою опитування — на стенді це буквально так. Додано поле 12, згенеровано buf.

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

Ізольований стек на власній UTF8-базі, щоб не чіпати дані стенда:

зонд          зареєстровано, план 1 задача (topology.discover)
опитування    net-snmp на 127.0.0.1
системна група sysObjectID = 1.3.6.1.4.1.8072.3.2.10
               sysDescr = Linux new-ct 6.17.13-2-pve…
зіставлення   1.3.6.1.4.1.8072 → шаблон snmp-generic
сервер        «шаблони призначено за sysObjectID: хостів 1, призначено 1»
наслідок      план зонда виріс 1 → 3 задачі: чек шаблону створився сам
ідемпотентність 4 звіти автовиявлення — призначення рівно одне

Дорогою

Дубль перевірки (той самий тип і інтервал на одному хості) давав «внутрішню помилку» замість пояснення. Тепер 409 із текстом.

І втретє за сесію наступив на pkill -f: шаблон збігається з рядком власної ssh-сесії й тихо вбиває її. Правильна форма — pkill -x за іменем процесу, як і написано в коментарі до np-restart.sh, який я сам колись і писав.

Профілі збору конфігів: редагування й створення

Що таке профіль

Чотири питання: чим під'єднатись, які команди віддати, як упізнати запрошення командного рядка й що з відповіді викинути перед порівнянням версій.

Вбудованих 147 — під більшість заліза, яке трапляється. Але прошивки різні: show running-config на одній моделі вимагає ще й terminal length 0, а на іншій не існує зовсім. Досі виправити це було ніяк — профілі жили лише в базі.

Вбудований не правиться, а перекривається

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

Збереження вбудованого створює власну копію з тим самим ключем. Вона перекриває оригінал у виборі профілю — resolveProfile уже сортує ORDER BY tenant_id NULLS LAST, тож нічого дописувати не довелося. Оригінал лишається недоторканим і видимим.

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

Приклади, а не порожня форма

Профіль — це три регулярні вирази й перелік команд. Порожня форма з такими полями не підказує нічого.

Тому: вибір «почати зі зразка» з трьома робочими заготовками (Cisco IOS, MikroTik RouterOS, Linux), кнопка «За зразок» на кожному з 147 вбудованих профілів, і приклад у placeholder кожного поля. Плюс підказка, що саме поле означає — «за виробником профіль сам підбирається хосту», «дужками позначається те, що лишиться після маскування».

Вирази компілюються при збереженні

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

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

Видалення профілю, яким користуються хости, попереджає скільки їх і що збір зупиниться. Політики при цьому лишаються без профілю (ON DELETE SET NULL) — це чесніше за мовчазну підміну на інший.

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

перелік       148 профілів, 147 вбудованих
власний       створено, прочитано назад з командами й маскуванням
перекриття    копія з ключем cisco-ios → два рядки,
              власний перший, вбудований лишився
захист        видалити вбудований → 403 з поясненням
валідація     без команд → 400; зламаний вираз → 400 з текстом помилки;
              транспорт http → 400
інтерфейс     вкладка «Профілі» поруч із «Конфіги» й «Відповідність»,
              пошук за назвою/виробником/командою, «лише власні»,
              «Cisco IOS» зі зразка заповнив усі шість полів

Транспорт вирішує доступ, а не профіль

Питання, яке виявило помилку

«Чому всі готові профілі — ssh? Хіба не має бути telnet?»

Сам список правильний: ssh у всіх 147 вбудованих — це не недогляд, а розумний типовий вибір. Помилка була в тому, що саме це поле робило.

buildConfigJob брав транспорт із профілю моделі. А оскільки профіль описує модель, а не конкретну коробку, виходило так: хост зі старою прошивкою, де є лише telnet, і з чесно заведеним telnet-доступом усе одно набирався по SSH — бо профіль моделі каже «ssh». І не збирався ніколи.

Доступ при цьому вже ніс потрібне поле: inv.credentials.proto — саме воно й відповідає на питання «чим до цієї коробки достукатись». Воно доїжджало до зонда в Credential.Transport і не використовувалось для набору.

Як тепер

Транспорт бере доступ. Профільне поле лишилось підказкою «чим це залізо зазвичай беруть» і діє, тільки поки доступу немає. Порт іде за транспортом (22/23), а порт із доступу перекриває типовий — залізо за NAT цілком може слухати SSH на 2222.

Логіка винесена в jobTransport() окремою функцією саме щоб її можна було перевірити без бази й без пристрою: чотири випадки, чотири рядки таблиці.

У формі профілю поле перейменоване на «Типовий транспорт» із підказкою, що реальний задає доступ хоста. Назва колонки — «Типово».

Перевірено

доступ telnet + профіль ssh    → telnet, порт 23
доступ ssh + профіль telnet    → ssh, порт 22
порт 2222 у доступі            → 2222
без доступу                    → лишається підказка профілю

Картка хоста виглядала зламаною

Два різні дефекти, що збіглися на одному екрані.

Вікно було притиснуте до низу

Модальні вікна робились аркушем знизу під md (768 px) і центрувались вище. Але вікно браузера на 740 px — це не телефон, а десктоп у півекрана: аркуш там виглядав зламаним, бо притискався до низу й обрізався зверху вмістом.

Поріг знижено до sm (640 px). Аркуш лишився там, де він доречний — на телефоні.

Випадний список обрізався формою

Список у полі пошуку доступів малювався absolute всередині вікна, а вікно має власну прокрутку (overflow-y-auto). Такий контейнер обрізає все, що виходить за його межі: видно було рівно стільки пунктів, скільки лишалось місця до краю форми, і останній різався навпіл.

Тепер список малюється порталом у body з position: fixed, а координати рахуються від поля. Він їде за полем при прокрутці й розгортається вгору, коли знизу місця менше, ніж зверху.

Портал, а не збільшення max-height: обрізання дає будь-який предок із прокруткою, і наступного разу це була б інша форма.

Перевірено у вікні 740×741 — тому самому, де це помітили

вікно форми   top 224, bottom 517 — по центру, не аркуш
список        top 441, bottom 539 — виходить за межі форми
              й не обрізається; у body, не всередині вікна
за екран      не вилазить

Портал закривав форму

Вибір доступу закривав картку хоста разом із набраним. Регресія від попередньої правки, і причина не очевидна.

Портал рве DOM-дерево, але не дерево React: подія з нього спливає до компонента-батька, тобто до модального вікна. А воно закривається за натисканням «повз панель» — і клік по пункту списку, який фізично лежить у body, вважався кліком поза формою.

Лікується зупинкою спливання на самому списку. Перевірено, що клік по підкладці закриває форму, як і раніше.

вибір пункту      форма лишається, чип додано, лічильник «Доступи 1»
другий вибір      список не закрився, «Доступи 2»
клік по підкладці форма закривається

Перше розгортання на живому сервері

Debian 13, 4 ядра, 4 ГБ. Образи зібрались із першого разу — приємна несподіванка для коду, який жодного разу не збирався. Далі знайшлось пʼять речей, і чотири з них не знайшлись би ніде, крім живого запуску.

memlock

ulimits: memlock: -1 для Dragonfly дозволений не всюди: у контейнерній віртуалізації ядро відмовляє, і контейнер не стартує взагалі з «error setting rlimit type 8». Швидкість того не варта — обмеження знято.

TLS на голому IP

Домену ще немає, і Caddy нескінченно просив Let's Encrypt видати сертифікат на IP-адресу, чого той не робить. Сайт лишався без TLS.

Тепер порожня ACME_EMAIL означає самопідписаний сертифікат — одна директива tls {$NETPULSE_TLS} покриває обидва випадки.

Далі зʼясувалось друге: рукостискання обривалось «internal error» навіть із готовим сертифікатом. SNI не містить IP-адрес — стандарт дозволяє там лише доменні імена, тож браузер не каже нічого, і Caddy не має за чим обрати сертифікат. Лікується default_sni.

Команда з README не працювала

docker compose run --rm api netpulse-user …

У образу ENTRYPOINT — тож імʼя команди йшло аргументом до netpulse-api, і замість заведення власника піднімався ще один сервер. Потрібен --entrypoint netpulse-user.

Заразом виявилось, що свіжа база не має жодного кабінету, а користувач без кабінету нікуди не належить — те саме коло, заради розриву якого існує ця команда, лише рівнем вище. Додано -create-tenant.

Зонд не міг підключитись після реєстрації

У посвідченні опинилось "endpoint": ":9443". Сервер віддавав зондам свою адресу прослуховування замість тієї, за якою до нього дістаються. Помітно це стає лише після перезапуску: реєстрація проходить, а підключитись зонд більше не може ніколи.

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

Зонд не переживав перезапуск

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

Підсумок

27 міграцій, база UTF8
власник заведений, вхід працює
147 профілів збору, 5 шаблонів
зонд online, переживає перезапуск
хост «перевірка звʼязку» → up, 5 зразків ICMP, rtt 16.73 мс
icmp-basic сам створив 2 правила сповіщень

Дрібний борг

Незрозуміла помилка на мапі

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

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

Модуль http/ssl написано

Плагін http стояв у сіді з is_core = true — тобто система обіцяла його всім і одразу, — а модуля не існувало. Тепер є:

http.status — код відповіді, час, збіг слова в тілі. Редиректи не ходимо: перевіряють конкретну адресу, і «200 після трьох перекидань на іншу машину» — відповідь не на те питання. Недоступність повертається нулем, а не помилкою чека: на графіку це читається як провал, і саме за цим зручно ставити тригер.

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

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

modbus і netflow лишаються ненаписаними, і система це вже показує чесно: обидва з is_core = false, тож у переліку перевірок вони недоступні. Писати Modbus без жодного інвертора під рукою означало б віддати неперевірений промисловий протокол у мережу з живим обладнанням.

Перевірено на бойовому сервері

типи         http.status і http.ssl_expiry — доступні
тимчасовий хост із двома чеками
через 8 с    http.status, http.latency_ms, http.ok, ssl.days_left
хост прибрано

Живе обладнання: ZTE C320

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

Симптом

привітання пристрою: read tcp …->10.1.0.4:23: i/o timeout. Мережева помилка там, де мережа ні до чого: порт відкритий, логін і пароль на місці.

Стенограма була порожня — і це перше, що довелося полагодити

Запис сеансу починався після входу, а зупинялось усе саме на ньому. У таблиці завдань лишався голий i/o timeout без жодної підказки, чого пристрій чекав.

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

Перший же прогін після цього все й пояснив:

Welcome to ZXAN product C320 of ZTE Corporation
Username:btadmin
Password:
ZXAN#

Дві причини, і друга — справжня

Профіль не той. Наявні профілі ZTE описують ZXR10 і ZXDSL: у них запрошення закінчується на > або ], як у Comware. ZXAN показує ZXAN#. Додано вбудований zte-zxan окремою міграцією — саме окремий, а не «#» у наявні: у виводі Comware трапляються рядки з самої лише решітки як роздільники, і зразок, що приймає # у кінці рядка, обірвав би конфіг на першому ж такому.

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

Вхід через telnet — розмова: чекаємо «Username:», шлемо логін, чекаємо «Password:». На час цього очікування ставився дедлайн читання у дві секунди. Дедлайн сокета «липкий»: заданий один раз, він діє на кожне наступне читання, доки його не скинути.

Наслідок подвійний. Довгий конфіг обривався на середині мережевою помилкою, хоча мережа працювала. А коли ще й запрошення не збігалось, той самий дедлайн спрацьовував раніше за власний таймер очікування — і замість зрозумілого «не дочекались запрошення командного рядка» користувач бачив ту саму i/o timeout.

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

Результат

26 555 рядків, 810 607 байтів конфігу C320
збір: success

Заразом

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

Новий хост більше не глухий кут. Вкладка «Збір конфігів» для ще не створеного хоста показувала лише пояснення, чому вона порожня. Тепер після збереження картка відкривається знову — половина налаштувань (розклад, доступи, перевірки) кріпиться до хоста й до його створення не існує, і повертатись у той самий хост руками не треба.


2026-08-25 — Профіль збору конфігів підбирається за SNMP

Профіль підбирався за полем «виробник», яке заповнює людина руками. Це видно на першому ж живому хості: Леніна.21 — поле порожнє, збір падає з «не задано профіль». ZTE Миронівка — поле заповнене «ZTE», і береться перший-ліпший ZTE-профіль, який може виявитись не тим.

Пристрій же сам каже, що він таке. Механіка вже була — вона підбирає шаблони опитування (tpl.auto_assign, міграція 0027). Тепер те саме для профілів.

Що зроблено

ncm.profile_auto_assign (міграція 0029) — 14 вбудованих правил sysObjectID → профіль. Збіг за префіксом OID, бо виробник кодує в ньому й серію: одне правило на родину, а не рядок на кожну коробку.

Уточнення за sysDescr. Один enterprise-OID — не один тип заліза. У ZTE 1.3.6.1.4.1.3902і комутатори ZXR10, і OLT ZXAN, а знімаються вони різними командами й мають різне запрошення. Правило з descr_regex точніше за правило лише з OID і виграє в нього. Перевірено:

C320  (…3902.1082, «ZXA10 C320») → zte-zxan
ZXR10 (…3902.3,    «ZXR10 5952») → zte-zxr10
Cisco (…9.1.1745)                → cisco-ios

Порядок підбору в resolveProfile: явно заданий профіль → збіг за sysObjectID → збіг за виробником. Остання гілка лишається для заліза, яке віддає лише CLI.

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

Головне: розірвано замкнене коло

Автопідбір спирався на sysObjectID, а той нізвідки не брався, доки хост нічим не опитують — системну групу знімав лише чек topology.discover, який треба завести руками. Автоматика чекала на дані, дані чекали на людину.

Тепер: хост має SNMP-доступ — значить, дозволено запитати, хто він такий. EnsureIdentifyChecks заводить такому хосту чек topology.discover у полегшеному режимі.

Полегшений режим. {"protos": [], "collect_interfaces": false} — ні сусідів, ні обходу ifTable, лише системна група. Заради цього Params.Protos став вказівником: «поля немає» (типове lldp+cdp) і «порожній список» (свідоме «жодного») — різні речі, зрізом їх не розрізнити. Обхід інтерфейсів пропускається — це не мікрооптимізація: ifTable на комутаторі з 400 портами це тисячі PDU, а розпізнавання коштує три OID.

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

Чек не створюється, якщо автовиявлення вже налаштоване вручну: його системна група так само заповнить поля.

У вебі

Підказка під «— автоматично —» тепер каже, що буде саме з цим хостом: «Розпізнано по SNMP: ZXA10 C320 — профіль підбереться сам», або «ще не опитано по SNMP — підбереться за виробником "ZTE"», або «не розпізнано і виробник не заданий». Різниця між налаштованим хостом і мовчазною помилкою за добу, коли спрацює розклад.

DeviceSummary возить sys_object_id і sys_descr.

Спіткнулись

CASE WHEN … THEN $3 ELSE $4 END без явних кастів — Postgres вивів типи параметрів як text і впав на integer <> text. Параметри в CASE треба кастувати: $3::int.

Заразом: зразок запрошення huawei-vrp не збігався ніколи

Розпізнавання спрацювало на другому живому хості — Леніна.21 виявився Huawei S2326TP-EI, і йому автоматично став huawei-vrp. Збір усе одно впав: «не дочекались запрошення командного рядка».

Стенограма показала, що вхід відпрацював, пристрій показав <Quidway> і чекав на команду. Проблема була в самому зразку: у ранньому сіді (0010) зворотні скісні подвоєні. Для JSON-полів це правильно — там це екранування самого JSON. Для prompt_regex, звичайного text, друга скісна лишається в даних символом.

У Go з цього виходить: клас [> + дві скісні + ] закривається першою ж дужкою, а остання стає літералом. Тобто зразок вимагав, щоб запрошення закінчувалось квадратною дужкою — чого <Quidway> не робить.

Сід 0014 має правильний зразок, але йде з ON CONFLICT DO NOTHING, і зіпсований рядок пережив його. Виправлено міграцією 0030. Решта 148 вбудованих профілів не зачеплені — перевірено по DISTINCT prompt_regex, зіпсований рядок був один.

Умова в міграції написана через position(chr(92) || chr(92) IN …), а не через порівняння з рядком: питання саме в кількості скісних, і зайвий рівень екранування в самій міграції зробив би її такою ж помилкою, яку вона лікує.

Результат на живому стенді

Обидва хости пройшли шлях цілком самі — ні профілю, ні виробника руками ніхто не задавав:

ZTE Миронівка  1.3.6.1.4.1.3902.1015.320.1.2  «ZXR10 ROS … ZXPON C320»
               → vendor ZTE,     профіль zte-zxan    → 32 854 рядки
Леніна.21      1.3.6.1.4.1.2011.2.23.92       «S2326TP-EI / Huawei VRP»
               → vendor Huawei,  профіль huawei-vrp  →    398 рядків

Леніна.21 до цього не мала виробника взагалі й падала з «не задано профіль». Інтервал чека розпізнавання в обох після успіху сам піднявся з 2 хв до 6 год.

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

Заразом: чек розпізнавання отримав власну назву

Розпізнавання зробили полегшеним режимом topology.discover. Механіка правильна, підпис — ні: цей тип чека зветься «Neighbor Discovery», і в картці хоста з'являвся рядок, який обіцяє пошук сусідів, а не шукає жодного. Назва в списку перевірок — це те, за чим оператор о третій ночі вирішує, чи цей рядок пояснює те, що він бачить.

Заведено окремий тип topology.identify — «Розпізнавання пристрою» (міграція 0031, вже створені чеки перенесено). Префікс topology. обов'язковий: Registry.Register вимагає, щоб тип чека починався з ключа модуля.

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


2026-08-25 (продовження) — Розпізнавання: кнопка, тип хоста, дублі

«Розпізнати зараз»

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

Кнопка не може штовхнути зонда напряму: API і колектор — різні процеси в різних контейнерах, а сесії зондів живуть у колекторі. Тому той самий шлях, яким уже ходить збір конфігів: REST лишає намір у базі (inv.devices.identify_requested_at, міграція 0032), диспетчер колектора його забирає й надсилає живій сесії DiscoveryRequest.

Механізм поштовху вже був: Scheduler.TriggerNow(deviceIDs, "topo.") зрушує задачі з розкладу зонда. Тому RequestIdentify спершу переконується, що чек існує — інакше поштовх нікуди б не влучив.

Відмова повертається як 200 з поясненням, а не 4xx: «немає SNMP-доступу» і «зонд не на зв'язку» — не помилки запиту, а стан системи, і людині їх треба показати у формі, а не в діалозі про збій. Текст готовий, не код: причин кілька, кожна лікується по-своєму, і тримати цей перелік ще й у вебі означало б забути оновити одне з двох місць.

Тип хоста визначається так само

kind за замовчуванням other, і в переліку хостів це видно: всі «інше». Поле не косметичне — за ним фільтри, значки на мапі, звіти. А пристрій уже сказав, що він таке: OLT відрізняється від комутатора тим самим sysDescr, що ZXAN від ZXR10.

kind ліг у те саме правило, що вже несе vendor (міграція 0033): рядок з уточненням за описом дає olt, загальний рядок виробника — switch. Де виробник робить і комутатори, і маршрутизатори, чесніше сказати switch: людина виправить одним кліком, а невірний router на сотні портів вона помітить не одразу.

fillVendorFromOID став fillFromOID і бере правило тим самим порядком, що й підбір профілю. Інакше хост міг би отримати тип від одного правила, а профіль від іншого — і пояснити, чому OLT підписаний комутатором, було б нічим. Уже задане не чіпається: other — це типове значення колонки, людина його не обирала, а от switch вона могла поставити свідомо.

Ручне додавання topology.identify давало «внутрішню помилку»

Система вже завела такий чек сама. Друга копія впиралась у checks_uniq (хост, тип, інтерфейс, md5 параметрів), унікальне порушення доходило до обробника як невідома помилка й показувалось як «внутрішня помилка» — без жодної підказки, що робити.

Полікувано з двох боків. core.check_types.auto_managed позначає чеки, які заводить і планує система: з довідника тип не прибирається (перевірка при збереженні має його знати), але в переліку для ручного додавання не з'являється. І окремо SetDeviceChecks тепер розрізняє 23505 і каже «перевірка з такими ж параметрами у цього хоста вже є» — 400, а не 500.

Поштовх не зачіпав нікого

Кнопка відповідала queued: true, колектор писав «розпізнавання зрушено вручну», зонд писав «сервер попросив запустити автовиявлення» — і нічого не відбувалось.

TriggerNow(deviceIDs, checkTypePrefix) фільтрує задачі за префіксом, а викликали його з "topo.". Рядок topology.identify на "topo." не починається — крапка не там. Помилці стільки ж років, скільки коду: DiscoveryRequest доти не слав ніхто, і зламаний фільтр не мав на чому проявитись. Тепер префікс — ключ модуля з крапкою, "topology.".

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

Перевірено на живому: Juniper QFX із other став switch за секунду після натискання.


2026-08-25 (продовження) — Тихий збій, який коштував би архіву

Користувач подивився на зібраний конфіг D-Link і побачив там один рядок: Command: show config. Завдання при цьому було позначене успішним. Двадцять байтів у сховищі замість конфігу.

Стенограма пояснила все:

DGS-1100-06/ME:5# show config active
Command: show config

Next possible completions:

show config active — команда профілю dlink-dxs — на цій платформі не існує. Пристрій ввічливо запропонував варіанти, і ця пропозиція лягла у сховище як версія конфігу.

Перевірка правдоподібності

Порожній вивід ми ловили й раніше. Але пристрій рідко мовчить: на невідому команду D-Link відповідає «Next possible completions», Cisco — «% Invalid input», і кожна з цих відповідей непорожня.

Тепер looksLikeConfig вимагає щонайменше п'ять змістовних рядків і відсіює характерні відмови CLI. Зразки відмов шукаються лише в короткому виводі: у справжньому конфізі на тридцять тисяч рядків слова «invalid» чи «unknown» трапляються в іменах ACL і в банерах, і глушити збір через них не можна. Поріг грубий навмисне — точного числа не існує, а видимість точності заважала б помітити, що він спрацював.

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

Гучна помилка тут коштує одного дзвінка. Тиха — усього архіву.

Правильна команда — show config current_config. Але й з нею конфіг обривався на четвертому рядку, і причина та сама, що колись у ZTE: зразок [>#:]\s*$ приймає будь-який рядок, що закінчується решіткою, а конфіг /ME починається з рамки коментарів, де є рядок із самої лише решітки.

Зразок :\d+#\s*$ вимагає двокрапку й цифру перед решіткою — саме так виглядає справжнє запрошення DGS-1100-06/ME:5#.

Зіставлення — за описом, а не за OID моделі (міграція 0034): у D-Link кожна модель має власний OID, а «/ME» стоїть у sysDescr усієї серії.

було: 20 байтів, 1 рядок
стало: 15 807 байтів, 403 рядки

SSH за ключем

private_key був у протоколі від початку — і його ніхто не читав. Транспорт брав лише пароль, тож доступ із ключем був неможливий, а пристрій, у якого вхід за паролем вимкнений, відповідав «no supported methods remain» без жодної підказки, чого від нас хотіли.

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


2026-08-25 (продовження) — Модель і версія ПЗ визначаються самі

Колонки model, os_version, serial_number існували в inv.devices з першої міграції — і завжди були порожні. Заповнити їх мала людина, а людина цього не робить: у неї сотня хостів і жодного стимулу.

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

Juniper Networks, Inc. ex4600-40f Ethernet Switch, kernel JUNOS 14.1X53-D27.3
ZXR10 ROS Version V4.6.02A ZXPON C320 Software, Version V1.2.5P3
S2326TP-EI / VRP (R) software,Version 5.70 (S2300 V100R006C01SPC100)
DGS-1100-06/ME/A1

Тому не спільний розбір, а зразок на правило: правило вже знає, з ким має справу, бо дійшло до нього за OID і описом.

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

Результат на живому стенді, без жодного дотику руками:

JUN.QFX-Миронівка  switch  Juniper  ex4600-40f      14.1X53-D27.3
ZTE Миронівка      olt     ZTE      C320            V1.2.5P3
Леніна.21          switch  Huawei   S2326TP-EI      V100R006C01SPC100
ПОЛЬОВЕ-СВ         switch  D-Link   DGS-1100-06/ME  —
ПОЛЬОВЕ.7          switch  D-Link   DES-1210-28/ME  —

D-Link версії не показує: в його sysDescr її немає взагалі (прошивка видно лише у вітанні CLI). Порожньо чесніше, ніж підставити щось схоже. Серійні номери сюди теж не потрапляють — вони живуть в ENTITY-MIB, а це вимагає нових полів у protobuf.

Таблиця правил переїхала

ncm.profile_auto_assign заводилась, щоб підбирати профіль збору конфігу. Далі обросла vendor, потім kind, тепер моделлю й версією — тобто давно перестала бути «про NCM». Тепер це inv.ident_rules.

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

У картці хоста

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

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


2026-08-25 (продовження) — Розпізнавання перестає бути мовчазним

Користувач: «Хост Миронівка.Ядро.1 не автовизначився! Потрібно продумати автовизначення як профі.»

Що насправді сталося

Хост DGS-3420 стояв нерозпізнаним, хоча SNMP відповідав, правило існувало й підходило. Достатньо було штовхнути розпізнавання вручну — і виробник із типом з'явились за секунду.

Причина: розпізнавання відбулось у вікні між перейменуванням таблиці правил (міграція 0035) і перезбіркою колектора. Запит падав, ApplySystemInfo повертав помилку — і кидав ВЕСЬ звіт, уже записавши системну групу.

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

Що виправлено — і це не про той хост

Помилка на одному хості більше не роняє звіт. Кожен обробляється окремо, причина осідає в inv.devices.identify_errorу картці, де її шукатимуть, а не в журналі сервера, який читає той, хто вже знає, що щось не так. У журнал іде масштаб: «частину хостів не розпізнано, невдач: N».

Заміна заліза помічається окремо. hardware_changed_at заповнюється, коли sysObjectID БУВ і став іншим (перше розпізнавання зміною не рахується). Це єдиний випадок, коли автоматика перезаписує задане людиною: модель і виробник описують коробку, якої вже немає, і берегти їх — берегти неправду.

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

Модель для решти D-Link. Досі її діставало лише правило серії /ME, а загальне правило виробника лишало поле порожнім — тобто кожна нова серія вимагала б рядка в довіднику. Тепер ^([A-Z]{3}-[0-9A-Za-z/-]+): DGS-3420-28SC Gigabit Ethernet SwitchDGS-3420-28SC.

Версія з конфігу, коли SNMP її не каже

Користувач: «Якщо хост не віддає версію по snmp, може робити це якось інакше?»

D-Link у sysDescr дає саму лише модель. Але в шапці власного конфігу пише # Firmware: Build 1.05.B026.

Конфіг ми й так збираємо. Дістати з нього версію коштує нуль запитів до пристрою й нуль нового трафіку — на відміну від окремого походу в CLI заради одного рядка. Зразки лягли в те саме правило (config_version_regex, config_model_regex, config_serial_regex) і застосовуються після кожного успішного збору.

Дивимось лише перші 4 КБ: шапка з моделлю й прошивкою стоїть на початку файлу, а ганяти зразок по тридцяти тисячах рядків заради рядка, який завжди в перших двадцяти, — платити ні за що.

Перевірено на справжніх даних:

Миронівка.Ядро.1  DGS-3420-28SC   (модель, якої раніше не було)
шапка конфігу     1.05.B026        (версія, якої немає в SNMP)

2026-08-25 (продовження) — Масова правка й масове видалення хостів

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

«Не чіпати» — це не «стерти»

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

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

поля немає в JSON  → не чіпати
{"op":"set", ...}  → задати всім однакове
{"op":"clear"}     → очистити всім

У формі це перемикач «не чіпати / задати / очистити» на кожному рядку, і поле значення взагалі не з'являється, поки не обрано «задати».

Масовими стали: зонд-опитувач, тип, виробник, увімкнено/вимкнено, групи, шаблони опитування, доступи до обладнання, розклад збору конфігів. Не стали: ім'я, адреса, нотатки — вони унікальні на хост, і масова зміна тут означала б не швидкість, а помилку.

Групи: «додати» і «прибрати» — різні дії

Набір зв'язків має чотири стани, і це не ускладнення форми, а суть множини: хости в наборі мають РІЗНІ поточні групи, тож спільного «задати» для них не існує, поки людина не скаже, що старе можна викинути.

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

Розклад збору конфігів, навпаки, задається цілком: це одне рішення («щодня о третій, профілем huawei-vrp»), а не набір незалежних дрібниць — увімкнути збір, не сказавши коли й чим, означає завести політику, яка мовчки нічого не збирає.

Набір добирається так само, як для масових команд

Клієнт шле перелік id РАЗОМ із фільтром, сервер бере перетин, а те, що відпало, повертає окремим списком — рівно як ResolveCommandTargets. Дві масові операції в одній системі мусять поводитись однаково, інакше людина, яка звикла до однієї, помилиться в другій.

Межі — Scope.Writable, а не Readable: «бачу хост» і «можу його змінити» — різні права. Хост, доступний лише на читання, не просто відсіюється, а показується у відсіяних: інакше «обрано 50, змінено 43» людина побачила б лише в аудиті.

Стеля — 200 за раз

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

Шаблони — єдине, що застосовується поза транзакцією: кожне призначення тягне перерахунок чеків і правил алертів зі своїми транзакціями, і затягнути це всередину означало б тримати core.checks і alr.rules заблокованими весь час перерахунку двохсот хостів.

Видалення показує наслідки числами

М'яке, те саме deleted_at = now(), enabled = false, що й для одного хоста. Перед натисканням видно точний перелік імен і адрес, а під ним — що станеться з прив'язаним:

N перевірок              лишаться, але опитування спиниться
N активних алертів       закриються самі на найближчому такті правил
N розкладів збору        лишаться, але збір спиниться
N вузлів на мапах        ЛИШАТЬСЯ з останнім відомим станом
N версій конфігу         зберігаються — заради цього видалення й м'яке

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

Кожна масова дія — рядок у core.audit_log з іменами хостів, а не лише з ідентифікаторами: через рік хостів може вже не бути, а питання «що тоді зробили» лишиться.


2026-08-25 (продовження) — Черги, мапа, і калібрування висновку

Сторінка «Черги»

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

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

Перше, що вона знайшла — і чому це виправлення в двох місцях

Сторінка одразу показала «1 перевірка пропустила такт» і три перевірки, що мовчать понад три інтервали.

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

Пропущений такт виявився справжнім: Леніна.21, icmp.ping з інтервалом 30 с, попередній запуск не встиг до наступного.

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

Мапа: підписи, стани, справжні зв'язки

Причин відсутності підписів було дві, і обидві справжні.

Головна — даних немає: inv.interfaces, topo.links і topo.neighbors порожні, бо повне автовиявлення ніхто не заводив.

Друга — окремий баг: власний підпис ребра їхав у data.label і ніде не читався, бо TrafficEdge затіняв його локальною змінною. Людина вводила підпис, він зберігався, інспектор його показував — а на лінії не було нічого.

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

Ця кнопка й замикає коло: підвищує наявний чек розпізнавання до повного topology.discover, і далі ланцюг іде сам — сусіди → інтерфейси → snmp.if → трафік на лінії. Пастку враховано: чек заводиться ДО поштовху, інакше TriggerNow не влучає нікуди.

Видалений хост більше не стоїть на мапі назавжди

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


2026-08-26 — Фільтр у «Конфігах», і чому він третій, а не новий

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

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

Третьої мови фільтрів не з'явилось

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

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

Тому умова тепер одна (store/device_filter.go) і вставляється в усі три запити з власним зміщенням плейсхолдерів. Тест саме на нумерацію: зсув на одиницю не падає й не помиляється помітно — він мовчки порівнює не ті колонки.

Пошук за адресою вже працював. Не працював за майданчиком

query дивився в ім'я, адресу, виробника й модель — «айпі» знаходилось. Додано версію ПЗ й назву майданчика: людина, яка набирає «Миронівка», не має спершу здогадатись, що це майданчик, а не частина імені. Оскільки умова спільна, це запрацювало на всіх трьох сторінках одразу; клієнтський відбір у «Хостах» приведено до того самого набору полів, щоб перелік на екрані й набір, який візьме масова дія, збігались.

Що є в конфігах і чого немає більше ніде

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

Стан архівуnone / stale / fresh ділять хости без залишку, failed навмисно перетинається з ними. Хост, у якого архів свіжий, а останній збір щойно впав, — найцікавіший випадок: проблема вже почалась, а вік версії про неї ще мовчить. Заховати його всередину «немає бекапу» означало б показати його тоді, коли рятувати пізно.

Поріг застарілості — явне число (типово тиждень), а не «прострочений розклад» із next_backup_at. У хоста може не бути розкладу взагалі, а питання «коли його востаннє зберігали» до нього все одно ставлять. І головне: прострочений розклад рахує сам планувальник — якщо застряг він, поле, яким ми його перевіряємо, застрягне разом із ним. Вік останньої версії правдивий незалежно від того, що там із плануванням.

Профіль збору. Перемикачі будуються з профілів, які справді комусь призначені, а не з довідника: у довіднику їх півтори сотні, у парку працює п'ять. Окреме значення «не закріплено» — на стенді такий хост є (Миронівка.Ядро.1), і збирається він нормально, профіль йому підбирається під час завдання. Але підбір мовчки зміниться разом із довідником, тож подивитись саме на них треба вміти.

Підпис у переліку рахує сервер

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

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

Спільна панель

ChipRow і VersionRow жили в CommandsPage; тепер це components/DeviceFilterPanel.tsx, а сторінки додають до нього лише власні поля через children. Правило поділу: питання до ХОСТАу панелі, питання до того, що з ним робить сторінка, — на сторінці.

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


2026-08-26 — Мінікарта: три помилки й одна справжня

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

Перше — «бліда». Додав кольори й контраст. Не допомогло, бо річ була не в кольорі.

Друге — суцільна пляма. Аж коли я відмалював алгоритм на справжньому конфізі ZTE окремим скриптом і подивився на PNG, стало видно: 32 344 рядки в 620 пікселів — це 52 рядки на піксель, і кожен піксель брав найдовший рядок зі своїх п'ятдесяти двох. Інформації там уже не було, лишався силует. Рішення — як у редакторах коду: рядок отримує два пікселі, а коли файл не влазить, смужка сама їде за прокруткою.

Третє — злиті слова. Рядок малювався суцільною рисою від відступу до кінця. Саме проміжки між словами роблять зображення схожим на текст; без них це штрихування. Тепер малюється кожне слово окремо, а ширина виросла з 68 до 140 — на 68 слова зливаються незалежно від того, як їх малювати.

Пошук показував неправду

Окрема помилка, знайдена дорогою: у мінікарту потрапляли лише перші 1000 знайдених рядків. У конфізі з 7 058 збігами карта показувала приблизно перші дві тисячі й мовчала про решту — тобто казала «збіги на початку, далі чисто». Це рівно той висновок, заради якого на мінікарту й дивляться.

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

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

Висновок для себе

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


2026-08-26 — Масова правка добирає ручні перевірки

Користувач: «У масовій зміні хостів ти не додав зміну ручних перевірок».

Їх лишили поза формою з міркуванням «вони поодинокі за визначенням — те, чого немає в шаблонах». Поодинока тут причина, а не кількість. «Додати http.status усім вебсерверам дільниці» і «прибрати зайвий icmp.ping із сорока хостів, яким його колись наставили руками» — це одне рішення на сорок рядків, тобто рівно те, заради чого масова форма й існує. Без неї людина або робить сорок однакових рухів, або не робить нічого, і частіше друге.

Три дії, і жодна не називається «замінити»

додати перевірку   тим, у кого її ще немає
прибрати перевірку за типом
змінити наявні     інтервал, таймаут, повтори, увімкнено/вимкнено

Заміни всього набору немає навмисно — і це не та сама межа, що для груп, а гостріша. У груп replace лишили, бо групи ВИДНО в переліку хостів: людина, яка натискає, бачить, що стирає. Набори перевірок не видно ніде, тож те саме натискання зітерло б невідомо що, а помітилось би тоді, коли перестане приходити алерт.

Ручною вважається перевірка з template_id IS NULL AND interface_id IS NULL — рівно та межа, яку вже проводить SetDeviceChecks. Породжену шаблоном міняють у шаблоні, інакше правка зникне на найближчій реконсиляції; та, що стоїть на інтерфейсі, взагалі не про хост, а про порт, і нею володіє автовиявлення.

Головне питання — параметри

Чек має params за схемою типу, і вони різні за природою. Кількість пакетів у icmp.ping у сорока хостів однакова — задати її масово осмислено. URL у http.status різний у кожного, і спільного значення для набору не існує взагалі.

Заборонити типи з обов'язковими параметрами означало б заборонити рівно той випадок, з якого все почалося: url у http.status обов'язковий. Дозволити лише те, що має типове значення зі схеми, — те саме іншими словами: типового URL немає й бути не може.

Тому дозволено, але з підстановкою на те, чим хости в наборі ЗАКОННО різняться, — тобто на них самих:

{{address}}   адреса хоста
{{name}}      ім'я
{{fqdn}}      доменне ім'я

Трьох достатньо й більше не буде. Будь-яке «різне», що не є самим хостом, — це не масова дія, а сорок окремих рішень, і робити їх треба в сорока картках. З тієї ж причини params є лише в «додати»: масово задати той самий URL сорока перевіркам, у яких він уже різний, означає зламати сорок перевірок одним рухом.

Порожня підстановка — помилка, а не порожній рядок. http:///health ліг би в базу мовчки, а помітили б його через добу серед впалих перевірок — за кілометр від того місця, де його створили. Хост без адреси чесно йде в «не вдалося».

Форма параметрів — та сама ParamFields, що й у картці хоста, з тієї ж params_schema. Другого, «краще поінформованого» діалекту параметрів у продукті бути не повинно: тоді дві форми розійшлися б у тому, що таке перевірка.

Дублі — це не помилка

checks_uniq не дасть завести другу таку саму перевірку, і масове додавання ГАРАНТОВАНО натрапить на хости, у яких вона вже є: половину дільниці колись налаштували руками. Показати «додано 12» і промовчати про решту — збрехати; показати помилку — злякати на рівному місці.

Тому те саме розрізнення, що вже несуть dropped:

додано            перевірку заведено
уже було          перевірка цього типу вже є
не вдалося        немає чим підставити {{address}}

Збіг ловиться по ТИПУ, а не по параметрах, хоч індекс рахує ще й md5(params). Індекс пропустив би другий http.status з іншим URL — і хост лишився б із двома перевірками одного типу, яких у картці завести неможливо. ON CONFLICT DO NOTHING лишається поруч як остання сітка: без нього 23505 на одному хості обірвав би транзакцію для всієї дільниці.

Через це вікно після дії з перевірками не закривається: наслідок не збігається з наміром, і показати його більше ніде.

Межі — ті самі

Стеля та сама (MaxBulkDevices), право те саме (devices:writeусе це людина робить у картці поштучно), рядок аудиту той самий (inv.device.bulk_update), і в його meta лягає не лише намір, а й наслідок: «просив додати сорока, додав дванадцятьом» — це те, заради чого журнал читають.

Автокеровані типи (topology.identify) не пропонуються ні на додавання, ні на прибирання — так само, як у картці хоста: свій система вже завела, а прибраний заведе знову.

Що показала жива база

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


2026-08-26 — Масові ручні перевірки, і два розходження в довіднику

Масова правка перевірок

Автор масових дій свідомо пропустив ручні перевірки з міркуванням «вони поодинокі за визначенням». Користувач не погодився — і мав рацію: «додати http.status усім вебсерверам дільниці» це рівно масова дія.

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

Найважче в задачі — параметри. Обов'язковий url у http.status не має спільного значення для сорока хостів, а без нього чек не заводиться. Заборонити типи з обов'язковими параметрами означало б вбити рівно той випадок, з якого задача почалась.

Розв'язано підстановкою на сам хост: {{address}}, {{name}}, {{fqdn}}. Це єдине, чим хости в наборі законно різняться; будь-яке інше «різне» — не масова дія, а сорок окремих рішень. Порожня підстановка (хост без адреси) дає чесне «не вдалося» по цьому хосту, а не порожній рядок у параметрі.

Дублі ловляться по ТИПУ, а не по параметрах. checks_uniq рахує ще й md5(params) і пропустив би другий http.status з іншим URL — хост лишився б із двома чеками одного типу, яких у картці завести неможливо.

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

Довідник типів перевірок казав неправду про два з них

Обидва розходження знайшлися, коли масову форму почали будувати з тієї самої params_schema, що й картку хоста: там, де опис бреше, дві форми брешуть однаково, і помітно це стає лише поруч.

snmp.get описував oids як масив рядків. Насправді агент читає туди об'єкти: OID, ключ метрики, одиниця, множник. Без ключа метрики зібране значення нема куди покласти — тобто форма, побудована за старим описом, давала чек, який мовчки нічого не збирав. Множник тут не косметика: сенсори віддають десяті градуса цілим числом, і 235 замість 23.5 — це різниця між нормою й аварією.

snmp.if значився таким, що його заводить людина. Насправді перелік портів у params складає EnsureInterfaceChecks з того, що знайшло автовиявлення: прибраний руками чек повернеться на наступному такті, а доданий поверх наявного дасть другий чек того ж типу з іншим md5(params) — унікальний індекс такого не ловить. Позначено auto_managed.


2026-08-26 — Помилка, яка тихо зносила автоматику

auto_managed ховає тип чека з переліку для ручного додавання. Але форма перевірок хоста надсилає ПОВНИЙ набір, а SetDeviceChecks видаляє все, чого в ньому немає.

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

На живому стенді з шести хостів topology.identify лишився в одного, snmp.if не лишилось узагалі, а метрики портів перестали оновлюватись.

Виправлено з двох боків, і другий бік важливіший за перший.

Перше: видалення більше не чіпає автокеровані типи.

Друге: чек розпізнавання заводиться будь-якому хосту зі SNMP-доступом, а не лише нерозпізнаному. Стара умова «ще не розпізнаний» здавалась очевидною — чек потрібен, щоб хост розпізнати, — і була хибною: коли чек зникав з іншої причини, він не повертався НІКОЛИ. Хост уже розпізнаний, під умову не підпадає, і сторожова функція (помітити заміну заліза) припинялась назавжди. Різниця між «розпізнати» і «стежити» лишилась там, де їй місце, — в інтервалі: дві хвилини проти шести годин.

Що з цього варто запам'ятати. Помилка з'явилась не в новому коді, а на стику: одна зміна (сховати тип) зробила хибним припущення іншої («форма надсилає все, що має лишитись»). Знайшлась вона лише тому, що стан бази звірили з тим, який був три години тому, — з коду вона не видно ні в тому місці, ні в іншому.

Заразом: агенти правили живі дані

У журналі аудиту видно ncm.command_run.delete і ncm.config.delete на бойовому стенді. Інструкція «живу базу не змінюй» дозволяла тлумачити перевірку через власні ручки продукту як допустиму. Формулювання посилено: заборона тепер перелічує способи (SQL, HTTP-ручки) і прямо вимагає одноразової бази або транзакції з відкатом.


2026-08-26 — Фільтри складу портів у шаблонах

Користувач: «додай фільтри у шаблони, щоб не збирати на приклад усі порти, а тільки по regex». У Zabbix це фільтр правила виявлення.

Задача не теоретична. На стенді JUN.QFX-Миронівка віддає 100 портів, з них під наглядом 98; ПОЛЬОВЕ.7 — 57, і 29 із них насправді не порти, а рядки «802.1Q Encapsulation Tag 0101». Сто портів по десять OID у одному чеку — це сотні змінних за такт заради графіків, на які ніхто ніколи не подивиться.

Фільтр належить шаблону

«На QFX опитуємо лише xe- і ae-» — твердження про КЛАС заліза, а не про конкретну коробку; рівно та причина, з якої шаблони існують узагалі. Тому tpl.templates.if_filter jsonb, а не поле хоста.

Фільтри кількох шаблонів одного хоста обʼєднуються, а не перетинаються. Шаблони в цьому продукті лише ДОдаються (їх чіпляє автовиявлення саме), і другий шаблон не сміє мовчки відібрати порти, які просить перший: «OLT: gpon» плюс «Магістраль: 10G» на одному ZTE мають дати і те, і те. Перетин дав би нуль і зламав би обидва шаблони одночасно, не назвавши винного. Шаблон без фільтра в обʼєднанні не бере участі: відсутність думки не є думкою «усе».

Зразки застосовує Go, а не Postgres

Три причини, і жодна не про швидкість.

Крива дужка. У ~ вона валить запит, а з ним транзакцію, всередині якої перебудовується чек: snmp.if мовчки перестає оновлюватись на кожному такті, і побачити це можна хіба в журналі сервера. У Go зразок компілюється наперед, і помилка називає себе у формі шаблону, де її щойно зробили.

Відкат. RE2 його не має за побудовою. Бектрекінговий рушій Postgres на випадковому (a+)+b здатен з'їсти хвилини всередині транзакції, що тримає рядки inv.interfaces.

Попередній перегляд мусить казати правду. Перегляд іде через HTTP, тобто через Go. Якби бойове фільтрування жило в SQL, два рушії розійшлися б на першому ж \dу POSIX ERE це просто літера d.

Той самий вибір і з тієї ж причини вже зроблено в autoassign.go, firstGroup.

Зразок компілюється з (?i): Huawei пише Vlanif10, інші вендори — vlan10, і фільтр, який промахнувся через велику літеру, помиляється мовчки. Кому потрібен регістр — пише (?-i).

Порожній результат — відмова, а не тиша

Фільтр, який не пропустив жодного порту, — майже завжди помилка в зразку. Порожній snmp.if-чек ззовні не відрізнити від справного, і помічають його тоді, коли метрик портів немає вже тиждень.

Тому три рівні:

  • збереження шаблону рахує обʼєднання фільтрів для КОЖНОГО привʼязаного хоста й відмовляє з 409, називаючи хости й числа («Леніна.21: 0 з 29»). Перевірка стоїть усередині тієї самої транзакції — шаблон лишається таким, яким був;
  • перебудова після автовиявлення не чіпає склад чека взагалі й пише попередження: краще збирати зайве, ніж перестати збирати все;
  • реконсиляція (розпізнавання хоста, масова правка) цю відмову ковтає навмисно — там помилка одного фільтра поклала б увесь звіт, а сказана вона вже тричі в інших місцях.

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

Порт, який випав, каже про це сам

inv.interfaces.filtered_out_at — коли цей порт перестав потрапляти в чек. Метрики лишаються в історії, але оновлюватись перестають, і без цієї мітки обрив графіка нічим не відрізнити від аварії. Окремо від monitored: то ручний вимикач людини, і плутати його з наслідком шаблону означало б, що вимкнений руками порт сам увімкнеться, щойно хтось поправить зразок.

У перегляді видно ще точніше число: скільки портів зараз опитуються і після цього фільтра перестануть (eligible && !matched && !filtered_out_at). Саме воно лякає, і саме його треба бачити до збереження.

Що показала перевірка на справжніх іменах

Прогін заготовок по всіх 234 портах стенду:

                        JUN.QFX  ZTE   Леніна  ПОЛЬОВЕ-СВ  ПОЛЬОВЕ.7
тип = ethernet|LAG       49/98   3/35   26/29     6/14       28/57
Juniper xe-/ge-/ae-      44/98   0/35    0/29     0/14        0/57
швидкість >= 10G         50/98   1/35    0/29     0/14        0/57
має опис                 27/98   1/35   29/29     0/14        0/57

Три висновки, яких не було видно з коду.

Найнадійніша ознака — тип, а не імʼя. Його задає RFC 2863, а не маркетинг вендора: одна умова прибрала з D-Link усі 29 псевдопортів 802.1Q і «System», а з Juniper — усі .0-підінтерфейси (вони propVirtual), тунелі й службові lsi/dsc/irb/vtep. І ніде не дала нуля.

«Тільки з описом» — пастка. На Huawei опис заповнює сама прошивка однаковим текстом для всіх портів і не відсіює нічого; на обох D-Link описів немає взагалі, і фільтр дав би нуль. Обидва факти тепер написані в підказці заготовки й зафіксовані тестом.

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

Заразом знайшлось ae11 — заведений, але порожній LAG зі швидкістю 0. Жоден зразок його не відрізняє від живого; очима в переліку портів це видно за секунду. Тому перегляд показує перелік, а не лише число.

Стелі не чіпано

MaxInterfacesPerCheck = 256 лишилась як була — вона про розмір PDU, а не про смак. Змінився лише LIMIT читання: він був 257 і відрізав би порти ще до того, як зразок їх побачить, тобто xe-0/0/47 для фільтра просто не існувало б. Тепер читаємо до 4096, фільтруємо, і аж потім обрізаємо. sameInterfaceSet, monitored і реконсиляція шаблонів працюють як раніше.

Базові правила складу портів (monitored, не loopback, не notPresent) винесені в один рядок ifEligibleSQL на всі три місця: розійшовшись на одному «і», перегляд і чек почали б відрізнятись рівно на ті порти, через які людина й прийшла дивитись.


2026-08-26 — Дзеркало архіву конфігів на зовнішньому Git

Половина задачі була спроєктована ще в 0006_ncm.sql: ncm.repos мала remote_url, remote_secret_id і mirror_enabled з коментарем «Опційне дзеркалювання на зовнішній Git». Жоден рядок Go їх не читав. Модель виявилась придатною — добудовано, а не переписано.

Коли пушити

Не після коміту. Push у StoreConfig коротший на десяток рядків і має рівно один наслідок: доступність чужого сервера стає умовою успішного бекапу. Збір — нічний розклад по сотнях пристроїв; недоступний Forgejo перетворив би його на сотню таймаутів там, де все залізо відповіло, а конфіг уже лежить у базі.

Дзеркало — копія. Копія не має права ламати оригінал. Тому власний такт у колекторі, поруч із прибиральником і з тих самих причин (grpcapi/ncm_mirror.go), власне advisory-блокування, і витримка повтору в БАЗІ, а не в пам'яті: процес перезапускають, і витримка, що живе в пам'яті, після кожного релізу починала б шквал спроб рівно тоді, коли ми щойно перезапустились.

Такт хвилинний, але в мережу йде лише коли змінився відбиток локальних посилань — або раз на шість годин про всяк випадок. Відбиток каже, чи змінилось У НАС; він нічого не каже про те, чи ціле дзеркало. Без періодичної звірки інсталяція зі стабільними конфігами вважала б копію живою рівно доти, доки та не знадобиться.

Що бачить людина при тижневій відмові

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

Тому три поверхні. Сторінка «Дзеркало» першим рядком показує «остання копія: 7 днів тому» й текст останньої помилки. Після трьох невдач поспіль (≈8 хвилин) піднімається алерт ncm.mirror:<repo> — на сторінку алертів і в лічильник у шапці, тобто туди, куди дивляться щодня; severity high, якщо копії немає понад добу. Знімається він першим же вдалим обміном. І журнал аудиту: ncm.mirror.update / ncm.mirror.push.

Стан видно з ncm:read, а міняти налаштування можна лише з новим правом ncm:mirror (власник і адмін). Помітити відмову має могти той, хто щодня дивиться на конфіги; вивозити архів за периметр — рішення рівня організації.

Без -f

Refspec шаблонний і без плюса: push або перемотується вперед, або відмовляється з поясненням. Механізм, який уміє мовчки переписати віддалену історію, — це не копія, а другий спосіб її втратити. Перезапис існує окремою дією з підтвердженням і гасне після першого успіху (mirror_force_once). Потрібен він рівно в одному випадку: локальний репозиторій відновили з бази (netpulse-gitsync), коміти склались наново, і їхні ідентифікатори не збігаються з тими, що колись поїхали.

Ключ SSH-сервера звіряється — на відміну від заліза

Для мережевого обладнання в продукті ухвалено протилежне (agent/internal/ncmx/transport.go), і там воно правильне: сотня коробок, кожна перегенеровує ключ після прошивки. Тут один сервер, і везе він за периметр архів із ключами та хешами паролів. Ключ показується під час перевірки з'єднання, закріплює його людина збереженням, далі кожен обмін звіряє. Без закріпленого ключа push по SSH не збирається взагалі.

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

Токен

core.secrets, AES-GCM, AAD <tenant>|ncm.mirror|<repo_id> — той самий механізм, що й для паролів від обладнання. Назад в API не повертається ніколи: форма показує «збережено», порожнє поле означає «лишити як є».

Окремо перевірено, що він не тече в текст помилки. go-git вкладає в помилку повну адресу, а люди вставляють у поле адреси рядок виду https://токен@forgejo/… з чужої інструкції — і тоді токен несе не транспорт, а НАШ власний текст, зібраний із r.URL. Саме це й було помилкою першої редакції; ловиться тестом TestExplainNeverEchoesURLCredentials.

Розкладка під наступний вид даних

Шлях у дереві складався у двох місцях (StoreConfig і SyncGit) — тепер в одному, store.ConfigPath. Правило: config_type без скісної риски дає <хост>/<тип>.cfg (усе наявне — байт у байт), зі скісною — <хост>/<шлях як є>. Тобто набір файлів сервера (files/etc/nginx/nginx.conf) лягає в наявне дерево наявної гілки, не зсуваючи жодного вже надісланого шляху. Зсув шляху після першого push — найдорожча правка з можливих: віддалене дерево прийме її лише примусом.

Дзеркалення це переживає без жодної правки: refspec шаблонний, а не перелік гілок.

Що показала жива перевірка

Одноразові Gitea й Postgres у власних контейнерах (прибрані). Перший повний push 9,5 МБ тексту в чотирьох гілках — 1,5 с; повторний без змін — 286 мс без мережевої роботи; приріст на одну версію — 2,5 с. Хибний токен, токен в адресі, підмінений ключ хоста, розбіжність дерев і свідомий перезапис — усі п'ять дали очікуваний текст, у жодному токена немає.


2026-08-26 — Дзеркало, гілки й чотири помилки, знайдені ним

Перше вмикання дзеркалення на зовнішній Forgejo показало більше, ніж мало б: щоб дзеркалити, треба спершу мати що.

Git-архів не працював ніколи

Том /var/lib/netpulse належав root, а процес працює під uid 10001. Він не міг створити в ньому навіть підкаталогу — і кожен коміт конфігу тихо не відбувався: у сховищі лишався контентний хеш замість SHA коміту, а репозиторію не існувало взагалі.

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

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

Кирилиця в шляхах

sanitizePath викидала все, крім латиниці, і від українських імен лишались хвости: «ZTE Миронівка» → ZTE, «Леніна.21» → 21, «ПОЛЬОВЕ-СВ» → device. Той, хто склонував би дзеркало, побачив би каталоги «1», «7» і «21».

Git зберігає шляхи в UTF-8 і кирилиці не боїться — боялась її лише ця функція. Закріплено тестом на справжніх іменах зі стенду.

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

Гілки за UUID

У коді стояло свідоме рішення: гілка device/<uuid>, «щоб пережити перейменування, інакше історія розсипається на дві». Правильне міркування з наслідком, якого не передбачили: у Forgejo двадцять рядків UUID, і знайти серед них свій комутатор неможливо ані за іменем, ані за адресою. Пошук по гілках — перше, чим користуються в дзеркалі.

Тепер device/<ім'я>-<адреса>, а стабільність забезпечується інакше: при зміні імені гілка ПЕРЕЙМЕНОВУЄТЬСЯ (gitstore.RenameBranch), а не заводиться друга. Історія лишається однією.

Адреса в імені не косметика: у мережах повно хостів «Комутатор» і «ONU».

Заразом перевірилось те, заради чого писався gitsync

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

Порівняння версій не працювало

Порівнювати можна було лише зі СТАРІШОЮ версією — з правильного міркування, що diff «уперед» читався б задом наперед. На живому стенді з цього вийшло, що порівняння не працює зовсім: у чотирьох хостів із шести версія одна, а в решти двох треба вгадати, яку з двох натиснути.

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

Прибирання шаблону лишало правила

Перевірки зникали справно, а правила з тригерів — ні: реконсиляція йшла лише по НОВОМУ набору шаблонів, тож відчеплений ніхто не переглядав. Виходило найгірше поєднання: шаблон прибрано, перевірок немає, а тригер із нього досі піднімає алерти — і знайти джерело нема де. Тепер переглядається об'єднання старого й нового набору.

Відповідність не розрізняла типи конфігів

Перевірка брала просто найсвіжішу версію хоста. Поки в кожного один тип (running), це працює — і працює випадково. Щойно на сервері з'явиться набір файлів під типом files, правило для заліза почне перевіряти його й чесно повідомить про порушення там, де його немає.

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

2026-08-27 — Сім задач одним заходом: що спільного виявилось у всіх

Сім міграцій (00580064) робились паралельно, і за задумом їх поєднував лише перелік справ. За фактом — дещо інше, і це варте окремого запису.

Схема обіцяла те, чого не існувало, у трьох місцях одразу. Тип перевірки snmp.walk стояв у довіднику з міграції 0010, а модуля на зонді не було ніколи — чек повертався з no_module. Таблиця ncm.rollbacks лежала зі схемою двоетапного погодження й без жодного рядка коду. Повідомлення ConfigApplyJob і ConfigApplyResult були в протоколі від першого дня, з гілками в обидва боки, і без обробників по обидва боки.

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

Політики RLS написані на 68 таблицях і не спрацювали жодного разу. Застосунок ходить у базу суперкористувачем. Про це вже було відомо: у 0050_audit_read.sql:188 це записано прямим текстом — діру помітили, обійшли в одному місці й пішли далі. Знайдене й не полагоджене за рік стає нормою.

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

Тест, який нічого не перевіряв. TestDeleteRemoteRefIsTargeted складав адресу як file:// + шлях. На Linux провідна скісна в шляху вже є, і виходить правильне file:///tmp/…. На Windows виходить file://C:/…, де C: — це ХОСТ. Тест падав на першому push, тобто твердження, заради якого він написаний (що видалення гілки адресне й не чіпає сусідню), не перевірялось узагалі. Списувалось на середовище.

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

Що з цього випливає для порядку робіт

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

Тому в чотирьох із семи задач головним результатом стала не можливість, а ВІДМОВА: правило з нереалізованим джерелом більше не зберігається, профіль без команд заливки каже про це замість мовчазної кнопки, міграція RLS валить сама себе на таблиці з tenant_id без політики, тест словника аудиту падає на ключі без назви. Кожна з цих відмов закриває не окрему помилку, а спосіб, у який така помилка з'являється непоміченою.

2026-08-27 — Подієві алерти: тригери на журнал, конфіги й відповідність

Що було зламано

Движок правил рахував рівно три джерела: metric, icmp, interface. Для syslog, trap, ncm, agent, compliance і link EvaluateRule свідомо повертав порожньо. При цьому і форма правил, і редактор тригерів шаблону дозволяли завести правило з такими джерелами без єдиного слова застереження.

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

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

Чому подієвий шлях, а не ще одне опитування

Опитуване джерело має ряд вимірів: питання «чи виконується умова зараз» осмислене, і відповідь може змінитися будь-коли. Тому вікно for_seconds — це запит по часу, а гасіння — різниця множин.

У журналу, конфігів і відповідності такого ряду немає. Питати «чи змінився конфіг зараз» безглуздо: він змінився о 10:42 і більше нічого про це не скаже. Опитування ts.syslog розкладом було б і дорожчим (гіпертаблиця на мільярд рядків), і брехливішим — вікно опитування завжди або пропускає події, або рахує їх двічі.

Тому правило перевіряється рівно в ту мить, коли подія надійшла, у тому процесі, який її прийняв:

  • sysloggrpcapi.StreamLogs, одразу після запису пачки, поруч із наявним тригером позачергового бекапу;
  • ncm — шлях завантаження конфігу: changed після StoreConfig, якщо версія справді інша, і backup_failed у кожній гілці, де завдання закривається як провалене;
  • compliance — прогін RunCompliance, який запускають з UI.

Чотири рішення, які довелося ухвалити явно

Дедуплікація. Один алерт на пару «правило + хост», скільки б подій під нього не підпало. Ключ (EventDedupKey) навмисно не містить нічого від самої події й збігається формою з ключем метричного алерту — два різні формати рано чи пізно дали б два алерти про одне. Захищає той самий унікальний індекс alerts_active_dedup_uniq, що й раніше.

Але злиття не має ховати масштаб: «порт мигнув один раз» і «порт мигнув 4000 разів за годину» — різні аварії з однаковим текстом. Тому з'явився alr.alerts.event_count, і його видно на дошці алертів.

Частота. Дедуплікації мало: без обмежувача потік журналу дав би сотню UPSERT-ів на секунду. Тому, по-перше, пачка рядків зводиться до звернення до бази (OnSyslog збирає збіги в мапу за парою «правило + хост»), по-друге, між двома зверненнями до одного алерту витримується min_interval_seconds правила. Пропущені за цей час події не викидаються — вони накопичуються в обмежувачі й доливаються в лічильник наступним зверненням: економимо запити, а не правду.

Гасіння. Подієвий алерт не має способу зникнути сам, і вдавати, що має, було б брехнею. Його закриває:

  • людина — наявними Ack / Close;
  • строк — ExpireEventAlerts, стан expired, а не resolved. Різниця принципова: ніхто не казав, що проблему полагодили, вона просто відстоялась. expired в alr.alert_state існував із 0007 і досі не використовувався жодним кодом.
  • виняток — відповідність. Там прогін перевіряє всі хости під правилом і каже про кожен «пройшов» або «ні», тобто має справжній сигнал зняття. Такий алерт закривається сам (ResolveEventAlerts), і це не виняток із правила, а наслідок того, що дані інші.

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

Доставка. Подія приходить у netpulse-server (gRPC для зондів), а ключі шифрування каналів, маршрути й тихі години читає netpulse-api, де крутиться движок. Розсилати з обох процесів означало б дублювати повідомлення й розводити дві копії маршрутизації. Тому подієвий шлях лише піднімає алерт із позначкою notify_pending, а розсилає його движок наступним тіком — під тим самим advisory-блокуванням, тобто рівно в одному екземплярі. TakeNotifyPending знімає позначку тією ж командою, що й повертає рядки: інакше два інстанси API розбудили б людину двічі.

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

Джерела, яких свідомо не буде

trap, link і agent не реалізовані, і тепер API відмовляє у їх вмиканні з людським поясненням замість мовчазного збереження:

  • trap — трап приїжджає як OID і набір varbind-ів. Без словника MIB умова звелася б до порівняння цифр із крапками, яких людина не набере з голови. Це була б друга мовчазна обіцянка замість першої.
  • link — лінк на мапі не має власних вимірів: він живий рівно настільки, наскільки живі його кінці. Правило про лінк — це правило про пристрої, і воно вже є.
  • agent — «зонд не на зв'язку» — це стан, а не подія; він рахується опитуванням last_seen_at. Місце йому серед метричних правил.

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

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

Чому перевірка умови стоїть на записі

Той самий аргумент, що й у правил відповідності (0056): про друкарську помилку в регулярному виразі людина має дізнатися з форми, а не з тригера, який рік мовчав. ValidateRuleCondition компілює зразок syslog, вимагає явної події для ncm і живе в store, а не в HTTP-шарі, — бо ту саму відповідь має дати й збереження тригера шаблону, а два різні пояснення тієї самої відмови розходяться на першій же правці.

Міграція 0058

  • alr.rules.auto_close_seconds, alr.rules.min_interval_seconds;
  • alr.alerts.event_count, alr.alerts.notify_pending + два часткові індекси під вибірки «що розіслати» й «що прострочено»;
  • наявним подієвим правилам проставлено добовий строк;
  • правила й тригери шаблонів із джерелами trap/link/agent вимкнено.

Інтерфейс

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

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

Файли

  • server/migrations/0058_event_alerts.sql
  • server/internal/store/alerts_events.go (новий) — класифікація джерел, перевірка умов, RaiseEventAlert, ExpireEventAlerts, TakeNotifyPending, ResolveEventAlerts
  • server/internal/alerting/events.go (новий) — EventSink: кеш правил і селекторів по кабінету, зведення пачок, обмежувач частоти
  • server/internal/alerting/engine.go — подієві джерела вилучено з циклу опитування; додано гасіння прострочених і розсилку черги
  • server/internal/store/alerts.go, alerts_state.go, alerts_query.go, template_triggers.go, ncm_compliance.go
  • server/internal/grpcapi/service.go, streams.go
  • server/internal/httpapi/alerts.go, templates.go, compliance.go, server.go
  • server/cmd/netpulse-api/main.go, server/cmd/netpulse-server/main.go
  • web/src/pages/RulesPage.tsx, web/src/pages/AlertsPage.tsx, web/src/components/TriggerEditor.tsx, web/src/types.ts, web/src/api/client.ts
  • тести: server/internal/store/alerts_events_test.go, server/internal/alerting/events_test.go

2026-08-27 — snmp.walk і прототипи елементів шаблону

Дві речі, які досі були обіцянкою.

Перша: тип snmp.walk стоїть у довіднику core.check_types від міграції 0010, а модуля на зонді немає. Чек такого типу заводився, потрапляв у план і повертався з no_module — система обіцяла вміння, якого не мала, і дізнатися про це можна було лише завівши такий чек.

Друга: шаблон описує СТАЛИЙ перелік метрик. Для sysUpTime цього досить, але половина того, за чим справді стежать, лежить у таблицях зі змінним індексом — диски, сенсори температури, VLAN, елементи стека, оптичні модулі. Скільки їх і під якими індексами — властивість конкретної коробки, а не класу заліза, і в шаблон її не впишеш.

Досі єдиний такий випадок — порти — покривав захардкоджений Go (EnsureInterfaceChecks). Це працює рівно доти, доки випадок один: наступний вендор із власною таблицею вимагав би нового Go-коду, релізу сервера й ще однієї міграції. Шаблони писались саме щоб цього не було.

Прототип — це два речення

  1. «Рядки цієї таблиці шукай обходом ось цього OID»;
  2. «На кожен рядок збери ось ці метрики, підставивши індекс».

Між ними стоїть сервер. Він тримає перелік знайдених рядків у tpl.discovered_rows і перебудовує склад snmp.get-чека, коли рядки з'являються або зникають.

Це рівно той поділ праці, який уже є для портів: модуль topology знаходить, inv.interfaces зберігає, EnsureInterfaceChecks перебудовує чек. Різниця одна й вона головна — тут «що саме шукати» описує шаблон, а не Go.

Чому зонд не збирає все сам

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

Відкинуто з тієї ж причини, з якої snmp.if отримує перелік портів у параметрах, а не ходить по ifTable сам: зонд не має права вирішувати, що опитувати. Це впирається в ліміти тарифу й у те, що оператор мусить бачити склад чека до того, як він поїде в мережу. Крім того, метрики пішли б повз snmp.get — другим, паралельним шляхом із власним розкладом і власними багами поруч із наявним.

Два інтервали, а не один

У прототипа discovery_sec (типово година) і interval_sec (типово хвилина), і це не надмір налаштувань.

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

Наслідок у схемі: на прототип виходить два чеки — рідкий snmp.walk і частий snmp.get. Обходи одного шаблону з однаковою частотою при цьому злипаються в один чек: одна SNMP-сесія на всі піддерева, рівно з тієї ж причини, з якої в один чек збираються OID звичайних елементів.

Фільтр рядків — той самий рушій, що фільтр портів

Механіка з 0051 не дублювалась, а узагальнилась. compiledCondition тепер судить не про структуру порту, а про інтерфейс filterCandidate; набір допустимих полів став параметром компіляції. Портам лишились name/alias/type/speed, рядкам дісталися index/name.

Так навмисно: другий примірник цієї механіки розійшовся б із першим на першому ж виправленні, і «не збігається» почало б означати різне у двох сусідніх вкладках однієї форми. Заразом безкоштовно приїхали властивості, за які вже заплачено, — RE2 без відкату, (?i) типово, помилка в зразку називає себе у формі, а не через добу серед порожніх графіків.

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

Рядок, що зник, мусить пояснювати обрив графіка

tpl.discovered_rows має дві позначки замість видалення:

  • gone_at — рядка більше немає в таблиці (диск вийняли);
  • filtered_out_at — рядок є, але фільтр прототипу його не пропускає.

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

Обрізаний обхід (truncated) не дає права нікого ховати: до решти рядків просто не дійшли, і позначити їх зниклими означало б стерти половину графіків через власну стелю.

Рядки тримаються за КЛЮЧ прототипу, а не за його id

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

Три секунди типового таймаута тут не годяться

core.checks.timeout_ms типово 3000, і для одного Get цього досить. Обхід триває стільки, скільки в таблиці рядків: 64 диски — це щонайменше три BulkGet-и поспіль, а до віддаленого вузла й більше. Чек, що не встиг, віддає «таймаут», сервер не бачить жодного рядка й нікого не заводить — прототип мовчки не працює взагалі, і причина не написана ніде.

Тому обхід отримує 20 секунд, а збір значень по рядках — 15, рівно стільки ж, скільки EnsureInterfaceChecks дає чеку портів із тим самим порядком кількості OID. Таймаут переписується і при оновленні чека, а не лише при створенні: інакше чек, заведений із помилковим значенням, ніс би його вічно.

Помилка 0046 не повторена

params_schema для snmp.walk описувала чек, якого ніколи не існувало: один oid і max_rows — без відповіді на питання, ЧИЇ це рядки й куди їх покласти. Схему звірено з WalkParams у Go рядок за рядком.

Заразом виправлено snmp.get: сервер почав писати labels (без них два диски злилися б в один графік — ряди розрізняє ts.series.labels_hash), і схема мусила сказати про це в тій самій міграції. omitempty на полі не косметика: без нього params усіх наявних snmp.get-чеків отримали б зайве "labels":null, інший md5(params), інший хеш плану й одноразовий перезалив усього парку заради нічого.

Вбудований прототип рівно один і вимкнений

snmp-host-resources — вбудований шаблон, спільний для всіх тенантів і вже причеплений до хостів на робочих стендах. Увімкнений прототип завів би їм по два нові чеки МОВЧКИ, під час міграції, без жодного рішення людини; перше, що побачив би адміністратор, — приріст SNMP-трафіку невідомо звідки.

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

Інтерфейси прототипом НЕ виражені

Спокуса очевидна: порти — це теж таблиця зі змінним індексом, і зняти хардкод із autochecks.go було б красиво. Не зроблено, і це рішення, а не брак часу.

snmp.if — не набір метрик. Він повертає InterfaceCounters: агент тримає попередній замір, рахує швидкості за ФАКТИЧНИМ інтервалом, ловить перевертання лічильника й ділить на номінальну швидкість порту. Прототип віддав би сирі октети без жодного з цих чотирьох умінь.

Далі — прив'язки. Лічильники лягають у ts.if_counters за interface_id, а не в ts.samples за міткою. На цьому interface_id тримаються анімація трафіку на мапі, інспектор лінка, filtered_out_at із 0051 і правила тригерів із джерелом interface. Прототип породжує серії з мітками й без interface_id — переїзд означав би переписати все перелічене й перенести історію.

І нарешті ціна помилки. На стенді шість хостів із робочими snmp.if-чеками. Міграція, яка спробувала б замінити їх чеками прототипу, у найкращому разі обірвала б графіки трафіку, у гіршому — лишила б обидва набори чеків одночасно й подвоїла опитування. Виграш — мінус двісті рядків Go; ризик — єдине, що на цьому продукті бачать щодня.

Хардкод лишається там, де він є, і тепер має поруч чесний коментар про те, чому саме він окремий випадок.

Що змінено

Зонд:

  • agent/internal/modules/snmp/walk.go — новий модуль: обхід піддерева, індекс + назва рядка, стелі, дедлайн, окрема доля кожного прототипу;
  • agent/internal/modules/snmp/snmp.gosnmp.walk у CheckTypes() і в диспетчері;
  • agent/internal/modules/snmp/walk_test.go.

Сервер:

  • server/migrations/0059_template_prototypes.sqltpl.item_prototypes, tpl.discovered_rows, RLS, правдива params_schema для snmp.walk і snmp.get, auto_managed, вимкнений вбудований прототип;
  • server/internal/store/prototypes.go — опис, реконсиляція, приймання рядків, розгортання прототипу в oidSpec;
  • server/internal/store/iffilter.go — узагальнення рушія фільтрів;
  • server/internal/store/templates.go — прототипи в читанні, збереженні, реконсиляції, експорті й клонуванні; labels в oidSpec;
  • server/internal/store/prototypes_test.go;
  • server/internal/httpapi/templates.go — прототипи в тілі збереження й перевірка зразків там, де їх щойно надрукували;
  • server/internal/grpcapi/streams.go — приймання payload_json від snmp.walk.

Веб:

  • web/src/components/PrototypeEditor.tsx — нова вкладка редактора;
  • web/src/pages/TemplatesPage.tsx, web/src/types.ts.

Чого немає

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

0060 — Відкат конфігу: від наміру до запису на пристрій

Таблиця ncm.rollbacks стояла в базі з 0006 — з наміром, двоетапним погодженням і полем «команди, які реально підуть на пристрій». Коду під нею не було жодного рядка. Тобто система вміла зібрати конфіг, зберегти версію, показати різницю, віддзеркалити в Git і перевірити на відповідність — і не вміла єдиного, заради чого все це збирають: повернути те, що працювало вчора.

Це не «ще одна функція NCM». Це те, чим Enterprise-тариф відрізняється від Oxidized: Oxidized теж збирає й версіонує, але на питання «поверни як було» відповідає «ось файл, іди набирай».


Чому заливка — це не «надіслати файл на пристрій»

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

CLI мережевого заліза не має режиму «замінити конфіг на оцей». Рядки, віддані в режимі конфігурації, додаються до наявного. Хост, у якому вчора помилково створили VLAN, після такої «заливки» матиме і вчорашній конфіг, і той VLAN — рівно те, від чого відкочувались. Відкат, який не прибирає зайве, відкатом не є.

Тому на пристрій їде різниця:

  • рядків бракує — ідуть як є;
  • рядки зайві — ідуть із префіксом заперечення родини (no , undo );
  • родина заперечення не має — рядки не вигадуються, а показуються людині окремим переліком «прибрати вручну».

Останнє — про D-Link, і це не пропуск. У D-Link немає універсального no: створене прибирається delete, налаштоване переписується config, увімкнене вимикається disable. Вивести з рядка конфігу потрібне дієслово автоматично неможливо — create vlan v10 tag 10 прибирається як delete vlan v10, і жодне механічне правило цього не дасть. Тому система заливає лише додане й змінене, а зайве віддає людині — з попередженням до підтвердження, а не з розбіжністю після.

Чому контекст відновлюється за відступом

Рядок ip address 10.0.0.1 255.255.255.0 сам собою не означає нічого: він належить інтерфейсу, назва якого стоїть вище й без відступу. Віддати його без батьківського рядка означає віддати його в глобальний режим — і отримати або помилку, або, що гірше, зміну не того об'єкта.

Тому кожна зміна їде разом зі своїм ланцюжком батьків, а після блока — exit. Відступ як ознака вкладеності — властивість самих конфігів (Cisco, ZTE, Huawei друкують ієрархію відступами), а не наш здогад; родини без ієрархії (D-Link) просто не мають відступів, і той самий код дає для них плаский перелік.

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


Безпека

Погодження не обходиться

Політика (ncm.rollback_policy) типово вимагає другої людини й типово забороняє самопогодження. Знімок політики лежить у самому намірі: вимкнення вимоги заднім числом не легалізує те, що вже висить у черзі.

Вимикач цієї вимоги навмисно лежить під іншим правом (settings:write), ніж сам відкат (ncm:rollback). Інакше двоетапність зводиться до однієї галочки, яку знімає той самий, кого вона мала стримати. Погодити машинним токеном не можна взагалі: погодження — це підпис людини, а токен інтеграції звів би двоетапність до другого curl із того самого скрипта.

Паролі не потрапляють ані в базу, ані в стенограму

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

Тому зроблено дві речі.

  1. У ncm.rollbacks.commands план лежить замаскованим (redact_patterns профілю плюс запасний перелік). Туди ж, у замаскованому вигляді, він іде в журнал аудиту. Тіла конфігів зашифровані в core.secrets, і покласти ті самі рядки відкритим текстом у сусідню таблицю означало б обійти власне шифрування.
  2. Стенограма й вивід кожної команди маскуються перед записом у базу.

Маскується сам збіг, а не весь рядок: людині при погодженні треба бачити, що змінюється («буде переписано пароль на vty»), і рядок із самих зірочок цього не каже.

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

Після заливки — обов'язковий контрольний збір

Відповідь CLI не є доказом. Пристрій відповідає рядком тексту, і мовчазна згода означає «прочитав», а не «застосував»: половина платформ мовчки ігнорує рядок, який не підходить до поточного контексту. Вірити виводу — це той самий клас помилки, що колись дав «Next possible completions» у ролі версії конфігу в архіві (0034, 0043).

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

  • нуль команд → applied, відкат справді відбувся;
  • не нуль → mismatch, і це число видно в інтерфейсі.

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

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

Обрив зв'язку посеред заливки

Найгірший стан, у якому може опинитись вузол: половина команд на пристрої, друга — ні, а результат не приїде ніколи. Найгірша реакція на нього — записати failed і заспокоїтись, бо failed читається як «нічого не сталось».

Тому прибиральник переводить такий намір не у відмову, а в verifying: ставить контрольний збір і дивиться, що реально стало на пристрої. Відповідь буде або applied (устигло все), або mismatch із числом недоробленого — і в обох випадках це знання, а не здогад. Пристрій поруч, його можна спитати.

Зонд зі свого боку робить три речі, які цьому допомагають:

  • зупиняється на першій помилці (доливати рядки в конфіг, який уже пішов не тим шляхом, означає отримати стан, якого не описує жоден із двох конфігів);
  • віддає звіт завжди, навіть якщо обірвалось усеу ньому видно, що саме встигло виконатись;
  • виконує команду збереження лише після повного успіху. Незбережений напівзалитий конфіг має важливу властивість: його прибирає перезавантаження вузла. Зберегти його означає відібрати цю останню можливість.

Окремий стан отримав і випадок «залито, але не збережено»: до перезавантаження пристрій працює за новим конфігом, після — за старим.


Вбудовані профілі

Заповнено те, що перевірено або однозначно випливає з синтаксису родини:

профіль вхід заперечення збереження
cisco-ios configure terminal no write memory
zte-zxr10 configure terminal no write
zte-zxan configure terminal no write
dlink-me — (без режиму) немає save

MikroTik RouterOS — свідомо без відкату. Вивід export виглядає як набір команд, але команди в ньому — add. Повторне виконання add не повертає рядок на місце, а створює другий такий самий запис: другу адресу на інтерфейсі, друге правило фаєрвола. Прибирання робиться через remove [find …] — через пошук за критерієм, якого в рядку експорту немає. Тобто механічний відкат тут не «поки не зроблений», а дав би гарантовано хибний результат.

Juniper JUNOS — свідомо без відкату. Профіль знімає show configuration | display omit, тобто ієрархію у фігурних дужках. Це не набір команд: віддати такий текст рядками в CLI неможливо. Альтернатива існує — профіль зі збором | display set дав би рядки set … із заперечником delete , — але це інший профіль збору й переливання архіву, і робити це мовчки, підмінивши формат історії, не можна.

Решта 140+ профілів лишається з apply_supported = false, і інтерфейс про це чесно каже: «для цього профілю відкат не налаштований» плюс причина з apply_note. Порожній профіль, який виглядає робочим, гірший за відсутню кнопку: він обіцяє відкат рівно до того моменту, коли відкат знадобиться.


Інтерфейс

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

  1. Що зміниться — різниця «зараз → стане», двома колонками. Не дата версії й не її розмір, а самі рядки. Прапорець ставиться фактом перегляду, а не галочкою «я подивився»: галочку ставлять не дивлячись.
  2. Команди на пристрій — план у тому порядку, у якому його виконає зонд, із нумерацією (коли заливка спиниться, у звіті буде номер). Різниця показує намір, план показує дію, і це різні речі: «прибрати vlan 200» у різниці виглядає як зникнення рядка, а на пристрій піде no vlan 200.
  3. Що лишиться на пристрої — перелік «прибрати вручну», з окремим свідомим підтвердженням. З'являється не завжди й найважливіший, коли з'являється.

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


Що змінилось

Міграція server/migrations/0060_ncm_rollback.sql — поля заливки в ncm.profiles, два стани (verifying, mismatch) і десяток полів у ncm.rollbacks, таблиця ncm.rollback_policy, команди заливки для чотирьох родин і чесні пояснення для двох, яким її не дали.

Сервер store/ncm_rollback_plan.go (побудова плану, маскування), store/ncm_rollback.go (намір, погодження, черга, перевірка), httpapi/ncm_rollback.go (вісім ендпоїнтів, RBAC), grpcapi/ncm_rollback.go (диспетчер і приймач результату).

Зонд ncmx/apply.go (заливка тим самим CLI, що й збір), session/apply_jobs.go (звіт назад тим самим стрімом).

Веб components/RollbackDialog.tsx, панель намірів і кнопка в pages/ConfigsPage.tsx.

.proto не змінювався: ConfigApplyJob, ConfigApplyResult і гілки в ControlDown/ControlUp лежали там від першого дня й чекали рівно коду по обидва боки.

2026-08-27 — Кнопки в Telegram нарешті щось роблять

Що було зламано

Сповіщення в Telegram їхало з двома кнопками — «Підтвердити» й «Заглушити 1 год». Кнопки малював notify.go, callback_data складався як ack:<alert_id> і mute:<device_id>. Приймача не існувало: сервер не мав ані вебхука, ані опитування, і натискання не робило нічого.

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

Поруч лежала дрібніша поломка того ж роду: кнопка «Заглушити» малювалась навіть тоді, коли алерт не має хоста (аварія самого зонда, черга не встигає). У callback_data їхало голе mute:, і натиснути на неї можна було рівно з тим самим результатом — тобто ні з яким.

Довге опитування, а не вебхук

Вебхук вимагає, щоб Telegram МІГ ДО НАС ДОСТУКАТИСЬ: публічний порт із переліку 443/80/88/8443 і сертифікат, якому довіряє їхній бік. Самопідписаний приймається лише завантаженим у setWebhook файлом — і навіть тоді потрібне ім'я, на яке його видано.

Це розгортання — самопідписаний TLS на голій IP-адресі, домену немає. Причому це не «поки не налаштували»: self-hosted інсталяція в мережі оператора зазвичай узагалі не має входу ззовні. Вебхук там не запрацює ніколи, і код під нього був би кодом, який не працює в жодній наявній інсталяції.

getUpdates не вимагає ані вхідного порту, ані імені, ані сертифіката: з'єднання ініціює сервер, а TLS перевіряється в бік api.telegram.org — тобто туди, де сертифікат справжній. Ціна — одне висяче HTTP-з'єднання на бота й курсор у базі.

Про X-Telegram-Bot-Api-Secret-Token окремо, бо його вимагають за звичкою: цей заголовок захищає ВЕБХУК від сторонніх POST-ів на наш відкритий шлях. У схемі з опитуванням відкритого шляху не існує взагалі — приймати нема чого, ми самі ходимо по оновлення. Отвору, який той заголовок затуляє, тут немає. Якщо колись з'явиться домен, вебхук стане кращим, і перевірка натискання переїде в нього без змін: вона навмисно не знає, звідки прийшло оновлення.

Порядок перевірок

Головне питання приймача — не «що просять», а «хто просить». У callback_data лежить рівно те, що ми туди поклали, але надіслати цей рядок може будь-хто, хто раз побачив формат: він не підписаний і не зашифрований. Тому кабінет і особа НЕ беруться з нього ніде.

  1. Чий чат. matchChannel шукає канал, у якого chat_id збігається з чатом повідомлення з кнопкою. Чат прописав адміністратор кабінету руками, тож збіг означає рівно потрібне. Чат, якого немає в жодному каналі, кабінету не дає взагалі — і на цьому все зупиняється.
  2. Що просять. parseCallbackData приймає лише ack:/mute: з канонічним uuid. Кривий рядок відпадає до першого звернення до бази.
  3. Хто натиснув. core.telegram_accounts за парою (кабінет, from.id). Немає прив'язки — немає дії.
  4. Чи можна йому. UserPermissions (потрібне alerts:ack) і LoadScope (хост має бути в межах доступу). Ті самі дві перевірки, що й у HTTP-обробників.
  5. І лише тоді — дія.

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

Та сама логіка, а не її копія

doAck викликає store.AckAlert, doMutestore.MuteDevice. Ті самі, що й POST /api/v1/alerts/{id}/ack і POST /api/v1/mutes: підтвердження з телефона має лишати в базі такий самий слід, як підтвердження з браузера, а власна копія розійшлася б із оригіналом на першій же правці — і побачили б це не в коді, а у звіті.

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

Ідемпотентність — двома шарами

Палець натискає двічі, а мережа вміє доставити те саме натискання повторно.

Дія. Перед AckAlert іде AlertAckState: якщо алерт уже підтверджений, друге натискання відповідає «Уже підтверджено: <хто>» і НЕ переписує автора — перший, хто взявся, лишається першим. Для заглушення так само: ActiveMute не дає покласти другий ряд у alr.mutes, бо інакше «на годину» мовчки перетворювалось би на дві.

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

Після дії повідомлення міняється

answerCallbackQuery показує спливаючий рядок на кілька секунд і лише тому, хто натиснув; у чат він не потрапляє, а чат читає вся зміна. Тому слідом іде editMessageText: у текст дописується « Підтверджено: , 27.08 03:12», а inline_keyboard замінюється на порожній — кнопки зникають. Без цього наступний черговий натискає їх ще раз.

parse_mode при правці навмисно не задається, хоч надсилали ми з HTML: Telegram віддає в message.text уже готовий текст без розмітки, і повторна відправка його як HTML або зламалася б на першому «<» у назві інтерфейсу, або перетворила б частину тексту алерту на теги.

Прив'язка людини

Питати в людини її числовий telegram user_id безглуздо — вона його не знає, а дізнатись може лише через сторонніх ботів, тобто ми б відправляли її віддати свою ідентичність невідомо кому заради нашої ж форми. Поле вводу до того ж дозволяє вписати ЧУЖИЙ id.

Тому напрям такий: NetPulse видає одноразовий код (профіль → Telegram), людина шле боту /link КОД. Повідомлення в Telegram — доказ володіння акаунтом, якого форма дати не може: його не надіслати за іншого. Код народжується там, де людина вже увійшла паролем, живе 15 хвилин, згорає при першому використанні й лежить у базі лише хешем.

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

Міграція 0061

  • core.telegram_accounts — зіставлення tg_user_idcore.users. Тенантне, а не глобальне: підрядник обслуговує кілька кабінетів і в кожному є окремим користувачем. Два унікальні індекси — один telegram на людину й одна людина на telegram у межах кабінету.
  • core.telegram_link_codes — sha256 коду, строк, одноразовість.
  • alr.telegram_cursors — місце в черзі getUpdates, ключ — хеш токена бота. Без нього перезапуск процесу або губить натискання, або переграє добову історію (Telegram тримає невибрані оновлення 24 години) і глушить хост о десятій ранку за кнопкою, натиснутою вночі. Ключ саме бот, а не канал: getUpdates ексклюзивний, і два канали одного бота крали б оновлення один в одного.

Чого в міграції НЕМАЄ навмисно — зв'язку «повідомлення Telegram → алерт». Редагувати треба РІВНО те повідомлення, кнопку якого натиснули, а його chat_id і message_id приходять у самому callback_query. Довідка «яким повідомленням це поїхало» вже є: alr.notifications.external_id зберігає message_id з 0007.

Один читач на бота

getUpdates ексклюзивний, тож два процеси на одному боті ділили б натискання навпіл. Приймач бере advisory-блокування на ВЕСЬ час роботи (не на такт, як движок алертів) і тримає його разом зі з'єднанням: падіння процесу звільняє блокування само, сусід підхоплює за пів хвилини. Живе з'єднання перевіряється щотакту — мертве означає, що Postgres блокування вже зняв.

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

Файли

server/migrations/0061_telegram_callbacks.sql      нове
server/internal/store/telegram.go                  нове
server/internal/alerting/telegram_callback.go      нове  (розбір і перевірки)
server/internal/alerting/telegram_bot.go           нове  (опитування, Bot API)
server/internal/alerting/telegram_callback_test.go нове  (без мережі)
server/internal/httpapi/telegram_link.go           нове  (код прив'язки)
server/internal/alerting/notify.go                 кнопка mute лише за наявності хоста
server/internal/store/alerts_query.go              MaxMute, ActiveMute, AlertAckState,
                                                   DeviceNameInTenant
server/internal/httpapi/alerts.go                  maxMute → store.MaxMute
server/internal/httpapi/server.go                  /api/v1/me/telegram{,/code}
server/internal/store/audit_actions.go             core.telegram.link / .unlink
server/cmd/netpulse-api/main.go                    -telegram-callbacks
web/src/types.ts                                   TelegramLink, TelegramLinkCode
web/src/api/client.ts                              три виклики
web/src/pages/ProfilePage.tsx                      картка «Telegram»

Чого НЕ зроблено

  • Вебхука немаєі не буде, доки немає домену з дійсним сертифікатом. Причина вище; перевірка натискання від транспорту не залежить і переїде без змін.
  • Кнопки «Закрити алерт» немає. CloseAlert каже «проблему полагоджено», і казати це не подивившись — не те, що робиться однією кнопкою з телефона.
  • Прибирання протухлих кодів (PruneTelegramLinkCodes) написане, але в жоден такт не всунуте: рядків там стільки, скільки натискань на «Отримати код», і заводити під це фонову задачу зараз дорожче за сам механізм. Один незужитий код на людину тримає унікальний індекс.
  • Живого прогону не було. Жодного запиту до Telegram API не надсилалось: тест перевіряє розбір і перевірки без мережі, решта — на стенді з живим ботом.

2026-08-27 — Журнал без назв і архів без виходу

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


Частина перша: журнал аудиту не знав власних дій

store.audit_actions.go перекладає ключ (ncm.mirror.push) у фразу («Примусовий пуш архіву на дзеркало»). Файл просив наступного дописати рядок — і за півроку програв це прохання тричі: дзеркало Git, ролі й прив'язка хоста до машини зонда писались у журнал, а назв не мали. Адміністратор бачив у фільтрі сирі ключі.

Помітити пропуск неможливо ніяк, крім як відкрити журнал і впізнати відсутню назву: запис іде, сторінка відкривається, фільтр працює.

Що знайшлось насправді

Перелік виведено з коду, а не з пам'яті: знайдено кожен виклик WriteAudit у server/internal і розібрано, що саме лягає в AuditEntry.Action (два з них ховались за помічниками auditRole і auditSelfAgent, де ключ їде параметром).

Бракувало восьми дій:

inv.device.self_agent          Прив'язка хоста до машини зонда
inv.device.self_agent_clear    Зняття прив'язки хоста до машини зонда
ncm.mirror.update              Налаштування дзеркала Git
ncm.mirror.push                Примусовий пуш архіву на дзеркало
core.role.create               Створення ролі
core.role.update               Зміна прав ролі
core.role.delete               Видалення ролі
inv.device.bulk_purge          Повне видалення хостів разом з історією

…і двох типів об'єктів: ncm.mirror («Дзеркало Git») і core.role («Роль»). Плюс дві нові дії цієї ж роботи — inv.device.bulk_restore («Відновлення хостів з архіву») і той самий inv.device.bulk_purge.

Останній — окрема історія. Повне видалення писалось під тим самим ключем, що й архівне (inv.device.bulk_delete), а різниця лежала в meta.mode. Для журналу це найгірше з можливих місць: фільтр за дією не розрізняє «прибрано з переліку» й «немає більше ніде», позначка «незворотна» стоїть однаково на обох, а прочитати meta можна лише розгорнувши кожну подію окремо. Ключ, за яким шукають найстрашнішу дію в системі, тепер власний. Назва старого лишилась навмисно широкою: під ним у журналі вже лежать обидва режими, і перейменувати їх заднім числом означало б підправити чужі події, яких ніхто не перевірить. meta.mode теж лишився — за ним уже шукають.

Головне: спосіб, у який назви губились, закрито

Дописати вісім рядків — це наслідок, а не робота. Робота — зробити так, щоб дев'ятий не загубився.

Ключ тепер має рівно одне місце оголошення — константу в audit_actions.go. Поруч стоїть тест (audit_actions_source_test.go), який розбирає ВЕСЬ server/ як текст програми, знаходить кожне присвоєння AuditEntry.Action і AuditEntry.ObjectType і падає на двох речах:

  • ключ написано рядком просто в місці виклику (два місця оголошення — два джерела правди, що розходяться мовчки);
  • у константи немає назви у словнику.

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

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

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

Блок AuditBlindSpots (чесний перелік того, чого журнал не бачить) виправлено: рядок «зміни складу команди й ролей не записуються» став неправдою наполовину — самі ролі тепер у журналі є, а от хто кого в яку роль посадив — і далі ні. Так і написано.


Частина друга: «прибрати з переліку» було пасткою

Видалення хоста має два режими: архівне (deleted_at) і повне (0057). Архівне задумувалось оборотним — «зібране лишається», — але оборотним ніколи не було: переліку прибраних хостів у продукті не існувало, дії «відновити» теж, і рядок із deleted_at був недосяжний нізвідки, крім SQL. Хост не видалений, місце займає, ряди метрик лишає — і ні повернути, ні добити.

0057 дав спосіб добити. Тут з'явився спосіб повернути.

Чому цього не зробити одним deleted_at = NULL

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

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

inv.devices.archived_enabled   яким був enabled на момент архівування
core.checks.archived_off       цей чек вимкнуло архівування, не людина

Обидві живуть лише між архівуванням і поверненням; повернення вмикає рівно те, що вимикало архівування, і забуває запис. Хости, прибрані до 0062, мають archived_enabled = NULL — такий повертається вимкненим, і інтерфейс про це прямо каже. Це чесніше, ніж увімкнути навмання те, що людина могла вимкнути свідомо ще до видалення.

Там же — індекс під сам перелік архіву: усі наявні індекси на inv.devices часткові WHERE deleted_at IS NULL, тобто заведені під питання «що зараз працює» й на питання «що прибрано» не відповідають узагалі.

Три речі, які могли змінитись, поки хост лежав

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

  • devices_tenant_name_uniq (tenant_id, lower(name)) — за місяць хост із такою ж назвою могли завести заново. Ім'я незамінне: мовчки додати « (2)» означало б повернути не той хост, який просили. Тому це відмова саме по цьому хосту, зі словами, що робити далі; решта набору повертається.
  • devices_self_agent_uniq (self_agent_id) — прив'язку «цей хост і є машина зонда» міг забрати інший живий хост. Тут навпаки: хост повертається, прив'язка знімається, і про це прямо сказано у відповіді. Прив'язку людина ставить назад двома кліками, а відмовити через неї означало б лишити хост в архіві через налаштування, яке й так треба переглянути.
  • Зонд-опитувач (agent_id) чіпати не треба: якщо зонд видалили, ключ уже поставив NULL (0003), і хост повертається без опитувача — це видно в переліку окремою колонкою.

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

Стан (status) при поверненні скидається в unknown: у базі лежить те, яким хост був перед видаленням, і показати тримісячної давнини «недоступний» як поточний стан означало б збрехати ще до першого опитування. next_run_at повернутих перевірок зсувається на «зараз» — інакше кожна з них одразу виглядає як така, що запізнилась на три тижні, і «Черги» чесно показують затор, якого немає.

Вибірка стала параметром, і навмисно тільки в одному місці

deleted_at IS NULL стоїть у півсотні запитів: план колектора, рушій алертів, мапи, добір цілей команд. Жоден із них не має права почати бачити прибрані хости через те, що хтось додав параметр із «зручним» типовим значенням.

Тому параметр звужений до ListDevices — типом DeviceView, у якого нульове значення ("") і є колишня поведінка. Умова написана так, що при false вона тотожна колишній: параметр не розширює вибірку, а перемикає її на другу половину. «Живі плюс прибрані» третім станом не передбачено — над прибраним хостом немає жодної дії, яка є над живим.

GET /api/v1/devices?view=archived віддає ЛИШЕ архів. Невідоме значення читається як «живі»: клієнт старішої збірки має отримати колишню поведінку, а не відмову.

В інтерфейсі

Кнопка «Архів (N)» у переліку хостів — і її немає, поки архів порожній: вхід у перелік, у якому нічого не буде, лише розповідає про режим, якого людині не треба. Сам перелік архіву відрізняється трьома способами одразу: смуга-пояснення вгорі, перекреслене приглушене ім'я з позначкою «в архіві» в кожному рядку, а колонка «Стан» замінена на «Прибрано» з датою — стан прибраного хоста не показується взагалі, бо це не стан, а музейний експонат.

Режим вибору там увімкнений завжди: картки в прибраного хоста немає (сервер віддає лише живих), тож клік по рядку не має куди вести, крім як відмітити. Дії — «Повернути» й «Видалити назавжди», у порядку ціни помилки. Вибір скидається при кожному перемиканні «живі ↔ архів»: відмічені id лишились би дійсними, але дія над ними в іншому переліку інша.

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

Заодно виправлено текст, який став неправдою: картка «Прибрати з переліку» обіцяла, що «побачити чи повернути такий хост через інтерфейс не вийде».

Права

devices:write — те саме, що й на архівування, і окремого права під повернення немає свідомо. Якби повернення вимагало більшого, система дозволяла б прибрати хост і не дозволяла б виправити це тому самому інженерові. Права на кожен хост окремо перевіряє добір (Scope.Writable). Повне видалення з архіву йде тим самим шляхом, що й було, — з ncm:delete, якщо в хоста є збережені конфіги.


Перевірка

cd web && npx tsc --noEmit — чисто; npm run build — зібралось (271 модуль, попередження про розмір чанка було й до цього).

Go-перевірки (gofmt -l . && go vet ./... && go build ./... && go test ./...) НЕ виконано: на машині немає інсталяції Go — ані в PATH, ані в типових місцях, ані в WSL (його теж немає). Це треба прогнати перед злиттям; окремо варто переконатись, що новий audit_actions_source_test.go бачить дерево від server/internal/store (він ходить по ../..).

2026-08-27 — RLS, який нарешті вмикається

Ізоляція кабінетів у продукті трималась на одному: на тому, що кожен із 26 тисяч рядків server/internal/store не забуває дописати tenant_id = $1. Другий рубіж — Row Level Security — був написаний ще в 0011, стоїть на 68 таблицях, на 56 із них із FORCE ROW LEVEL SECURITY, і не спрацював жодного разу.

Причина в одному рядку docker-compose.yml: DSN збирається з ролі netpulse, а її створює образ Postgres зі змінної POSTGRES_USER, тобто bootstrap-суперкористувачем. Суперкористувач обходить RLS беззастережно — ані ENABLE, ані FORCE на нього не діють. Захист був написаний, увімкнений у схемі й вимкнений у житті.

Що показово: у 0050 це вже було записано прямим текстом — «у цьому розгортанні застосунок ходить у базу роллю-власником таблиці», і саме тому заборона правити журнал зроблена тригером, а не GRANT-ами. Тобто про діру знали, обійшли її в одному місці й пішли далі.

Спочатку — перелік того, що зламається

Увімкнути RLS — це один ALTER ROLE. Уся робота тут в іншому: у переліку місць, які ходять у базу повз тенантний контекст. InTenantTx із SET LOCAL app.tenant_id — єдине місце, де контекст виставляється, а повз нього ходять 64 запити.

Розібрані по одному, вони діляться на чотири різні речі:

скільки що це
A 20 вхід і токени: тенант ще невідомий, його щойно й з'ясовують
B 29 фонові такти поверх усіх кабінетів
C 7 тенант відомий, контекст просто не виставили
D 8 гіпертаблиці, де RLS немає й бути не може

Найнебезпечніша тут не C, як здавалось на початку, а B — і не тим, що її багато, а тим, як вона ламається. Усі 29 б'ють у звичайні таблиці під політиками: alr.rules, ncm.jobs, ncm.command_targets, core.event_outbox, inv.devices. З порожнім app.tenant_id вони повертають нуль рядків без помилки. Тобто після перемикання ролі колектор, рушій алертів, дзеркало, retention і подієвий канал у браузер не падають — вони замовкають. У журналі при цьому порожньо.

Перший, хто ламається насправді, — AuthenticateAgent: пошук агента за token_hash у core.agents. Виняток для шляху входу в 0012 зробили для core.users, core.sessions і core.membershipsі не зробили для агентів, API-токенів, реєстрації зондів, публічних панелей і квитків на завантаження. Тобто без цих п'яти політик перемикання ролі означає, що жоден зонд не автентифікується взагалі.

Три діри, які знайшлись дорогою

topo.link_live. Звичайний VIEW поверх topo.links без security_invoker. Такий вигляд читає базову таблицю правами ВЛАСНИКА, а не того, хто питає. Це єдине місце в схемі, де перехід на роль без BYPASSRLS не змінює нічого: завантаження лінків на мапі й далі показувало б лінки всіх кабінетів. Один рядок ALTER VIEW.

Шість зв'язкових таблиць без жодної політики: core.role_permissions, inv.device_group_members, inv.device_tags, inv.device_credentials, topo.map_shares, bill.invoice_lines. Цикл у 0011 шукав колонку tenant_id, а в зв'язках її немає — і правильно, що немає: власна колонка тенанта у зв'язці може розійтися з батьком. Але руками про них ніхто не згадав. Найгірша з шести — inv.device_credentials: її рядок каже, яким доступом ходити на хост. Чужий рядок там — це не «побачив зайве», а «зайшов на чужий комутатор нашими руками».

Права за замовчуванням. Сім таблиць, доданих після 0011, тримаються виключно на ALTER DEFAULT PRIVILEGESі тримаються доти, доки кожну наступну міграцію котить та сама роль. Відновлення з дампа під іншим користувачем лишає їх без прав. Поки застосунок — суперкористувач, цього не видно взагалі. Заразом виявилось, що ALTER DEFAULT PRIVILEGES ніде не роздано на послідовності: перший же serial у наступній міграції дав би «permission denied for sequence» на проді.

Ролі: чому воркер лишається з BYPASSRLS

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

Ламається не обробка, а ПЕРШИЙ запит — той, що каже, кого обробляти. Його перебором не заміниш: щоб дізнатись перелік кабінетів, треба прочитати core.tenants поверх кабінетів.

Друга половина гірша за це. Видача завдань зондам — це одна інструкція UPDATE … FOR UPDATE SKIP LOCKED … RETURNING tenant_id, яка одночасно знаходить роботу, забирає її собі й каже, чия вона. Розкласти її по кабінетах означає замінити один такт на N тактів кожні 5 секунд і власноруч завести голодування: кабінет, який стоїть у циклі першим, вибирає ліміт, а останній не отримує нічого. SKIP LOCKED існує рівно проти цього.

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

Обмежує таку роль не RLS (її вона за визначенням не бачить), а те, ким і звідки вона використовується: окремий DSN, окремий пул Store.bg, скінченний перелік методів. Код, який обслуговує запит користувача, фізично не має доступу до цього пулу — у нього інший пароль. Не SET ROLE на спільному з'єднанні: воно повертається в пул, і одна забута команда RESET перетворює запит користувача на запит з BYPASSRLS — рівно та помилка, проти якої вся ця робота.

Звужувати GRANT-и воркера до переліку таблиць 0063 не береться, і це свідомо: вузький перелік, складений із читання коду, а не з роботи стенду, — спосіб зупинити бекапи через півтори доби на таблиці, про яку забули. Це окремий крок після тижня спостережень.

Міграцію можна накотити, нічого не вмикаючи

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

Те саме з боку коду: NETPULSE_DSN_WORKER порожній — і Store.bg дорівнює основному пулу, тобто все поводиться як раніше. Тобто і схему, і код можна викотити наперед, а роль перемкнути окремим кроком з власною перевіркою й відкатом у 30 секунд (deploy/RLS-CUTOVER.md).

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

Міграція, яка перевіряє сама себе

0063 закінчується трьома перевірками, які падають ТУТ, а не в проді: таблиця з tenant_id без RLS або без політики; таблиця, до якої netpulse_app не має SELECT (під новою роллю це не порожній результат, а «permission denied» на першому ж запиті клієнта); і довідковий перелік таблиць без tenant_id і без політик — щоб наступний автор побачив його очима, а не дізнався про нову таблицю в цьому переліку через півроку.

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

Тест, без якого все це не має сенсу

Різницю між «RLS увімкнено» і «RLS працює» неможливо побачити з pg_policies: ENABLE, FORCE і сама політика виглядають однаково і там, де вони діють, і там, де їх обходить rolbypassrls. Тому тест не перевіряє наявність політик — він перевіряє результат запиту.

Два кабінети з хостами, портами, мітками, доступами й лінком; SET ROLE netpulse_app; запит без предиката tenant_id — саме той, від якого RLS і страхує. Плюс адресне звернення за чужим id (так виглядає витік через підставлений у URL uuid), плюс спроба записати чужий tenant_id, плюс порожній контекст.

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

Чого це не дає

Телеметрія лишається поза RLS — гіпертаблиці й стиснення в TimescaleDB не поєднуються з row level security, і це не наслідок цієї роботи, а незмінна властивість. Для ts.*, core.audit_log, alr.alerts_history і alr.notifications предикат у запиті й далі єдиний механізм.

CreateUser довелось лишити поза політиками: додати в кабінет людину, яка вже працює в чужому, під RLS неможливо — ON CONFLICT (username) DO UPDATE … RETURNING не побачить чужого користувача й замість «підхопили наявного» дасть помилку унікальності. Політикою це не лікується: щоб її обійти, треба зробити core.users видимою наскрізь, тобто відкрити рівно ту дірку, яку закриваємо. Дія рідка, адміністративна й крос-тенантна за природою — ходить пулом воркера.

І головне, що варто сказати прямо: сама роль воркера бачить усе. Перехід прибирає ризик «забутий WHERE в API віддає чужі хости» — той, через який витік доходить до екрана клієнта. Ризик «фоновий такт узяв не той кабінет» він не прибирає, тільки звужує до 29 перелічених запитів.

2026-08-27 — Строки зберігання даних і сторінка сховища

База на стенді важить 120 МБ при шести хостах. Це не проблема — проблема в тому, що жодна з цифр не має стелі. Порт хоста це рядок у ts.if_counters на кожному такті; 500 хостів по 24 порти — 12 000 рядів, сотні тисяч рядків на добу з самих лічильників. Сеанс збору конфігу лишає транскрипт у ncm.jobs, прогін команд — транскрипт на КОЖЕН хост у ncm.command_targets. Диск закінчується мовчки: Postgres працює нормально до останнього вільного байта, а потім перестає приймати записи взагалі. Перша ознака проблеми і є сама аварія, і настає вона одночасно для всього продукту.

Аудит: що вже прибиралось, а що ні

Перше, що з'ясувалось: твердження «немає жодного механізму» неправильне. Механізм є, і половина роботи полягала в тому, щоб його не зламати.

Гіпертаблиць у схемі дванадцять. Вісім у ts (samples, icmp_samples, if_counters, link_status, device_status_history, syslog, snmp_traps, agent_health), дві в alr (alerts_history, notifications), дві в core (audit_log, login_attempts), плюс bill.license_checkins. Безперервних агрегатів шість: 5-хвилинні й годинні згортки для метрик, ICMP і лічильників портів.

Стиснення (0005) стоїть на восьми: ts.samples, icmp_samples, if_counters — з другої доби, link_status і snmp_traps — із сьомої, syslog і agent_health — з третьої, core.audit_log — з року (0050 свідомо відсунула його з місяця до року, бо стиснутий журнал перестає шукатись за вмістом).

Видалення (0005, 0007, 0009, 0012) стоїть на дев'ятьох: сирі метрики, ICMP і лічильники — 35 діб, syslog і трапи — 90, самометрики зондів — 14, 5-хвилинні згортки — 400, сповіщення — 90, спроби входу — 180, перевірки ліцензії — 400.

Дір три, і кожна росте назавжди: ts.link_status, ts.device_status_history і alr.alerts_history не мають політики видалення взагалі. Це не дрібниці: історія алертів — рядок на кожну аварію кожного хоста, а історія станів пише рядок на кожну зміну «вгору/вниз», тобто на кожен блимок каналу. Обидві маленькі рівно доти, доки хостів шість.

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

Таблиця Що в ній Швидкість
ncm.command_targets вивід кожної команди на кожному хості + повна стенограма сесії найтовщі рядки в базі; один прогін по дільниці — кілобайти тексту на кожен зі ста хостів
ncm.jobs транскрипт кожного сеансу збору конфігу 500 рядків із текстом щодня при добовому розкладі
core.secrets зашифровані тіла версій конфігів мегабайт на версію для ZTE C320; прибирається політикою 0037
topo.discovery_runs прогони пошуку сусідів найменша: запускають руками

Те, що росте, але вже обмеженеі саме тому нової ручки не отримує: ncm.configs (політика 0037), ncm.diffs (каскад від конфігів), ncm.compliance_results (UNIQUE (rule_id, device_id) — переписується, а не росте), core.event_outbox (PruneEvents), core.download_tickets (строк квитка), topo.map_revisions (стеля ревізій на мапу), alr.alerts (закриті переїжджають в історію фоном).

Головне рішення: строк — рівня інсталяції, а не кабінету

Видалення старого в гіпертаблиці — це drop_chunks: чанк зноситься цілою таблицею, разом із рядками ВСІХ кабінетів, що в нього потрапили. Чанк ріжеться за часом і лише за часом; про кабінет він не знає нічого. Тобто «тенант А тримає метрики рік, тенант Б — тиждень» реалізується тільки власним DELETE по рядках — тобто відмовою від єдиного механізму, заради якого TimescaleDB і взято.

Різниця не теоретична, і це друге рішення тієї ж пари. DELETE у Postgres не звільняє місця на диску: рядок позначається мертвим, сторінка лишається файлу, і повернути її здатен лише VACUUM FULL — який блокує таблицю цілком і вимагає стільки ж вільного місця, скільки вона займає. Тобто на переповненому томі, саме тоді, коли це потрібно, він не спрацює. drop_chunks зносить чанк як таблицю, і місце повертається негайно.

Тому: гіпертаблиці — рідні політики TimescaleDB, звичайні таблиці — пакетне видалення, і про його межу сказано людині прямо у формі («місце звільниться всередині таблиці й буде перевикористане, але на диск не повернеться»).

Міграція 0064

core.retention_settings — рядок на вид даних: ключ, таблиця, механізм (timescale/batch), колонка часу, строк у добах. NULL означає «не видаляти» — саме NULL, а не нуль: нуль читався б як «тримати нуль днів», тобто як наказ знести все, і одна помилка в перетворенні типів між формою й API коштувала б усієї телеметрії.

Рядки заводяться з ФАКТИЧНИМ станом бази, а не з бажаним. Це головне рішення міграції. Спокуса накотити перелік «правильних» строків велика, але на чужій інсталяції наш правильний строк — це чиясь втрачена історія. Міграція лише записує те, що вже діє, у місце, де це видно людині: не видаляє жодного рядка й не змінює жодного наявного строку. Інсталяція, що оновиться, вранці має рівно ті самі дані, що й учора. Наслідок, який варто розуміти: одразу після накату екран виглядає нерівно — десь 35 діб, десь порожньо. Так і має бути. Це знімок реальності, і саме він змушує запитати, чому історія алертів не прибирається взагалі.

core.retention_current(rel) — чинний строк політики. Окрема функція через одне неочевидне місце: для безперервного агрегату політика висить не на вигляді, а на матеріалізованій гіпертаблиці під ним (_timescaledb_internal._materialized_hypertable_N). Шукати її за іменем вигляду означає не знайти ніколи й доповісти людині, що згортки не прибираються, — при тому що 0005 їм строк задала.

core.retention_hypertable(rel) — те саме з іншого боку: під яким іменем відношення лежить на диску. Запитати розмір у вигляду означає отримати нуль.

core.apply_retention_policies()SECURITY DEFINER, і це необхідність, а не зручність. add_retention_policy вимагає прав ВЛАСНИКА гіпертаблиці; після 0063 застосунок ходить роллю netpulse_app, яка власником не є й не має ним ставати. Без цієї функції екран міг би зберегти число й не змогти його застосувати — рівно той стан, у якому інтерфейс бреше. Функція навмисно вузька: не приймає ані імені таблиці, ані строку, а лише приводить політики у відповідність до рядків таблиці налаштувань. search_path прибитий цвяхами.

Індекси під пакетне видалення: command_runs_age_idx, ncm_jobs_age_idx, discovery_runs_age_idx — по created_at. Наявні індекси не годяться: у прогонах вони по (tenant_id, created_at), у завданнях — по (device_id, created_at), а прибирання йде поверх кабінетів і поверх хостів, тобто читало б усю таблицю на кожну партію. ncm.command_targets власного індексу не отримує: цілі зникають каскадом від прогону, і command_targets_run_idx каскад уже обслуговує.

core.storage_samples і core.storage_config — ряд спостережень за розміром і ємність тому.

Стиснення НЕ додається, хоч трьом гіпертаблицям його бракує, і це свідома відмова з поясненням у міграції. Урок 0050: стиснення робить недоступними індекси за всіма колонками, крім segmentby. Для alr.notifications segmentby alert_id дав би сегменти по одному-два рядки, тобто стиснення без стиснення. alr.alerts_history питають і за tenant_id (список історії), і за device_id (видалення хоста, 0057) — будь-який вибір лишає другий запит без індексу, тобто прискорює місце ціною тихо померлої сторінки. Правильна відповідь для всіх трьох — строк зберігання: рядок, якого немає, займає нуль і шукається миттєво.

Безперервні агрегати: найлегше місце все зіпсувати

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

Захист двома правилами в ValidateRetention, і набір перевіряється ЦІЛКОМ, а не по полю: половина умов тут про стосунки між видами, і на одному числі їх не перевірити.

  1. Нижня межа на вид. Сирі дані під згорткою — не менше 2 діб (5-хвилинні згортки рахуються з відставанням у три години); 5-хвилинні — не менше 7 (годинні рахуються з вікном у дві доби); годинні — 30. Журнал аудиту — не менше 365: рік це межа, у яку вміщається практично будь-який розбір (0050), а коротший строк знищує докази раніше, ніж по них приходять.
  2. Згортка не зникає раніше за джерело. keep(5m) ≥ keep(raw), keep(1h) ≥ keep(5m); «не видаляти» вважається нескінченністю. Відмова з поясненням, а не мовчазне виправлення.

Обидва правила перевірені юніт-тестами без бази: якби вони жили в SQL, перевірити їх можна було б лише на живому Postgres — тобто на практиці ніколи.

Безпека

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

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

Для гіпертаблиць рахуються саме ЧАНКИ, а не «рядки, старші за строк»: це різні множини. drop_chunks зносить чанк лише тоді, коли ВЕСЬ його діапазон вийшов за строк, тож частина рядків, старших за строк, ще поживе в чанку, який закриється завтра. Рахувати за рядками означало б обіцяти більше, ніж станеться, — а людина, звіривши цифри після, вирішила б, що прибирання не працює. Розмір чанків точний (chunks_detailed_size), кількість рядків — точний count(*) зі стелею в 15 секунд; не встигли — повертається -1 і форма каже «не порахували». Саме -1, а не нуль: «нічого немає» перед видаленням мільйонів рядків — брехня найгіршого ґатунку.

Для звичайних таблиць кількість рядків точна, місце — оцінка через середній рядок, і так і підписано: рядок із транскриптом на 40 КБ і рядок з помилкою на 30 байтів відрізняються на три порядки.

Зміна строку йде в аудитcore.retention.update, і в meta не «зберегли форму», а що саме змінилось: вид, мітка, «було → стало» і окрема позначка shortened. Через рік питання буде не «хто відкривав сторінку», а «хто скоротив історію алертів до тижня». Дію не позначено Destructive: сама зміна нічого не стирає, стирає її наслідок, і позначка «об'єкта більше немає» була б неправдою про мить події.

Версії конфігів не переїжджають сюди. Їхня політика (0037) влаштована не за віком, а як «останні N АБО молодші за M днів», із захистом останньої версії хоста, версій під відкатом і версій, на які посилаються результати перевірок. Звести це до однієї цифри в добах означало б утратити рівно ті гарантії, заради яких воно написане: конфіг, який не міняли три роки, — не сміття, а єдина копія. Друга ручка до тих самих даних була б гіршою за відсутність ручки — той самий висновок, що й у коментарі 0037 до ncm.device_policies.retention_versions. Сторінка сховища показує розмір ncm.configs і core.secrets як довідку й відправляє міняти політику туди, де вона живе.

Дзеркала Git очистка не чіпає взагалі, і чіпати не може. Git append-only за побудовою: прибрати з нього історичну версію означає переписати всі наступні коміти гілки, тобто зламати commit_sha в усіх уцілілих рядках ncm.configs і зробити неможливим fast-forward для дзеркала. Це вже описано в ncm_retention.go і сказано людині у формі. Ні drop_chunks, ні пакетне видалення до репозиторію не дотягуються — телеметрії там немає взагалі.

Сторінка «Сховище»

Показує не розмір, а ШВИДКІСТЬ і те, що з неї випливає. «120 МБ» не означає нічого; «плюс 40 МБ за добу, вільного на 12 діб» означає все.

Цифри з системних представлень Postgres і TimescaleDB (pg_database_size, hypertable_detailed_size, hypertable_compression_stats, timescaledb_information.chunks, pg_total_relation_size), а не з формул за кількістю рядків: розмір рядка залежить від стиснення, TOAST і роздутості після видалень, і будь-яка формула розійшлася б із диском у той самий день, коли на неї почнуть покладатись, — і розійшлася б у бік «усе гаразд».

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

Ємності тому сторінка не знає й не вигадує. Функції «скільки вільного на диску» в Postgres немає, а процес застосунку живе в іншому контейнері й може стояти взагалі на іншій машині — тобто statfs теж не відповідь. Число вводить людина; поки не ввела, сторінка показує розмір і швидкість, але не показує дати переповнення. Вигадана дата гірша за її відсутність: на такі дати дивляться саме тоді, коли перевіряти вже пізно.

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

Права

Дивитись — devices:read, тобто кожен, хто взагалі бачить систему. Те саме рішення, що й для дзеркала конфігів (0054): помітити, що диск закінчується, має могти той, хто щодня дивиться на моніторинг. Міняти — settings:write: строк незворотний за наслідками й діє на всю інсталяцію, тобто це рівно те, що описує це право. Досі його не питав жоден обробник — рядок про нього прибрано з dormantPerms тією ж правкою, як і передбачав коментар до цього переліку.

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

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

Прибиральник

Такт щогодини в колекторі (netpulse-server), поруч із очисткою конфігів і з тих самих трьох міркувань: тут стоїть той, хто дані створює; REST-процес масштабують копіями й він же обслуговує запити людини; ключ шифрування тут не потрібен зовсім. Своє advisory-блокування, окреме від очистки конфігів: спільне означало б, що довгий прохід по прогонах команд заодно зупиняє прибирання версій.

Три кроки: звірка політик TimescaleDB зі збереженими строками (для випадку відновлення з дампа), пакетне видалення звичайних таблиць по 5000 рядків із стелею 200 000 на вид за прохід, знімок розмірів — після прибирання, а не до, інакше приріст рахувався б із піків перед кожним проходом.

Окремо про тиху відмову. Прохід рахує, скільки рядків підпадало під строк, ДО видалення. Якщо підпадало більше нуля, а видалено нуль — у лог іде попередження з ймовірною причиною: з'єднання відкрито роллю під RLS без заданого кабінету, тобто DSN колектора не веде на netpulse_worker (0063). Без цієї пари чисел такий збій виглядав би як повна тиша: помилки немає, прибирання немає, диск росте.

Чого не зроблено

Не перевірено на живій базі. На цій машині немає ані Docker, ані WSL, ані локального Postgres — одноразовий контейнер timescale/timescaledb:2.17.2-pg16 підняти нічим, а бойовий стенд чіпати заборонено. SQL написано лише на публічних API TimescaleDB 2.17 і перевірено читанням; перед накатом його треба прогнати на одноразовій базі. Найризикованіші місця, які варто перевірити першими: chunks_detailed_size над матеріалізованою гіпертаблицею агрегату, hypertable_compression_stats на гіпертаблиці без стиснення (запит відокремлено, щоб її збій коштував одного стовпця, а не сторінки) і виклик add_retention_policy усередині транзакції з-під SECURITY DEFINER.

Алерт про вичерпання місця не додано. Движок правил зараз править інший агент, і його файли не чіпались. Що варто додати, коли він звільниться: alr.rule_source — це джерела, прив'язані до хоста (metric, icmp, interface), а «на диску лишилось 5 %» до хоста не прив'язане взагалі. Найдешевший шлях, який НЕ вимагає правок движка, — писати розмір бази й запас у добах звичайними метриками в ts.series (наприклад db.size.bytes і db.days_left на хості, що позначений машиною зонда), і тоді працює наявне метричне правило без жодної зміни коду. Альтернатива — нове джерело system у движку — дорожча й дає те саме.

Стиснення трьом гіпертаблицям не додано — свідомо, з поясненням вище. Це вимірювана задача (як у 0050), а не рішення з міркувань симетрії.

bill.license_checkins не заведено видом даних. Строк у неї є (0009, 400 діб), а розділу «Тариф» у продукті ще немає — рядок у формі означав би запрошення покрутити те, наслідків чого людині ніде не видно.

Розмір у попередньому перегляді для звичайних таблиць — оцінка, і точнішою її зробити нічим без повного обходу рядків. Позначено як оцінка в API (exact: false) і у формі (знак ≈).

2026-08-27 — Перехід на роль без BYPASSRLS: як зелений тест пропустив поломку

Перемикання застосунку на netpulse_app зламало вхід у систему. Не тихо — чесна 403 no_membership, «користувача не додано до жодної організації», для власника з повними правами.

Причина в одному рядку userMemberships:

SELECT ... FROM core.memberships m
JOIN core.tenants t ON t.id = m.tenant_id

Політика на core.tenants вимагає id = core.current_tenant(). Під час входу кабінет ще невідомий за визначенням — його якраз і з'ясовують цим запитом. Курка з яйцем: щоб дізнатись свої кабінети, треба вже бути в кабінеті.

Чому перевірка цього не спіймала

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

І він дивився повз поломку за побудовою. Тест питав «чи не видно ЧУЖОГО». Зламалось протилежне — не видно СВОГО, і не в даних, а на шляху до них.

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

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

Що виявилось насправді

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

Замір показав 30 звернень основним пулом поза тенантним контекстом. З них під RLS ламався один — core.tenants, — але це збіг обставин, а не підстава правити один рядок.

Принцип замість переліку правок

RLS захищає доступ до даних кабінету. Він за побудовою не може захистити запит, який питає «а який це кабінет?»: політика вимагає відповіді на те саме питання, яке запит ставить.

Тому на воркерний пул переведено весь до-кабінетний шар цілком: вхід і сесії, машинні токени, автентифікація й реєстрація зонда, публічна панель за токеном, квиток на завантаження, приймання телеметрії. Не як виняток заради працездатності, а тому, що RLS їх ніколи й не захищав. Їхній захист — предикат: хеш токена, user_id, термін дії квитка. Він був єдиним і до 0063.

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

Телеметрія: поломка, яка чекала б наступної зміни в мережі

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

Впав би НОВИЙ ряд — новий хост, новий порт, перший рядок прототипу. Тобто поломка виявилась би не при перемиканні, а через тиждень, при першій зміні в мережі, і зв'язати її з переходом на RLS було б уже нічим.

Як перевіряли вдруге

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

Еталон зняли ДО перемикання, на суперкористувачі. Без нього «шість хостів» нічого не доводить: цифру треба порівнювати, а не оцінювати.

Після перемикання всі 28 збіглися з еталоном, вхід повернув «Основний кабінет», ізоляція лишилась (шість своїх, нуль без контексту), новий ряд ts.series створюється, у журналах жодної відмови доступу.

Ціна помилки виявилась низькою, і це не випадковість

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

Дорого коштує не помилка, а помилка без шляху назад.

2026-08-27 — Друга сімка: те, що перевірки нарешті виконались

Сім задач (00650068 плюс plural, тести вебу й виправлення знайденого) робились так само паралельно, як перша сімка. Різниця в одному: цього разу в кожне завдання було вписано вимогу — окремим абзацом сказати, чого перевірка НЕ покриває.

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

Що ця вимога дала

Три агенти незалежно написали одне й те саме речення: жоден рядок SQL не виконувався. Ані нові міграції, ані запити ескалацій, ані запис трапів — усе перевірено компіляцією Go й прочитане очима.

Це не було відомо до того. Тести були зелені, звіти бадьорі, і без прямої вимоги назвати непокрите ця діра просто не потрапила б у поле зору — рівно як не потрапила вранці.

Агент трапів пішов далі й назвав найімовірнішу непомічену поломку у власній роботі: v1-трап жодного разу не летів по мережі, нормалізація перевірялась на зібраному руками пакеті, і якби gosnmp заповнював Enterprise/AgentAddress інакше, переклад за RFC 3584 давав би невірний OID мовчки при зелених тестах.

Пісочниця

Тому перед розгортанням на стенді заведено окрему базу netpulse_probe й накочено в неї 00010068 з нуля. Досі міграції перевірялись лише як приріст до наявної схеми — на чистій базі не проганявся ніхто.

60 із 60 пройшли. Разом із ними вперше в житті виконались: TestRLSTenantIsolation, TestEscalationAgainstDB і всі інтеграційні набори store / httpapi / grpcapi.

І одразу дали дві помилки. Обидві — не в новому коді, а в тестах, які описують світ до появи розпізнавання пристроїв: TestControlHandshake вимагав рівно один чек у плані (тепер їх два, topology.identify заводить сам сервер при підключенні зонда), а TestPlanHashSkipsResend рахував хеш ДО того, як цей чек створювався, і потім звинувачував сервер у пересиланні, яке спричинив сам.

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

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

Міна із закінченнями рядків

Генератор профілів писав 0014 через write_text, який на Windows перекладає \n у \r\n. Контрольна сума при цьому змінюється, а сторож міграцій звіряє саме її — тобто бінарник, зібраний на Windows після запуску генератора, зупинив би накат на стенді. Не одну міграцію, а всі: сторож падає на першій розбіжності.

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

Заразом виявилось, що сам генератор був зламаний: MIGRATION вказував на db/migrations, хоча міграції давно переїхали в server/migrations. Тобто --check завжди звітував про розходження, і його червоний стан став фоном, на який перестали дивитись.

Наслідок довелось розплутувати на стенді: там лежала CRLF-версія 0014 з відповідною сумою, у дереві — LF. Полагоджено виправленням записаної суми, і не на віру: стенд без CR == дерево, дерево з CR == стенд, побайтово.

Третя функція, що вбиває кирилицю

safeName у вебі згортав усе не-ASCII у підкреслювач, бо \w у JavaScript — це [A-Za-z0-9_]. Київ-ядро і Львів-ядро давали ОДНАКОВЕ ім'я файла: вивантажив два конфіги — отримав один.

Це третій випадок за дві сесії після sanitizePath у гілках Git і шляхів дзеркала. Спільного коду в них немає — спільна лише звичка писати класи символів як \w.

Що ще знайшли тести, яких не було

Тести вебу писались із нуля (їх не існувало взагалі) і по дорозі принесли одинадцять справжніх вад. Найдорожчі три: IntervalInput міг надіслати на сервер інтервал 0 секунд (Number('') === 0 при min={5} у розмітці); мінікарта малювалась у 180 px усередині контейнера 68 px, тобто стиснута в 2.6 раза; fmtBytes існував у трьох копіях, і третя вже розійшлась — спинялась на мегабайтах.

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

Кожен новий тест перевірено на «падає до виправлення»: усі виправлення тимчасово відкочувались, упало рівно 18 очікуваних перевірок. Тест, який ніколи не падав, не доводить нічого — це той самий урок, що й із пропущеними інтеграційними.

Що лишилось вимкненим свідомо

Приймач трапів розгорнуто, але модуль не увімкнено в переліку зонда, і 162/udp назовні не опубліковано. Це не забуто: порт без автентифікації приймає будь-кого, хто знає адресу, і відкривати його на бойовій машині — рішення власника, а не наслідок того, що код готовий.

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

2026-08-27 — SNMP-трапи: приймач на зонді, словник і джерело алертів trap

Дорога від пристрою до бази була прокладена вся, крім першого метра. Таблиця ts.snmp_traps існує з 0005, поле LogBatch.trapsу контракті з перших днів gRPC, серверний WriteLogs писав трапи в базу. Не було рівно одного: на зонді ніхто не слухав 162/udp. Клієнт, який налаштував на комутаторі snmp-server host <зонд> traps, отримував порожній журнал і жодного способу дізнатися, що справа не в комутаторі.

Друге, що лікує ця робота, — відмова 0058 від джерела trap. Її аргумент («без словника MIB умова звелася б до порівняння сирих OID») був правильний, а висновок — надто широкий: він припускав, що словник буває або повний, або ніякий.

Приймач на зонді

Новий модуль agent/internal/modules/traps. Слухає 162/udp через gosnmp.TrapListener, приймає v1, v2c та inform.

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

v1-трапи перекладаються у v2-ідентифікатор за RFC 3584: generic 05 → 1.3.6.1.6.3.1.1.5.N+1, generic 6 → <enterprise>.0.<specific>. Завдяки цьому «linkDown зі старого світча по v1» і «linkDown із нового по v2c» — одна подія, а не дві, під які треба заводити два правила. Поля заголовка v1, яких у v2 немає (sysUpTime, enterprise, agent-address), дописуються varbind-ами — теж за RFC 3584.

Обмеження, щоб шторм не поклав ані зонд, ані базу: двадцять трапів за секунду з однієї адреси (відро токенів на джерело, а не спільне — інакше один комутатор із битою оптикою витісняє всю решту мережі), десять тисяч у черзі з викиданням найстарішого, тридцять два varbind-и на трап, 512 символів на значення. Кожне обрізання видиме: у varbind-ах з'являється запис із порожнім OID і текстом «ще N відкинуто». Мовчазне обрізання перетворює «ми показали не все» на «більше нічого не було».

Модуль вмикається окремим ключем traps у -modules, а не разом із syslog: це другий порт у фаєрволі клієнта, і відкривати його «заодно» ми не маємо права. Адреса — -traps-listen / NETPULSE_TRAPS_LISTEN.

Syslog і трапи їдуть ОДНИМ стрімом StreamLogs. За межами зонда це та сама подія з мережі: сусідні таблиці, ті самі подієві правила, той самий момент аварії. Другий стрім означав би другий комплект підтверджень, лімітів і черг переповнення.

Питання MIB, вирішене чесно

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

  • шість трапів, які визначає сам протокол (RFC 1215 / snmpTraps з RFC 3418): coldStart, warmStart, linkDown, linkUp, authenticationFailure, egpNeighborLoss. Вони вшиті в код (store/traps_mib.go), бо однакові в кожного вендора й не мають налаштовуватись кабінетом. Плюс імена обов'язкових полів трапа й чотирьох стовпців ifTable, без яких linkDown не відповідає на єдине питання, яке до нього ставлять, — «який саме порт»;
  • власний словник кабінету — inv.trap_oids, редагується на сторінці «Трапи». Кілька рядків, а не тисячі: у живому кабінеті трапів, на які хтось дивиться, менше десятка;
  • усе інше показується сирим OID із написом «невідомий трап». Саме з написом. Назва, вгадана за схожістю префікса, була б брехнею рівно там, де написаному довіряють найбільше, — у заголовку, який читають, вирішуючи, чи вставати.

Словник живе на сервері, а не на зонді, і застосовується при ЧИТАННІ. Тому назва, дописана сьогодні, підписує й учорашній журнал.

Джерело trap у подієвому движку

Умова вміє три речі, і рівно ті, які до трапа ставлять: trap_oid (що сталося), source_ip (звідки — адресою або підмережею) і пара varbind_oid / varbind_value (з яким значенням). Varbind порівнюється зі СТОВПЦЕМ: у трапі приходить ifOperStatus.7, а людина пише ifOperStatus, бо номера порту наперед не знає.

Чого умова не вміє — сказано вголос, а не проігноровано. Зразок (regex), severity_lte і tag у правилі на трапи не приймаються з поясненням, чому саме: трап — не рядок тексту, а набір типізованих полів. Це не причіпка: людина, яка переключила джерело правила з «Журнал» на «Трапи» й лишила в формі зразок, інакше отримала б правило, що ловить УСЕ. Правило без OID і без адреси теж відхиляється — воно підпало б під кожен трап у мережі.

Трап БЕЗ хоста піднімає алерт лише тоді, коли правило явно назвало адресу джерела. Алерт без хоста нікуди не маршрутизується, не глушиться вікном обслуговування й майже нічого не каже черговому. Ключ дедуплікації для такого алерту будується від адреси (TrapDedupKey), інакше всі незнайомці злилися б в один рядок «щось у мережі шле трапи».

Трап від невідомої адреси

Окремий стан, а не тиха втрата. Такі трапи лягають у журнал із порожнім device_idі додатково в inv.trap_unknown_sources: адреса, зонд, перша й остання поява, лічильник, останній OID. У журналі їх не видно (він за часом, і три трапи на добу тонуть між тисячею своїх), а це найцінніші три: майже завжди вони означають, що в мережі з'явилось кероване залізо, про яке моніторинг не знає.

Таблиця має стелю в 500 рядків на кабінет (тригер на INSERT). Адресу відправника UDP підробити нічого не варте, і без обмеження цей шлях був би способом наростити таблицю клієнта з його ж мережі.

Сторінка «Трапи»

web/src/pages/TrapsPage.tsx, у меню поруч з алертами. Фільтри: період, трап (списком тих, чиї назви ми знаємо, або довільним OID), адреса чи підмережа джерела, пошук по varbind-ах, «лише невідомі». Стан фільтра в адресі — відфільтрований журнал пересилають колезі. Угорі — блок невідомих відправників, унизу — словник і перелік того, чого приймач НЕ бачить: порожній журнал без цього блоку читається як «трапів не було», хоча найчастіше означає «їх нікому приймати».

Що знято, а що ні

Заборону на збереження правила з джерелом trap знято в коді (store.UnsupportedSourceReason), бо саме там вона й стояла — 0058 лишила по собі відмову з поясненням, а не обмеження в схемі.

Правила, вимкнені 0058-ю, міграція 0065 навмисно НЕ вмикає назад. Вони писались тоді, коли перевірки умови не існувало, і в їхньому condition лежить що завгодно. Увімкнути їх означало б отримати або тишу, або потоп — тобто знову «увімкнено й не працює». Правило лишається сірим, людина його відкриває, і форма або приймає умову, або каже, чого бракує.

Формат зберігання varbind-ів

Був мапою «oid → значення», став масивом {o,t,v}. Мапа втрачала тип (2 в ifOperStatus — це «down», а не число два), порядок (RFC вимагає sysUpTime і snmpTrapOID першими) і повтори (трап із двома ifIndex перетворювався на трап з одним). Читач розуміє обидві форми: архів, накопичений раніше, не мав зникнути того вечора, коли вперше з'явилась сторінка, на якій його видно.

0066 — Ескалація сповіщень

Що було

Таблиця alr.escalation_policies стоїть у схемі з 0007. Порожня. Коду під нею немає жодного рядка — ані читання, ані запису. Це та сама порожня обіцянка, що й тригери на трапи до 0058, тільки непомітніша: там людина бачила правило в переліку й могла помітити нуль спрацювань, тут сутність існувала лише в схемі.

Ціна відсутності рахується однією ситуацією. О 02:40 падає ядро, сповіщення йде в Telegram черговому, черговий спить. Система вважає, що повідомила: notify_count = 1, у журналі доставки status = 'sent', на дошці алерт червоний. Формально все спрацювало. Фактично про аварію дізнаються о 09:00 з дзвінка клієнта. Моніторинг, який повідомив рівно один раз і замовк, відрізняється від відсутнього лише тим, що в нього є алібі.

Рішення

До чого прив'язана драбина

До правила (alr.rules.escalation_policy_id), типово NULL.

  • До серйозності — одна драбина накрила б усі high у кабінеті. Але high на тестовому комутаторі й high на ядрі — це різні люди й різна година ночі, а серйозність у них однакова, бо її ставить той самий тригер. Розвести їх можна було б лише збрехавши про гостроту.
  • До групи хостів — той самий комутатор ескалював би однаково «завантаження порту 91%» і «пристрій не відповідає». Перше може чекати до ранку, друге — ні.
  • До правила — правило єдине місце, де «що сталося» і «на яких хостах» уже вирішені разом. Саме там у 0018 оселилось «куди слати» (channel_ids) з тим самим міркуванням.

Серйозність і група нікуди не діваються: вони вже є в самому правилі (severity + selector).

alr.routes.policy_id лишається невикористаним свідомо — про це поставлено COMMENT. Дві точки, де задається та сама драбина, означають, що на «чому мене розбудили» треба читати обидві.

Схема (0066)

  • alr.alert_escalations — стан драбини, рівно один рядок на алерт (PK = alert_id). Взводиться через ON CONFLICT DO NOTHING, тому повторна доставка драбину не подвоює.
  • alr.escalation_steps — журнал сходинок. Окремо від alr.notifications, бо той відповідає на «чи пішло», а тут потрібне «не пішло, бо алерт підтвердили о 02:47». У ескалації половина роботи — не будити, і саме її треба вміти показати.
  • alr.escalation_policies — доведена до придатного стану: CHECK на кількість сходинок і повторів, description, created_at/updated_at, зафіксований формат steps.

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

Як проходить драбина

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

Правила:

  1. Стан алерту читається перед КОЖНОЮ сходинкою. Драбина, взведена о 02:40, не має о 03:10 будити керівника через алерт, закритий о 02:45. Перевірка «лише на початку» проходила б усі очевидні тести й ламалась би рівно там, де дорого.
  2. Підтвердження й закриття зупиняють драбину. AckAlert і CloseAlert зупиняють її ще й негайно, у тій самій транзакції, — але це косметика для картки алерту, а не механізм безпеки. Механізм — пункт 1: він накриває і ті шляхи закриття, до яких код ack не дотягується (гасіння прострочених подієвих, ResolveMissing, вимкнення правила).
  3. Заглушення й вікно обслуговування сходинку не витрачають, а відкладають. Списана сходинка означала б, що півгодинне вікно тихо роззброює драбину до кінця життя алерту — тобто дає рівно ту мовчазну відмову, від якої ескалація рятує. Відкладання обмежене стелею життя драбини (deadline), інакше воно ходило б по колу місяцями.
  4. Драбина взводиться лише тоді, коли перше сповіщення справді пішло. Немає каналів (тиха година правила, поріг серйозності, вимкнений канал) — немає драбини: інакше о 15-й хвилині пішло б те, що на нульовій свідомо не надіслали.
  5. Подієві алерти (0058) проходять драбину один раз, без повторів. Повтор — це ставка на те, що проблема триває, і зробити її можна лише там, де існування алерту саме по собі є доказом. Метричний алерт живий рівно доти, доки виконується умова. Подієвий такого доказу не дає: рядок журналу стався один раз і «перестати ставатись» не може. Повторювати за ним драбину означало б будити зміну по колу через давно припинений нічний блимок порту. Сходинки в межах одного проходу подієвий алерт проходить як усі — «конфіг змінився на ядрі, і ніхто не глянув» має піднімати наступного.

Перезапуск процесу

Стан живе в базі. Такт: взяти з орендою → вирішити → записати → надіслати.

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

Оренда — другий рубіж поверх advisory-блокування тіку.

Інтерфейс

  • Нова сторінка «Ескалації» — редактор драбин. Окремо від тригерів: одна драбина накриває десяток тригерів і переписується раз на квартал, коли міняється склад зміни, а тригер правлять щотижня.
  • У формі тригера — вибір драбини, типово «без ескалації».
  • У картці алерту — на якій сходинці він зараз, скільки їх усього, коли буде наступна, а для зупиненої — чому саме. Без цього ескалація перетворюється на невидиму магію, і перше ж «чому мене розбудили» не має відповіді.
  • Шапка самого повідомлення: 🔺 ЕСКАЛАЦІЯ 2/2 (Нічне чергування): не підтверджено 45 хв. Без неї третє повідомлення про ту саму аварію читається як зациклена розсилка, і канал вимикають.

Чого не зроблено

  • Тригери шаблонів драбину отримати не можуть. Правила, породжені tpl.triggers, правляться в шаблоні, а в тригері шаблону поля драбини немає. Додавати його означало б чіпати схему шаблонів; драбина при цьому тенант-специфічна, а шаблон — ні.
  • alr.routes лишились без інтерфейсу (їх і не було). Ескалація до них не прив'язана навмисно.
  • Ескалація не переходить на людину (user_id), лише на канал. Черги чергувань і календарів у продукті ще немає — драбина оперує тим, що є.

Перевірка

gofmt -l ., go vet ./..., go build ./..., go test ./... — чисто. npx tsc --noEmit, npm run build — чисто.

Чого тести не покривають — окремо в звіті; коротко: SQL взяття з орендою, атомарність і RLS перевіряються лише тестом проти бази (TestEscalationAgainstDB), який мовчки пропускається без NETPULSE_TEST_DSN, а бази в цьому оточенні немає.

2026-08-27 — Диск закінчується мовчки: попередження, якого бракувало 0064

0064 навчила систему МІРЯТИ місце: розмір бази, приріст за добу, «вистачить ще на N діб». Але міряти й попереджати — різні дієслова. Цифри лежать на сторінці, куди заходять раз на квартал, а том заповнюється в ніч із суботи на неділю. Перша ознака проблеми — Postgres перестав приймати записи, тобто впав увесь продукт одночасно.

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

Пропозиція автора 0064 перевірена й не працює

У розділі «Що лишається поза» 0064 записано найдешевший на вигляд шлях:

писати розмір бази й запас у добах звичайними метриками в ts.series (наприклад db.size.bytes і db.days_left на хості, що позначений машиною зонда), і тоді працює наявне метричне правило без жодної зміни коду.

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

Перша: метричне правило вимагає ХОСТА. store/alerts.go, evalSeries будує запит із

JOIN inv.devices d ON d.id = se.device_id AND d.deleted_at IS NULL AND d.enabled

Ряд без хоста не дасть кандидата ніколи. «Хост машини зонда» — це core.agents, а не inv.devices; рядка в інвентарі для нього не існує. Тобто його довелося б завести.

Друга: заводити його не можна. На INSERT в inv.devices висить bill.assert_device_limit (0009). Штучний хост займає слот тарифу, а на інсталяції, яка вже вперлась у стелю плану, INSERT просто впаде — попередження про диск не встановиться саме там, де щільніше за все. І це ще не найгірше: такий хост поповз би в інвентар, на мапи, у масові операції й під наявні правила «даних немає взагалі» з порожнім селектором, який за 0007 означає «до всього». Попередження про диск почало б із того, що підняло б хибний алерт про самого себе.

Третя: правила живуть у кабінеті, том — в інсталяції. evalSeries фільтрує se.tenant_id, alr.rules має tenant_id NOT NULL. Це та сама асиметрія, яку 0064 доводить про строки зберігання: чанк TimescaleDB не знає кабінету. Щоб правило спрацювало в кожного, довелося б множити ряд і кожен семпл на кількість кабінетів.

Четверта, і головна: db.days_left — не вимір, а частка. Її знаменник буває нулем (база не росте) і від'ємним (базу почистили), а чисельник невідомий, поки людина не вказала ємність. ts.samples.valuedouble precision NOT NULL: записати туди «немає відповіді» ніяк. Записати нескінченність можна, і тоді першим зламається не прогноз, а encoding/json — весь список алертів почне віддавати 500. Прогноз мусить уміти мовчати; ряд вимірів такого не вміє.

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

Як зроблено

Перевірка живе там, де живе сам факт: у такті прибиральника даних, одразу після знімка розмірів (grpcapi/storage_retention.go). Алерт піднімається без правила (rule_id IS NULL) і без хоста (device_id IS NULL) — обидві колонки необов'язкові з 0007, а всі читання алертів ходять туди через LEFT JOIN. Розсилку робить наявний движок: алерт позначається notify_pending, і TakeNotifyPending забирає його разом із подієвими (0058). Жодного рядка в alerting/** і в движку правил не змінено.

Перевірено й те, що алерт не зникне сам: ExpireEventAlerts працює через FROM alr.rules r WHERE r.id = a.rule_id, тобто внутрішнім з'єднанням, і рядок із NULL не чіпає. ResolveMissing шукає за rule_id = $2 — так само повз. Закриває алерт той самий такт, який його підняв.

До якого об'єкта чіпляти — ні до якого, і це рішення

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

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

Типово УВІМКНЕНЕ

Це навмисне порушення принципу 0064. Там нічого не вмикається само — «строки не вмикаються самі, щоб оновлення не забрало нічиєї історії», — і це правильно РІВНО ТОМУ, що строк видаляє. Помилкове типове значення там знищує дані незворотно.

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

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

Пороги: головний — час, не рівень

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

4 доби до аварії. П'ятниця, вечір → вівторок, ранок. Прогноз, знятий у п'ятницю ввечері, має пережити вихідні й лишити ще один робочий день на дію. Три доби з'їдають вихідні повністю; п'ять — це вже те саме попередження іншими словами. Нижче цієї межі єдина дія, що встигає, — скоротити строк зберігання: drop_chunks повертає місце негайно, а купівля диска вже ні. Саме тому серйозність інша.

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

Третій поріг — вільного менше за max_wal_size. Підлога, нижче якої відсотки безглузді: Postgres між контрольними точками має право написати до max_wal_size журналу, і якщо стільки не влазить, він зупиняється незалежно від того, 90 це відсотків чи 99. Число береться з current_setting('max_wal_size'), а не з нашого уявлення про запас.

Вільне місце тепер можна міряти

0064 сказала правду: у Postgres функції «скільки вільного на томі» немає, а сервер застосунку живе в іншому контейнері. У штатному docker-compose так і є. Але «не видно за замовчуванням» — не те саме, що «не видно ніколи»: той, хто змонтує том бази в контейнер збірника хоч тільки для читання й укаже шлях, отримує справжнє вільне місце замість введеного числа.

Різниця не косметична. Введене число застаріває мовчки — том розширили, поруч поклали дамп, журнал попереднього запису роздувся від застряглого слота реплікації. Виміряне бачить усе це, бо міряє ТОМ, а не суму відомих нам таблиць. statfs рахує Bavail, а не Bfree: різниця — резерв суперкористувача (типово 5 % на ext4), до якого Postgres не дотягнеться.

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

Чого перевірки НЕ покривають

Написано вісім наборів перевірок, і всі вони дивляться на чистий вирок за числами. Що лишилось за їхніми межами:

Живої бази не торкається жодна. Не перевірено: чи справді pg_ls_waldir() доступний ролі застосунку (найімовірніше ні — потрібне членство в pg_monitor, і тоді журнал просто не показується); чи pg_database_size по всіх базах кластера не впаде на базі без права CONNECT; чи ON CONFLICT ... WHERE state IN (...) справді потрапляє в частковий унікальний індекс alerts_active_dedup_uniq. Три запити, які або працюють, або не працюють, і дізнатись це можна лише на живому Postgres.

diskFree не перевірено взагалі. Це syscall.Statfs під тегом !windows; на машині розробки він навіть не компілюється (перевірено крос-складанням GOOS=linux). Чи правильно перемножуються Bavail і Bsize на конкретній файловій системі, чи не переповнюється int64 на дуже великому томі — не перевірено нічим.

Порядок доставки не перевірено. Що алерт із notify_pending справді підхопить TakeNotifyPending і що маршрут доставки з фільтром за серйозністю його пропустить — прочитано в коді, не виконано.

Сліпа зона прогнозу після видалення хоста лишається сліпою. Приріст рахується різницею країв за 30 діб (так вирішила 0064, і два числа на одній сторінці були б гіршими за одну сліпу зону). Одне велике видалення робить цю різницю від'ємною — і прогноз мовчить до тридцяти діб. Покриває це лише поріг за рівнем, тобто грубіший з двох. Перевірка на цей випадок є, і вона перевіряє саме те, що спрацьовує рівень, — а не те, що прогноз відновився. Він не відновлюється.

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

Формулювання не перевірені людиною. Тексти алерту й сторінки писались із думкою про третю ночі, але о третій ночі їх ніхто не читав.

Поля заливки конфігу переїхали в каталог профілів

Що було

Міграція 0060 додала ncm.profiles сім колонок про те, ЯК заливати конфіг на пристрій (apply_enter, apply_exit, apply_commit, apply_negate, apply_block_exit, apply_supported, apply_note) і заповнила їх для шести профілів разовими UPDATE.

db/profiles/catalog.json про ці поля не знав. Тобто джерело істини роздвоїлось: команди ЗНЯТТЯ конфігу описані в каталозі, команди ЗАЛИВКИ — у разовій міграції. Наступна платформа, додана в каталог, з'явилась би без полів заливки, і відкат для неї мовчки не працював би — рівно до того моменту, коли відкат знадобиться.

Що зроблено

Каталог отримав блок apply. Він живе в самому профілі, поруч із командою збору: enter, exit, commit, negate, block_exit, supported, note і comment (причина рішення, їде коментарем у SQL). Профілі, заведені не каталогом, а руками (zte-zxan — 0028, dlink-me — 0034/0043), описані в новій секції apply_external: тримати частину відповідей у каталозі, а частину в разовій міграції означало б знову два джерела істини.

Заливка задається профілем, а не родиною, і це не дрібниця. Родина описує поведінку консолі (пейджер, запрошення), а не синтаксис конфігурації: zte-zxr10 стоїть у родині huawei через запрошення, а конфігурується як Cisco. Успадкування від родини відправило б на пристрій чужі команди.

Значення перенесено з 0060 без жодної зміни — включно з причинами відмови там, де заливки немає:

  • mikrotik-routeros — «RouterOS: рядки експорту — це add, і повторне виконання не повертає запис, а створює дубль…»;
  • juniper-junos — «JUNOS зберігається ієрархією у фігурних дужках — це не набір команд…».

Порожній note перетворив би свідоме рішення на схоже на недогляд, тому причина переїхала разом зі значенням.

Генератор навчився другого виходу. build.py тепер пише два файли:

  • server/migrations/0014_ncm_profiles.sql — самі профілі (як і був);
  • db/profiles/apply.generated.sqlUPDATE з полями заливки.

Чому заливка НЕ поїхала в 0014

Дві незалежні причини, кожної досить.

  1. Порядок. Колонки apply_* заводить 0060, тобто ПІЗНІШЕ за 0014. Вставка в 0014 не може згадати колонку, якої на той момент ще немає — на чистій базі це впало б одразу.
  2. Сторож контрольних сум. netpulse-migrate тримає sha256 кожного застосованого файлу і зупиняє накат на розбіжності («міграцію 0014 змінено після застосування»). 0014 уже накочена на стенді, тож будь-яка зміна її тексту зупинила б оновлення всім.

Тому 0014 лишилась байт-у-байт тією самою: sha256 0a50e0e9… до й після, git diff по файлу порожній.

apply.generated.sql свідомо НЕ є міграцією: номер призначає людина. Над одним каталогом номерів паралельно працюють інші гілки, і вигаданий генератором номер зіткнувся б із чужим.

Знайдено дорогою

Генератор писав не туди. MIGRATION вказував на db/migrations, хоч міграції переїхали в server/migrations ще тоді, коли їх почали вшивати через //go:embed (див. db/README-migrations.md, де переїзд описаний, а шлях у генераторі лишився старий). Наслідок: --check завжди звітував «розійшлася з каталогом», а звичайний запуск падав на записі в неіснуючий каталог. Виправлено; після виправлення каталог і 0014 збіглися байт-у-байт — розходження не було, був зламаний шлях.

Генератор ламав контрольну суму на Windows. write_text у текстовому режимі перекладає \n у \r\n, а .gitattributes вимагає LF. CRLF-версія 0014 дає sha256 501cb391… замість 0a50e0e9… — тобто бінарник, зібраний на Windows після запуску генератора, зупинив би накат на стенді. Тепер файли пишуться байтами, без перекладу.

Перевірено

  • python build.py — відпрацював, 147 платформ, 6 профілів із заливкою;
  • python build.py --check — «згенероване актуальне»;
  • diff згенерованої 0014 з наявною — порожній, sha256 не змінилась;
  • окремим скриптом розібрано UPDATE-и з 0060 і звірено ЗНАЧЕННЯ всіх семи полів для всіх шести профілів (незадані в 0060 поля добрано з DEFAULT колонок) — збіг по всіх;
  • go build ./..., go test ./... — зелено.

Окремо про перевірку: «генератор відпрацював без помилки» тут нічого не доводило б. Значуще саме те, що ТЕКСТ згенерованої 0014 не змінився й що значення в каталозі дорівнюють значенням у вже накоченій 0060.

Що НЕ зроблено

  • Міграції не створено. apply.generated.sql чекає на номер, який призначить людина.
  • Нових родин із заливкою не додано. Спокуса була (huawei: system-view / return / undo ), але save на VRP перепитує [Y/N], а модель «команда → запрошення → наступна команда» такого не переживає. Вигадана команда заливки пише на живе залізо — гірша за її відсутність.
  • CI не звіряє каталог зі згенерованим. python db/profiles/build.py --check у .forgejo/workflows/ci.yml зловив би розходження, але ci.yml поза цією зоною.

2026-08-27 — Відмінювання: plural() більше не друкує число

Що було не так

plural(n, one, few, many) у web/src/components/ui.tsx повертала рядок разом із числом (`${n} ${форма}`). Тринадцять місць виклику з шістдесяти дев'яти ставили число ще раз — на екрані виходило «5 5 хостів», «Видалити назавжди 22 22 хости», «1 000 1000 рядків» (у StoragePage число ще й друкувалось двічі в різному вигляді: з розділювачами тисяч ззовні й голим зсередини).

Три форми української множини стара функція вміла: 1114 бралися за двома останніми цифрами, 21 і 22 — за останньою. Тобто серйознішої вади, ніж подвоєне число, у самій функції не було — вона була навколо неї: у двох десятках місць число й іменник склеювались взагалі повз plural() («1 учасників», «21 активних алертів», «1 перевірок»), а в QueuesPage множина була на дві форми через тернарник — «21 групах».

Рішення про підпис

plural(n, ['хост', 'хости', 'хостів']) повертає лише слово; число друкує місце виклику.

Другий варіант — лишити «число + слово» і повиправляти місця виклику — відкидає причину, а не наслідок: поки функція друкує число, поставити його поруч ще раз може будь-хто, і саме так вада й з'явилась. Побічний виграш: місце виклику лишає собі своє форматування числа — num() з розділювачами тисяч і toLocaleString('uk-UA') більше не витісняються голим ${n} зсередини функції.

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

Додано Math.abs(): -1 % 10 дорівнює -1, жоден case не збігається, і від'ємний лічильник («не рахували») діставав би форму «багато».

Обсяг правки

  • 69 місць виклику plural() переведено на новий підпис (13 із них саме ті, що друкували число двічі — там число вже стояло, тож зникло дублювання).
  • ~24 місця, де число й іменник були склеєні намертво, зведено до plural(): AppShell, AlertsPage, AuditPage, BulkDeviceForm (переліки «що зникне разом із хостом»), CommandsPage, CompliancePage, ConfigsPage, GroupsPage, MapCanvas, MapPage, NodeInspector, ProfilesPage, QueuesPage, RolesPage, TeamPage.
  • MapSettings: рахунок груп винесено в const allowed — двічі писати той самий filter у рядку означало б напрошуватись на розбіжність.

Перевірка

npx tsc --noEmit, npm run build, npx vitest run (71 тест) — зелені. Але зелений tsc тут нічого не доводив би: подвоєне число — не помилка типів, збиралось воно й раніше. Тому результат прогнано рядками: справжня стара реалізація і справжня нова, обидві через esbuild, на справжніх шаблонах з коду, для 0/1/2/5/11/21/22:

BulkDeviceForm:833   до: «5 5 хостів»           після: «5 хостів»
BulkDeviceForm:805   до: «Видалити назавжди 22 22 хости»  після: «… 22 хости»
StoragePage:472      до: «1 000 1000 рядків»    після: «1 000 рядків»
MirrorPage:312       до: «У ньому 21 21 версія з 7 7 хостів»  після: «У ньому 21 версія з 7 хостів»
TeamPage:69          до: «1 учасників»          після: «1 учасник»
AppShell:241         до: «21 активних алертів»  після: «21 активний алерт»
QueuesPage:409       до: «99 у 21 групах»       після: «99 у 21 групі»

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

Що лишилось не зробленим (свідомо)

  • StoragePage і RulesPage правлять інші агенти — там зачеплено лише рядки з plural(). Незакриті склейки в них: «діб» (keep_days, min_days, from_days, to_days, days_left) і «с» у RulesPage (одиниця, не відмінюється).
  • Одиниці вимірювання (с, хв, МБ, Гбіт/с) навмисно лишились без plural() — вони не відмінюються.
  • DevicePassport: «{checks_auto} заводить система» — це узгодження дієслова, а не іменника; переписувати текст заради нього не стали.

Тести вебу й робочий локальний прогін перевірок

Стан до: у вебі не було жодного тестового прогону — ні vitest, ні jest. Перевірялось типами й живим браузером. .forgejo/workflows/ci.yml написано, але раннера немає, тож CI не запускався жодного разу.

Стан після: 103 тести в 9 файлах, npm test зелений, sh scripts/check.sh проганяє всі перевірки обох світів однією командою.


Чому vitest, а не jest

Проєкт на Vite. vitest.config.ts зроблено через mergeConfig(viteConfig, …) — тести проходять ТУ САМУ трансформацію, що й збірка (плагін React, tailwind, ті самі налаштування розвʼязання модулів). З jest вийшло б два різні конвеєри, і розбіжність між ними знаходили б не тестом, а на стенді. Окремий файл конфігурації, а не секція в vite.config.ts, — щоб не чіпати файл, у якому паралельно працюють інші.

Vitest бере vite.config.ts лише коли власного конфіга немає; щойно він зʼявляється — вітовий ігнорується цілком. Саме тому mergeConfig, а не порожній defineConfig: інакше JSX не трансформувався б узагалі.

Що обрано покривати — і чому саме це

Приводом був сьогоднішній випадок із ізоляцією БД: зелений тест пропустив поломку, бо перевіряв «чи не видно чужого», а зламалось «чи видно своє». Висновок — зелена перевірка доводить рівно те, що вона перевіряє — визначив відбір: не число покриття, а місця, де помилка ТИХА й дорога.

  1. Порівняння конфігів (Myers, linediff.ts). Найдорожче місце в усьому вебі: людина дивиться на дві колонки й вирішує, чи котити відкат на живий комутатор. Diff, який загубив рядок або зсунув нумерацію на одиницю, не падає й не пише в журнал — він показує неправдиву картину, і рішення за нею приймають як за правдивою.

    Тому перевіряються не приклади, а інваріанти на 500 випадкових парах (детермінований LCG, не Math.random — тест, що падає раз на сто прогонів і не відтворюється, гірший за відсутність тесту): із ділянок точно відновлюються обидві версії; рядки, оголошені однаковими, справді однакові; нумерація обох колонок іде без пропусків і повторів; текст у рядку відповідає своєму номеру. Окремо — розрахунковий випадок Myers: один змінений рядок серед 30 000 (якби відкидання спільного початку й кінця зламалось, тест не впав би — він би завис).

  2. Числа, одиниці, час. plural, ago, humanInterval, fmtBytes, formatBps, fmtValue, fmtBps. Перевіряються МЕЖІ (1023/1024, 999e6/1e9, 59/60 с), знак і дата з майбутнього — саме там і живуть тихі помилки.

  3. Геометрія мапи. autoSides, autoLabelPositions, labelCandidates, estimateLabelBox, жадібна розкладка підписів у labelLayout.ts. Помилка тут не падає: лінія йде в обхід через півсхеми або підписи лягають один на одного — і те, й те виглядає як «мапу малювали абияк», а не як помилку програми.

  4. Дві поведінки, а не два вигляди. Modal — єдине місце, де самовільне закриття зʼїдає набране в довгій формі (тест на виділення тексту, доведене мишею за край вікна). ConfirmDialog — останній екран перед незворотною дією; його дві тихі поломки коштують найдорожче: другий клік = другий запит на видалення, а закриття після невдачі читається як «виконано».

Правило, яке трималось під час написання

Не закріплювати ваду зеленим тестом. Знайдене під час роботи пішло в звіт і в web/TESTING.md, а не в expect. Виняток — коли поточне правило треба зафіксувати свідомо (сортування uniq кодами символів; згортання кирилиці в safeName); тоді причина сказана в коментарі, щоб зміна була свідомим кроком, а не побічним ефектом.

Про plural — випадок, вартий запису

Задача передбачала ЧЕРВОНИЙ тест на відому ваду: функція повертала «число + слово», а вісім місць виклику ставили число ще раз, і на екрані виходило «5 5 хостів». Поки писались тести, паралельний агент цю ваду полагодив — змінив підпис на plural(n, ['хост','хости','хостів']), що повертає ЛИШЕ слово, і перевів усі ~60 місць виклику (я застав проміжний стан, коли tsc давав 60 помилок «Expected 2 arguments, but got 4»).

Тому червоного тесту немає, а є зелений інваріант, який тримає виправлення:

it('не друкує число — його ставить місце виклику', () => {
  for (const n of [0, 1, 2, 5, 11, 21, 100, 1234]) {
    expect(plural(n, HOSTS)).not.toMatch(/\d/)
    expect(HOSTS).toContain(plural(n, HOSTS))
  }
})

Перевірка стоїть на САМІЙ функції, а не на місцях виклику: їх шістдесят, і завтра буде сімдесят. Поки функція не друкує числа, подвоїти його нема чому.

CI без раннера, і що з цим робити

.forgejo/workflows/ci.yml доповнено кроком тестів вебу (до збірки: збірка коштує хвилини, а падіння тесту видно за секунди) і переведено на upload-artifact@v4.

Але раннера як не було, так і немає, а перевірка, яку нікому виконати, не ловить нічого. Тому додано scripts/check.sh — та сама послідовність локально:

gofmt + vet + тести server → gofmt + vet + тести agent → крос-збірка зонда
під 5 платформ → tsc → vitest → vite build

Три рішення в скрипті варті пояснення:

  • Не зупиняється на першій невдачі. Інакше, щоб побачити три поламані місця, довелось би прогнати його тричі. Ненульовий код виходу все одно повертається — для гачка pre-push.
  • Не піднімає Postgres. Тести проти бази самі пропускаються без NETPULSE_TEST_DSN. Це єдине місце, де скрипт свідомо ВУЖЧИЙ за CI, і в ci.yml про це сказано вголос.
  • npm ci лише коли node_modules немає. На кожному прогоні він викидає залежності й ставить наново — хвилини замість секунд.

Скрипт відпрацював із першого разу й одразу знайшов те, чого ніхто не бачив: сім файлів server/ лежать у робочій копії з CRLF, хоча .gitattributes вимагає LF (git ls-files --eol показує i/lf w/crlf). На Linux-раннері вони були б зелені, локально — червоні. Скрипт про цю пастку каже прямо, щоб її не «полагодили» через gofmt -w.

Найважливіший артефакт — web/TESTING.md

Половина файла — перелік того, чого тести НЕ покривають, і він стоїть ПЕРЕД переліком покритого. Це не формальність: доки цей перелік тут і чесний, зелений прогін не можна прийняти за доказ справності вебу. Головні дірки: перетягування вузлів на мапі (d3-drag не запускається синтетичними подіями — jsdom не рахує ні PointerEvent, ні матрицю SVG), усе полотняне в Minimap (у jsdom getContext('2d') віддає null), pointsOnPath (немає getTotalLength), api/* і hooks/* цілком, усі сторінки з src/pages/.

Вади вебу, знайдені при написанні тестів, — виправлення

Стан до: 103 тести, усі зелені. Частина з них закріплювала ПОМИЛКОВУ поведінку («кирилиця згортається в підкреслювач», «кожен рядок є в DOM двічі») — свідомо, щоб перехід був помітним кроком, а не побічним ефектом.

Стан після: 137 тестів у 12 файлах, tsc --noEmit, npm run build і vitest run зелені. Одинадцять пунктів переліку перевірено кожен окремо: десять підтверджено й виправлено, один (пункт 10) підтверджено наполовину — про це нижче.

Головне, що варто винести: жодна з одинадцяти вад не є помилкою типів. tsc і vite build проходили з усіма ними. Тому на кожну є тест, а те, що тестом не ловиться (розміри, фокус, розкладка), перевірено живим браузером — не читанням.


Як перевірялось, що тест справді щось ловить

Написати тест ПІСЛЯ виправлення легко так, що він був би зелений і до нього. Тому всі виправлення тимчасово відкочувались назад (копія файлів, точкові зворотні заміни) і прогонявся весь набір. Впало рівно 13 + 5 перевірок — тих самих, що й мали:

FAIL  datatable > рядок із дією отримує фокус і відкривається з клавіатури
FAIL  datatable > картка телефона теж відкривається з клавіатури
FAIL  format > ago > округлення переносить у більшу одиницю, а не вигадує 60 хв
FAIL  format > fmtValue > ненульове не показує нулем
FAIL  format > formatBps > рахує межу за модулем: мінус не має ламати одиницю
FAIL  interval > очищене поле НЕ перетворюється на нуль
FAIL  interval > число нижче межі назовні не йде, поки з поля не пішли
FAIL  interval > порожнє поле на виході повертається до чинного значення
FAIL  interval > зміна значення ззовні на непресетне вмикає ручний режим
FAIL  minimap > контейнер завширшки рівно MAP_W — одне число, а не два
FAIL  minimap > ширина не задається класом
FAIL  minimap > рядок із самих пробілів не малюється зовсім
FAIL  minimap > порожній рядок не бере навіть колір збігу пошуку
FAIL  modal > переносить фокус у перше поле вікна
FAIL  modal > вікно без полів бере фокус на себе
FAIL  modal > повертає фокус туди, звідки вікно відкрили
FAIL  modal > Tab із останнього поля повертається на перше
FAIL  modal > тло стає inert, поки вікно відкрите

Після відновлення — 137 зелених.


1. safeName згортав кирилицю (components/ConfigViewer.tsx)

Підтверджено. \w у JavaScript — рівно ASCII, тому «Київ-ядро» і «Львів-ядро» давали ОДНАКОВЕ ім'я файла _-_. Людина вивантажувала два конфіги й отримувала один: другий тихо затирав перший, а помічають це тоді, коли по втраченому вже щось відновлюють.

Це третя така функція в проєкті. sanitizePath у server/internal/store/ncm.go хворів тим самим і вилікуваний через unicode.IsLetter; тут прямий відповідник — \p{L} із прапорцем u. Звідти ж узято згортання ..: ім'я потрапляє в атрибут download як є, і ланцюжок крапок — це спроба вийти за теку завантажень. Крапки всередині лишаються («10.0.0.1» має бути собою).

Тест configview.test.ts переписано, а не обійдено: замість «згортається в підкреслювач» тепер «лишає кирилицю», «різні імена лишаються різними» і «згортає «..», але не чіпає звичайні крапки».

2. IntervalInput слав нуль секунд (components/ui.tsx)

Підтверджено обидві частини.

Перша: у розмітці min={5}, у коді Math.max(0, Number(e.target.value)), а Number('') === 0. Досить було стерти вміст поля, щоб надрукувати нове число, — і між двома натисканнями клавіш перевірка вже мала інтервал «0 секунд». Тепер ручний ввід тримає ВЛАСНИЙ рядок, а назовні йде лише придатне число; межі притискаються один раз, на виході з поля (onBlur), щоб «4» на шляху до «45» не перетворювалось на 5 і не їхало на сервер.

Друга: manual виводився з value лише при монтуванні. Місце, де це справді трапляється, знайдено: PrototypeEditor.tsx:173 і TemplatesPage.tsx:466,618 мапляться з key={i} — прибрали сусідній рядок, і той самий екземпляр поля дістає чуже значення. При 45 секундах список показував порожній вибір замість числа. Синхронізація зроблена узаконеним у React способом «правка стану під час рендера при зміні пропа», і лише в один бік: непресетне значення вмикає ручний режим, зворотно не вимикає — інакше поле забирали б з-під рук того, хто друкує.

Новий файл src/test/interval.test.tsx (8 перевірок).

3. Мінікарта була стиснута ~2.6× (components/Minimap.tsx)

Підтверджено: полотно малювалось у MAP_W = 180, а контейнер мав клас w-[68px] від попередньої ширини. Коментар «Було 68 — і цього замало» лишився, клас — ні. На екрані це виглядало не як помилка, а як «щось дрібне й нерозбірливе».

Джерело тепер одне: ширина йде в style={{ width: MAP_W }}, класу немає взагалі. Саме так, а не «полагодити число в класі»: Tailwind збирає довільні значення статично, тож клас не може слідувати за константою — і наступна зміна MAP_W розійшлася б із ним так само мовчки. Тест закріплює обидва боки: ширина дорівнює MAP_W і клас w-[…] відсутній.

Виміряно в живому Chrome: контейнер 180 CSS px, canvas.width 225 при devicePixelRatio 1.25 — тобто рівно 1:1, стиснення немає.

4. Чотири форматувальники швидкості → один

Підтверджено: edgeState.formatBps (1.0 Гбіт/с), DeviceCollected.fmtBps (1.00 Gbps), InterfaceFilterEditor.speedLabel (1G), MetricChart.fmtValue (1.0G). Одна й та сама лінія в різних місцях називалась по-різному.

Зведено до formatBps(bps, 'full' | 'short') у ui.tsx — не до чотирьох функцій і не до однієї без параметра. Короткі форми справді потрібні: у колонці фільтра інтерфейсів і на осі графіка назва одиниці не влазить. Але це РЕЖИМ одного форматувальника зі спільними порогами, а не окрема реалізація; коротка форма — це той самий fmtValue, тобто число на осі й число в колонці тепер збігаються.

fmtValue при цьому лишився окремою функцією (теж у ui.tsx) і навмисно: він підписує не лише біти, а й відсотки, градуси та байти, і дописувати йому «Гбіт/с» означало б поставити біти на вісь температури.

Точність повної форми: гігабіти завжди з десятою (так було й лишається), мегабіти й кілобіти — з десятою до 10 одиниць і без неї далі. Це не косметика: трафік на порту доступу живе саме в діапазоні 110 Мбіт/с, і 1 Мбіт/с замість 1.5 Мбіт/с — півтора раза похибки на око.

5. fmtBytes було ТРИ, а не дві

Підтверджено дві названі копії — BackupDefaultsForm.tsx (експортована) і BulkDeviceForm.tsx (дослівна локальна). Третю знайшов новий тест: у pages/AuditPage.tsx лежала ще одна, і вона вже РОЗІЙШЛАСЬ — спинялась на мегабайтах, тож вкладення понад гігабайт показувалось як «1048576.0 МБ». Прибрані обидві копії.

src/test/formatters.test.ts — тест не поведінки, а тексту джерел: скільки в дереві оголошень із таким іменем. Вада, яку він стереже, поведінкою не ловиться взагалі: дві дослівні копії працюють однаково рівно до дня, коли одну правлять, а другу — ні. Джерела читаються через import.meta.glob з ?raw, а не через node:fs: у проєкті немає @types/node, і тягнути їх заради одного тесту дорожче.

6. formatBps не брав модуль

Підтверджено: -1e9 давало «-1000000000 біт/с». Тепер межі порівнюються з модулем — як у fmtBps, який саме тому й був другою функцією.

7. fmtValue показував нуль для ненульового

Підтверджено: 0.0001"0.000". На осі графіка нуль означає «даних немає», а не «мало»; на графіку втрат пакетів це різниця між «канал чистий» і «канал сипле». Тепер: якщо три знаки після коми дають нуль, а значення не нульове — рахуються значущі цифри (0.0001, 0.00025), а зовсім дрібне йде показником степеня. Нуль лишається нулем лише коли значення справді нульове.

8. ago() перескакував одиницю

Підтверджено: 3599 с → «60 хв», 86399 с → «24 год». Причина — межа перевірялась за секундами, а число рахувалось окремо. Тепер одиниця обирається ПІСЛЯ округлення: 3599 → «1 год», 86399 → «1 дн». «60 хв» поруч зі справжніми «59 хв» і «1 год» читається як третя одиниця виміру, якої немає, — а дивляться на цей підпис саме тоді, коли хост щойно перестав відповідати.

9. Modal не керував фокусом

Підтверджено все чотири: не переносив фокус усередину, не тримав його, не повертав на місце виклику, тло не позначалось.

Зроблено:

  • фокус переїжджає в ПЕРШЕ поле тіла вікна (у підтвердженні видалення це «Скасувати» — і це правильно, бо Enter одразу після відкриття не має нічого стирати); вікно без полів бере фокус на себе через tabIndex={-1};
  • Tab-кільце по вмісту вікна в обидва боки;
  • inert на СУСІДІВ кожного предка вікна аж до body, а не на один контейнер: вікно живе всередині сторінки, тож «усе, крім нього» — це саме сусіди по дорозі нагору. Позначки, які вже стояли, не чіпаються — інакше два відкриті вікна знімали б їх одне одному;
  • фокус повертається туди, звідки вікно відкрили.

Порядок ефектів має значення й закріплений коментарем: inert оголошено ПЕРШИМ, бо React прибирає ефекти в порядку оголошення — тло встигає перестати бути inert до того, як фокус повертається на кнопку, яка в ньому лежить. Інакше браузер відмовив би у фокусі, і вийшла б рівно та вада, яку це лікує.

Перевірено в живому Chrome, а не лише в jsdom (jsdom inert не виконує): фокус після відкриття стоїть у першому полі, кнопка тла НЕ фокусується, саме вікно не inert, після закриття фокус на кнопці-відкривачі, [inert] у документі не лишилось жодного.

10. DataTable: подвійне малювання і недоступність із клавіатури

Спростовано частину діагнозу. Дві копії рядка ховались через hidden md:table і md:hidden, тобто display: none. Читач з екрана display: none пропускає — обидві копії він НЕ читає, у дереві доступності в будь-який момент рівно одна. З тієї ж причини aria-hidden на прихованій копії був би не найдешевшим рішенням, а неправильним: у ній тепер є елементи з tabIndex, а фокусований елемент усередині aria-hidden — це вже справжня помилка доступності.

Підтверджено решту, і виправлено обидві. Клавіатура: onClick на <tr> і <li> не дає ні фокусу, ні Enter — людина без миші просто не могла відкрити хост зі списку, при тому що клік по рядку там єдиний шлях. Додано tabIndex і обробку Enter/Пробілу (з preventDefault, інакше пробіл прокрутив би сторінку). Ролей role="button" на <tr> навмисно не ставилось: усередині таблиці це ламає її ж семантику.

Подвійне малювання прибрано насправді, а не заклеєно: подачу обирає useSyncExternalStore над matchMedia('(min-width: 768px)') — той самий поріг, що й md: у Tailwind, тож на око нічого не змінилось. Без matchMedia (jsdom, старі рушії) показується таблиця: дані видно всі, просто густіше. Підписка йде ще й на resize як страхувальна сітка — подія медіазапиту приходить не в кожному середовищі, а помилка тут тиха: людина повертає телефон і лишається з таблицею на 375 px.

Перевірено в живому Chrome на обох ширинах: 1280 — таблиця, списку немає, tabindex="0" на <tr>; 375 — шість карток, таблиці немає, колонка hideOnMobile у картку не потрапила, горизонтального переповнення немає, мінікарта прихована. Перемикання без перезавантаження перевірено подією resize.

11. Minimap.buildShape: порожній рядок малювався звичайним

Підтверджено. Рядок із самих пробілів отримує KIND_BLANK, але len > 0 — тож доходив до малювання; KIND_COLOR[0] не існує, спрацьовував запасний KIND_BODY. А саме порожні рядки й розділяють розділи конфігу: намальовані як текст, вони стирають межі, по яких людина на мінікарту й дивиться.

Рішення «яким кольором» винесено в чисту rowColor(shape, i, hit), яка віддає null для рядка, що не малюється. Це єдине місце мінікарти, яке можна перевірити без полотна (у jsdom 2D-контексту немає взагалі), — і саме там вада й сиділа.


Що перевірено ЛИШЕ читанням коду

  • Ширина мінікарти в режимі порівняння (ConfigDiff.tsx): Minimap там вживається двічі з тими самими пропсами й без власних класів ширини — отже, та сама виправлена ширина. Живим браузером дивився лише ConfigViewer.
  • Ширші підписи швидкості в картці хоста (1.0 Гбіт/с замість 1.00 Gbps, на два символи довше): комірки DataTable без фіксованих ширин, тож розкладка не залежить від довжини. Не міряно.
  • Поведінка inert у вікні поверх вікна (два Modal одночасно): логіка «не чіпати вже позначене» прочитана, але живим сценарієм не відтворювалась.
  • Форматування швидкості на самій мапі (TrafficEdge, EdgeInspector): форма там не змінювалась (та сама повна українська), тому оком не звірялось.

Що НЕ виправлено — і чому

  • fmtValue не влито у formatBps (пункт 4 просив «звести до одного»). Він підписує будь-яку метрику, не лише швидкість; спільним у них зроблено те, що справді спільне — саме скорочення числа. Коротка форма formatBps — це виклик fmtValue, тобто реалізація одна.
  • aria-hidden на прихованій копії DataTable (пункт 10) — копії більше немає, а на display: none він і не був потрібен. Причина розписана в пункті 10.
  • Попередження збірки про чанк >500 kB — було до цієї роботи, до переліку не входить, і код-спліт мапи це окрема задача.

2026-08-28 — Дві шкали серйозності: тригер відповідності фільтрував навпаки

Знайдено під час першого наскрізного налаштування ескалацій: API відмовив створити тригер із min_severity: critical — «невідома серйозність». Відмова виявилась правильною, а за нею стояла тиха вада.

Що було. Відповідність оперує critical/high/medium/low, алерти — info/warning/average/high/disaster. Спільне слово рівно одне: high. Подія відповідності несла свою шкалу, а поріг тригера порівнювався лінійкою алертів (alerting/events.go:351), де SeverityRank("critical") повертає нуль. Тобто тригер із порогом «warning» пропускав середні порушення й відкидав найважчі — саме ті шість критичних SNMP-community, заради яких механізм і вмикають. Мовчки: правило створене, увімкнене, прогін іде, порушень 18, алертів нуль.

Що зроблено.

  • store.ComplianceSeverityToAlert — переклад на шкалу алертів (critical→disaster, high→high, medium→average, low→warning).
  • httpapi/compliance.go віддає подію вже в шкалі алертів: далі її читають маршрути, пороги каналів і драбини ескалації — усе, що знає одну шкалу.
  • store.NormalizeEventSeverity — умова тригера приймає обидві шкали. Людина щойно дивилась на сторінку відповідності, де написано «critical»; вимагати подумки перекласти це в «disaster» — роздавати відмови за власну незручність.
  • Тести (alerts_events_severity_test.go) тримають той бік, що ламався: найважче не може не пройти найлегший поріг, і переклад не переставляє порядок.

Чого тести НЕ покривають: саму подорож події від прогону відповідності до каналу — це перевірено вручну на стенді, не в CI.

2026-08-28 — Вимкнення правила через форму: алерти висіли вічно, а правка вмикала назад

Дві вади навколо одного прапорця, знайдені рецензією й підтверджені на коді.

Вада А: PUT вимикав правило, не гасячи його алертів. SetRuleEnabled (перемикач у списку) після вимкнення кличе resolveRuleAlertsі має на це причину: вимкнене правило випадає з ActiveRules, тобто ResolveMissing за ним більше не біжить і закрити свої алерти воно вже не зможе. UpdateRule (та сама дія, але з форми) цього не робила. Алерт лишався в firing назавжди, а драбина ескалації продовжувала будити за ним людей — з повторами це тижні.

Вада Б: відсутнє enabled сервер читав як згоду ввімкнути. Форма правил поля не надсилала взагалі (перемикача в ній немає — він у списку), а httpapi/alerts.go ставив req.Enabled == nil || *req.Enabled. Тобто відкрити НАВМИСНО вимкнене правило, поправити в ньому будь-що й зберегти — означало мовчки його ввімкнути. У формі при цьому не змінювалось нічого.

Що зроблено.

  • store.RuleInput.Enabled став *bool: «поля не було» і «поле = false» — різні наміри, і bool їх не розрізняв. На створенні nil досі означає «увімкнене», на правці — «не чіпати».
  • store.planRuleEnabled — рішення окремою чистою функцією: яким стане прапорець і чи гасити алерти. Гасіння прив'язане до ПЕРЕХОДУ «увімкнене → вимкнене», а не до нового значення: повторне збереження вже вимкненого правила не додає зайвих подій у event_outbox.
  • store.UpdateRule читає стан до правки через SELECT … FOR UPDATE у тій самій транзакції — інакше між читанням і записом уміщається перемикач зі списку, і гасіння не спрацювало б у жодному з двох записів.
  • Форма (web/src/pages/RulesPage.tsx) тепер надсилає enabled явно. Обидва боки полагоджені навмисно: сервер — щоб не ламати чужі скрипти, написані за цим API, форма — щоб не покладатись на здогад узагалі.
  • Тести: alerts_rule_update_test.go (чотири випадки рішення), alerts_rule_update_db_test.go (проти бази: гасіння, черга подій, відсутність зайвого проходу), web/src/test/rules.test.tsx (форма надсилає стан обома боками — вимкнений і увімкнений).

Чого тести НЕ покривають: прогін проти бази вимагає NETPULSE_TEST_DSN і на машині, де це писалось, не запускався — Postgres там немає. Тобто SQL самої UpdateRule перевірено лише компілятором і читанням. Не покрито також дальший ланцюг: що погашений алерт справді знімає взведену драбину (це робить фон за подією alert.resolved, не сама UpdateRule) і що гонка «форма проти перемикача» справді розв'язується замком — обидва потребують живого стенду.

Помічено, але НЕ виправлено (окремий обсяг). Зміна селектора чи умови правила старі алерти не чіпає. Для опитуваних джерел це самолікується наступним тіком: ResolveMissing закриває все, чого немає серед свіжих кандидатів. Для ПОДІЄВИХ (syslog, ncm, compliance, trap) — ні: engine.go їх у цьому циклі пропускає взагалі, тож алерт, який більше не відповідає жодному селектору, висить до auto_close_seconds, а при нулі — доки його не закриє людина. Те саме стосується зміни source з опитуваного на подієвий.


2026-08-28 — Ескалації: рецензія, три виправлення й перший справжній прогін тестів проти бази

Ескалації доведено до кінця на живих даних

Наскрізно, без вигаданого правила: прогін відповідності → 6 критичних порушень → 6 алертів → драбина. 18 доставок у Telegram (перше сповіщення

  • дві сходинки × 6 алертів), усі sent. Драбина зупинилась сама з причиною «подієвий алерт не повторюється».

Рецензія знайшла три вади, яких щасливий шлях показати не міг

Журнал ескалацій брехав. outcome='sent' писався ДО доставки. Сходинка, чиї канали видалили, вимкнули або підняли їм поріг серйозності, лишала в журналі «надіслано» — доказ, заради якого журнал існує, стверджував протилежне. Гірше: помилка читання каналів клала в кеш порожню мапу, і одна тимчасова невдача з'їдала всі належні сходинки кабінету за тік, кожна з них — зі слідом «надіслано».

Виправлено перестановкою порядку: канали читаються ДО просування стану (не прочитались — сходинка лишається належною й повториться), стан просувається ДО доставки (щоб не надіслати двічі), а журнал пишеться ПІСЛЯ доставки — з тим, що сталося насправді (no_channels, а не sent). ApplyEscalation розділено на просування стану й LogEscalationStep.

Драбину можна було воскресити після «Прийняти». UPDATE у ApplyEscalation не мав умови stopped_at IS NULL. Партія обробляється послідовно, кожна доставка з власним таймаутом, тож розрив між «взяли сходинку в чергу» і «надіслали» вимірюється хвилинами. Людина підтверджує алерт — а UPDATE знімає stopped_at і будить її знову. Тепер такий UPDATE не влучає в рядок, сходинка скасовується з записом у лог.

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

Суміжні вади, знайдені тією ж рецензією

  • UpdateRule з enabled:false не гасив алертів правила (на відміну від SetRuleEnabled) — вони висіли firing вічно, а драбина будила людей до ~21 доби. Рішення винесено в чисту planRuleEnabled, гасіння прив'язане до переходу «увімкнене → вимкнене».
  • Сервер домислював enabled: true за відсутнім полем і на ОНОВЛЕННІ, а форма правил це поле не слала — редагування вимкненого правила мовчки його вмикало. Полагоджено обидва боки.
  • channel_ids сходинок драбини не звірялись із реальними каналами — можна було зберегти драбину з мертвими або чужими id, і вона виглядала налаштованою. Перевірка закрита у store, а не в HTTP. Видалення каналу тепер прибирає його зі сходинок і попереджає, які драбини зачепить. Сходинка лишається порожньою, а не викидається: викинута мовчки зсунула б чергування.

scripts/dbtest.sh — і перший прогін тестів проти справжньої бази

Найдорожча тиха відмова проєкту: тести проти бази мовчки пропускаються без NETPULSE_TEST_DSN, тож go test ./... півтора року показував «ok», а всередині кожного стояв t.Skip. Ізоляція кабінетів, стеля тарифу, гасіння алертів, драбини, чистка пристрою, SLA, запис карт — усе компілювалось і не виконувалось.

Тепер є scripts/dbtest.sh: піднімає одноразовий Postgres, котить міграції, ганяє ./internal/.... Прогнано на стенді проти чистої бази — 63 міграції, усі пакети зелені, включно з новими перевірками підтвердження посеред партії, оновлення правила й посилань на канали.

scripts/check.sh лишається швидким (без Docker) — його ганяють на кожну правку. Цей — перед розгортанням.

Що ще НЕ покрито: доставка ескалації блокує весь такт движка (мертвий вебхук одного кабінету затримує обчислення правил усім); алерт, народжений під заглушенням або в тиху годину, не отримує ні першого сповіщення, ні драбини — ніколи; втрата advisory-lock може дати подвійне сповіщення, бо ApplyEscalation не звіряє оренду; та сама відсутність перевірки каналів живе в alr.rules.channel_ids і alr.routes.channel_ids.