Netpulse_SasS/HISTORY.md
zotac bee55be3d1 Етап 4: редактор мапи — зв'язки, видалення, відкат
POST /api/v1/maps/{id}/undo повертає полотно до попереднього знімка.
Відкат оформлюється як нова ревізія, а не відмотування лічильника:
інакше клієнт зі старим номером тихо перезаписав би відкочене.
Ідентифікатори вузлів зберігаються, тож ребра прив'язуються назад самі.

В UI: малювання зв'язку від краю вузла, видалення по Delete, кнопка
відкату. Намальоване рукою ребро не прив'язується до topo.links —
лінія на полотні це подання, а не факт про мережу.

Виправлено флак у тестах: TestSchedulerRunsTaskAndFillsCredentials
перевіряв канал статусів знімком, хоча SUCCEEDED надсилається вже після
запису в sink. Під навантаженням падав раз на п'ять; тепер 0 з 8.

Перевірено наживо: намальовано зв'язок -> відкат -> видалено вузол ->
відкат повернув той самий id, ребро й живий стан лінка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:46:18 +03:00

42 KiB
Raw Blame History

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

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


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

Створено

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

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

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

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

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

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

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

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

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

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

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


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

Створено

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

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

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

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

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

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

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


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

Створено

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

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

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

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

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

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

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

Репозиторій

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


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

Створено

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

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

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

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

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

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

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

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

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

Чого ще немає

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

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

Створено

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

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

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

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

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

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

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

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

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

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

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

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

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

Створено

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

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

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

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

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

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

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

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

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


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

Створено

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

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

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

Перевірено

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

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

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

Чого ще немає

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

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

Створено

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

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

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

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

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

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

Перевірено

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

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


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

Створено

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

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

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

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

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

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

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

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

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

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

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

Створено

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

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

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

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

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

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

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

Далі

  • Підкладки-плани приміщень на полотні (потрібен прийом файлів).
  • Історія метрик для графіків.
  • EnrollmentService + видача сертифікатів зондам.