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>
42 KiB
NetPulse — журнал розробки
Стислий лог: що зроблено, які рішення прийняті, що далі. Мета — щоб наступна сесія не перечитувала весь код.
2026-08-14 — Етап 1: схема БД
Створено
netpulse/
├── HISTORY.md ← цей файл
├── docker-compose.yml TimescaleDB 2.17/pg16 + DragonflyDB
└── db/
├── README.md ERD, ключові рішення, ізоляція тенантів
├── migrate.ps1 накат міграцій + schema_migrations
└── migrations/
├── 0001_core.sql tenants, users, RBAC, secrets, audit
├── 0002_inventory.sql sites, devices, interfaces, credentials
├── 0003_agents_plugins.sql plugins, agents, check_types, checks, outbox
├── 0004_topology.sql neighbors, links, maps, nodes, edges, backgrounds
├── 0005_telemetry_timescale.sql hypertables, CAGG, compression, retention
├── 0006_ncm.sql repos, profiles, jobs, configs, diffs, rollback
├── 0007_alerting.sql rules, alerts, channels, routes, maintenance
├── 0008_dashboards.sql dashboards, widgets, SLA
├── 0009_billing_licensing.sql plans, subscriptions, entitlements, invoices, licenses
├── 0010_seed.sql довідники
└── 0011_rls.sql Row Level Security
Прийняті архітектурні рішення
- Фізична топологія ≠ візуальна.
topo.links(що є в мережі) окремо відtopo.map_edges(як намальовано). Один лінк — на багатьох мапах. - Зв'язки port→port через FK на
inv.interfaces, не текстом. Пара нормалізована черезLEAST/GREATEST, щоб A→B і B→A не дублювались. - Автовиявлення в два кроки: сирі
topo.neighbors(LLDP/CDP/ARP/FDB +confidence) → резолвер →topo.links. Прапорецьis_pinnedзахищає ручні лінки. - Дві моделі метрик: узагальнена
ts.series+ts.samples(плагіни реєструютьmetric_keyбез DDL) і широкіts.icmp_samples/ts.if_countersдля гарячих шляхів мапи. - Анімація трафіку має ланцюг даних:
if_counters.util_out_pct→ viewtopo.link_live→map_edges.animation. - Ліміти тарифу перевіряються двічі:
bill.entitlementsв API + тригери в БД. - Секрети лише шифровані (
core.secrets: ciphertext/nonce/auth_tag/key_id, AES-GCM-256, DEK у KMS). - Тіло конфігів у Git, метадані в БД (
ncm.configs.commit_sha+content_hash). - RLS за замовчуванням на кожній таблиці з
tenant_id; порожнійapp.tenant_id→ порожній результат.
Проблеми, на які натрапив (щоб не повторювати)
- Порядок seed ↔ RLS. Спершу RLS був 0010, seed 0011 — це ламається: після
FORCE ROW LEVEL SECURITYнавіть власник схеми не вставить довідники зtenant_id IS NULL. Файли переставлені місцями. - CAGG не працюють у транзакції.
CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)падає всередині транзакційного блоку.migrate.ps1детектить це по вмісту файлу й вимикає--single-transactionдля 0005.
Помилки, знайдені прогоном на живому Postgres
window— зарезервоване слово.core.sla_targets.window→period_kind.core.check_types.keyбув доменомcore.slug, але ключі мають виглядicmp.ping— крапка не проходить. Замінено наtextз власним CHECK^[a-z0-9]+(\.[a-z0-9_]+)+$; так самоcore.checks.check_type.- Домен
core.slugне пропускав підкреслення, а ключі фіч —http_checks,auto_discovery. Регекс розширено до[a-z0-9_-]. - RLS блокує не CAGG, а стиснення. Початкове припущення «CAGG + RLS несумісні» виявилось хибним. Реальна відмова TimescaleDB 2.29:
operation not supported on hypertables that have columnstore enabled— тобто конфлікт саме з compression. Тому в 0011 виключено всі hypertables (черезtimescaledb_information.hypertables), а не три захардкоджені.
Перевірено на живому стенді
Debian 13 (LXC, 192.168.1.203) / PostgreSQL 17.11 / TimescaleDB 2.29.1. Усі 11 міграцій — на чисту БД без помилок. Створено: 81 таблиця, 12 hypertables, 6 continuous aggregates, 25 фонових job-ів, 63 RLS-політики, 220 індексів.
db/tests/smoke.sql — 8 функціональних перевірок, усі PASS:
нормалізація пари лінків, CHECK на device-ноду, дедуплікація алертів,
ліміт пристроїв тарифу Free, стан полотна мапи одним запитом,
ребро port→port з живим util_pct, роллап CAGG, ізоляція тенантів RLS.
Бази на сервері: netpulse (схема + smoke-дані). Перестворити:
sudo -u postgres dropdb netpulse && createdb -O netpulse netpulse, далі міграції.
2026-08-14 — Етап 2 (частина 1): protobuf-контракт агент↔сервер
Створено
netpulse/
├── buf.yaml, buf.gen.yaml
├── proto/
│ ├── README.md контракт: сервіси, потоки, семантика, безпека
│ └── netpulse/v1/
│ ├── common.proto Status, Transport, Error, DeviceTarget, Credential, AgentHealth
│ ├── agent.proto EnrollmentService, AgentService, ControlUp/ControlDown
│ ├── telemetry.proto SeriesDescriptor, MetricSample, IcmpResult, InterfaceCounters
│ ├── discovery.proto NeighborRecord, InterfaceRecord, DiscoveredDevice
│ ├── ncm.proto ConfigJob, ConfigUpload (header/chunk/trailer), ConfigApplyJob
│ └── logs.proto SyslogEntry, SnmpTrap, LogBatch
├── gen/go/ згенерований код (комітиться)
└── test/contract/ наскрізні gRPC-тести на bufconn
Прийняті рішення
- Форма контракту випливає з одного обмеження: усі з'єднання ініціює агент.
«Команда з сервера» — це повідомлення у зустрічному напрямку вже відкритого
агентом bidi-стріму
Control, а не RPC у бік агента. - Чотири окремі стріми (Control / Telemetry / Logs / Config), а не один: пачка семплів не має блокувати heartbeat, сплеск syslog під час аварії не має топити телеметрію.
- Інтернування серій. Агент реєструє серію раз під
series_ref, далі шле лише номер. Заміряно: 66 → 25 байт на семпл.series_refживе в межах сесії. - Швидкості рахує агент (лише він знає точний інтервал опитування), але шле й сирі лічильники — щоб сервер міг перерахувати заднім числом.
- Scrub/redact конфігів — на сервері. Агент віддає сирий текст; правила
живуть у
ncm.profilesі змінюються без оновлення агентів у полі. - Топологію зводить сервер. Агент доповідає лише «на порту X бачу chassis Y».
Task.params_json— непрозорі байти. Новий плагін не потребує зміни .proto. Модуль-виконавець виводиться з префіксаcheck_typeдо крапки.- At-least-once + upsert. Дедуплікацію дають PK схеми БД
(ts, device_id)тощо. - Зворотний тиск диктує сервер у
Welcomeі кожномуTelemetryAck. - Самооновлення підписане Ed25519 — інакше компрометація CDN = RCE в мережі кожного клієнта.
Перевірено на стенді
Debian 13 (192.168.1.203): protoc 3.21.12, Go 1.24.4, buf 1.72.0.
protoc— усі 6 файлів валідні;buf lint(STANDARD) — без зауважень;- генерація Go+gRPC,
go build,go vet— чисто; go test ./test/contract/...— 5/5 PASS: рукостискання й push плану задач, інтернування серій, реакція на невідомийseries_ref, чанкування конфігу зі звіркою sha256, відмова при пошкодженій контрольній сумі.
Дрібниця для наступного разу: buf.yaml довелось звільнити від
RPC_REQUEST_STANDARD_NAME та сусідніх правил — вони припускають пари
запит-відповідь, а ControlUp/ControlDown це незалежні потоки подій.
2026-08-14 — Етап 2 (частина 2): Go-агент
Створено
netpulse/
├── .gitignore, .gitattributes (репозиторій: LF усюди, крім .ps1)
└── agent/
├── README.md будова, рішення, параметри чеків
├── go.mod replace → ../gen/go
├── cmd/netpulse-agent/ точка входу, GOMEMLIMIT, keepalive
└── internal/
├── config/ прапорці + NETPULSE_*, mTLS
├── module/ контракт модуля, реєстр, маршрутизація
├── telemetry/ interner.go (series_ref) + buffer.go
├── scheduler/ min-heap, семафор, schedule_offset
├── session/ gRPC-клієнт, реконект, ack, план задач
└── modules/icmp, /snmp
Прийняті рішення
- Модулі вкомпільовані, без динамічного завантаження. Один бінарник має працювати на Alpine, Windows і роутері з musl. «Активація» = дозвіл сервера.
- Креденшели беруться на момент виконання, не з плану: у них TTL, і прострочені не віддаються взагалі — інакше агент заблокує обліковий запис на половині комутаторів клієнта.
- Розклад вирівняний по сітці інтервалу, тому після рестарту задача повертається у свій слот, а не з'їжджає.
- Буфер викидає найстаріше. Після відновлення зв'язку цінніший поточний стан. Зміни статусу викидаються останніми й пролазять у батч першими.
- Швидкості інтерфейсів рахує агент (знає фактичний інтервал); при
перевороті лічильника —
counter_resetзамість стрибка на терабіт. - Один писар у контрольний стрім — gRPC не допускає паралельних Send.
GOMEMLIMIT48 МБ у коді: хай GC працює агресивніше, ніж OOM killer осліпить моніторинг саме тоді, коли він потрібен.
Перевірено на стенді
Debian 13, Go 1.25.13. go vet чисто, go test ./... -race — усі пакети ok.
Релізний бінарник (CGO_ENABLED=0 -trimpath -s -w): 12 МБ, базовий
RSS 11.6 МБ у циклі реконекту (бюджет 30 МБ).
Не перевірено
TestPingLoopbackпід звичайним користувачем в unprivileged LXC пропускається: ядро не дає ані unprivileged-, ані raw-сокета,sysctl ping_group_rangeнедоступний. Під root на тому ж стенді тест проходить — ICMP-модуль перевірений проти реального сокета. У проді потрібенCAP_NET_RAW.- Модуль snmp не перевірявся проти живого пристрою — на стенді немає
SNMP-агента. Компілюється й проходить vet; логіка перевороту лічильників
і
util_pctчекає на реальне обладнання.
Репозиторій
https://git.zotac.keenetic.link/zotac/Netpulse_SasS.git (Forgejo).
Читання анонімне, push вимагає токена — Forgejo не пускає навіть у
публічний репозиторій без автентифікації (Credentials are incorrect).
Коміти лежать локально в main і чекають на токен.
2026-08-14 — Етап 2 (частина 3): серверна сторона AgentService
Створено
netpulse/server/
├── README.md рішення, параметри, стан перевірки
├── cmd/netpulse-server/ TLS, keepalive, m'яка зупинка, keyring із -dek
└── internal/
├── crypto/ AES-GCM-256, keyring із ротацією ключів
├── store/ agents, plan, credentials, telemetry, discovery, ncm
└── grpcapi/ AgentService + перехоплювачі автентифікації
Плюс правки в агенті: -token і передача його в метаданих кожного виклику;
Interner.MarkAllPending() — перереєстрація серій на початку сесії.
Прийняті рішення
- Ізоляція тенантів робиться двічі: RLS (
SET LOCAL app.tenant_id) плюс явний предикатtenant_id. Не перестраховка: RLS не працює на гіпертаблях, а саме туди йде вся телеметрія. schedule_offset— чиста функція відcheck_id, тож будь-який вузол сервера дає те саме значення.- Хеш плану — лише з полів, що впливають на поведінку. Зміна опису пристрою не змушує переливати 50 000 задач.
- Токен каже, ЯКИЙ це зонд; сертифікат — що він має право говорити. Чужий
agent_idпри валідному токені →PermissionDenied. - DEK не покидає сервер. Дамп БД без ключів не дає жодного пароля. Комплект креденшелів із TTL 1 год.
- Запис телеметрії —
ON CONFLICT DO NOTHING(at-least-once). - Статус пристрою — один запит із умовним записом в історію, інакше два
воркери наввипередки писали б неіснуючі переходи
up→up. - Впевненість зіставлення спадає за надійністю ознаки: chassis-id 95 → MAC 90 → IP 80 → sysName 60. Останнє низьке навмисно: sysName вводить людина.
- Перереєстрація серій замість обнулення нумерації. Спершу агент мав би скидати interner на реконекті, але це викидало б увесь накопичений за час обриву буфер — саме ті дані, заради яких він накопичувався.
Перевірено на стенді
11 інтеграційних тестів проти живої БД зі схемою Етапу 1 і справжнього gRPC —
усі PASS з -race. Найцінніше: повтор батчу не дублює ані рядки, ані переходи в
історії; зустрічний звіт B→A не створює другий лінк; ручний (is_pinned) лінк не
затирається; тіло конфігу лежить зашифрованим.
Живий наскрізний прогін (справжній агент + справжній сервер + БД, 40 с,
icmp.ping кожні 5 с проти 127.0.0.1): 5 ICMP-семплів, метрики icmp.rtt_avg
0.108 мс / jitter / loss, статус unknown → up з причиною icmp і рівно одним
переходом в історії, heartbeat із RSS 11.6 МБ і dropped_samples=0, зонд
позначений offline після зупинки, clock skew −0.9 мс.
Знайдено під час перевірки
Приведення типу на місці ($1::text) не розв'язує конфлікт виведення типів у
Postgres, а нав'язує тип обом уживанням параметра. Коли $1 потрібен і як uuid
для колонки, і як text для конкатенації, кастувати треба протилежне уживання:
VALUES ($1::uuid, …, '/шлях/' || $1 || '.git'). Та сама пастка двічі: у сіді
тесту (VALUES ($1, $1, …) для core.slug і text) і в ncm.repos.
Чого ще немає
- Git-двигун (libgit2): тіло конфігу шифрується в
core.secrets,commit_shaтимчасово = hex контентного хеша. Дедуплікація йprev_config_idпрацюють, тож diff будується вже зараз. EnrollmentService— зонди заводяться вставкою вcore.agents.- Сервер не надсилає
TaskDelta(лише повний план) і не ініціюєConfigJob.
2026-08-14 — Етап 2 (частина 4): модуль topology, автовиявлення наскрізь
Створено
agent/internal/snmpx/ спільний SNMP-транспорт + розбір індексів OID
agent/internal/modules/topology/ LLDP, CDP, ARP, FDB + інвентар портів
server/cmd/netpulse-secret/ заведення шифрованих секретів із CLI
Плюс: scheduler.OnDiscovery і TriggerNow, доставка звітів у session,
обробка DiscoveryRequest, реєстрація модуля в main.
Прийняті рішення
- SNMP-транспорт винесено в
snmpx— ним користуються два модулі, а два незалежні набори однієї логіки це два незалежні набори багів. - Звіти автовиявлення йдуть окремим RPC, не телеметричним стрімом: вони рідкі, великі й не прив'язані до моменту часу так, як метрики.
- Черга звітів коротка й витісняє найстаріший. Знімок топології актуальний рівно доти, доки описує поточний стан; накопичувати застарілі немає сенсу.
ifHighSpeedмає пріоритет надifSpeed: 32-бітне поле впирається в 4.29 Гбіт/с, і на 10G порт показував би неправильний знаменник дляutil_pct.ifNameперекриваєifDescr: самеifNameвіддає LLDP як port-id, тому саме за ним зійдеться лінк.netpulse-secretяк окремий інструмент — секрети не можна вставити звичайним SQL, а UI ще немає.
Знайдено живим прогоном (обидва — справжні помилки)
- Префікс типу чека не збігався з ключем модуля. У сіді було
topo.discoverпри плагініtopologyіssl.expiryпри плагініhttp. Агент маршрутизує задачі саме за префіксом, тож зонд відхилив би їх як адресовані неіснуючому модулю. Перейменовано наtopology.discoverіhttp.ssl_expiry, а інваріант закріплено обмеженням у БДcheck_types_prefix_matches_plugin— щоб наступна така неузгодженість не доїхала до поля. - Унікальний індекс
topo.neighborsсхлопував ARP-сусідів. Ключ складався зchassis_id+port_id, яких в ARP і FDB немає взагалі: з двох сусідів на одному порту зберігався один. Доданоremote_macдо індексу.
Перевірено проти справжнього SNMP-агента
На стенді піднято snmpd + lldpd (LLDP-MIB через AgentX). Живий прогін
агент → сервер → БД:
- інвентар портів зі справжнього
ifTable:loіeth0, MACbc:24:11:07:68:67, 10 Гбіт/с саме черезifHighSpeed; - 2 сусіди зі справжньої ARP-таблиці, обидва з локальним портом
eth0; - шлюз
192.168.1.1зіставлено за MAC із впевненістю 90; - лінк
snmp-host:eth0 → gatewayзведено,capacity_bps10 Гбіт/с; - три проходи поспіль — лінк один, дублікатів немає;
snmp.getзбираєsys.uptime, помилок чеків немає.
Усі три набори тестів (агент, сервер, контракт) проходять з -race.
Не перевірено
- LLDP і CDP на живих сусідах.
lldpdзареєстрував MIB, але сусідів немає — поруч немає другого пристрою, що шле LLDP. РозбірlldpRemTableіcdpCacheTableпокритий лише тестами на індекси OID; ARP-гілка того самого коду перевірена на живих даних. snmp.if: сервер поки не генерує для нього перелік інтерфейсів, тому чек нікуди не призначається.
2026-08-14 — Етап 2 (частина 5): автостворення snmp.if-чеків
Створено
server/internal/store/autochecks.go — з виявлених інтерфейсів формується
snmp.if-чек і одразу штовхається живій сесії як TaskDelta.
Прийняті рішення
- Чек оновлюється, а не задвоюється. Унікальний індекс
core.checksвключаєmd5(params), тому наївний upsert плодив би новий рядок на кожну зміну складу портів. Шукаємо існуючий чек за(device_id, 'snmp.if'). - Склад портів порівнюється як множина. Postgres не гарантує порядок
ключів у
jsonb— пряме порівняння рядків давало б хибну зміну на кожному обході, і агент отримував би новий план щоразу. - Без SNMP-креденшела чек не створюється: він лише щохвилини писав би помилку автентифікації. Інтерфейси при цьому все одно зберігаються — вони потрібні мапі незалежно від того, чи є чим їх опитувати.
- Loopback і
notPresentвідсіюються — графіка не дають, місце в PDU займають. Ліміт 256 портів на чек: далі опитування не вкладається у власний таймаут. - Зміна штовхається живому зонду. Чекати наступного перепідключення — це години порожніх графіків після кожного нового комутатора.
Знайдено живим прогоном
OID у запиті без провідної крапки, а pdu.Name — з нею. Пошук у мапі
результатів мовчки нічого не знаходив, і чек виглядав як «жоден інтерфейс не
відповів». snmp.get працював, бо там уже була нормалізація, а snmp.if — ні.
Канонізацію винесено в snmpx.Normalize і застосовано з обох боків у GetUints.
Перевірено наскрізь проти справжнього SNMP
Агент і сервер запущені як є, без жодного ручного кроку між ними:
topology.discoverзнайшовloіeth0→inv.interfaces;- сервер створив
snmp.if-чек на 1 порт (loopback відсіяно), 10 Гбіт/с; - дельта доїхала до живої сесії (
надіслано_наживо: true); - агент опитав справжні HC-лічильники:
in_octets625 246 266,in_bps11 938; util_out_pct0.000001 % — знаменник ізifHighSpeed;- помилок чеків немає.
Це повний шлях даних для анімації трафіку на мапі. Усі три набори тестів
проходять з -race.
2026-08-14 — Етап 3: REST/WebSocket API для UI
Створено
server/
├── API.md ендпоїнти, протокол WebSocket, приклади
├── cmd/netpulse-api/ окремий процес: HTTP + WS
└── internal/
├── httpapi/server.go роутер, Bearer-автентифікація, обробники
├── httpapi/ws.go hub, насос подій, насос завантаження каналів
└── store/
├── maps.go стан полотна з живими статусами
├── events.go читання core.event_outbox
├── inventory.go пристрої, зонди
└── apitokens.go автентифікація токенів UI
Прийняті рішення
- API — окремий процес від AgentService. Зонди й браузери мають різні профілі
навантаження, периметри й цикли релізів. Спільний лише шар
store. - Стан мапи віддається одним викликом разом зі статусами. Без цього мапа малювалася б сірою й доганяла кольори сотнею дозапитів.
link_statusвиводиться з кінців лінка, а не читається з колонки.topo.links.statusніхто не підтримує; писати туди означало б оновлювати всі лінки пристрою на кожну зміну статусу. Стан лінка — похідна величина.- Події пишуться тією ж транзакцією, що й зміна. Інакше WebSocket міг би розповісти про перехід, якого в базі ще (або вже) немає.
- Опитування outbox замість LISTEN/NOTIFY. NOTIFY не переживає падіння підписника й обмежений 8 КБ; тут потрібна гарантія, що зміна статусу не загубиться між перезапусками API.
- Дві частоти розсилки: статус — подія (миттєво), завантаження — величина (раз на 5 с). Частіше за оновлення лічильників (60 с) — це та сама цифра по колу.
- Токен WebSocket їде підпротоколом, бо браузер не дозволяє довільні заголовки; в URL він не потрапляє, а отже й у логи проксі.
- Підписник, що не встигає читати, відключається, а не гальмує решту.
Перевірено
10 інтеграційних тестів проти живої БД, справжнього HTTP і WebSocket — усі з -race.
Найцінніші: TestWebSocketDeliversStatusChange проганяє справжній батч телеметрії
через applyDeviceStatus → outbox → hub → браузер; TestLinkStatusFollowsEndpoints
перевіряє, що лінк червоніє від падіння кінця або порту без змін у topo.links.
Живий прогін — агент, netpulse-server і netpulse-api разом проти справжнього
snmpd: мапа з 2 вузлами up (RTT 0.113 і 0.827 мс), ребро з портом eth0,
лінк=up, живий util_pct, зонд online з RSS 11.6 МБ і dropped_samples=0.
target_port порожній — і це правильно: лінк знайдено через ARP, а ARP не повідомляє
порт віддаленої сторони.
Чого ще немає
- Усі ендпоїнти read-only: редактор мапи потребує
PATCHз оптимістичним блокуванням заrevision(колонка є, обробника немає). - Немає
GET /api/v1/metricsдля графіків і віддачіalr.alerts. - Автопобудова мапи з
topo.links— поки SQL-скрипт, не кнопка.
2026-08-14 — Етап 3 (частина 2): запис у мапу
Створено
server/internal/store/maps_write.go патч полотна, знімки, автопобудова
server/internal/httpapi/maps_write.go POST/PATCH/DELETE + /build
Ендпоїнти: POST /api/v1/maps, PATCH /api/v1/maps/{id},
DELETE /api/v1/maps/{id}, POST /api/v1/maps/{id}/build.
Прийняті рішення
- Усі скалярні поля патча — вказівники; nil означає «не чіпати». Перетягування шле лише x/y, і якби відсутні поля трактувались як порожні, кожен рух миші стирав би стиль, розмір і прив'язку до пристрою.
- Ребро може посилатися на вузол, створений тим же патчем, за
client_id— інакше зв'язок до нового вузла вимагав би двох запитів і проміжного стану. - Оптимістичне блокування за
revision. Той, хто спізнився, отримує 409, а не тихо затирає чужу правку: у NOC над однією мапою працюють кілька людей. - Знімок пишеться тією ж транзакцією, що й зміна. Інакше після збою в історії лишався б крок, якого в мапі немає, і відкат ламав би її. Зберігаються останні 50.
- Невідоме поле в тілі — 400. Мовчки проковтнути друкарську помилку клієнта означає, що правка «збереглася», але не застосувалась.
- Автопобудова ідемпотентна: наявні вузли не дублюються, координати не чіпаються — інакше кожен запуск скидав би ручну розкладку.
- Ліміт тарифу → 402, а не 500: UI має показати пропозицію змінити тариф.
map.updatedнесе лише ревізію, не патч. Розсилати дельти означало б тримати на сервері модель того, що бачить кожен клієнт — це вже CRDT, окрема задача.
Знайдено тестами
revision <= $2 - $3 з двома нетипізованими параметрами дає
operator is not unique: unknown - unknown. Postgres не може вивести оператор
віднімання, коли обидва операнди — плейсхолдери. Потрібні явні касти.
Перевірено
23 інтеграційні тести API (усі з -race), з них 12 нових на запис.
Найцінніші: драг не затирає сусідні поля; друга вкладка зі старою ревізією отримує
409, а перша правка ціла; видалення вузла не лишає ребер у нікуди; повторна
автопобудова не скидає ручну розкладку.
Живий прогін проти даних, які агент зібрав сам: створення мапи → автопобудова (+2 вузли, +1 ребро) → драг (ревізія 3) → патч зі старою ревізією (409) → повторна побудова (+0/+0) → координати x=1500 y=640 збережені, 2 знімки в історії.
2026-08-14 — Етап 4: фронтенд, мапа в браузері
Створено
web/
├── README.md
├── package.json, vite.config.ts, tsconfig.json
└── src/
├── types.ts типи API
├── api/client.ts REST + ApiError (isConflict/isPlanLimit)
├── api/ws.ts WebSocket із реконектом
├── hooks/useLiveMap.ts завантаження, живі оновлення, запис
└── components/ MapCanvas, DeviceNode, TrafficEdge
React 18 + React Flow 12 + Tailwind 4 + Vite 6. Збірка 345 КБ (112 КБ gzip).
Прийняті рішення
- Сервер — джерело істини, крім моменту перетягування. Поки вузол тягнуть,
позицію диктує миша; синхронізація пропускає вузли з
dragging. - Зберігаємо на відпусканні й лише якщо вузол зрушив.
- Успішний патч застосовується до локального стану — інакше вузол «повертається» при першій же події.
- Конфлікт ревізій не приховується: 409 → повідомлення + перечитування.
- Швидкість анімації обернено пропорційна завантаженню, при нулі анімації немає взагалі.
- Стан з'єднання завжди на екрані: замерзла мапа виглядає як здорова.
Знайдено роботою з живим UI (обидві виправлені)
- Ревізія росла від самих кліків. За час, поки я робив скріншоти, мапа
пройшла 2 → 5: React Flow віддає
onNodeDragStopна будь-яке натискання, і кожен клік писав порожню ревізію, змушуючи всі відкриті полотна перечитуватись. - Вузол відкочувався після збереження. Хук оновлював лише номер ревізії, тож
наступна подія
device.statusперебудовувала список зі старими координатами.
Перевірено в браузері проти повного стека
Агент + сервер + API + справжній snmpd: два зелені вузли з живим RTT (0.05 і
1.04 мс), ребро eth0 → ? із <0.01% з 10.0 Гбіт/с, індикатор «наживо, 1 с тому»,
зонд probe-snmp linux/amd64 з RSS 11.1 МБ, кнопка «Добудувати з топології»
додала 2 вузли й 1 ребро з topo.links.
Обмеження перевірки
- Драг не відтворюється автоматизацією: синтетичні події вказівника не запускають d3-drag у React Flow. Шлях «драг → PATCH → БД» покритий тестами сервера, але саме через UI лишається неперевіреним автоматично.
- Ребра не малюються у прихованій вкладці: React Flow міряє вузли в
requestAnimationFrame, який там не викликається. Вузли рендеряться, бо це звичайний DOM. Скріншот у згорнутій панелі непридатний для перевірки.
2026-08-14 — Етап 4 (частина 2): редактор доведено до робочого стану
Створено
server/internal/store/maps_undo.go відкат до попереднього знімка
server/internal/httpapi/maps_write.go POST /maps/{id}/undo
web/src/components/MapCanvas.tsx onConnect, onDelete
web/src/hooks/useLiveMap.ts undo + canUndo
Прийняті рішення
- Відкат — нова ревізія, а не відмотування лічильника. Інакше клієнт із номером 10 після повернення до 9 отримав би «свою» ревізію знову актуальною й тихо перезаписав відкочене.
- Ідентифікатори вузлів зберігаються при відкаті — ребра прив'язуються назад самі, а виділення в UI й зовнішні посилання не ламаються.
- Порядок відновлення — ребра геть → вузли геть → вузли назад → ребра назад: зовнішні ключі не дозволяють інакше.
- Намальоване рукою ребро не прив'язується до
topo.links: лінія на полотні — це подання, а не факт про мережу. Автовиявлення прив'яже саме. - Видалення не перелічує ребра вузла: на полотні їх прибирає React Flow, у базі — каскад FK.
Знайдено флак у тестах (виправлено)
TestSchedulerRunsTaskAndFillsCredentials падав приблизно раз на п'ять під
навантаженням: тест перевіряв канал статусів знімком (for len(ch) > 0),
а STATE_SUCCEEDED надсилається вже ПІСЛЯ запису в sink. На завантаженій машині
проміжок розширювався. Замінено на очікування з дедлайном; 8 прогонів під
штучним навантаженням — 0 падінь.
Перевірено наживо
Повний ланцюг проти працюючого стека: намальовано зв'язок (ребер 1 → 2) → відкат (2 → 1) → видалення вузла (2 вузли → 1, ребер 0) → відкат повернув той самий id вузла, ребро й живий стан лінка. UI показує обидві кнопки й підказку про жести.
Далі
- Підкладки-плани приміщень на полотні (потрібен прийом файлів).
- Історія метрик для графіків.
EnrollmentService+ видача сертифікатів зондам.