8118 lines
595 KiB
Markdown
8118 lines
595 KiB
Markdown
# 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_live` → `map_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.window` → `period_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` і `eth0` → `inv.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](ROADMAP.md).
|
||
Коротко: користувачі й права (схема є, коду немає) → алерти → шаблони опитування
|
||
(нова підсистема `tpl.*`) → керування зондом із UI → NCM до кінця → мобільний
|
||
адаптив і PWA.
|
||
|
||
---
|
||
|
||
## 2026-08-15 — Етап 5: користувачі, вхід і права
|
||
|
||
Досі продукт умів усе, крім найпростішого: впустити людину. Доступ давав
|
||
машинний токен у змінній збірки — тобто одні права на всіх і жодного способу
|
||
відрізнити, хто що зробив. Цей етап закриває саме це.
|
||
|
||
### Створено
|
||
|
||
- `db/migrations/0012_auth.sql` — три RLS-політики винятку для шляху входу,
|
||
гіпертаблиця `core.login_attempts`, два індекси на `core.sessions`.
|
||
- `server/internal/auth/password.go` — argon2id (2 проходи, 64 МБ, 32 байти),
|
||
вивід у форматі PHC, звірка через `subtle.ConstantTimeCompare`.
|
||
- `server/internal/auth/token.go` — власний HS256 JWT: `Sign`, `Verify`.
|
||
- `server/internal/store/users.go` — автентифікація, сесії, членства, команда.
|
||
- `server/internal/httpapi/principal.go` — `Principal` замість старого
|
||
`authenticated`; людина й машина зводяться до одного набору прав.
|
||
- `server/internal/httpapi/auth.go`, `users.go` — 9 нових ендпоїнтів.
|
||
- `server/cmd/netpulse-user/main.go` — CLI для першого власника.
|
||
- `web/src/api/session.ts`, `components/LoginPage.tsx`, переписані `client.ts`
|
||
і `App.tsx`.
|
||
- `server/internal/httpapi/auth_test.go` — 11 тестів.
|
||
|
||
### Прийняті рішення
|
||
|
||
**Заголовок JWT звіряється байт у байт, а не парситься.** Класична атака
|
||
підміни алгоритму (`alg: none`, `alg: HS256` замість RS256) можлива лише там,
|
||
де сервер питає токен, яким алгоритмом його перевіряти. Тут константа
|
||
`{"alg":"HS256","typ":"JWT"}` порівнюється як рядок — питати нічого й нема в
|
||
кого. Підпис перевіряється **до** строку дії: інакше протермінований підроблений
|
||
токен відрізнявся б за текстом помилки від протермінованого справжнього.
|
||
|
||
**Права читаються з БД на кожному запиті, а не беруться з токена.** Класти їх
|
||
у claims було б швидше на одне звернення, але тоді відкликана роль жила б до
|
||
кінця TTL. Порожній набір прав — це не «нічого не можна», а сигнал, що членство
|
||
зникло: такий токен відхиляється повністю.
|
||
|
||
**Access-токен живе в замиканні модуля, не в `localStorage`.** З localStorage
|
||
його забирає будь-який XSS; із замикання — ні. Ціною є втрата токена при
|
||
перезавантаженні сторінки, тому на старті робиться тихий `refresh` по кукі.
|
||
|
||
**Refresh-токен ротується.** Стара сесія відкликається, видається нова. Це не
|
||
дає викраденій кукі жити паралельно з живою: другий власник отримає
|
||
`401 no_session` — крадіжка стає видимою подією, а не тихою.
|
||
|
||
**Один refresh на всі паралельні запити.** Перший же екран робить чотири
|
||
запити одразу. Без черги з одного обміну ротація зробила б усі, крім першого,
|
||
недійсними, і людину викидало б на вхід рівно тоді, коли все гаразд.
|
||
|
||
**Невідомий email і невірний пароль нерозрізненні** — однаковий код і однаковий
|
||
час: на неіснуючому користувачі спалюється фіктивна перевірка argon2id. Без неї
|
||
різниця в часі сама розказує, які адреси зареєстровані.
|
||
|
||
**Три обмеження вшито в API, а не в UI**: роль `owner` не видається через
|
||
HTTP (лише CLI), не можна змінити роль собі, не можна прибрати себе. Усі три
|
||
захищають від одного — організації без жодного власника.
|
||
|
||
**Перший власник заводиться CLI.** Публічна реєстрація в B2B-інсталяції — це
|
||
не зручність, а дірка; «створи першого користувача через веб, поки нікого
|
||
немає» — гонка, яку неможливо закрити чесно.
|
||
|
||
### Знайдено при написанні
|
||
|
||
`FORCE ROW LEVEL SECURITY` робив вхід неможливим за побудовою: щоб знайти
|
||
користувача за email, треба знати тенант, а тенант відомий лише після того, як
|
||
користувача знайдено. Виправлено трьома політиками, які пускають `SELECT`
|
||
**тільки** коли `core.current_tenant() IS NULL` — тобто рівно на шляху входу,
|
||
де тенанта ще нема. Читання під уже виставленим тенантом лишається обмеженим
|
||
як було.
|
||
|
||
### Перевірено
|
||
|
||
- `go test ./... -race` — 37 тестів httpapi, з них 11 нових; `go vet` чисто.
|
||
- `tsc --noEmit` чисто, `vite build` — 353 КБ JS (114 КБ gzip).
|
||
- Живий прогін проти `netpulse_it`: 8 кроків від входу власника до
|
||
відкликаної сесії — усі коди очікувані (розписано в [API.md](server/API.md)).
|
||
- У браузері: форма входу → інженер входить → мапа з живими RTT і «наживо».
|
||
Під глядачем кнопки редагування зникають, полотно нередаговане, блок зондів
|
||
не показується.
|
||
- Мобільний вигляд (375×812): бічна панель повністю за кадром, полотно на всю
|
||
ширину, горизонтального скролу немає; бургер висуває панель поверх полотна
|
||
із затемненням.
|
||
|
||
### Обмеження перевірки
|
||
|
||
Вкладка браузера прихована, тому CSS-переходи стоять на `currentTime: 0` —
|
||
виміри бічної панелі робилися після примусового `getAnimations().finish()`.
|
||
Це артефакт середовища, не застосунку.
|
||
|
||
Сторінки керування командою в UI ще немає — є API й методи клієнта
|
||
(`team`, `roles`, `addMember`, `setRole`, `removeMember`). Додати користувача
|
||
поки можна лише CLI або запитом.
|
||
|
||
---
|
||
|
||
## 2026-08-15 — Етап 9: алерти й сповіщення
|
||
|
||
Система вміла малювати мапу, але мовчала, коли щось падало. Схема
|
||
(`alr.*`) лежала готовою з Етапу 1 і повністю порожньою. Тепер вона
|
||
працює.
|
||
|
||
### Створено
|
||
|
||
- `server/internal/store/alerts.go` — правила, обчислення умов
|
||
(icmp / interface / metric / no_data), побудова предикатів селектора.
|
||
- `server/internal/store/alerts_state.go` — життєвий цикл алерту,
|
||
придушення, граф топології й визначення першопричини.
|
||
- `server/internal/store/alerts_query.go` — списки, ack, mute, CRUD правил.
|
||
- `server/internal/store/alerts_channels.go` — канали, маршрути, тихі
|
||
години, журнал доставки.
|
||
- `server/internal/alerting/engine.go` — цикл обчислення під
|
||
advisory-блокуванням.
|
||
- `server/internal/alerting/notify.go` — Telegram, webhook, SMTP,
|
||
шаблони повідомлень, SSRF-захист.
|
||
- `server/internal/httpapi/alerts.go` — 12 ендпоїнтів.
|
||
- `web/src/hooks/useAlerts.ts`, `components/AlertsPanel.tsx`, шина подій
|
||
у `api/ws.ts`.
|
||
- 20 нових тестів (11 у `store`, 9 в `alerting`).
|
||
|
||
### Прийняті рішення
|
||
|
||
**Движок не має стану між тіками.** Вікно `for_seconds` — це запит по
|
||
часу до TSDB, а не лічильник у пам'яті. Тому перезапуск процесу нічого
|
||
не збиває, а два процеси дали б однаковий результат. Лічильник у
|
||
пам'яті довелося б і зберігати, і відновлювати, і синхронізувати між
|
||
екземплярами — три способи розійтися з реальністю замість нуля.
|
||
|
||
**Дві семантики вікна.** Без `agg` умова має триматися **всі** виміри
|
||
вікна — це і є антифлап: одна втрачена відповідь не будить людину. З
|
||
`agg` порівнюється агрегат. Обидві потрібні: «недоступний три хвилини
|
||
поспіль» і «середнє завантаження за 5 хв вище 85%» — різні питання.
|
||
|
||
**Список метрик і операторів закритий.** Значення з `condition`
|
||
потрапляє в текст запиту (агрегатну функцію не підставиш параметром),
|
||
тому все, що йде в SQL, проходить whitelist, а все інше — лише
|
||
параметром. Послаблення тут — це SQL-ін'єкція через JSON у таблиці
|
||
правил.
|
||
|
||
**Кореляція за межею зони недоступності.** Причина аварії — той, у кого
|
||
лишився живий сусід; хто оточений мертвими — наслідок. Це те, заради
|
||
чого будувалась `topo.links`: інакше падіння маршрутизатора дає сорок
|
||
сповіщень, серед яких губиться єдине потрібне. Пристрій без зв'язків
|
||
завжди лишається причиною — топологія про нього нічого не знає, і
|
||
списати його на чужу аварію було б вигадкою.
|
||
|
||
**Дедуплікація індексом, а не перевіркою в коді.** Частковий унікальний
|
||
індекс `alerts_active_dedup_uniq` робить другий алерт на ту саму
|
||
проблему неможливим навіть якщо два движки якимось чином працюють
|
||
одночасно.
|
||
|
||
**Сповіщення шле лише щойно піднятий і не придушений алерт.**
|
||
Продовження вже відомої проблеми не є новиною. Перевірено прогоном: за
|
||
три тіки кількість сповіщень не зросла.
|
||
|
||
**Тенант без маршрутів отримує все в усі придатні канали.** Підключили
|
||
Telegram — має працювати. Вимагати ще й маршрут означало б мовчати саме
|
||
там, де налаштування щойно зроблене й здається повним.
|
||
|
||
**Тиха година не глушить `disaster`.** Сенс чергування в тому, щоб
|
||
його підняли.
|
||
|
||
**SSRF-захист із виходом для self-hosted.** Адресу вебхука задає
|
||
користувач тенанта, а запит іде з сервера — у SaaS це класичний вектор.
|
||
Але в self-hosted вебхук майже завжди веде саме всередину, у
|
||
корпоративний Mattermost. Тому заборона знімається прапорцем процесу,
|
||
і рішення приймає адміністратор сервера, а не користувач тенанта.
|
||
|
||
**Стеля ручного заглушення — тиждень.** Безстрокове «не турбувати» —
|
||
найпоширеніший спосіб тихо вимкнути моніторинг назавжди.
|
||
|
||
### Знайдено роботою з живою системою (три справжні помилки)
|
||
|
||
**Вимкнене або видалене правило лишало свої алерти висіти вічно.**
|
||
`alr.alerts.rule_id` має `ON DELETE SET NULL`, а вимкнене правило
|
||
випадає з вибірки движка — в обох випадках алерти ставали сиротами,
|
||
яких нікому закрити. Виправлено закриттям алертів у тій самій
|
||
транзакції, до видалення правила.
|
||
|
||
**Перехід у «придушено» не публікував події.** Людина глушила пристрій,
|
||
движок за 10 секунд переводив алерт у `suppressed`, а UI про це не
|
||
дізнавався до перезавантаження сторінки. Причина: подія публікувалась
|
||
лише для нових і закритих алертів. Додано `alert.updated` і читання
|
||
попереднього стану через CTE у тому ж знімку, що й UPSERT — інакше
|
||
відрізнити «стан змінився» від «проблема триває» неможливо.
|
||
|
||
**Кнопки дій на телефоні були 26 px.** Виміряно в мобільному вигляді:
|
||
для «Прийняти» і «Заглушити», які натискають пальцем уночі, це
|
||
промахи. Піднято до 38 px на вузьких екранах, на десктопі щільність
|
||
збережено.
|
||
|
||
### Перевірено наживо
|
||
|
||
Проти справжніх даних (2 пристрої, 240 ICMP-вимірів за 10 хв):
|
||
|
||
- Правило з порогом 0.5 мс підняло алерт на `gateway` (0.98 мс) і **не**
|
||
підняло на `snmp-host` (0.08 мс) — поріг рівно посередині.
|
||
- `no_data` і «помилки на порту» мовчали, як і мали.
|
||
- Вебхук дійшов: 5 доставок на 5 подій, жодного повтору за багато тіків.
|
||
- Заглушення пристрою придушило рівно його алерт, сусідній лишився
|
||
активним.
|
||
- Вимкнення правила закрило його алерти й не зачепило чужий `ack`.
|
||
- Глядач отримує 403 на ack і 200 на читання.
|
||
- У браузері: індикатор пульсує червоним, панель відкривається, ack
|
||
оновлює список наживо, придушений алерт іде під окремий фільтр.
|
||
- Мобільний вигляд 375 px: панель на весь екран, горизонтального скролу
|
||
немає.
|
||
|
||
### Чого свідомо не робив
|
||
|
||
Ескалацій (`alr.escalation_policies`) і повторних сповіщень немає —
|
||
алерт сповіщає один раз. Кнопки Ack/Mute у Telegram відмальовуються, але
|
||
приймача `callback_data` не написано. Web Push відкладено до Етапу 10
|
||
разом із PWA. Правила з джерел `syslog`/`trap`/`ncm` движок пропускає:
|
||
вони обробляються подіями, а не опитуванням.
|
||
|
||
|
||
---
|
||
|
||
## 2026-08-15 — Етап 5+: повноцінний веб
|
||
|
||
Питання «а повноцінний веб коли?» було справедливим. До цього моменту
|
||
«веб» — це був **один екран**: логін, мапа й панель алертів. Ані
|
||
навігації, ані сторінок; усе, що вміло API, доводилось викликати
|
||
curl-ом. Цей етап закриває розрив між готовим бекендом і тим, що людина
|
||
може натиснути.
|
||
|
||
### Створено
|
||
|
||
- `web/src/components/AppShell.tsx` — каркас: бічна навігація, шапка,
|
||
індикатор зв'язку, лічильник алертів.
|
||
- `web/src/components/ui.tsx` — примітиви: `DataTable`, `Modal`,
|
||
`Button`, `Field`, `StatusBadge`, `Toggle`, `Card`.
|
||
- Сім сторінок у `web/src/pages/`: `MapPage`, `DevicesPage`,
|
||
`AlertsPage`, `RulesPage`, `ChannelsPage`, `AgentsPage`, `TeamPage`,
|
||
`ProfilePage`.
|
||
- `web/src/hooks/useLiveRefresh.ts` — перечитування зі злиттям сплеску.
|
||
- `react-router-dom` як залежність; `App.tsx` переписано на маршрути.
|
||
|
||
### Прийняті рішення
|
||
|
||
**Пункт меню, на який немає права, не показується взагалі.** Кнопка,
|
||
що завжди дає 403, гірша за її відсутність: вона обіцяє можливість,
|
||
якої немає, і змушує людину гадати, що вона зробила не так. Пряме
|
||
посилання при цьому лишається робочим — його можна отримати від колеги
|
||
чи успадкувати після зміни ролі, тому маршрут показує зрозуміле
|
||
пояснення з назвою потрібного права, а не порожній екран.
|
||
|
||
**Стартова сторінка залежить від ролі.** Глядача без права на мапи
|
||
вітати відмовою — поганий перший екран, тому домівкою стає перший
|
||
доступний розділ.
|
||
|
||
**Таблиця на телефоні перестає бути таблицею.** Горизонтальний скрол на
|
||
375 px — це спосіб зробити дані формально присутніми й фактично
|
||
нечитабельними. Тому `DataTable` малює картки, а другорядні колонки
|
||
позначаються `hideOnMobile`, щоб картка лишалась короткою.
|
||
|
||
**Форма правила показує списком те саме, що приймає сервер.** Списки
|
||
метрик дублюють whitelist бекенда свідомо: без цього про друкарську
|
||
помилку людина дізнавалась би не з форми, а з правила, яке мовчки
|
||
нічого не знаходить.
|
||
|
||
### Знайдено роботою з живим UI (три справжні помилки)
|
||
|
||
**WebSocket жив усередині мапи.** Найсерйозніша з трьох. Сокет
|
||
створював `useLiveMap`, тому на кожній сторінці, крім мапи, живих
|
||
оновлень не було взагалі: лічильник алертів у шапці замерзав, щойно
|
||
людина йшла з мапи, і показував стан на момент входу. Помітно це стало
|
||
лише тоді, коли сторінок стало більше однієї. З'єднання винесено в
|
||
модуль-одинак `live`, яким володіє оболонка; `useLiveMap` тепер лише
|
||
слухає спільну шину. Заодно стан зв'язку видно з будь-якої сторінки —
|
||
замерзлий інтерфейс виглядає точно так само, як справний.
|
||
|
||
**Сторінка пристроїв перечитувала все на кожну подію алерту.** Виміряно
|
||
лічильником запитів у браузері під штучним сплеском: одна аварія з
|
||
десятком алертів давала десяток пар запитів — клієнт додавав
|
||
навантаження рівно тоді, коли серверу найважче. Виправлено спільним
|
||
хуком зі злиттям сплеску плюс переходом на алерти з оболонки замість
|
||
власного опитувача: **той самий сплеск тепер коштує 2 запити замість
|
||
дванадцяти**.
|
||
|
||
**Канал із секретом показувався як «секрету немає».** `HasSecret`
|
||
ставився всередині гілки розшифровки, а перелік для UI викликається без
|
||
ключа навмисно. Наявність секрету — факт про канал, а не наслідок того,
|
||
чи його зараз читають.
|
||
|
||
### Перевірено в браузері проти повного стека
|
||
|
||
- Усі вісім сторінок рендеряться з живими даними: 2 пристрої, 5 правил,
|
||
1 канал, 1 зонд, 4 учасники.
|
||
- Створення правила через форму: правил 4 → 5, умова записалась
|
||
коректно (`loss_pct > 5, всі виміри 120 с`).
|
||
- Створення користувача через форму: учасників 3 → 4.
|
||
- Кнопка «Перевірити» на каналі — пробне повідомлення дійшло у приймач.
|
||
- Глядач: у меню 5 пунктів замість 7, кнопки «+ Правило» немає, усі
|
||
10 перемикачів заблоковані, `/team` прямим посиланням дає екран із
|
||
поясненням.
|
||
- Мобільний 375 px: меню закрите, бургер висуває, перехід закриває його
|
||
сам; таблиці стають картками; горизонтального скролу немає.
|
||
- Мапа після переносу сокета працює: вузли з живим RTT, «наживо»,
|
||
лічильник часу оновлюється.
|
||
|
||
### Чого ще немає у вебі
|
||
|
||
Дашбордів і графіків історії (потрібен `GET /api/v1/metrics`, якого
|
||
немає), сторінки налаштувань зонда, редагування правила (лише
|
||
створення й вимкнення), маршрутів сповіщень і вікон обслуговування
|
||
(є в схемі й у движку, у UI — ні), NOC-режиму на телевізор.
|
||
|
||
|
||
---
|
||
|
||
## 2026-08-15 — Вхід за логіном, групи хостів і права доступу
|
||
|
||
Три зауваження за одну сесію: перемикачі виглядають зламано, входити треба
|
||
логіном, а не поштою, і бракує заббіксівського — груп, прав на них, додавання
|
||
хостів і редагування мапи. Плюс два власні спостереження користувача про
|
||
сторінку правил і мапу.
|
||
|
||
### Створено
|
||
|
||
- `db/migrations/0013_groups_login.sql` — `core.users.username`,
|
||
`core.user_groups`, `core.user_group_members`, `core.group_permissions`,
|
||
функції `core.device_access_level` і `core.accessible_devices`, RLS на нові
|
||
таблиці.
|
||
- `server/internal/store/groups.go` — CRUD груп, хостів і обчислення `Scope`.
|
||
- `server/internal/httpapi/groups.go` — 11 ендпоїнтів.
|
||
- `web/src/pages/GroupsPage.tsx` — групи хостів і груп доступу на одному екрані.
|
||
- `web/src/components/NodeInspector.tsx` — підпис, значок, розмір, ширина,
|
||
колір, закріплення вузла.
|
||
- Форма хоста (створення й редагування) і фільтр за групами на сторінці хостів.
|
||
- Створення мапи з транслітерацією slug.
|
||
|
||
### Прийняті рішення
|
||
|
||
**Логін замість пошти.** У мережевій інсталяції половина облікових записів
|
||
технічні — `noc`, `monitoring`, `oncall`, — і скриньки не мають узагалі. Пошта
|
||
лишилась необов'язковим полем для сповіщень. Сервер шукає за обома, тому людину,
|
||
яка за звичкою ввела email, ніхто не відхиляє.
|
||
|
||
**Наявним користувачам логін вивели з пошти**, а збіги розвели суфіксом за
|
||
порядком створення. Мовчки злити `admin@a.com` і `admin@b.com` в один логін
|
||
було б не міграцією, а втратою акаунта. Механізм одразу знадобився: на стенді
|
||
жили залишки від тестових прогонів, і `admin` дістався саме їм.
|
||
|
||
**Ролі й групи — два незалежні виміри.** Роль каже, що людині вільно робити;
|
||
група доступу — над якими хостами. Інженер над однією філією та інженер над
|
||
усією мережею мають однакову роль і різний доступ, і змішати це в один список
|
||
прав неможливо без втрати сенсу.
|
||
|
||
**Хто не входить у жодну групу — не обмежений групами.** Це свідомо не
|
||
по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова
|
||
інсталяція починається з питання «чому порожньо». Тут звуження вмикається тоді,
|
||
коли його справді налаштували.
|
||
|
||
**Заборона перемагає дозвіл.** Інакше її можна обійти, додавши хост у будь-яку
|
||
іншу групу.
|
||
|
||
**Фільтр видимості накладається в самому запиті**, а не після вибірки:
|
||
відсіювати вже прочитане означало б тягнути з БД чужі рядки й покладатися на те,
|
||
що жоден не проскочить у відповідь.
|
||
|
||
**Правки вузла застосовуються кнопкою, а не на кожну літеру.** Кожне збереження
|
||
— це нова ревізія полотна й подія для всіх, хто дивиться на ту саму мапу.
|
||
|
||
### Знайдено й виправлено
|
||
|
||
**Перемикач вилазив за межі треку.** Скарга була «вигляд глюкнутий»; вимір
|
||
показав причину: у ручки не заданий `left`, тож вона стає на статичну позицію,
|
||
а та у `<button>` зсунута типовим `text-align: center`. Обчислений `left`
|
||
виходив 18px замість 2px, ручка виступала на 14px і накривала сусідній хрестик.
|
||
|
||
**Сторінка правил не оновлювала лічильник «активних».** Друга скарга, і причина
|
||
не та, що здається. Після вимкнення правила його алерти закриваються одразу, а
|
||
після повернення піднімаються лише наступним тіком движка — тобто через секунди
|
||
ПІСЛЯ нашого перечитування. Сторінка не була підписана на живі події, тому
|
||
показувала нулі до перезавантаження. Той самий недогляд, що раніше знайшовся на
|
||
сторінці хостів; тепер обидві користуються спільним хуком.
|
||
|
||
**Канал із секретом показувався як «секрету немає»** — прапорець ставився
|
||
всередині гілки розшифровки, а перелік для UI викликається без ключа навмисно.
|
||
|
||
### Перевірено наживо
|
||
|
||
Прогін проти справжнього стенду:
|
||
|
||
```
|
||
вхід за логіном 'admin' 200, роль owner
|
||
вхід тим самим, але поштою 200 — сумісність збережена
|
||
дві групи хостів 201/201
|
||
хости розкладено gateway→Магістраль, snmp-host→Доступ
|
||
новий хост через API 201
|
||
група доступу для 'eng' лише «Доступ», рівень read
|
||
що бачить eng snmp-host і test-host, обидва writable=false;
|
||
gateway зник із вибірки
|
||
алерти під eng 1 замість 3
|
||
eng редагує чужий хост 403
|
||
```
|
||
|
||
У браузері: вхід логіном (у шапці «admin», не пошта); перемикачі вміщаються в
|
||
трек в обох станах із відступом 2px; правила оновлюються 2 → 0 → 2 без
|
||
перезавантаження; сторінка груп показує обидві половини; інспектор вузла змінив
|
||
підпис, значок і ширину до 220px із ревізією 13 → 14; створення мапи дало slug
|
||
`kyiv-iadro` з «Київ ядро» і порожнє полотно.
|
||
|
||
### Чого ще немає
|
||
|
||
Динамічних груп (`inv.device_groups.kind='dynamic'` у схемі є, правило відбору
|
||
не читається), успадкування прав між групами, редагування груп хостів після
|
||
створення (лише створення й видалення), масових операцій над хостами.
|
||
|
||
---
|
||
|
||
## 2026-08-15 — Опитування хоста з форми й редагування користувачів
|
||
|
||
Зауваження було точним: «додаєш хост — чому не можна вказати, як його
|
||
опитувати». Форма створення хоста, яку я зробив раніше, збирала назву,
|
||
адресу, тип і групи — і не збирала головного. Хост, доданий через UI,
|
||
не опитувався **взагалі**: жодного рядка в `core.checks`. Наявні два
|
||
хости на стенді працювали лише тому, що їхні перевірки я засіяв через
|
||
SQL.
|
||
|
||
### Створено
|
||
|
||
- `server/internal/store/checks.go` — типи перевірок, перевірки хоста,
|
||
доступи до обладнання.
|
||
- `server/internal/httpapi/checks.go` — 5 ендпоїнтів.
|
||
- `web/src/components/ChecksEditor.tsx` — редактор опитування, який
|
||
будує поля параметрів із `params_schema`, що віддає сервер.
|
||
- `UpdateUserProfile` у store + розширений `PATCH /api/v1/team/{id}`.
|
||
- `EditUserForm` на сторінці «Команда».
|
||
|
||
### Прийняті рішення
|
||
|
||
**Перевірки — частина створення хоста, а не окремий крок.** Вимагати
|
||
другого запиту означало б зробити «хост, який не опитується» типовим
|
||
станом. Новий хост у формі одразу отримує `icmp.ping`: це єдина
|
||
перевірка, яка працює будь-де без налаштування.
|
||
|
||
**Поля параметрів будуються з JSON Schema типу.** Захардкодити їх у
|
||
фронтенді означало б забувати оновити його щоразу, коли плагін додає
|
||
новий тип. Сервер уже віддає `params_schema` — форма її і читає.
|
||
|
||
**Правка перевірки йде за `id`.** Унікальний індекс `checks_uniq`
|
||
включає `md5(params)`, тому «видалити й вставити» на зміні інтервалу
|
||
спрацювало б, а на зміні параметрів створило б ДРУГУ перевірку того
|
||
самого типу.
|
||
|
||
**Профіль редагується лише в того, хто працює тільки в цій
|
||
організації.** `core.users` глобальна, і логін із паролем — власність
|
||
людини, а не тенанта. Адмін філії, який змінює пароль тому, хто тим
|
||
самим акаунтом заходить у сусідню організацію, ламає їй доступ там, і
|
||
вона про це не дізнається. Спроба дає `409 shared_user`; роль і групи
|
||
локальні й редагуються завжди.
|
||
|
||
**Порожнє поле у формі означає «не чіпати».** Тому перевірки шлються
|
||
лише коли форма справді їх завантажила: збереження форми, відкритої до
|
||
завантаження, інакше стерло б усе опитування хоста.
|
||
|
||
### Знайдено при написанні
|
||
|
||
`core.plugins` не має колонки `enabled` — активація на тенанта живе в
|
||
`core.plugin_installs`. Перший варіант запиту падав із
|
||
`column p.enabled does not exist`. Заодно з'ясувалося, що
|
||
`plugin_installs` порожня, тому доступність рахується як «плагін
|
||
базовий АБО явно ввімкнений»: вимагати «встановлення» для пінга
|
||
означало б зустрічати кожного нового клієнта порожнім списком перевірок.
|
||
|
||
### Перевірено наживо
|
||
|
||
```
|
||
типи перевірок 8 доступних із 9 (modbus не базовий і не встановлений)
|
||
доступ SNMP створено 201
|
||
хост із трьома перевірками 201
|
||
що записалось icmp.ping 30 с, snmp.get 60 с, snmp.if 300 с
|
||
правка інтервалу і зняття однієї 10 с, params {"count": 5}, лишилось 2 — без задвоєння
|
||
хост без перевірок створюється, перевірок 0
|
||
профіль + пароль змінено 204
|
||
вхід новим паролем 200
|
||
вхід старим паролем 401 bad_credentials
|
||
перейменування логіна 204, вхід новим логіном 200
|
||
спроба змінити роль собі 403
|
||
```
|
||
|
||
У браузері: форма нового хоста показує `icmp.ping` із полями `count` і
|
||
`packet_size`, узятими зі схеми, і список із семи інших доступних типів;
|
||
доданий через UI хост записав `icmp.ping` з інтервалом 30 с і `snmp.get`
|
||
із двома OID.
|
||
|
||
### Чого ще немає
|
||
|
||
Форми створення доступу (SNMP-community) у вебі — доступи заводяться
|
||
через API, а у формі хоста лише прив'язуються. Перевірок на рівні групи
|
||
хостів (у Zabbix це шаблони — Етап 6). Історії й графіків за зібраними
|
||
метриками.
|
||
|
||
---
|
||
|
||
---
|
||
|
||
## 2026-08-15 — Каталог команд збору конфігу
|
||
|
||
Щоб знімати конфіги, треба знати команду для кожної платформи: у Cisco
|
||
це `show running-config`, у Huawei `display current-configuration`, у
|
||
MikroTik `export`, а Eltex SMG узагалі віддає конфіг через `cat`. Зібрав
|
||
це в каталог.
|
||
|
||
### Створено
|
||
|
||
- `db/profiles/catalog.json` — 147 платформ, 67 вендорів: команда збору,
|
||
команда startup-конфігу, підготовка консолі, скільки службових рядків
|
||
відкинути, альтернативи для інших моделей родини.
|
||
- `db/profiles/build.py` — збірка міграції з каталогу, з режимом
|
||
`--check`.
|
||
- `db/profiles/README.md` — будова каталогу й як додати платформу.
|
||
- `db/migrations/0014_ncm_profiles.sql` — згенерований результат.
|
||
|
||
### Прийняті рішення
|
||
|
||
**Каталог — джерело істини, міграція породжується.** Два описи одного й
|
||
того самого розійшлися б із першою ж правкою, і невідомо було б, який
|
||
справжній. Тому міграція має в шапці «ФАЙЛ ЗГЕНЕРОВАНО», а `build.py
|
||
--check` уміє звірити, чи вона не відстала.
|
||
|
||
**Каталог — код, а не дані клієнта.** Він однаковий для всіх
|
||
інсталяцій, має переглядатись у code review і їхати разом із релізом.
|
||
Тенант при цьому може завести власний профіль через `tenant_id` —
|
||
вбудовані лишаються недоторканими.
|
||
|
||
**Родина CLI, а не вендор.** `bdcom`, `arista`, `brocade` і ще з десяток
|
||
говорять діалектом Cisco; `h3c` і `3com` — діалектом Huawei. Тримати
|
||
промпт і вимкнення пейджера один раз на родину — різниця між правкою в
|
||
одному місці й правкою в сорока.
|
||
|
||
**Пейджер додається лише мережевому CLI.** Частина «мережевих»
|
||
пристроїв знімає конфіг шелом (`cat /etc/config/cfg.yaml` в Eltex SMG і
|
||
TAU, `tail` в Eltex RG). Відправити в bash `terminal datadump` означало
|
||
б гарантовану помилку на кожному зборі.
|
||
|
||
**Лічильники в шапці обчислюються.** Перший варіант мав зашите «148
|
||
платформ» поруч зі згенерованими 147 — розбіжність з'явилась одразу, а
|
||
на першій доданій платформі стала б звичкою не вірити коментарям.
|
||
|
||
### Перевірено
|
||
|
||
Міграція накочена на `netpulse_it`: 148 профілів (147 із каталогу плюс
|
||
раніше засіяні), 67 вендорів, дублікатів немає — унікальний індекс
|
||
`ncm_profiles_key_uniq` спрацював, а `ON CONFLICT DO NOTHING` зберіг
|
||
наявні. Вибірково звірено складні випадки: Cisco ASA (`more
|
||
system:running-config` без службових `changeto system`), NXOS, FWSM,
|
||
Huawei VRP3, H3C, Juniper JUNOSe, Eltex SMG/TAU (без пейджера).
|
||
|
||
`build.py --check` після збірки каже «міграція актуальна» — генерація
|
||
детермінована.
|
||
|
||
### Чого це ще не дає
|
||
|
||
**Виконувати профілі нікому.** Агентського модуля `ncm` (SSH/Telnet)
|
||
немає — це Етап 7. Зараз це готові дані, які чекають на виконавця.
|
||
|
||
---
|
||
|
||
## 2026-08-16 — Етап 7: збір конфігів запрацював
|
||
|
||
Профілі з попереднього кроку були даними без виконавця. Тепер ланцюг
|
||
замкнено: кнопка в UI → черга → диспетчер → зонд → SSH → назад у БД.
|
||
|
||
### Створено
|
||
|
||
- `agent/internal/ncmx/` — знімання конфігу по CLI: `session.go`
|
||
(розбір потоку), `transport.go` (SSH і Telnet), `collect.go`
|
||
(виконання завдання).
|
||
- `agent/internal/session/config_jobs.go` — приймання `ConfigJob` і
|
||
вивантаження стрімом.
|
||
- `server/internal/store/ncm_jobs.go` — черга, побудова завдання з
|
||
профілю й доступу, закриття.
|
||
- `server/internal/grpcapi/ncm_dispatch.go` — диспетчер.
|
||
- 16 тестів на розбір консолі.
|
||
|
||
### Прийняті рішення
|
||
|
||
**Усе будується навколо пошуку промпту.** Консоль мережевого пристрою —
|
||
не програмний інтерфейс: немає ані коду завершення, ані довжини
|
||
відповіді. Єдиний спосіб зрозуміти, що команда відпрацювала — побачити
|
||
знову запрошення.
|
||
|
||
**Промпт шукається лише в хвості накопиченого (512 байтів).** Конфіг
|
||
може містити рядок, схожий на запрошення — `banner motd #` трапляється
|
||
в кожній другій мережі, — і пошук по всьому тексту обривав би збір на
|
||
середині.
|
||
|
||
**Дедлайн на паузу між байтами, а не на всю операцію.** Збір із
|
||
великого шасі триває хвилини, і загальний ліміт довелося б ставити
|
||
навмання. Тиша ж означає одне з двох: пристрій завис або промпт не той.
|
||
Тому й помилка окрема — `ErrPromptTimeout` підказує, що лікується вона
|
||
не повтором, а виправленням `prompt_regex`.
|
||
|
||
**Черга в БД між процесами.** REST і AgentService — різні процеси;
|
||
живу сесію зонда тримає лише другий. `FOR UPDATE SKIP LOCKED` не дає
|
||
двом екземплярам надіслати одне завдання двічі.
|
||
|
||
**Ключі SSH мережевого обладнання не звіряються.** Свідомо: залізо
|
||
перегенеровує ключ після кожної заміни прошивки, і known_hosts на сотні
|
||
пристроїв означав би або вимикати перевірку щотижня, або не збирати
|
||
конфіги зовсім. Захист тут дає сегмент керування, а не TOFU. Переліки
|
||
алгоритмів навмисно широкі — інакше половина парку відпаде з «no common
|
||
algorithm».
|
||
|
||
### Знайдено живим прогоном (три справжні помилки)
|
||
|
||
Прогін ставили проти самого стенду: у нього є SSH, і `cat
|
||
/etc/os-release` віддає текст так само, як консоль віддає конфіг.
|
||
|
||
**Сервер ігнорував поле `encoding`.** Агент стискав тіло gzip і чесно
|
||
рахував sha256 від оригіналу, а сервер рахував від стиснених байтів —
|
||
і відхиляв кожен бекап як «тіло не відповідає заявленому sha256». Поле
|
||
було в контракті з Етапу 2, реалізації не було ніколи: інтеграційний
|
||
тест користувався `encoding: "none"` і повз цю дірку проходив.
|
||
|
||
**Промпт обрізався не по рядку.** Типовий шаблон `[>#]\s*$` збігається
|
||
лише з символом запрошення, тому в конфізі лишалось ім'я пристрою
|
||
окремим рядком: «…interface Gi0/1» + «sw1». Ріжемо весь рядок.
|
||
|
||
**У конфіг потрапляло сміття терміналу.** Перший успішний збір дав 295
|
||
байтів і 12 рядків там, де файл має 286 і 10. Транскрипт (який сам же
|
||
модуль і зберіг) показав причину: escape-послідовності bash
|
||
`ESC[?2004l` і подвоєні `\r\r\n` від псевдотерміналу — PTY додає свій
|
||
`\r` до пристроєвого `\r\n`, і кожен рядок подвоювався. Після
|
||
виправлення: 285 байтів і рівно 10 рядків, різниця з оригіналом лише у
|
||
фінальному переводі рядка, який відрізається свідомо.
|
||
|
||
### Перевірено наживо
|
||
|
||
```
|
||
завдання в черзі → диспетчер забрав, зонд отримав
|
||
зонд зайшов по SSH → виконав команду профілю
|
||
вивантажив gzip-стрімом → сервер розпакував, звірив sha256
|
||
статус → success, конфіг 285 байтів / 10 рядків
|
||
повторний збір → unchanged, другої версії не створено
|
||
```
|
||
|
||
### Чого ще немає
|
||
|
||
Планувальника за `ncm.device_policies.cron` — збір запускається лише
|
||
вручну або зовнішнім тригером. Тригера за Syslog-подією
|
||
(`%SYS-5-CONFIG_I`). Git-двигуна: конфіг лягає в БД зашифрованим, але
|
||
коміту в репозиторій ще немає, тому `commit_sha` порожній. Візуального
|
||
diff у вебі й кнопки «зібрати зараз» в інтерфейсі — API є, сторінки
|
||
немає.
|
||
|
||
---
|
||
|
||
## 2026-08-16 — Візуальний diff конфігів
|
||
|
||
Збір працював, але подивитись на зібране було ніде: жодної сторінки, а
|
||
`lines_added` завжди 0 — порівняння ніхто не рахував.
|
||
|
||
### Створено
|
||
|
||
- `server/internal/difftext/` — порядкове порівняння з ділянками й
|
||
контекстом; 10 тестів.
|
||
- `server/internal/store/ncm_read.go` — читання версій, розшифровка
|
||
тіла, порівняння з кешем.
|
||
- Три ендпоїнти: версії, текст, diff.
|
||
- `web/src/pages/ConfigsPage.tsx` — хости зліва, історія й diff справа.
|
||
|
||
### Прийняті рішення
|
||
|
||
**Власне порівняння, а не бібліотека.** Потрібен рівно один алгоритм на
|
||
рядках із виводом у формі, яку розуміє UI. Зовнішня залежність принесла
|
||
б підтримку слів, символів, кольорів у терміналі — десяток речей, які
|
||
тут ніколи не знадобляться.
|
||
|
||
**Спільний початок і кінець відкидаються до основного алгоритму.** У
|
||
конфігах змінюється кілька рядків із тисячі; без цього кроку
|
||
квадратична таблиця будувалася б там, де досить порівняти десяток
|
||
рядків.
|
||
|
||
**Занадто великі й повністю різні тексти позначаються `truncated`.**
|
||
Понад чотири мільйони клітинок — це вже пара конфігів, що розійшлися
|
||
повністю, і точність там нічого не дає: людині однаково читати весь
|
||
блок. Чесна позначка краща за правдоподібний, але вигаданий diff.
|
||
|
||
**Зміни поруч зливаються в одну ділянку.** Інакше сусідні правки давали
|
||
б дві ділянки з дубльованим контекстом між ними.
|
||
|
||
**Порівняння кешується в `ncm.diffs`.** Diff двох конкретних версій
|
||
незмінний назавжди; рахувати його при кожному відкритті сторінки — це
|
||
палити процесор на відому відповідь. Підсумок `+N/−M` заразом
|
||
дозаписується у version, щоб список історії не розшифровував два тіла
|
||
на кожен рядок.
|
||
|
||
**Порівняння з попередньою версією відкривається одразу.** Питання
|
||
завжди одне — «що змінилось цього разу», — і вибір «з чим порівняти»
|
||
був би зайвим кроком перед відповіддю.
|
||
|
||
### Перевірено наживо
|
||
|
||
Створили другу версію, змінивши команду профілю (`cat /etc/os-release`
|
||
→ `cat /etc/os-release /etc/hostname`):
|
||
|
||
```
|
||
історія 2 версії, друга is_change=true
|
||
diff @@ −8,3 +8,4 @@, один доданий рядок «new-ct» з контекстом
|
||
лічильник +1 −0 дозаписався в історію після обчислення
|
||
повний текст 292 байти, читається
|
||
```
|
||
|
||
### Чого ще немає
|
||
|
||
Тригера за Syslog. Git-двигуна: `commit_sha` поки дорівнює хешу вмісту,
|
||
справжнього репозиторію немає. Порівняння з довільною версією в UI (API
|
||
вміє через `?from=`, кнопки немає).
|
||
|
||
---
|
||
|
||
## Планувальник бекапів за cron
|
||
|
||
Збір конфігів працював, але лише руками. Це не NCM: сенс бекапу в
|
||
тому, що він робиться сам, а «зайти й натиснути» — це та сама
|
||
відсутність бекапу, просто з кращим самопочуттям.
|
||
|
||
### Свій парсер cron замість бібліотеки
|
||
|
||
`robfig/cron` зробив би це за десять рядків, але тягнув би залежність
|
||
заради того, що вміщується в одному файлі й не змінюється з 1975 року.
|
||
Розбір — у бітові маски `uint64` на поле, пошук наступного запуску —
|
||
покроково по хвилинах із запобіжником у чотири роки: перебір дешевший
|
||
за арифметику з календарем і не має її крайніх випадків.
|
||
|
||
**Правило dom/dow — об'єднання, не перетин.** Якщо задані і день
|
||
місяця, і день тижня, підходить збіг за будь-яким. Це виглядає
|
||
неочікувано, доки не спробуєш записати «щоп'ятниці та першого числа» —
|
||
іншого способу немає.
|
||
|
||
**Неможливий розклад чесно відмовляє.** `0 0 30 2 *` — 30 лютого не
|
||
буває; запобіжник у чотири роки перетворює це на помилку, а не на
|
||
зациклений процес. 29 лютого при цьому знаходиться правильно (2028).
|
||
|
||
11 тестів на парсер пройшли з першого запуску.
|
||
|
||
### Планувальник
|
||
|
||
Тік раз на хвилину — дрібніше cron не буває. Advisory-блокування
|
||
(`0x6e70_6263`, «npbc»), бо інакше кожен екземпляр сервера в кластері
|
||
поставив би своє завдання на той самий хост.
|
||
|
||
**Порядок кроків важливий: спершу перенести `next_backup_at`, потім
|
||
ставити завдання.** Падіння між кроками коштує одного пропущеного
|
||
бекапу. Зворотний порядок дав би нескінченну чергу однакових завдань —
|
||
а це кладе і пристрій, і сервер.
|
||
|
||
**Некоректний cron відсувається на добу, а не пишеться в журнал
|
||
щохвилини.** Скарга має лишитись помітною, але не перетворитись на шум,
|
||
у якому потонуть справжні помилки.
|
||
|
||
**`next_backup_at IS NULL` = «час настав».** Так виглядає щойно
|
||
збережена політика. З цієї ж причини будь-яка зміна політики обнуляє
|
||
позначку: розклад міг стати частішим, і чекати за старим було б
|
||
неправильно.
|
||
|
||
### Форма
|
||
|
||
Чотири готові розклади покривають майже все, що справді налаштовують;
|
||
довільний cron лишився, але не першим, що бачить людина.
|
||
|
||
**Знайдена й виправлена помилка:** вибір «свій розклад…» нічого не
|
||
робив. Обробник `select` мав вигляд `e.target.value && setCron(...)`, а
|
||
значення цього пункту — порожній рядок, тож умова відсікала саме той
|
||
випадок, заради якого пункт існує. Режим тепер тримається окремим
|
||
прапорцем, а не виводиться з виразу: інакше поле не відкривалось би й
|
||
тоді, коли власний вираз випадково збігся з пресетом.
|
||
|
||
### Перевірено наживо
|
||
|
||
Розклад `* * * * *` на stand-host:
|
||
|
||
```
|
||
trigger | status | створено
|
||
schedule | unchanged | 09:39:08
|
||
наступний | останній
|
||
09:40:00 | 09:20:31
|
||
```
|
||
|
||
У браузері: вибір «свій розклад…» відкриває поле з поточним виразом;
|
||
`0 99 * * *` відхиляється сервером із текстом «розклад: години: 99 поза
|
||
межами 0..23» і модалка лишається відкритою; `*/30 * * * *`
|
||
зберігається, і заголовок хоста стає «за розкладом */30 * * * *».
|
||
Розклад повернуто на `0 3 * * *`.
|
||
|
||
---
|
||
|
||
## Етап 6. Шаблони опитування
|
||
|
||
Досі кожен OID заводився руками на кожному хості. Те, що знімається з
|
||
Mikrotik, однакове на всіх Mikrotik — але цей факт жив у голові
|
||
інженера й повторювався стільки разів, скільки в мережі пристроїв.
|
||
|
||
### Схема
|
||
|
||
`tpl.templates` → `tpl.items` → `tpl.device_templates`. Вбудовані
|
||
шаблони — `tenant_id IS NULL`, той самий прийом, що в `ncm.profiles`.
|
||
|
||
**Елемент — це одна метрика, а не чек.** Тримати в шаблоні «чек»
|
||
означало б змішати те, що описує людина (метрику), з тим, що вигідно
|
||
машині (пачку OID в одному PDU).
|
||
|
||
**У `tpl.items` немає власного `tenant_id`.** Він завжди дорівнював би
|
||
шаблоновому, а дублювання ключа ізоляції — це запрошення до
|
||
розбіжності. Видимість успадковується від шаблону через `EXISTS`.
|
||
|
||
### Реконсиляція
|
||
|
||
Елементи групуються за (шаблон, тип, інтервал) в один `snmp.get`. Сотня
|
||
окремих чеків замість однієї пачки — це сотня SNMP-сесій там, де
|
||
досить кількох PDU. Інтервал у ключі групування, бо пачка ходить
|
||
цілком.
|
||
|
||
**Порядок ключів фіксується сортуванням.** `params` потрапляє в хеш
|
||
плану, і «однаковий шаблон дав інший хеш» через порядок обходу map — це
|
||
перезалив плану на кожному тіку звірки.
|
||
|
||
**`core.checks.template_id` + частковий унікальний індекс.** Спільний
|
||
`checks_uniq` містить `md5(params)`, тож будь-яка правка списку OID
|
||
виглядала б як новий чек.
|
||
|
||
**`ListDeviceChecks` і `SetDeviceChecks` тепер обходять чеки шаблонів.**
|
||
Інакше вони показувалися б у формі ручних перевірок і зникали на кожне
|
||
збереження, щоб за секунду з'явитися знову.
|
||
|
||
### Звірка планів — те, чого бракувало весь час
|
||
|
||
Чеки міняє REST-процес, а живу сесію зонда тримає AgentService. Досі
|
||
зміна доїжджала до зонда лише при обриві зв'язку — тобто, за нормальної
|
||
роботи, ніколи. Тік раз на п'ять секунд звіряє хеш плану в базі з тим,
|
||
що зараз у зонда, і перезаливає повний план при розбіжності.
|
||
|
||
Повний план, а не дельта: дельта не знає, що зникло.
|
||
|
||
### Знайдено живими прогонами
|
||
|
||
**Креденшели не їхали разом із планом.** Хост, приписаний зонду вже
|
||
після його підключення, отримував задачі й падав на кожній із «немає
|
||
SNMP-креденшелів». Пачка доступів видається на `Hello`, а тоді цього
|
||
хоста в ній ще не було. Тепер іде разом із планом.
|
||
|
||
**Форма хоста відв'язувала зонд.** Поле «Зонд» починалося порожнім,
|
||
підпис обіцяв «— не змінювати —», а сервер трактував порожнє значення
|
||
буквально. Будь-яке збереження форми лишало хост у списку й мовчки
|
||
припиняло його опитувати. Помітили, коли після збереження шаблону в
|
||
журналі з'явилось «план зонда оновлено, задач: 0». Форма тепер тягне
|
||
поточний зонд, а підпис каже правду: «— без зонда —».
|
||
|
||
**`hrProcessorLoad.1` у вбудованому шаблоні — здогадка, а не адреса.**
|
||
Це таблиця, індексована процесором; на net-snmp вона віддала «No Such
|
||
Instance». Свій же коментар у міграції казав, що неперевірений шаблон
|
||
гірший за відсутній — прибрали з базового, лишили у вендорному
|
||
Mikrotik, де індекс фіксований.
|
||
|
||
### Перевірено наживо
|
||
|
||
Шаблон, створений повністю через веб (OID введено без крапки на
|
||
початку — сервер дописав її сам), прив'язаний до хоста в тій самій
|
||
формі:
|
||
|
||
```
|
||
чеки хоста qa-netsnmp 60с qa.users ← .1.3.6.1.2.1.25.1.5.0
|
||
snmp-generic 60с sys.uptime_sec ← .1.3.6.1.2.1.1.3.0
|
||
snmp-host-resources 300с sys.users; sys.processes (два OID, одна пачка)
|
||
план зонда оновлено за секунди, без переп'єднання
|
||
метрики sys.uptime_sec = 862334.20 s (snmpget: 9 днів 22:46 — збігається)
|
||
sys.processes = 41, qa.users = 0
|
||
вбудований DELETE → 403 builtin, PUT → 403 builtin
|
||
власний DELETE → 204, породжені ним чеки зникли каскадом
|
||
```
|
||
|
||
### Чого ще немає
|
||
|
||
Автопризначення шаблону за `sysObjectID`. Прототипів (шаблон, що
|
||
успадковує інший). Тригерів усередині шаблону — поки правила алертів
|
||
заводяться окремо. `snmp.walk` як тип елемента: таблиці з динамічним
|
||
індексом (CPU по ядрах, диски, сенсори) шаблон описати не вміє.
|
||
|
||
---
|
||
|
||
## Історія метрик і те, що знайшлося дорогою
|
||
|
||
Система збирала телеметрію в `ts.samples` і не показувала її ніде: щоб
|
||
побачити зібране, треба було йти в `psql`. Шаблони, зроблені на минулому
|
||
кроці, збирали метрики в нікуди.
|
||
|
||
**Джерело обирається за кроком.** Читати сирі точки за місяць — це
|
||
мільйони рядків заради трьохсот пікселів; брати годинні бакети на вікні
|
||
в п'ять хвилин — це графік з однієї точки. Роллапи `ts.samples_5m` і
|
||
`ts.samples_1h` уже існували з Етапу 1, лишалось ними скористатися.
|
||
|
||
**Крок рахується з бажаної кількості точок, а не приходить ззовні.**
|
||
Інакше вузьке вікно з дрібним кроком повернуло б десятки тисяч точок, з
|
||
яких екран покаже сотні.
|
||
|
||
**Пропуск і нуль — різні речі.** Значення точки — вказівник: лінія,
|
||
проведена через діру в даних, каже «все було добре», хоча насправді
|
||
нічого не відомо. Це найгірший різновид брехні в моніторингі.
|
||
|
||
**`series_id` приходить від клієнта, тож належність перевіряється явно.**
|
||
Таблиці `ts.*` не під RLS (несумісно зі стисненням), і без цієї
|
||
перевірки чужий ідентифікатор віддав би чужі дані.
|
||
|
||
### Графік без бібліотеки
|
||
|
||
`recharts` коштує понад сотню кілобайт стисненого коду, а потрібна з них
|
||
одна ламана. SVG до того ж масштабується під будь-який контейнер без
|
||
переобчислення на ресайз.
|
||
|
||
**Ряди групуються за одиницею виміру.** Відсотки й біти на секунду на
|
||
спільній осі перетворюють графік на пряму лінію біля нуля.
|
||
|
||
**Пропуск розриває лінію.** Кожен відрізок — окремий `M…L…`, і між ними
|
||
лишається порожнеча — саме те, що сталося насправді.
|
||
|
||
**Метрика, яка не буває від'ємною, не отримує від'ємну вісь.** Відступ
|
||
знизу корисний для читабельності, але «−3 % втрат» читається як помилка
|
||
даних.
|
||
|
||
### Перевірено наживо
|
||
|
||
```
|
||
6 год крок 72 с, джерело сирі дані
|
||
тиждень крок 2016 с, джерело роллап 5m
|
||
snmp-host if.in_bps · eth0 = 18.5k bps, sys.processes = 41,
|
||
sys.uptime = 87.5M ticks — усе з підписом порту й одиницею
|
||
```
|
||
|
||
### Знайдено живим прогоном: зонд працює рівно годину
|
||
|
||
Метрики SNMP замовкли о 12:05 — рівно через годину після того, як зонд
|
||
отримав доступи. У журналі — жодного слова.
|
||
|
||
`CredentialTTL` дорівнює годині, і `Credentials()` свідомо не віддає
|
||
прострочені: інакше зонд довбав би комутатори старим паролем і
|
||
заблокував обліковий запис. Правильне рішення. Але **поновлення не
|
||
просив ніхто**: повідомлення `CredentialRequest` є в контракті з Етапу 2,
|
||
сервер його обробляє, агент — не надсилає. Тобто будь-яка інсталяція
|
||
припиняла збирати SNMP через годину після старту й мовчала про це.
|
||
|
||
Тепер зонд просить нову пачку за десять хвилин до кінця терміну, не
|
||
частіше ніж раз на хвилину. Причина в запиті розрізняє `expiring` і
|
||
`expired` — за журналом видно, чи встигли.
|
||
|
||
---
|
||
|
||
## Шаблон перестав бути «набором OID»
|
||
|
||
Шаблон описував лише `snmp.get`. Пінг заводився руками — і це змушувало
|
||
пам'ятати, що саме шаблон покриває, а що ні.
|
||
|
||
Тепер елемент має `params` (те саме, що лягає в `core.checks.params`), а
|
||
`oid` і `metric_key` стали необов'язковими. Пачкою в один PDU
|
||
збираються тільки OID: два пінги з різними параметрами — це просто два
|
||
пінги. Для негрупованих типів елемент відповідає окремому чеку, і його
|
||
слід у `core.checks.template_item_key` дозволяє впізнати рядок.
|
||
|
||
**Ключ елемента, а не його id.** Збереження шаблону перезаписує елементи
|
||
цілком, тож id живуть недовго, а ключ стабільний за визначенням.
|
||
|
||
Вбудований шаблон «Доступність (ICMP)» — той, який чіпляють першим.
|
||
|
||
### Обмін шаблонами
|
||
|
||
Експорт: усі одним файлом, окремий шаблон кнопкою на картці, і ще один
|
||
вхід — просто з форми редагування. Імпорт: глобальний (файл або
|
||
вставлений текст) і локальний, що замінює вміст відкритого шаблону.
|
||
|
||
Формат свій. Zabbix-YAML описує елемент ключем виду `snmp.get[...]`, з
|
||
препроцесингом, value maps і тригерами — нічого з цього тут немає, і
|
||
вдавати сумісність означало б мовчки втрачати половину імпортованого.
|
||
|
||
Ідентифікатори з документа прибираються: на іншому стенді вони нічого не
|
||
значать, а лишені створюють ілюзію, що імпорт «відновить те саме».
|
||
|
||
---
|
||
|
||
## Спільний розклад бекапів
|
||
|
||
Розклад існував лише поштучно: щоб бекапити сто пристроїв, треба було
|
||
сто разів відкрити форму.
|
||
|
||
Спільний розклад заводить політику кожному придатному хосту з
|
||
`follows_default = true`. Хост, якому задали власний розклад, прапорець
|
||
втрачає.
|
||
|
||
**Прапорець, а не порівняння значень.** Власний розклад може випадково
|
||
збігтися зі спільним, і тоді зміна спільного мовчки потягла б за собою
|
||
хост, який навмисно налаштували окремо.
|
||
|
||
**Вимкнення спільного зупиняє лише тих, хто йому слідує.** Форма показує
|
||
`following_count` і `custom_count`: без цих двох чисел вона не каже
|
||
головного — кого саме зачепить зміна.
|
||
|
||
---
|
||
|
||
## Куди йде алерт
|
||
|
||
Правило вміло сказати «за яких умов», але не «кому». Маршрути
|
||
(`alr.routes`) вирішують інше завдання — спільну політику на всі правила
|
||
разом, і для звичайного «це правило важливе, шліть черговому в Telegram»
|
||
вони заважкі.
|
||
|
||
Порядок вирішення: канали правила → маршрути тенанта → усі придатні
|
||
канали.
|
||
|
||
**Канали правила перекривають маршрути повністю.** Інакше «шліть це
|
||
черговому» перетворювалося б на «шліть це черговому і ще туди, куди
|
||
вирішить спільна політика» — тобто на щось, чого людина не просила.
|
||
|
||
Разом із каналами правило отримало тихі години (той самий формат, що в
|
||
маршрутах — щоб не заводити другий діалект того самого поняття), вибір
|
||
груп хостів і перемикач повідомлень про відновлення.
|
||
|
||
---
|
||
|
||
## Дрібниці, які насправді не дрібниці
|
||
|
||
**Відступи в картках шаблонів.** `Card` навмисно без внутрішнього
|
||
відступу — на інших сторінках його діти самі малюють `px-4 py-2.5` і
|
||
роздільники на всю ширину. На сторінці шаблонів картка — звичайний блок
|
||
тексту, і клас загубився. Текст стояв упритул до рамки.
|
||
|
||
**Українська множина.** «1 метрик» — дрібниця, з якої складається
|
||
враження, що інтерфейс писали не для людей. `plural(n, one, few, many)`
|
||
з правилом 11–14.
|
||
|
||
**Інтервал опитування вводиться руками.** Список сам по собі не годиться:
|
||
рано чи пізно комусь потрібні 45 секунд, і відсутність такої можливості
|
||
робить продукт «майже підходящим». Ручний ввід сам по собі теж не
|
||
годиться: у дев'яти випадках із десяти значення є в списку.
|
||
|
||
**Підтвердження показує наслідки, а не питає «Ви впевнені?».** «Видалити
|
||
групу?» і «Видалити групу? 34 хости втратять межі доступу» — різні
|
||
питання, і людина відповідає на них по-різному. Вбудований `confirm()`
|
||
другого не вміє, тому свій діалог.
|
||
|
||
**Кнопки видалення стали кнопками.** Сірий `✕` у кутку рядка формально
|
||
існував і фактично не знаходився — користувач повідомив, що видалення
|
||
користувачів «немає».
|
||
|
||
---
|
||
|
||
## «Просить логін після кожного оновлення сторінки»
|
||
|
||
Дві різні поломки в одному симптомі.
|
||
|
||
**Перша: користувач без пошти не міг поновити сесію взагалі.** Разом із
|
||
входом за логіном пошта стала необовʼязковою, але `RotateSession` читав
|
||
`email::text` без `COALESCE`. Scan падав, помилка перетворювалась на
|
||
«сесія недійсна» — і такі люди могли увійти, але після перезавантаження
|
||
сторінки летіли на форму входу знову й знову. Живий прогін: свіжий
|
||
користувач без адреси, перший же `/auth/refresh` → 401.
|
||
|
||
**Друга: ротація токена не переживала подвійного обміну.** React у
|
||
режимі розробки виконує ефекти двічі, тож відновлення сесії йшло двома
|
||
запитами поспіль: перший обмінював токен і відкликав старий, другий
|
||
приносив уже відкликаний. Це не лише про режим розробки — дві вкладки,
|
||
відкриті одночасно, дають рівно ту саму гонку в проді.
|
||
|
||
Тепер `core.sessions.replaced_by` тримає ланцюг, і щойно відкликаний
|
||
токен веде до свого наступника. Вікно — 30 секунд: вистачає на
|
||
подвійний обмін від того самого клієнта й замало для реального повтору
|
||
перехопленого токена, той приходить хвилинами пізніше. Перевірено: обмін
|
||
старим токеном одразу → 200, він же через дві хвилини → 401.
|
||
|
||
Плюс засувка на клієнті: відновлення сесії рівно одне на завантаження
|
||
сторінки.
|
||
|
||
---
|
||
|
||
## Однакові відступи
|
||
|
||
Сторінки розповзлися: одні малювали тіло з `p-3 md:p-4`, інші клали
|
||
`p-4` на внутрішній блок, треті не мали відступу взагалі — і текст стояв
|
||
упритул до бічної панелі.
|
||
|
||
`PageBody` тепер один на всіх: відступи, прокрутка й `min-h-0` в одному
|
||
місці. Заміряно в браузері: усі десять сторінок дають рівно 16 px від
|
||
краю.
|
||
|
||
---
|
||
|
||
## Мапа: видалення й вигляд вузла
|
||
|
||
**Видалення мапи** — кнопка була відсутня, хоча ендпоїнт існував із
|
||
Етапу 4. З підтвердженням, яке пояснює, що саме зникне: схема, а не
|
||
хости й дані.
|
||
|
||
**Форма вузла.** Картка, пігулка або крапка. Крапка потрібна не для
|
||
краси: на схемі з сотні вузлів важливо бачити стан усіх, а не читати
|
||
сотню підписів. Плюс товщина рамки, колір заливки, приховування підпису
|
||
й цифр пінга.
|
||
|
||
**Аварія й попередження світяться.** Рамка іншого кольору не помітна
|
||
периферійним зором, а ореол (`box-shadow` у два шари) видно навіть на
|
||
віддаленому масштабі, коли підписи вже не читаються. Червоний лишається
|
||
тільки за обривом — якщо ним підсвічувати ще й «невідомо», оператор
|
||
перестане на нього реагувати.
|
||
|
||
---
|
||
|
||
## Дашборди
|
||
|
||
Схема `core.dashboards` і `core.dashboard_widgets` лежала з Етапу 1 і не
|
||
мала жодного рядка коду. Тепер має.
|
||
|
||
**Сітка — дванадцять колонок.** 12 ділиться на 2, 3, 4 і 6, тож
|
||
половина, третина й чверть ширини задаються цілими числами, а не
|
||
«41.6 %».
|
||
|
||
**Плитки замінюються цілком.** Дашборд редагується як єдине полотно, і
|
||
часткові оновлення дали б спосіб отримати розкладку, якої людина не
|
||
бачила.
|
||
|
||
**Перший дашборд стає головним сам.** Інакше людина створює його й не
|
||
розуміє, чому головна сторінка досі порожня.
|
||
|
||
**Кожна плитка вантажить своє.** Спільний завантажувач на дашборд
|
||
виглядав би охайніше, але змусив би плитку «текст» чекати на запит
|
||
метрик сусідки.
|
||
|
||
Види: графік, число, шкала, список алертів, сітка хостів, текст.
|
||
Перевірений список видів дублюється на сервері навмисно — збережена
|
||
плитка, яку ніхто не малює, виглядає як зламаний дашборд.
|
||
|
||
**Права окремі від мап.** Дашборд збирає дані з усього тенанта, і «може
|
||
дивитись мапу» не означає «може дивитись зведення по всіх майданчиках».
|
||
Видані тим ролям, які вже мають відповідний рівень доступу до мап.
|
||
|
||
---
|
||
|
||
## Esc і клік повз вікно
|
||
|
||
Жодна модалка не закривалася нічим, крім хрестика.
|
||
|
||
Слухач Esc — на `document`, а не на панелі: фокус може стояти в полі
|
||
вводу, у випадному списку або взагалі ніде, і вимагати спершу «влучити»
|
||
кудись — це рівно те, чого від Esc не чекають.
|
||
|
||
Клік повз панель закриває **за `mousedown`, а не за `click`**: інакше
|
||
виділення тексту, доведене мишею за межі вікна, закривало б форму разом
|
||
із набраним.
|
||
|
||
Заголовок вікна став липким: у довгих формах (шаблон із десятком
|
||
метрик) хрестик інакше їхав за межі екрана.
|
||
|
||
---
|
||
|
||
## Редагування там, де його не було
|
||
|
||
**Канали сповіщень** взагалі не редагувалися — лише створювались і
|
||
видалялись. Вид каналу лишається незмінним: telegram із конфігом
|
||
вебхука — це інший обʼєкт, і чесніше завести новий.
|
||
|
||
**Протокол доступу** тепер змінюється. З одним застереженням: секрет
|
||
зашифрований під видом старого протоколу, тож при зміні пароль треба
|
||
ввести заново. Сервер відмовляє з поясненням, кнопка в формі теж не
|
||
дає зберегти — краще сказати це до спроби, ніж після.
|
||
|
||
---
|
||
|
||
## Картка хоста в стилі Zabbix
|
||
|
||
Форма була однією довгою простинею з інтервалами опитування посередині.
|
||
Стала вкладками: Хост, Шаблони, Доступи, Збір конфігів, Ручні перевірки —
|
||
у порядку частоти звернень, а не у порядку появи в коді.
|
||
|
||
**Інтервалів опитування тут більше немає.** Вони живуть у шаблонах. Два
|
||
джерела правди про одне число рано чи пізно розійдуться, і розійдуться
|
||
саме тоді, коли треба буде швидко зрозуміти, чому хост опитується не
|
||
так, як написано.
|
||
|
||
**Новий хост отримує шаблон «Доступність (ICMP)», а не ручний чек.**
|
||
Перевірено: створений через форму хост дістав `icmp.ping` з інтервалом
|
||
30 с саме від шаблону, разом із групою, обраною в пікері.
|
||
|
||
**Групи, шаблони й доступи обираються пошуком.** Список галочок працює
|
||
на пʼяти позиціях і перестає працювати на пʼятдесяти. Обране показане
|
||
чипами зверху, тож вибір видно цілком, не гортаючи. Backspace у
|
||
порожньому полі прибирає останній чип — так поводяться всі поля з
|
||
тегами, і руки це вже знають.
|
||
|
||
**Ручні перевірки лишились окремою вкладкою.** Вони потрібні для
|
||
одиничних випадків, але це вже не типовий шлях, і в першому екрані їм
|
||
нема чого робити.
|
||
|
||
---
|
||
|
||
## Графіки описуються в шаблоні
|
||
|
||
«Завантаження каналу — це вхід і вихід на одній осі» — властивість класу
|
||
пристроїв, а не окремого хоста. Малювати той самий графік руками на
|
||
кожному означає повторювати одне рішення стільки разів, скільки в мережі
|
||
заліза.
|
||
|
||
`tpl.graphs` тримає назву, вид і перелік **ключів метрик**, а не
|
||
посилань на елементи шаблону: графік має право показувати й те, що
|
||
прийшло з іншого шаблону — саме так виглядає «трафік поруч із помилками
|
||
на тому ж порту».
|
||
|
||
Види: лінії, з заливкою, з накопиченням, стовпчики, шкала, число.
|
||
|
||
**Накопичення обирає людина, а не евристика.** Сума має сенс там, де
|
||
вона сама щось означає — трафік по портах, місце на дисках. На
|
||
відсотках завантаження це була б нісенітниця.
|
||
|
||
**Жорсткі межі осі з шаблону перекривають пораховані.** «Завантаження
|
||
від 0 до 100» має виглядати однаково на всіх хостах, інакше графіки не
|
||
порівняти очима.
|
||
|
||
**Графік без жодного знайденого ряду не показується.** Порожня рамка з
|
||
підписом «Памʼять» на хості, де памʼять не збирається, — це обіцянка
|
||
даних, яких немає.
|
||
|
||
### Перевірено наживо
|
||
|
||
```
|
||
snmp-host «Час роботи» (stat) → 877.6k s
|
||
gateway «Втрати пакетів» (area) → вісь жорстко 0–100 з шаблону
|
||
gateway «Час відгуку» (line) → дві лінії, вісь 0–2.6 з даних
|
||
```
|
||
|
||
---
|
||
|
||
## Знайдено живим прогоном: полотно вбивало інтерфейс
|
||
|
||
Після додавання групового виділення сторінка перестала реагувати —
|
||
`Maximum update depth exceeded` у `SelectionListener`. React Flow
|
||
викликає `onSelectionChange` на кожному рендері, а мій обробник щоразу
|
||
створював новий масив: колбек міняв стан сторінки, сторінка
|
||
перемальовувала полотно, полотно кликало колбек. Цикл зʼїдав головний
|
||
потік, і мертвими ставали **всі** сторінки, не лише мапа.
|
||
|
||
Тепер набір віддається назовні лише коли справді змінився — порівняння
|
||
за склеєним рядком ідентифікаторів.
|
||
|
||
---
|
||
|
||
## Вигляд мапи
|
||
|
||
Схема виглядала як сітка сірих прямокутників на чорному. Причини були
|
||
конкретні, і кожна лікується окремо.
|
||
|
||
**«? → ?» на кожній лінії.** Так виглядав підпис ребра, коли
|
||
автовиявлення ще не зіставило порти. Читалося це як зламаний інтерфейс,
|
||
хоча означало лише «поки невідомо». Тепер невідомий порт не друкується
|
||
взагалі — порожнеча чесніша, вона нічого не обіцяє. Так само зі
|
||
швидкістю: «— з 10.0 Гбіт/с» зникає, поки трафік не виміряний.
|
||
|
||
**Вузол став карткою з ієрархією.** Градієнт замість пласкої заливки,
|
||
кольорова смуга стану зліва, значок і підпис в одному рядку, крапка
|
||
стану праворуч. Смуга несе стан навіть тоді, коли рамку перефарбували
|
||
під майданчик: колір рамки — рішення про схему, колір смуги — факт про
|
||
пристрій.
|
||
|
||
**Пульсація лише за обривом.** Рух там, де все гаразд, навчає не
|
||
дивитись на рух узагалі.
|
||
|
||
**Порти показуються на наведення.** Вісім завжди видимих кружечків на
|
||
кожному вузлі перетворюють схему на россип точок.
|
||
|
||
**Фон — радіальна підсвітка й дві сітки.** Дрібна дає відчуття масштабу,
|
||
велика — орієнтацію; одна або рябить, або не читається, залежно від
|
||
кроку, а крок задає людина.
|
||
|
||
**Специфічність довелося підняти.** Стилі React Flow приходять з .tsx і
|
||
потрапляють у документ пізніше за index.css, тож одного класу було
|
||
замало — правила стали `.react-flow.netpulse-canvas`.
|
||
|
||
---
|
||
|
||
## Вбудовані шаблони редагуються
|
||
|
||
Раніше відповіддю було «вбудований не редагується — зробіть копію». Це
|
||
межа нашої моделі даних, а не відповідь на питання людини, яка хоче
|
||
підправити OID під своє залізо.
|
||
|
||
Тепер правка вбудованого шаблону створює **власну копію тенанта з тим
|
||
самим ключем**, а список показує її замість оригіналу. Оригінал лишається
|
||
недоторканим для решти тенантів. Форма попереджає про це прямим текстом
|
||
до збереження.
|
||
|
||
Плюс окрема кнопка «Копія» — для випадку, коли потрібні обидва варіанти:
|
||
«як було» і «як хочу». Ключ отримує суфікс, бо копія з тим самим ключем
|
||
сховала б оригінал.
|
||
|
||
**Ключі елементів і графіків виводяться на сервері.** Прямий виклик API
|
||
без `key` давав 500 від перевірки домену `core.slug`. Ключ потрібен базі,
|
||
але жодного рішення не несе — вимагати його від клієнта означало
|
||
відповідати помилкою там, де можна просто зберегти.
|
||
|
||
---
|
||
|
||
## Вибір шаблонів картками
|
||
|
||
Пошуковий рядок із чипами добре працює для груп — там важлива лише
|
||
назва. Для шаблонів важливо ще й що всередині: скільки метрик, для якого
|
||
виробника, що воно робить. Список чипів цього не показує, тож людина
|
||
відмічала навмання й ішла перевіряти на іншу сторінку.
|
||
|
||
Тепер картка з назвою, описом і лічильником метрик. Обрані спливають
|
||
угору — інакше після вибору картка лишається десь у середині списку, і
|
||
незрозуміло, чи вибір зарахувався.
|
||
|
||
---
|
||
|
||
## Мобільна сітка карток
|
||
|
||
На телефоні картка вилазила за екран і обрізалась. Причина не в
|
||
брейкпойнтах: елемент грід-сітки типово має `min-width: auto` й не
|
||
стискається нижче ширини вмісту. `min-w-0` на картці — і 410 px стали
|
||
351 px рівно за шириною сітки.
|
||
|
||
---
|
||
|
||
## Межа помилок навколо полотна
|
||
|
||
Цикл рендеру в React Flow одного разу вже поклав увесь застосунок:
|
||
мертвими стали всі сторінки, і людині лишалося перезавантажити вкладку.
|
||
Причину виправлено, але наслідок треба було обмежити — тепер полотно
|
||
падає саме, а навігація й решта інтерфейсу лишаються живими.
|
||
|
||
---
|
||
|
||
## Етап 8. Реєстрація зонда
|
||
|
||
Найбільше вузьке місце до запуску: агент заводився `INSERT`-ом у базу, а
|
||
токен вписувався в командний рядок руками. Поставити зонд у клієнта було
|
||
неможливо — людина зі стійки не має доступу до бази й не повинна його
|
||
мати.
|
||
|
||
### Одноразове запрошення
|
||
|
||
`core.agent_enrollments`: sha256 токена, підказка імені, набір модулів,
|
||
строк. Токен існує рівно один раз — у відповіді на створення.
|
||
|
||
**Уся видача в одній транзакції під `FOR UPDATE`.** Два агенти,
|
||
стартовані з однієї скопійованої команди, інакше створили б два зонди з
|
||
одного запрошення. Перевірено живим прогоном: другий отримує
|
||
`PermissionDenied`.
|
||
|
||
**Ім'я підбирається суфіксом, а не відмовою.** Людина, яка ставить
|
||
п'ятий агент на однакових машинах, не має вигадувати імена — вона хоче,
|
||
щоб він просто запрацював.
|
||
|
||
### Знайдено живим прогоном
|
||
|
||
**Реєстрація не проходила автентифікацію.** Мій же коментар стверджував,
|
||
що окремий gRPC-сервіс сам собою виводить виклик з-під інтерсептора. Це
|
||
неправда: інтерсептор реєструється на весь сервер. Перший же запуск
|
||
агента дав `Unauthenticated: немає токена зонда` — на виклику, яким
|
||
токен і видається. Виняток тепер заданий явно, повним префіксом сервісу.
|
||
|
||
**Запуск із самим посвідченням падав.** `validate()` вимагав `-agent-id`,
|
||
не знаючи про файл посвідчення, тож агент із валідним
|
||
`/etc/netpulse/agent.json` відмовлявся стартувати. Перевірка переїхала в
|
||
`main`, де враховані всі три джерела: прапорці, файл і реєстрація.
|
||
|
||
### Керування зондом
|
||
|
||
Ім'я, модулі й ліміти правляться з картки. Форма каже прямим текстом, що
|
||
зміни доїдуть при наступному підключенні: мовчазна затримка виглядає як
|
||
«не зберіглося».
|
||
|
||
Ліміти зливаються в наявний `jsonb` через `||`, а не заміняють його: там
|
||
можуть лежати поля, яких форма не знає, і затирати їх мовчки — найшвидший
|
||
спосіб зламати те, чого не бачив.
|
||
|
||
Видалення зонда лишає хости без зонда, а не видаляє їх: інакше заміна
|
||
заліза, на якому стояв агент, коштувала б усієї історії.
|
||
|
||
### Перевірено наскрізно
|
||
|
||
```
|
||
запрошення POST /agent-enrollments → np_enr_…, модулі [icmp snmp]
|
||
реєстрація agent -enroll → «зареєстровано як "QA зонд"»
|
||
посвідчення /tmp/np-agent-id.json, права 0600, токен np_agt_…
|
||
сесія встановлено одразу після реєстрації
|
||
повтор той самий токен → PermissionDenied
|
||
перезапуск лише -identity, без токена → та сама сесія
|
||
керування PATCH модулі й ліміти → 204, значення застосовані
|
||
```
|
||
|
||
|
||
## Зв'язки на мапі
|
||
|
||
### Порти на вузлі
|
||
|
||
Чотири боки, на кожному пара портів з однаковим ім'ям: `l`, `r`, `t`,
|
||
`b`. React Flow розрізняє джерело й ціль за типом, тож одне ім'я обслуговує
|
||
обидва напрямки. Розійдуться імена (наприклад `l` і `l-s`) — збережене
|
||
ребро не знайде пари, і лінія просто не намалюється, без жодної помилки.
|
||
|
||
Обгортка портів — `Fragment`, а не `<span>`: `<span>` стає елементом
|
||
flex-розкладки й розсуває вміст вузла. Чотири такі обгортки з'їдали підпис,
|
||
і на схемі лишались картки без імен.
|
||
|
||
### Бік підключення
|
||
|
||
Лінія без явної прив'язки більше не чіпляється до лівого краю навпомацки.
|
||
`autoSides()` рахує бік із взаємного розташування вузлів: більша різниця по
|
||
X — пара «праворуч → ліворуч», більша по Y — «знизу → зверху». React Flow
|
||
сам найкоротший бік не шукає.
|
||
|
||
Бік правиться в інспекторі: `авто | ліворуч | праворуч | зверху | знизу`.
|
||
Три стани в патчі, а не два — порожньо не чіпає, `auto` знімає прив'язку,
|
||
решта задає конкретний бік:
|
||
|
||
```sql
|
||
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`.
|
||
|
||
```css
|
||
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 і в банерах, і глушити
|
||
збір через них не можна. Поріг грубий навмисне — точного числа не
|
||
існує, а видимість точності заважала б помітити, що він спрацював.
|
||
|
||
Помилка показує, що саме сказав пристрій. Без цього людині лишалось би
|
||
здогадуватись, чим команда з профілю не підійшла її залізу.
|
||
|
||
Гучна помилка тут коштує одного дзвінка. Тиха — усього архіву.
|
||
|
||
### Профіль для D-Link /ME
|
||
|
||
Правильна команда — `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 Switch` → `DGS-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 — Сім задач одним заходом: що спільного виявилось у всіх
|
||
|
||
Сім міграцій (0058–0064) робились паралельно, і за задумом їх поєднував
|
||
лише перелік справ. За фактом — дещо інше, і це варте окремого запису.
|
||
|
||
**Схема обіцяла те, чого не існувало, у трьох місцях одразу.** Тип
|
||
перевірки `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` розкладом було б і дорожчим
|
||
(гіпертаблиця на мільярд рядків), і брехливішим — вікно опитування
|
||
завжди або пропускає події, або рахує їх двічі.
|
||
|
||
Тому правило перевіряється рівно в ту мить, коли подія надійшла, у тому
|
||
процесі, який її прийняв:
|
||
|
||
* `syslog` — `grpcapi.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.go` — `snmp.walk` у `CheckTypes()`
|
||
і в диспетчері;
|
||
* `agent/internal/modules/snmp/walk_test.go`.
|
||
|
||
Сервер:
|
||
|
||
* `server/migrations/0059_template_prototypes.sql` — `tpl.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`, `doMute` — `store.MuteDevice`. Ті
|
||
самі, що й `POST /api/v1/alerts/{id}/ack` і `POST /api/v1/mutes`:
|
||
підтвердження з телефона має лишати в базі такий самий слід, як
|
||
підтвердження з браузера, а власна копія розійшлася б із оригіналом на
|
||
першій же правці — і побачили б це не в коді, а у звіті.
|
||
|
||
З тієї ж причини стеля заглушення переїхала з `httpapi` у
|
||
`store.MaxMute`: кнопку «Заглушити» тепер натискають із двох місць, і
|
||
політика в них одна.
|
||
|
||
### Ідемпотентність — двома шарами
|
||
|
||
Палець натискає двічі, а мережа вміє доставити те саме натискання
|
||
повторно.
|
||
|
||
*Дія.* Перед `AckAlert` іде `AlertAckState`: якщо алерт уже
|
||
підтверджений, друге натискання відповідає «Уже підтверджено: <хто>» і
|
||
НЕ переписує автора — перший, хто взявся, лишається першим. Для
|
||
заглушення так само: `ActiveMute` не дає покласти другий ряд у
|
||
`alr.mutes`, бо інакше «на годину» мовчки перетворювалось би на дві.
|
||
|
||
*Вигляд.* `withStatus` не дописує рядок, який у тексті вже є. Друге
|
||
натискання приводить повідомлення в той самий вигляд, що й перше, — а
|
||
саме це людина перевіряє очима.
|
||
|
||
### Після дії повідомлення міняється
|
||
|
||
`answerCallbackQuery` показує спливаючий рядок на кілька секунд і лише
|
||
тому, хто натиснув; у чат він не потрапляє, а чат читає вся зміна. Тому
|
||
слідом іде `editMessageText`: у текст дописується
|
||
«✅ Підтверджено: <email>, 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_id` → `core.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`:
|
||
|
||
```sql
|
||
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 — Друга сімка: те, що перевірки нарешті виконались
|
||
|
||
Сім задач (0065–0068 плюс `plural`, тести вебу й виправлення знайденого)
|
||
робились так само паралельно, як перша сімка. Різниця в одному: цього
|
||
разу в кожне завдання було вписано вимогу — окремим абзацом сказати,
|
||
**чого перевірка НЕ покриває**.
|
||
|
||
Вимога з'явилась не з методології, а з ранкової поразки: тест ізоляції
|
||
RLS був правильний і зелений, і пропустив зламаний вхід, бо перевіряв
|
||
«чи не видно чужого», коли зламалось «чи видно своє».
|
||
|
||
### Що ця вимога дала
|
||
|
||
Три агенти незалежно написали одне й те саме речення: **жоден рядок SQL
|
||
не виконувався**. Ані нові міграції, ані запити ескалацій, ані запис
|
||
трапів — усе перевірено компіляцією Go й прочитане очима.
|
||
|
||
Це не було відомо до того. Тести були зелені, звіти бадьорі, і без
|
||
прямої вимоги назвати непокрите ця діра просто не потрапила б у поле
|
||
зору — рівно як не потрапила вранці.
|
||
|
||
Агент трапів пішов далі й назвав найімовірнішу непомічену поломку у
|
||
власній роботі: v1-трап жодного разу не летів по мережі, нормалізація
|
||
перевірялась на зібраному руками пакеті, і якби gosnmp заповнював
|
||
`Enterprise`/`AgentAddress` інакше, переклад за RFC 3584 давав би
|
||
невірний OID мовчки при зелених тестах.
|
||
|
||
### Пісочниця
|
||
|
||
Тому перед розгортанням на стенді заведено окрему базу `netpulse_probe`
|
||
й накочено в неї 0001–0068 **з нуля**. Досі міграції перевірялись лише
|
||
як приріст до наявної схеми — на чистій базі не проганявся ніхто.
|
||
|
||
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 0–5 →
|
||
`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` будує запит із
|
||
|
||
```sql
|
||
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.value` —
|
||
`double 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.sql` — `UPDATE` з полями заливки.
|
||
|
||
## Чому заливка НЕ поїхала в 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 число
|
||
ще й друкувалось двічі в різному вигляді: з розділювачами тисяч ззовні й
|
||
голим зсередини).
|
||
|
||
Три форми української множини стара функція вміла: 11–14 бралися за
|
||
двома останніми цифрами, 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»).
|
||
|
||
Тому червоного тесту немає, а є зелений інваріант, який тримає виправлення:
|
||
|
||
```ts
|
||
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 одиниць і без неї далі. Це не косметика:
|
||
трафік на порту доступу живе саме в діапазоні 1–10 Мбіт/с, і `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`.
|
||
|
||
---
|
||
|
||
## 2026-08-28 — Рецензія виправлень: половина була закрита лише наполовину
|
||
|
||
Рецензія попереднього коміту знайшла, що виправлення пересунули вади, а
|
||
не прибрали. Усе нижче — наслідок саме цієї другої рецензії.
|
||
|
||
### Мій власний скрипт не мав запобіжника, про який сам писав
|
||
|
||
`scripts/dbtest.sh` у шапці стверджував «напрямляти на робочу базу не
|
||
можна» — і не робив нічого, щоб це забезпечити. Перевірено фактично:
|
||
`NETPULSE_TEST_DSN=.../netpulse` пішов котити міграції на базу з бойовим
|
||
іменем, і зупинила його лише відсутність сервера на тому порту. Забута в
|
||
профілі шелла змінна — і міграції з гілки лягають на робочу базу.
|
||
|
||
Тепер ім'я бази мусить містити `probe` або `test`, інакше відмова з
|
||
поясненням. Перевірка навмисно дурна й невідмикна: розумніша
|
||
(маркерна таблиця, прапорець згоди) перекладає рішення на людину рівно
|
||
в той момент, коли людина поспішає. Плюс ім'я контейнера з номером
|
||
процесу (два прогони поруч не вбивають бази один одному) і збереження
|
||
бази після ЧЕРВОНОГО прогону — подивитись, що в таблицях, інакше
|
||
неможливо саме тоді, коли треба.
|
||
|
||
### Журнал усе ще брехав — тепер про доставку
|
||
|
||
Попереднє виправлення звузило брехню з «канали не прочитались» до
|
||
«канали прочитались, доставка впала»: `sent` рахував проходження
|
||
фільтра, бо `sendText` ковтав помилку й нічого не повертав. Telegram
|
||
лежить десять хвилин — у журналі «надіслано», а в `alr.notifications`
|
||
поруч `failed`. Два журнали суперечили один одному.
|
||
|
||
`sendText` повертає помилку; сходинка розрізняє три результати:
|
||
`no_channels` (не було кому слати), `failed` (слали, не дійшло), `sent`.
|
||
Заодно остання сходинка проходу пише в журнал `sent`, а не `done`:
|
||
`done` правильне як причина зупинки драбини, але підрахунок «скільки
|
||
разів реально слали» через нього щоразу недорахував по одному.
|
||
|
||
### `stopped_at IS NULL` закривав лише один шлях із чотирьох
|
||
|
||
Умова рятувала від «Прийняти» й ручного закриття — бо тільки ці шляхи
|
||
ставлять `stopped_at`. А `resolveRuleAlerts` (вимкнення чи видалення
|
||
правила) і `ResolveMissing` (метрика відновилась) рядка драбини не
|
||
чіпають: сходинка, взята в партію до гасіння, дзвонила за погашеним
|
||
алертом.
|
||
|
||
Тепер сходинка, що має спрацювати, вимагає ще й `алерт усе ще firing` —
|
||
одним `EXISTS` у тому ж `UPDATE`. Умова стоїть тут, а не в кожному з
|
||
шляхів гасіння, саме тому, що їх багато й побільшає: перевіряти стан у
|
||
момент дії надійніше, ніж пам'ятати про драбину в кожному новому місці.
|
||
Драбина при цьому не застрягає — наступний такт бачить `resolved` і
|
||
зупиняє її штатно (є тест).
|
||
|
||
### Ескалація більше не блокує весь движок
|
||
|
||
`escalate()` жив у тіку движка ПЕРЕД обчисленням правил. Доставка
|
||
синхронна, до сотні сходинок у партії, десятки секунд таймауту на
|
||
мертвому вебхуці — тобто один кабінет із непрацюючим каналом зупиняв
|
||
обчислення правил УСІМ: нові аварії не піднімались, перші сповіщення не
|
||
йшли. Механізм, який існує, щоб аварію точно помітили, робив аварії
|
||
непомітними.
|
||
|
||
Тепер `RunEscalations` — свій такт. Своє блокування не потрібне: черга
|
||
розбирається через `FOR UPDATE SKIP LOCKED` плюс оренда рядка.
|
||
|
||
### Аварія всередині вікна обслуговування не будила нікого й ніколи
|
||
|
||
Алерт, народжений заглушеним, не отримував сповіщення (бо заглушений), а
|
||
коли вікно закінчувалось — теж не отримував, бо вже «не новий»: перехід
|
||
`suppressed → firing` лише перемальовував екран. Драбина не взводилась
|
||
теж. Аварія о 03:00 усередині вікна 02:30–03:30 лишалась невидимою до
|
||
кінця свого життя.
|
||
|
||
Тепер такий перехід сповіщається нарівні з новим алертом. І відлік
|
||
драбини ведеться від ПЕРШОГО СПОВІЩЕННЯ, а не від `started_at`: інакше
|
||
драбина протухла б ще у вікні й висипалась одним залпом. «П'ятнадцять
|
||
хвилин на підтвердження» починаються тоді, коли людина вперше могла щось
|
||
зробити.
|
||
|
||
### Канали правил: та сама вада, той самий спосіб
|
||
|
||
`alr.rules.channel_ids` (`uuid[]`, FK неможливий) приймав будь-які UUID,
|
||
зокрема з чужого кабінету. Наслідок гірший, ніж у драбині: `targets()`
|
||
не знаходить канал, `sent == 0` — і через це не взводиться навіть
|
||
драбина. Одна помилка в id глушила і сповіщення, і резервний механізм.
|
||
Перевірка закрита у store тим самим способом, що для сходинок.
|
||
`DeleteChannel` тепер чистить посилання і в правилах, і в маршрутах.
|
||
|
||
`alr.routes` виявились без жодного шляху запису — екрана й обробника
|
||
немає, покласти туди чужий id можна лише прямим SQL. Перевірку туди не
|
||
додавали: не було б куди її поставити.
|
||
|
||
### І те, заради чого все це — прогін проти справжньої бази
|
||
|
||
`dbtest.sh` одразу впіймав те, чого не бачив жоден тест на структурах:
|
||
`channel_ids uuid[] NOT NULL DEFAULT '{}'` виглядає безпечним, але
|
||
DEFAULT спрацьовує лише на ВІДСУТНІЙ стовпець — nil-зріз із Go їде явним
|
||
NULL і валить запис. Правило без власних каналів (типовий стан:
|
||
«сповіщати за загальними маршрутами») давало 500 на цілком звичайній дії.
|
||
|
||
**Що лишається відкритим:** ескалація ігнорує тиху годину правила — алерт
|
||
о 21:59 ескалює всю ніч, о 22:01 не ескалює ніколи (потрібне рішення, що
|
||
тиха година означає для драбини); втрата advisory-lock теоретично дає
|
||
подвійне сповіщення (`ApplyEscalation` не звіряє оренду токеном);
|
||
`LoadChannels` падає цілком через один нерозшифровний секрет, і кабінет
|
||
лишається без ескалацій до стелі життя драбини.
|
||
|
||
---
|
||
|
||
## 2026-08-28 — Тиха година й драбина: обидві відповіді правильні, тому це вибір
|
||
|
||
Асиметрія, яку неможливо було пояснити людині, що її переживе:
|
||
|
||
```
|
||
аварія о 21:59 — сповіщення пішло, драбина взвелась і дзвонить усю ніч
|
||
аварія о 22:01 — сповіщення притлумила тиха година, каналів «не
|
||
знайшлось», драбина не взвелась — і не взведеться
|
||
НІКОЛИ, бо взводять лише новий алерт
|
||
```
|
||
|
||
Дві хвилини різниці — протилежні наслідки, причому гірший (повна тиша на
|
||
аварії) виглядає як тиша справна.
|
||
|
||
**Половина перша — вада, і її просто виправлено.** Тиха година
|
||
роззброювала драбину назавжди, бо `targets()` повертав порожньо, а
|
||
`shouldArmEscalation` бачив у цьому «немає куди слати». Тепер `targets()`
|
||
розрізняє «каналів немає» і «канали є, просто зараз ніч», і драбина
|
||
взводиться в другому випадку. Механізм, що існує на випадок «перше
|
||
сповіщення не спрацювало», більше не вимикається саме тоді, коли перше
|
||
сповіщення не спрацювало.
|
||
|
||
**Половина друга — не вада, а вибір, і тому це прапорець.** Міграція 0072
|
||
додає `alr.escalation_policies.respect_quiet_hours`:
|
||
|
||
* `false` (типово, поведінка що діяла) — драбина пробивається. Тиха
|
||
година стосується ПЕРШОГО сповіщення, а сенс драбини рівно в тому, щоб
|
||
дійти, коли перше проґавили. Так працює чергування.
|
||
* `true` — сходинка відкладається до кінця тихої години, як під
|
||
заглушенням, і НЕ витрачається: інакше одна ніч тихо роззброювала б
|
||
драбину до кінця життя алерту.
|
||
|
||
Правильна відповідь залежить від того, чи є в кабінету нічна зміна — це
|
||
вирішує кабінет, не ми. Типове значення зберігає наявну поведінку:
|
||
оновлення не має тихо роззброїти нічні дзвінки в тих, у кого вони
|
||
працюють.
|
||
|
||
`disaster` пробивається за будь-якого значення — виняток не новий,
|
||
`targets()` уже пропускає його крізь тиху годину маршруту й правила. Одне
|
||
слово в двох місцях мусить означати одне.
|
||
|
||
Відлік драбини ведеться від ПЕРШОГО СПОВІЩЕННЯ, а не від `started_at`:
|
||
для алерту, з якого щойно зняли заглушення, це різниця між «драбина
|
||
попереду» і «драбина протухла ще у вікні й висиплеться одним залпом».
|
||
|
||
### Прогін проти бази знову збрехав — і це виправлено в самому прогоні
|
||
|
||
`dbtest.sh` на стенді котив схему готовим ОБРАЗОМ, а тести брав із нового
|
||
дерева. Образ зібрано з розгорнутого коду, тож він знав лише старі
|
||
міграції: падіння виглядало як «немає стовпця respect_quiet_hours», хоча
|
||
стовпець у гілці є. Тепер міграції котяться з того ж дерева, що й тести
|
||
(`go run ./cmd/netpulse-migrate`). Перевірено: 64 міграції, усе зелене.
|
||
|
||
---
|
||
|
||
## 2026-08-28 — Прив'язка Telegram: сторінка була, дороги до неї не було
|
||
|
||
Власник натиснув кнопку під сповіщенням у Telegram і отримав «Ваш Telegram
|
||
не прив'язано до NetPulse. Профіль → Telegram → отримати код». Відповідь
|
||
правильна по суті. Виходу з неї не існувало.
|
||
|
||
**Що показала база:** нуль прив'язок і нуль згенерованих кодів за весь
|
||
час. Механізмом не скористався ніхто, включно з власником.
|
||
|
||
**Чому.** Сторінка профілю з карткою прив'язки існує й працює. Пункту
|
||
меню в неї не було; єдиний вхід — власне ім'я користувача в правому
|
||
верхньому куті. А малювалось воно за умовою «є ім'я або пошта», і `/me`
|
||
віддавало лише пошту — порожню в облікового запису власника, який
|
||
заводить установник і який входить ЗА ІМЕНЕМ. Тобто в типовій інсталяції
|
||
вхід у профіль не малювався взагалі, і сторінка була досяжна лише
|
||
введенням адреси вручну.
|
||
|
||
Тип `Me` у фронтенді при цьому вже містив `username`. Сервер його просто
|
||
не слав — знову «обіцянка без виконання», тільки на межі шарів.
|
||
|
||
**Виправлено чотири речі, кожна закриває свій бік:**
|
||
|
||
* `/me` віддає `username` — і читає його З БАЗИ. Перша спроба віддавала
|
||
`Principal.Username` — і прогін на стенді показав порожнє: це поле
|
||
оголошене в структурі й НЕ ЗАПОВНЮЄТЬСЯ НІДЕ, а в токені лежить лише
|
||
пошта. Ще одна обіцянка без виконання — вловлена лише тому, що я
|
||
перевірив виправлення наживо, а не на збірці. Новий `UserByID`
|
||
читає ім'я з `core.users`: покласти його в токен означало б, що після
|
||
перейменування людина чверть години бачить у шапці старе ім'я.
|
||
* Вхід у профіль малюється ЗАВЖДИ для людини (не для машинного токена),
|
||
з іконкою й підписом «Мій профіль» у підказці; без імені й пошти пише
|
||
просто «Профіль» замість того, щоб зникнути.
|
||
* Пункт меню «Обліковий запис → Мій профіль». Це свідомий виняток із
|
||
власного правила файлу («група з одного пункту додала б заголовок,
|
||
який нічого не групує»): усі інші групи — про систему, ця — про того,
|
||
хто на неї дивиться, і саме через різницю в роді профіль нікуди не
|
||
вписався й лишився без пункту. `perm` у пунктів меню став
|
||
необов'язковим: вигадувати право для власного профілю означало б
|
||
брехати про модель доступу заради типу.
|
||
* Текст бота називає те, що людина побачить на екрані, а не назву
|
||
сторінки в коді.
|
||
|
||
Плюс сторінка каналів більше не мовчить: біля telegram-каналу видно, чи
|
||
прив'язаний Telegram у того, хто дивиться, і що саме не працюватиме без
|
||
цього. Не червоним — це незавершене налаштування, а не поломка.
|
||
|
||
### Оренда сходинки: токен замість строку (0073)
|
||
|
||
Остання знахідка рецензії ескалацій. `TakeDueEscalations` ставила в рядок
|
||
лише СТРОК оренди (2 хв), а партія з сотні сходинок із мережевими
|
||
таймаутами легко його переростає. Оренда спливала, другий інстанс брав ту
|
||
саму сходинку з тим самим `step_idx` і доставляв її — а перший доставляв
|
||
теж. Два дзвінки о третій ночі й два рядки «надіслано».
|
||
|
||
Тепер у рядку лежить `lease_token`, і `ApplyEscalation` пише лише за його
|
||
збігу. Довша оренда цього не вирішила б: довший строк — це пересунута
|
||
межа, і він псує протилежний випадок (процес, що впав одразу після
|
||
взяття). При розбіжності не відбувається НІЧОГО: запис не проходить,
|
||
доставки немає (стан пишеться перед нею), оренду того, хто зараз працює,
|
||
не зривають. Сходинка лишається належною, і її доводить до розв'язку
|
||
власник токена — або, якщо і він зник, наступний такт після спливання
|
||
строку.
|
||
|
||
Свідомо НЕ вимагається, щоб оренда була ще й живою: інакше кожна повільна
|
||
партія втрачала б усі свої сходинки, і драбина повзла б зі швидкістю
|
||
однієї сходинки за такт.
|
||
|
||
Прогнано проти справжньої бази: **65 міграцій, усі пакети зелені**.
|
||
|
||
---
|
||
|
||
## 2026-08-28 — Тести проти бази більше не запускає людина
|
||
|
||
`scripts/dbtest.sh` існував рівно один день і за цей день виявився
|
||
найрезультативнішою перевіркою проєкту: чотири справжні вади за один
|
||
прогін, включно з `nil`-зрізом, що їхав явним `NULL` повз `DEFAULT '{}'`,
|
||
і міграцією, якої не бачив старий образ. І весь цей день його запускав я
|
||
руками. Найдорожча перевірка на ручному приводі — це та сама вада, від
|
||
якої скрипт і написано, тільки на поверх вище: її пропускають рівно тоді,
|
||
коли поспішають, тобто перед розгортанням.
|
||
|
||
Тепер у `.forgejo/workflows/ci.yml` є окрема робота `dbtest`.
|
||
|
||
**Чому окрема, а не крок у `server`.** Вона повільна й червоніє з іншої
|
||
причини: не «код не збирається», а «схема й код розійшлись». Змішані в
|
||
одну роботу, ці причини читаються як одна пляма, а швидкий сигнал про
|
||
поламану збірку чекав би на базу.
|
||
|
||
**Чому вона викликає скрипт, а не повторює його кроки в yaml.** Робота
|
||
`server` свою послідовність «накат + `go test`» уже має, і дубль тут
|
||
свідомий: дублюється не перевірка коду, а перевірка ІНСТРУМЕНТА.
|
||
`dbtest.sh` — це те, що запускає людина перед розгортанням; переписані в
|
||
yaml кроки не помітять, як він зогнив, і зелений конвеєр мирно
|
||
співіснуватиме зі зламаним скриптом. Дубль дешевий: роботи йдуть по
|
||
черзі (місткість раннера 1), кеш збірки спільний.
|
||
|
||
**Головне, що довелось вирішити чесно: звідки береться Postgres.**
|
||
Скрипт уміє підняти його сам — але для цього йому потрібен docker
|
||
УСЕРЕДИНІ контейнера роботи, тобто прокинутий сокет докера хоста. Це
|
||
рівно той дозвіл, який перетворює будь-який workflow із будь-якої гілки
|
||
на root на цій машині, включно з даними бойової бази. Один раз ця ціна
|
||
вже заплачена — за сам раннер, і `deploy/docker-compose.ci.yml` називає
|
||
її вголос. Роздавати її ще й кожній роботі не треба, і не довелось: у
|
||
скрипта є друга гілка, «готова база», і саме вона тут використана. Базу
|
||
дає `services:`, контейнер створює демон докера ЗОВНІ роботи, а робота
|
||
бачить лише хост `db` у своїй мережі. Докера в ній немає.
|
||
|
||
**Запобіжник імені виявився не формальністю.** Скрипт відмовляється
|
||
працювати, якщо в імені бази немає `probe` або `test`. У роботі `server`
|
||
база зветься `netpulse_ci` — перевірено фактично: з нею скрипт падає ще
|
||
до підключення. Тому в `dbtest` своя база, `netpulse_probe`. Підлаштовано
|
||
конвеєр, а не запобіжник: він стоїть між міграціями з гілки й бойовою
|
||
базою, і послаблювати його заради зручності CI означало б міняти єдину
|
||
справжню гарантію на косметику.
|
||
|
||
**Що станеться, якщо база не піднялась.** Це найважливіше питання до
|
||
цілої роботи, бо саме тут повторити початкову ваду найлегше. Відповідь:
|
||
робота ПАДАЄ, і падає тричі поспіль на різних рубежах. Крок «Очікування
|
||
бази» опитує 5432 дев'яносто секунд і виходить із текстом «тестів проти
|
||
бази НЕ БУЛО». Далі сам скрипт: DSN заданий, отже свого контейнера він не
|
||
піднімає й одразу котить міграції — а `netpulse-migrate` без бази
|
||
повертає ненульовий код (перевірено локально: `rc=1`). І нарешті сторож
|
||
«Прогін проти бази справді відбувся» вимагає в логу накат саме тієї
|
||
кількості міграцій, що лежить у каталозі: порожній лог означає, що скрипт
|
||
до бази не дійшов, а «схема актуальна» — що база пережила минулий прогін
|
||
і накат з нуля не перевірявся.
|
||
|
||
Мовчазного пропуску, як у `grpcapi`, тут не може бути з іншої причини,
|
||
структурної: `NETPULSE_TEST_DSN` — ЄДИНА змінна, яку читає скрипт. Той
|
||
самий рядок котить міграції й доїжджає до `go test`. У роботі `server`
|
||
джерел два (`NETPULSE_DSN` і `NETPULSE_TEST_DSN`), і рівно тому там
|
||
потрібен окремий сторож пропуску — розійтись двом джерелам є з чого.
|
||
|
||
**Чого не перевірено.** Того, що цей раннер узагалі виконує `services:`.
|
||
Робота `server` покладається на них теж, але `ci.yml` не запускався
|
||
жодного разу — раннер стоїть, конвеєр на ньому не бігав. Локально
|
||
перевірено все, що можна без раннера: YAML розбирається, кожен `run`
|
||
проходить `sh -n`, скрипт із бойовим іменем бази відмовляється працювати,
|
||
з `netpulse_ci` — теж, з `netpulse_probe` і недосяжною базою повертає 1,
|
||
а тіло сторожа, витягнуте прямо з `ci.yml`, дає правильний вердикт на
|
||
чотирьох варіантах логу. Зеленого наскрізного прогону не було: докера на
|
||
цій машині немає.
|
||
|
||
---
|
||
|
||
## 2026-08-28 — Відкат уперше дійшов до живого заліза. І показав пароль
|
||
|
||
Механізм відкату конфігурації існував із 0035 і НІКОЛИ не виконувався на
|
||
справжньому обладнанні. Сьогодні його прогнали до самої межі — плану,
|
||
заявки й погодження — і на залізо свідомо НЕ писали.
|
||
|
||
### Три відмови, і кожна правильна
|
||
|
||
* **ZTE OLT, усі 4 наявні версії:** «різниця завелика для автоматичного
|
||
відкату: версії розійшлися майже повністю». Для цього пристрою архів
|
||
для порівняння практично непридатний — щозбору конфіг інший цілком.
|
||
* **D-Link ПОЛЬОВЕ-СВ і ПОЛЬОВЕ.7:** план пройшов, заявка відмовилась —
|
||
«312 рядків неможливо прибрати автоматично» (на другому 671). Профіль
|
||
чесно пояснює причину: D-Link не має універсального заперечення рядка.
|
||
* **Погодити відкат у цьому кабінеті неможливо в принципі:**
|
||
`require_approval: true`, `allow_self_approve: false`, користувач один.
|
||
Це не вада коду — це зіткнення правила безпеки з реальністю, і воно
|
||
стосується КОЖНОГО нового клієнта: усі починають самі.
|
||
|
||
### Вада, яку відмова випадково прикрила
|
||
|
||
Перший рядок збереженого конфігу — `Command: show config current_config`,
|
||
тобто відлуння команди, а не конфігурація. Планувальник порахував його
|
||
рядком конфігу й поклав у команди до заливки `Command: show config`. Якби
|
||
312 рядків не зупинили заявку, на живий комутатор пішла б безглузда
|
||
команда. Врятувала чужа перевірка, не своя.
|
||
|
||
### І головне: пароль адміністратора лежав відкритим
|
||
|
||
У конфігу ПОЛЬОВЕ-СВ:
|
||
|
||
```
|
||
create account admin btadmin
|
||
<пароль>
|
||
<пароль ще раз>
|
||
disable password encryption
|
||
```
|
||
|
||
Комутатор питає пароль ІНТЕРАКТИВНО, і `show config` віддає відповіді
|
||
окремими рядками — без жодного ключового слова поруч. `redactLines`
|
||
маскує порядково за зразками (`password`, `community`, `secret`…), тож
|
||
такий рядок для нього — просто слово. Пароль опинявся в плані відкату, у
|
||
`ncm.rollbacks` і — оскільки маскування перед записом у git НЕМАЄ ВЗАГАЛІ
|
||
— у git-дзеркалі, тобто за межами сервера.
|
||
|
||
**Виправлено:** `redactLines` отримав стан. Після рядка, що заводить
|
||
обліковий запис, наступні до двох односкладових рядків маскуються як
|
||
відлуння пароля. Ім'я облікового запису лишається читабельним — людина
|
||
при погодженні мусить бачити, ЩО змінюється. Односкладовий рядок сам по
|
||
собі не маскується (`enable`, `end`, `exit` не постраждали) — тест на це є.
|
||
|
||
**Що НЕ виправлено й потребує рішення власника:** тіла конфігів ідуть у
|
||
git-дзеркало ДОСЛІВНО. Маскувати їх означає зламати відновлення з архіву
|
||
(NCM цінний саме побайтовою точністю); не маскувати — означає, що
|
||
кожен секрет із конфігу виїжджає на зовнішній git-сервер. Це вибір, а не
|
||
вада, і зробити його має власник.
|
||
|
||
---
|
||
|
||
## 2026-08-28 — Тести проти бази нарешті біжать самі
|
||
|
||
`scripts/dbtest.sh` заведено в CI окремою роботою `dbtest`. Прогнано на
|
||
раннері: **задача 110, success** — тобто тести проти справжнього Postgres
|
||
уперше виконались без людини.
|
||
|
||
Рішення, які варто пам'ятати:
|
||
|
||
* **Базу дає `services:`, а не докер-сокет.** Скрипт уміє працювати з
|
||
готовою базою через `NETPULSE_TEST_DSN` і тоді свого контейнера не
|
||
піднімає. Прокидати сокет усередину роботи означало б роздати кожній
|
||
роботі root на хості — уникнули повністю.
|
||
* **Запобіжник імені спрацював не в теорії.** DSN роботи `server`
|
||
(`netpulse_ci`) скрипт відкинув: ані `probe`, ані `test` в імені. Тому
|
||
в `dbtest` своя база `netpulse_probe`. Підлаштували конвеєр, а не
|
||
запобіжник — саме так і треба, інакше запобіжник за місяць вимкнуть.
|
||
* **Окремий сторож проти «зеленого прогону, що нічого не зробив»:**
|
||
вимагає в логу `застосовано міграцій: N` (N рахується з
|
||
`migrations/*.sql`) і підсумкове `усе зелене проти бази`. Порожній лог
|
||
або `схема актуальна` — це червоне. Вада, від якої написаний увесь
|
||
скрипт, не має відтворитись у CI.
|
||
|
||
Заодно з'ясувалось, що ROADMAP брехав: CI не «чекає на раннера» — він
|
||
працює весь день, задачі 85–111. На коміт c83324a пройшли всі п'ять
|
||
робіт: hygiene, web, server, dbtest, agent.
|
||
|
||
### Рішення власника: дзеркало лишається дослівним (2026-08-28)
|
||
|
||
Питання стояло так: тіла конфігів ідуть у git-дзеркало точнісінько
|
||
такими, як на пристрої, — разом із секретами, які в них є. Маскувати
|
||
означало б зламати відновлення з архіву, бо цінність NCM саме в
|
||
побайтовій точності.
|
||
|
||
**Обрано точність.** Дзеркало пише конфіг як є; git-сервер вважається
|
||
довіреним. Пароль скомпрометованого облікового запису власник змінює сам.
|
||
|
||
Що це означає надалі, щоб не переобговорювати:
|
||
|
||
* маскування діє там, де конфіг читає ЛЮДИНА (план відкату, стенограми
|
||
завдань) — і саме там сьогодні залатали дірку з відлунням пароля;
|
||
* архів і дзеркало НЕ маскуються навмисно;
|
||
* отже, доступ до дзеркала треба вважати рівносильним доступу до всіх
|
||
паролів у мережі. Це не побічний ефект, а свідомо прийнята ціна.
|
||
|
||
---
|
||
|
||
## 2026-08-28 — Пісочниця вміє другий шлях: оновлення з версії на версію
|
||
|
||
Установку з нуля пісочниця перевіряє з Етапу 12, і саме вона знайшла дві
|
||
вади, які інакше зустрів би перший клієнт. Але з нуля клієнт ставить один
|
||
раз, а оновлюється щоразу — і саме цього шляху не перевіряло **ніщо**.
|
||
ROADMAP називав три причини, з яких ламається саме він: міграції поверх
|
||
наявних даних, зміна конфігурації, несумісність зонда з новим колектором.
|
||
Перевіряв їх перший клієнт.
|
||
|
||
Тепер є `./netpulse sandbox upgrade`.
|
||
|
||
### Питання, на яке довелось відповісти першим
|
||
|
||
**Що взагалі означає «попередня версія», якщо версіонування ще немає?**
|
||
Тегів немає, реєстру немає, образ завжди `netpulse/server:dev`. Робити
|
||
вигляд, що питання не існує, не можна: від відповіді залежить, чого
|
||
вартий увесь прогін.
|
||
|
||
Варіантів було три.
|
||
|
||
* **Тег.** Найправильніший — і сьогодні неможливий. Перевірка оновлення є
|
||
вхідним квитком *до* першого тегу. Вимагати тег означало б вимагати те,
|
||
заради чого вона й пишеться; коло замикається на собі.
|
||
* **Збережений дамп бази.** Найдешевший і доводить найменше. Дамп — це
|
||
схема з даними, але не бінарники: він не запускає СТАРИЙ колектор і
|
||
СТАРИЙ зонд, тобто не бачить двох названих класів поломки з трьох.
|
||
Гірше інше: дамп старіє мовчки. Його зробила версія, яку вже ніхто не
|
||
збере, і коли прогін почервоніє, розрізнити «зламався код» і «протух
|
||
дамп» буде нічим. Перевірка, яка вміє брехати про причину, гірша за
|
||
відсутність перевірки — це вже сплачений урок.
|
||
* **Коміт git.** Обрано. Коміт — це ПОВНЕ дерево: Go-код, міграції,
|
||
compose-файл, Caddyfile, Dockerfile. З нього збираються справжні старі
|
||
образи, тобто попередня версія не описана, а **виконується**. Той самий
|
||
ref дасть той самий стенд і за півроку.
|
||
|
||
І головне: це не тимчасове рішення. Тег у git — теж ref, тому в день
|
||
першого тегу тут не зміниться жодного рядка, `--from v0.1.0` запрацює
|
||
сам.
|
||
|
||
Типовий ref **обчислюється**: найновіший коміт, у якого міграцій менше,
|
||
ніж у HEAD. Причина та сама, що й у решті установника — зелений прогін,
|
||
який нічого не перевірив, гірший за відсутність прогону. Оновлення без
|
||
жодної нової міграції доводить лише те, що служби перезапустились, і
|
||
мовчки видається за доказ, що міграції котяться поверх даних. Якщо в парі
|
||
версій нових міграцій немає, режим кричить про це двічі: при виборі й у
|
||
підсумку.
|
||
|
||
### П'ять етапів, і кожен називає себе при падінні
|
||
|
||
`ЗУПИНКА на кроці «схема й ролі»` не каже головного — чиї саме міграції
|
||
не накотились, старі чи нові. Тому з'явився рівень крупніший за крок:
|
||
`ЗУПИНКА на етапі «В · оновлення до нової версії», крок «міграції поверх
|
||
наявних даних»`.
|
||
|
||
* **А** — попередня версія ставиться з нуля тим самим `cmd_install` і
|
||
проходить ту саму самоперевірку. Якщо впало тут, оновлення ні до чого:
|
||
зламана сама попередня версія.
|
||
* **Б** — наливання. Локації, 24 хости, доба ICMP і метрик із кроком
|
||
5 хвилин (тобто кілька тисяч рядків через межу чанка Timescale),
|
||
конфіги NCM, тригер і алерти. Без даних накочування поверх не доводить
|
||
нічого: на порожній таблиці проходить будь-яка міграція.
|
||
* **В** — дамп, збірка нової версії, міграції, перезапуск служб.
|
||
* **Г** — та сама `selfcheck`, що й після чистої установки. Окремого
|
||
набору тверджень навмисно немає.
|
||
* **Д** — кількості «до» проти «після», по рядках.
|
||
|
||
### Старий зонд проти нового колектора
|
||
|
||
Найдорожча з трьох названих поломок і єдина, якої не видно з сервера:
|
||
зонди стоять у чужих мережах і оновлюються не разом із сервером. Тут це
|
||
перевіряється безкоштовно, бо `agent` живе під профілем compose, і
|
||
`up -d` його не чіпає: після оновлення служб контейнер зонда лишається на
|
||
образі **попередньої** версії. Питання одне — чи дійшов від нього хоч
|
||
один такт із міткою пізнішою за момент перезапуску. Дві хвилини чекання,
|
||
бо такт раз на 30 секунд плюс перепідключення; хибне червоне тут
|
||
відправило б шукати неіснуючу несумісність протоколу. Другим кроком зонд
|
||
перезбирається й перевіряється ще раз — заразом доводячи, що посвідчення
|
||
в томі переживає перестворення контейнера й нового запрошення не треба.
|
||
|
||
### Ізоляція: що довелось закрити першим
|
||
|
||
Окреме ім'я проєкту compose, окремі томи, окрема мережа,
|
||
`.env.sandbox-upgrade` замість бойового `.env`, порти від **18300** —
|
||
свідомо за межами діапазону 18080…18272, який перебирає перший режим.
|
||
|
||
Але головне знайшлось не тут. Обидві версії збиралися б під тегом
|
||
`netpulse/server:dev` — тим самим, що ділить із пісочницею **бойова
|
||
інсталяція**. Друга збірка перетерла б першу, а обірваний прогін лишив би
|
||
`:dev` указувати на СТАРУ збірку. Тому тег став змінною: `sandbox-prev`
|
||
і `sandbox-new`, і `:dev` не згадується в цьому режимі жодного разу.
|
||
Прибирання зносить і теги.
|
||
|
||
Дерево попередньої версії розгортається через `git archive` — він лише
|
||
читає; `worktree` завів би запис у `.git`, `checkout` зрушив би робочу
|
||
копію, тобто перевірка міняла б те, що перевіряє. Лежить дерево в
|
||
`.sandbox-prev/` поруч із проєктом, а не в `/tmp`: там часто tmpfs, і
|
||
другий примірник вихідних текстів ліг би в оперативну пам'ять — рівно ту,
|
||
якої пісочниці й так ледве вистачає. З контексту збірки його виключає
|
||
новий `.dockerignore` (заодно звідти прибрано `.env`: контекст іде
|
||
демонові цілком, і паролям бази там робити нічого).
|
||
|
||
`./netpulse sandbox down` тепер прибирає **обидва** режими одразу.
|
||
Пам'ятати, у якому режимі стенд піднімали, людина не зобов'язана.
|
||
|
||
### Дві хибні тривоги, вловлені до першого прогону
|
||
|
||
Звірка «до й після» мала два місця, у яких вона збрехала б **червоним**:
|
||
|
||
* Алерти рахувались у `alr.alerts` за `dedup_key`. Але фоновий такт
|
||
переносить погашені алерти в `alr.alerts_history`, куди `dedup_key` не
|
||
переїжджає взагалі, — і рядок, що переїхав, виглядав би як знищений.
|
||
Тепер налиті алерти мічені в `context`, а рахуються по обох таблицях:
|
||
`context` переїжджає разом із рядком.
|
||
* Ряди метрик рахувались за `metric_key='cpu.util'` без прив'язки до
|
||
хоста. Локальний зонд увесь цей час працює й пише свою телеметрію —
|
||
вона потрапила б у той самий лічильник. Тепер кожен точний запит
|
||
звужено до хостів `sb-sw-*`.
|
||
|
||
Правил у звірці два, і різниця між ними змістовна. `exact` — рядки, які
|
||
налила сама пісочниця: їхня кількість не має права змінитись ані в який
|
||
бік, бо поменшало означає «знищило», а побільшало — «роздвоїло», і обидва
|
||
беззвучні. `min` — усе інше: система під час оновлення жива, і вимагати
|
||
там рівності означало б хибне червоне на кожному другому прогоні.
|
||
|
||
### Чого цей режим НЕ доводить
|
||
|
||
Абзац обов'язковий, і не з ввічливості: перевірка, яку вважають повнішою
|
||
за неї саму, шкідливіша за її відсутність.
|
||
|
||
* **Гілку «наявна інсталяція» в міграторі.** База пісочниці народжується
|
||
чистою, тому `public.netpulse_install.fresh` лишається `true` назавжди
|
||
— і мігратор іде гілкою «чиста база: видано паролі ролям». Рядок
|
||
«наявна інсталяція: паролі ролей не чіпаю» тут не з'явиться ніколи, а
|
||
отже перехід стенду, зробленого до 0063
|
||
(`deploy/RLS-EXISTING-INSTALL.md`), лишається неперевіреним.
|
||
* **Зміни в самому установнику між версіями.** Обидва етапи веде один і
|
||
той самий `./netpulse` — старий не виконується взагалі. Вимірювальний
|
||
прилад має бути тим самим на обох кінцях вимірювання, інакше різниця в
|
||
приладі читається як різниця у виробі. Ціна відома й прийнята.
|
||
* **Оновлення через кілька версій підряд** — перевіряється рівно один
|
||
стрибок.
|
||
* Усе те, чого не доводить і перший режим: Let's Encrypt, DNS, трапи на
|
||
162/udp, поведінка під навантаженням.
|
||
|
||
### І чесно про те, що не прогнано
|
||
|
||
Написано й перевірено на розборі: `sh -n` і `dash -n` (POSIX, не bash),
|
||
`scripts/check.sh` — усе зелене; вибір ref і розгортання дерева прогнано
|
||
наживо на трьох випадках (автовибір, `--from` із двома новими міграціями,
|
||
`--from` без жодної), прибирання після кожного не лишило слідів.
|
||
**Самого стенду не піднято жодного разу: у цьому оточенні немає docker.**
|
||
Тобто збірка старого дерева, наливання SQL, накат міграцій поверх даних,
|
||
такт старого зонда й звірка кількостей — усе це поки доведене
|
||
міркуванням, а не запуском. Саме той стан, проти якого написана
|
||
пісочниця; перший прогін на стенді має бути перед першим тегом, а не
|
||
після.
|
||
|
||
### Прогін пісочниці оновлення на стенді (2026-08-29)
|
||
|
||
Перший наскрізний прогін `./netpulse sandbox upgrade`. Пройшов повністю:
|
||
`ec4b2cd → d73f693`, міграція 0073 накотилась поверх наповненої бази,
|
||
самоперевірка зелена, сліди прибрані.
|
||
|
||
**Дорогою знайшлась вада в самій перевірці, і саме того класу, від якого
|
||
вся ця робота й починалась.** Перший прогін звітував таблицю «дані до й
|
||
після» з ОДНОГО рядка — «хости 24 → 24, ok». Виглядало як повна
|
||
перевірка. Насправді проб вісімнадцять, а виконувалась одна:
|
||
`docker exec` читає stdin, а stdin у цьому циклі — той самий перелік
|
||
проб. Перша ж проба з'їдала решту, цикл завершувався після одного оберту,
|
||
і жодного натяку на це не було: рядок є, «ok» є.
|
||
|
||
Виправлено одним `</dev/null`. Після нього таблиця показує те, заради
|
||
чого існує:
|
||
|
||
```
|
||
проби icmp 6936 → 6936 exact
|
||
відліки метрик 6936 → 6936 exact
|
||
конфіги ncm 24 → 24 exact
|
||
алерти 12 → 12 exact
|
||
```
|
||
|
||
Тобто міграція пройшла по базі з майже сімома тисячами рядів телеметрії,
|
||
двома десятками конфігів і живими алертами — а не по порожній.
|
||
|
||
**Що прогін НЕ доводить:** «попередня версія» тут — сусідній коміт із
|
||
різницею в одну міграцію. Перед справжнім випуском треба брати `--from`
|
||
із тією версією, з якої клієнти оновлюватимуться насправді. Гілка
|
||
мігратора «наявна інсталяція» цим режимом не досяжна за побудовою: база
|
||
пісочниці народжується чистою. І `журнал аудиту 0 → 0` — проба з
|
||
правилом `min`, тобто нуль у ній нічого не доводить; чому після повної
|
||
установки аудит порожній, варто подивитись окремо.
|
||
|
||
---
|
||
|
||
## 2026-08-29 — Хто заходив і хто що вимкнув
|
||
|
||
Дві сліпі зони, знайдені питанням «чому в пісочниці аудит порожній».
|
||
Відповідь на саме питання виявилась нудною (установник робить лише те,
|
||
що в переліку сліпих зон), а от дорога до неї — ні.
|
||
|
||
### Історія входів: 93 записи були в базі й ніде не показувались
|
||
|
||
`core.login_attempts` наповнювалась на кожній спробі входу від першого
|
||
дня. Читалась рівно в одному місці — для стримування підбору. Ні
|
||
ендпойнта, ні сторінки, ні згадки у вебі. Тобто на питання «хто заходив
|
||
у мій моніторинг» продукт мав відповідь у базі й не мав способу її
|
||
показати. Для B2B це перше, що питає служба безпеки клієнта.
|
||
|
||
**Головним була не верстка.** У таблиці немає `tenant_id` — саме тому ці
|
||
події й не в аудиті (журнал вимагає кабінет, а на момент перевірки
|
||
пароля кабінет ще невідомий). Показати «як є» означало б віддати одному
|
||
кабінету спроби входу чужих людей. Прив'язка непряма: спроба →
|
||
користувач → членство, трьома шляхами одночасно (`user_id`, `username`,
|
||
`email`) в одному `JOIN LATERAL` з `tenant_id` усередині. Саме `JOIN`, а
|
||
не `LEFT JOIN`: без збігу рядок ПРИБИРАЄТЬСЯ, а не лишається без імені.
|
||
|
||
На бойових даних це виявилось не теорією: у власника **немає пошти**,
|
||
тож за `email` не прив'язується ЖОДНА з 94 спроб. Шлях через `username`
|
||
дає 93. Тобто зіставлення «як усі роблять» показало б порожню сторінку —
|
||
і виглядало б справним.
|
||
|
||
Невдала спроба з неіснуючим логіном не належить нікому. Обрано:
|
||
**рядком не показувати, але порахувати** — «ще N спроб з M адрес не
|
||
показані». Сигнал «логіни перебирають» лишається, чужа людина в чужий
|
||
кабінет не потрапляє. Однозначно правильного варіанта тут немає, і в
|
||
коді так і написано.
|
||
|
||
### Аудит: алерти й склад команди
|
||
|
||
За цілий день роботи (драбина, тригери, канал, правила відповідності,
|
||
політика відкату) у журналі не з'явилось нічого. Тепер пишуться правила
|
||
алертів, канали, драбини, правила відповідності, а також додавання
|
||
людини в кабінет, зміна ролі, вилучення й скидання пароля.
|
||
|
||
Два рішення про зміст:
|
||
|
||
* **вимкнення видно з НАЗВИ дії** (`alr.rule.disable`), а не з різниці
|
||
подробиць. Питання «хто вимкнув правило, за яким приходив алерт»
|
||
має відповідатись переліком, а не порівнянням двох записів;
|
||
* **`config` каналу не їде в запис ВЗАГАЛІ.** Там не лише токен бота: у
|
||
webhook-каналі лежить адреса (доступ на запис у чужий чат) і заголовок
|
||
`Authorization`. Замість вмісту — прапорець `secret_changed`.
|
||
|
||
Перевірено наживо: створення й видалення каналу лягли в журнал, токен у
|
||
ньому не з'явився.
|
||
|
||
### Помилкова тривога, названа для чесності
|
||
|
||
Я вирішив, що сім подій у `core.event_outbox` «висять». Ні: усі сім
|
||
позначені доставленими, а `pruneLoop` щогодини прибирає доставлені,
|
||
старші за граничний вік. Найстаршій було 11 годин. Механізм справний.
|